feat!: start
@@ -5,4 +5,6 @@ out/
|
|||||||
.next/
|
.next/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.wrangler/
|
.wrangler/
|
||||||
.env
|
.env
|
||||||
|
metadata.json
|
||||||
|
data/
|
||||||
@@ -7,13 +7,9 @@ COPY LICENSE ./
|
|||||||
|
|
||||||
# Copy the relevant package.json and package-lock.json files.
|
# Copy the relevant package.json and package-lock.json files.
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY packages/formatters/package*.json ./packages/formatters/
|
COPY packages/server/package*.json ./packages/server/
|
||||||
COPY packages/parser/package*.json ./packages/parser/
|
COPY packages/core/package*.json ./packages/core/
|
||||||
COPY packages/types/package*.json ./packages/types/
|
|
||||||
COPY packages/wrappers/package*.json ./packages/wrappers/
|
|
||||||
COPY packages/addon/package*.json ./packages/addon/
|
|
||||||
COPY packages/frontend/package*.json ./packages/frontend/
|
COPY packages/frontend/package*.json ./packages/frontend/
|
||||||
COPY packages/utils/package*.json ./packages/utils/
|
|
||||||
|
|
||||||
# Install dependencies.
|
# Install dependencies.
|
||||||
RUN npm install
|
RUN npm install
|
||||||
@@ -21,13 +17,12 @@ RUN npm install
|
|||||||
# Copy source files.
|
# Copy source files.
|
||||||
COPY tsconfig.*json ./
|
COPY tsconfig.*json ./
|
||||||
|
|
||||||
COPY packages/addon ./packages/addon
|
COPY packages/server ./packages/server
|
||||||
COPY packages/formatters ./packages/formatters
|
COPY packages/core ./packages/core
|
||||||
COPY packages/parser ./packages/parser
|
|
||||||
COPY packages/types ./packages/types
|
|
||||||
COPY packages/wrappers ./packages/wrappers
|
|
||||||
COPY packages/frontend ./packages/frontend
|
COPY packages/frontend ./packages/frontend
|
||||||
COPY packages/utils ./packages/utils
|
COPY scripts ./scripts
|
||||||
|
COPY resources ./resources
|
||||||
|
|
||||||
|
|
||||||
# Build the project.
|
# Build the project.
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -43,25 +38,18 @@ WORKDIR /app
|
|||||||
# The package.json files must be copied as well for NPM workspace symlinks between local packages to work.
|
# The package.json files must be copied as well for NPM workspace symlinks between local packages to work.
|
||||||
COPY --from=builder /build/package*.json /build/LICENSE ./
|
COPY --from=builder /build/package*.json /build/LICENSE ./
|
||||||
|
|
||||||
COPY --from=builder /build/packages/addon/package.*json ./packages/addon/
|
COPY --from=builder /build/packages/core/package.*json ./packages/core/
|
||||||
COPY --from=builder /build/packages/frontend/package.*json ./packages/frontend/
|
COPY --from=builder /build/packages/frontend/package.*json ./packages/frontend/
|
||||||
COPY --from=builder /build/packages/formatters/package.*json ./packages/formatters/
|
COPY --from=builder /build/packages/server/package.*json ./packages/server/
|
||||||
COPY --from=builder /build/packages/parser/package.*json ./packages/parser/
|
|
||||||
COPY --from=builder /build/packages/types/package.*json ./packages/types/
|
|
||||||
COPY --from=builder /build/packages/wrappers/package.*json ./packages/wrappers/
|
|
||||||
COPY --from=builder /build/packages/utils/package.*json ./packages/utils/
|
|
||||||
|
|
||||||
|
COPY --from=builder /build/packages/core/out ./packages/core/out
|
||||||
COPY --from=builder /build/packages/addon/dist ./packages/addon/dist
|
|
||||||
COPY --from=builder /build/packages/frontend/out ./packages/frontend/out
|
COPY --from=builder /build/packages/frontend/out ./packages/frontend/out
|
||||||
COPY --from=builder /build/packages/formatters/dist ./packages/formatters/dist
|
COPY --from=builder /build/packages/server/dist ./packages/server/dist
|
||||||
COPY --from=builder /build/packages/parser/dist ./packages/parser/dist
|
|
||||||
COPY --from=builder /build/packages/types/dist ./packages/types/dist
|
COPY --from=builder /build/resources ./resources
|
||||||
COPY --from=builder /build/packages/wrappers/dist ./packages/wrappers/dist
|
|
||||||
COPY --from=builder /build/packages/utils/dist ./packages/utils/dist
|
|
||||||
|
|
||||||
COPY --from=builder /build/node_modules ./node_modules
|
COPY --from=builder /build/node_modules ./node_modules
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENTRYPOINT ["npm", "run", "start:addon"]
|
ENTRYPOINT ["npm", "run", "start"]
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "aiostreams",
|
"name": "aiostreams",
|
||||||
"version": "1.21.1",
|
"version": "2.0.0",
|
||||||
"description": "Stremio addon to combine streams into one addon",
|
"description": "AIOStreams consolidates multiple Stremio addons and debrid services into a single, easily configurable addon. It allows highly customisable filtering, sorting, and formatting of results and supports proxying all your streams through MediaFlow Proxy or StremThru for improved compatibility and IP restriction bypassing.",
|
||||||
"main": "dist/server.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"release": "commit-and-tag-version",
|
"release": "commit-and-tag-version",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"build": "npm -w packages/types run build && npm -w packages/utils run build && npm -w packages/parser run build && npm -w packages/formatters run build && npm -w packages/wrappers run build && npm -w packages/addon run build && npm -w packages/frontend run build",
|
"prebuild": "node scripts/generateMetadata.js",
|
||||||
|
"build": "npm -w packages/core run build && npm -w packages/server run build && npm -w packages/frontend run build",
|
||||||
"build:watch": "tsc --build --watch",
|
"build:watch": "tsc --build --watch",
|
||||||
"start": "npm -w packages/addon start",
|
"start": "node packages/server/dist/server",
|
||||||
"start:addon": "npm -w packages/addon start",
|
"start:addon": "npm run start",
|
||||||
"start:addon:dev": "npm -w packages/addon run start:dev",
|
"start:dev": "cross-env NODE_ENV=development tsx watch packages/server/src/server.ts",
|
||||||
"start:frontend:dev": "npm -w packages/frontend run dev",
|
"start:addon:dev": "npm run start:dev",
|
||||||
"start:cloudflare-worker:dev": "npm -w packages/cloudflare-worker run dev",
|
"start:frontend:dev": "npm -w packages/frontend run dev"
|
||||||
"deploy:beamup": "beamup",
|
|
||||||
"deploy:cloudflare-worker": "npm -w packages/cloudflare-worker run deploy"
|
|
||||||
},
|
},
|
||||||
"author": "Viren070",
|
"author": "Viren070",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -23,20 +22,14 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.10",
|
|
||||||
"beamup-cli": "^1.3.0",
|
|
||||||
"commit-and-tag-version": "^12.5.0",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.2",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"vitest": "^2.1.5"
|
"vitest": "^2.1.5",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"super-regex": "^1.0.0",
|
|
||||||
"undici": "^7.2.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aiostreams/addon",
|
|
||||||
"version": "1.21.1",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run --passWithNoTests",
|
|
||||||
"test:watch": "vitest watch",
|
|
||||||
"build": "tsc",
|
|
||||||
"prepublish": "npm run build",
|
|
||||||
"start": "node dist/server.js",
|
|
||||||
"start:dev": "cross-env NODE_ENV=dev tsx watch src/server.ts"
|
|
||||||
},
|
|
||||||
"description": "Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.",
|
|
||||||
"dependencies": {
|
|
||||||
"@aiostreams/formatters": "^1.0.0",
|
|
||||||
"@aiostreams/types": "^1.0.0",
|
|
||||||
"@aiostreams/utils": "^1.0.0",
|
|
||||||
"@aiostreams/wrappers": "^1.0.0",
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"express": "^4.21.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
import { AddonDetail, Config } from '@aiostreams/types';
|
|
||||||
import {
|
|
||||||
addonDetails,
|
|
||||||
isValueEncrypted,
|
|
||||||
parseAndDecryptString,
|
|
||||||
serviceDetails,
|
|
||||||
Settings,
|
|
||||||
unminifyConfig,
|
|
||||||
} from '@aiostreams/utils';
|
|
||||||
|
|
||||||
export const allowedFormatters = [
|
|
||||||
'gdrive',
|
|
||||||
'minimalistic-gdrive',
|
|
||||||
'torrentio',
|
|
||||||
'torbox',
|
|
||||||
'imposter',
|
|
||||||
'custom',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const allowedLanguages = [
|
|
||||||
'Multi',
|
|
||||||
'English',
|
|
||||||
'Japanese',
|
|
||||||
'Chinese',
|
|
||||||
'Russian',
|
|
||||||
'Arabic',
|
|
||||||
'Portuguese',
|
|
||||||
'Spanish',
|
|
||||||
'French',
|
|
||||||
'German',
|
|
||||||
'Italian',
|
|
||||||
'Korean',
|
|
||||||
'Hindi',
|
|
||||||
'Bengali',
|
|
||||||
'Punjabi',
|
|
||||||
'Marathi',
|
|
||||||
'Gujarati',
|
|
||||||
'Tamil',
|
|
||||||
'Telugu',
|
|
||||||
'Kannada',
|
|
||||||
'Malayalam',
|
|
||||||
'Thai',
|
|
||||||
'Vietnamese',
|
|
||||||
'Indonesian',
|
|
||||||
'Turkish',
|
|
||||||
'Hebrew',
|
|
||||||
'Persian',
|
|
||||||
'Ukrainian',
|
|
||||||
'Greek',
|
|
||||||
'Lithuanian',
|
|
||||||
'Latvian',
|
|
||||||
'Estonian',
|
|
||||||
'Polish',
|
|
||||||
'Czech',
|
|
||||||
'Slovak',
|
|
||||||
'Hungarian',
|
|
||||||
'Romanian',
|
|
||||||
'Bulgarian',
|
|
||||||
'Serbian',
|
|
||||||
'Croatian',
|
|
||||||
'Slovenian',
|
|
||||||
'Dutch',
|
|
||||||
'Danish',
|
|
||||||
'Finnish',
|
|
||||||
'Swedish',
|
|
||||||
'Norwegian',
|
|
||||||
'Malay',
|
|
||||||
'Latino',
|
|
||||||
'Unknown',
|
|
||||||
'Dual Audio',
|
|
||||||
'Dubbed',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function validateConfig(
|
|
||||||
config: Config,
|
|
||||||
environment: 'client' | 'server' = 'server'
|
|
||||||
): {
|
|
||||||
valid: boolean;
|
|
||||||
errorCode: string | null;
|
|
||||||
errorMessage: string | null;
|
|
||||||
} {
|
|
||||||
config = unminifyConfig(config);
|
|
||||||
const createResponse = (
|
|
||||||
valid: boolean,
|
|
||||||
errorCode: string | null,
|
|
||||||
errorMessage: string | null
|
|
||||||
) => {
|
|
||||||
return { valid, errorCode, errorMessage };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.addons.length < 1) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'noAddons',
|
|
||||||
'At least one addon must be selected'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.addons.length > Settings.MAX_ADDONS) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'tooManyAddons',
|
|
||||||
`You can only select a maximum of ${Settings.MAX_ADDONS} addons`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// check for apiKey if Settings.API_KEY is set
|
|
||||||
if (environment === 'server' && Settings.API_KEY) {
|
|
||||||
const { apiKey } = config;
|
|
||||||
if (!apiKey) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingApiKey',
|
|
||||||
'The AIOStreams API key is required'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let decryptedApiKey = apiKey;
|
|
||||||
if (isValueEncrypted(apiKey)) {
|
|
||||||
const decryptionResult = parseAndDecryptString(apiKey);
|
|
||||||
if (decryptionResult === null) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'decryptionFailed',
|
|
||||||
'Failed to decrypt the AIOStreams API key'
|
|
||||||
);
|
|
||||||
} else if (decryptionResult === '') {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'emptyDecryption',
|
|
||||||
'Decrypted API key is empty'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
decryptedApiKey = decryptionResult;
|
|
||||||
}
|
|
||||||
if (decryptedApiKey !== Settings.API_KEY) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidApiKey',
|
|
||||||
'Invalid AIOStreams API key. Please use the one defined in your environment variables'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const duplicateAddons = config.addons.filter(
|
|
||||||
(addon, index) =>
|
|
||||||
config.addons.findIndex(
|
|
||||||
(a) =>
|
|
||||||
a.id === addon.id &&
|
|
||||||
JSON.stringify(a.options) === JSON.stringify(addon.options)
|
|
||||||
) !== index
|
|
||||||
);
|
|
||||||
|
|
||||||
if (duplicateAddons.length > 0) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'duplicateAddons',
|
|
||||||
'Duplicate addons found. Please remove any duplicates'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const addon of config.addons) {
|
|
||||||
if (Settings.DISABLE_TORRENTIO && addon.id === 'torrentio') {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'torrentioDisabled',
|
|
||||||
Settings.DISABLE_TORRENTIO_MESSAGE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = addonDetails.find(
|
|
||||||
(detail: AddonDetail) => detail.id === addon.id
|
|
||||||
);
|
|
||||||
if (!details) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidAddon',
|
|
||||||
`Invalid addon: ${addon.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (details.requiresService) {
|
|
||||||
const supportedServices = details.supportedServices;
|
|
||||||
const isAtLeastOneServiceEnabled = config.services.some(
|
|
||||||
(service) => supportedServices.includes(service.id) && service.enabled
|
|
||||||
);
|
|
||||||
const isOverrideUrlSet = addon.options?.overrideUrl;
|
|
||||||
if (!isAtLeastOneServiceEnabled && !isOverrideUrlSet) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingService',
|
|
||||||
`${addon.options?.name || details.name} requires at least one of the following services to be enabled: ${supportedServices
|
|
||||||
.map(
|
|
||||||
(service) =>
|
|
||||||
serviceDetails.find((detail) => detail.id === service)?.name ||
|
|
||||||
service
|
|
||||||
)
|
|
||||||
.join(', ')}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (details.options) {
|
|
||||||
for (const option of details.options) {
|
|
||||||
if (option.required && !addon.options[option.id]) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingRequiredOption',
|
|
||||||
`Option ${option.label} is required for addon ${addon.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
option.id.toLowerCase().includes('url') &&
|
|
||||||
addon.options[option.id] &&
|
|
||||||
((isValueEncrypted(addon.options[option.id]) &&
|
|
||||||
environment === 'server') ||
|
|
||||||
!isValueEncrypted(addon.options[option.id]))
|
|
||||||
) {
|
|
||||||
const url = parseAndDecryptString(addon.options[option.id] ?? '');
|
|
||||||
if (url === null) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'decryptionFailed',
|
|
||||||
`Failed to decrypt URL for ${option.label}`
|
|
||||||
);
|
|
||||||
} else if (url === '') {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'emptyDecryption',
|
|
||||||
`Decrypted URL for ${option.label} is empty`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
Settings.DISABLE_TORRENTIO &&
|
|
||||||
url.match(/torrentio\.strem\.fun/) !== null
|
|
||||||
) {
|
|
||||||
// if torrentio is disabled, don't allow the user to set URLs with torrentio.strem.fun
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'torrentioDisabled',
|
|
||||||
Settings.DISABLE_TORRENTIO_MESSAGE
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
Settings.DISABLE_TORRENTIO &&
|
|
||||||
url.match(/stremthru\.elfhosted\.com/) !== null
|
|
||||||
) {
|
|
||||||
// if torrentio is disabled, we need to inspect the stremthru URL to see if it's using torrentio
|
|
||||||
try {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
// get the component before manifest.json
|
|
||||||
const pathComponents = parsedUrl.pathname.split('/');
|
|
||||||
if (pathComponents.includes('manifest.json')) {
|
|
||||||
const index = pathComponents.indexOf('manifest.json');
|
|
||||||
const componentBeforeManifest = pathComponents[index - 1];
|
|
||||||
// base64 decode the component before manifest.json
|
|
||||||
const decodedComponent = atob(componentBeforeManifest);
|
|
||||||
const stremthruData = JSON.parse(decodedComponent);
|
|
||||||
if (stremthruData?.manifest_url?.match(/torrentio.strem.fun/)) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'torrentioDisabled',
|
|
||||||
Settings.DISABLE_TORRENTIO_MESSAGE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
} catch (_) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidUrl',
|
|
||||||
` Invalid URL for ${option.label}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.type === 'number' && addon.options[option.id]) {
|
|
||||||
const input = addon.options[option.id];
|
|
||||||
if (input !== undefined && !parseInt(input)) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidNumber',
|
|
||||||
`${option.label} must be a number`
|
|
||||||
);
|
|
||||||
} else if (input !== undefined) {
|
|
||||||
const value = parseInt(input);
|
|
||||||
const { min, max } = option.constraints || {};
|
|
||||||
if (
|
|
||||||
(min !== undefined && value < min) ||
|
|
||||||
(max !== undefined && value > max)
|
|
||||||
) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidNumber',
|
|
||||||
`${option.label} must be between ${min} and ${max}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allowedFormatters.includes(config.formatter)) {
|
|
||||||
if (config.formatter.startsWith('custom') && config.formatter.length > 7) {
|
|
||||||
const jsonString = config.formatter.slice(7);
|
|
||||||
const data = JSON.parse(jsonString);
|
|
||||||
if (!data.name || !data.description) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidCustomFormatter',
|
|
||||||
'Invalid custom formatter: name and description are required'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidFormatter',
|
|
||||||
`Invalid formatter: ${config.formatter}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const service of config.services) {
|
|
||||||
if (service.enabled) {
|
|
||||||
const serviceDetail = serviceDetails.find(
|
|
||||||
(detail) => detail.id === service.id
|
|
||||||
);
|
|
||||||
if (!serviceDetail) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidService',
|
|
||||||
`Invalid service: ${service.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const credential of serviceDetail.credentials) {
|
|
||||||
if (!service.credentials[credential.id]) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingCredential',
|
|
||||||
`${credential.label} is required for ${service.name}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// need at least one visual tag, resolution, quality
|
|
||||||
|
|
||||||
if (
|
|
||||||
!config.visualTags.some((tag) => Object.values(tag)[0]) ||
|
|
||||||
!config.resolutions.some((resolution) => Object.values(resolution)[0]) ||
|
|
||||||
!config.qualities.some((quality) => Object.values(quality)[0])
|
|
||||||
) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'noFilters',
|
|
||||||
'At least one visual tag, resolution, and quality must be selected'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [min, max] of [
|
|
||||||
[config.minMovieSize, config.maxMovieSize],
|
|
||||||
[config.minEpisodeSize, config.maxEpisodeSize],
|
|
||||||
[config.minSize, config.maxSize],
|
|
||||||
]) {
|
|
||||||
if (min && max) {
|
|
||||||
if (min >= max) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidSizeRange',
|
|
||||||
"Your minimum size limit can't be greater than or equal to your maximum size limit"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.maxResultsPerResolution && config.maxResultsPerResolution < 1) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidMaxResultsPerResolution',
|
|
||||||
'Max results per resolution must be greater than 0'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.mediaFlowConfig?.mediaFlowEnabled &&
|
|
||||||
config.stremThruConfig?.stremThruEnabled
|
|
||||||
) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'multipleProxyServices',
|
|
||||||
'Multiple proxy services are not allowed'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (config.mediaFlowConfig?.mediaFlowEnabled) {
|
|
||||||
if (!config.mediaFlowConfig.proxyUrl) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingProxyUrl',
|
|
||||||
'Proxy URL is required if MediaFlow is enabled'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!config.mediaFlowConfig.apiPassword) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingApiPassword',
|
|
||||||
'API Password is required if MediaFlow is enabled'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.stremThruConfig?.stremThruEnabled) {
|
|
||||||
if (!config.stremThruConfig.url) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingUrl',
|
|
||||||
'URL is required if Stremthru is enabled'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!config.stremThruConfig.credential) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingCredential',
|
|
||||||
'Credential is required if StremThru is enabled'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(config.excludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS ||
|
|
||||||
(config.strictIncludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS
|
|
||||||
) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'tooManyFilters',
|
|
||||||
`You can only have a maximum of ${Settings.MAX_KEYWORD_FILTERS} filters`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = [
|
|
||||||
...(config.excludeFilters || []),
|
|
||||||
...(config.strictIncludeFilters || []),
|
|
||||||
];
|
|
||||||
filters.forEach((filter) => {
|
|
||||||
if (filter.length > 20) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidFilter',
|
|
||||||
'One of your filters is too long'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!filter) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidFilter',
|
|
||||||
'Filters must not be empty'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (config.regexFilters) {
|
|
||||||
if (!config.apiKey) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingApiKey',
|
|
||||||
'Regex filtering requires an API key to be set'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.regexFilters.excludePattern) {
|
|
||||||
try {
|
|
||||||
new RegExp(config.regexFilters.excludePattern);
|
|
||||||
} catch (e) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidExcludeRegex',
|
|
||||||
'Invalid exclude regex pattern'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.regexFilters.includePattern) {
|
|
||||||
try {
|
|
||||||
new RegExp(config.regexFilters.includePattern);
|
|
||||||
} catch (e) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidIncludeRegex',
|
|
||||||
'Invalid include regex pattern'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.regexSortPatterns) {
|
|
||||||
if (!config.apiKey) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'missingApiKey',
|
|
||||||
'Regex sorting requires an API key to be set'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split the pattern by spaces and validate each one
|
|
||||||
const patterns = config.regexSortPatterns.split(/\s+/).filter(Boolean);
|
|
||||||
// Enforce an upper bound on the number of patterns
|
|
||||||
if (patterns.length > Settings.MAX_REGEX_SORT_PATTERNS) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'tooManyRegexSortPatterns',
|
|
||||||
`You can specify at most ${Settings.MAX_REGEX_SORT_PATTERNS} regex sort patterns`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const delimiter = '<::>';
|
|
||||||
const delimiterIndex = pattern.indexOf(delimiter);
|
|
||||||
let name: string = 'Unamed';
|
|
||||||
let regexPattern = pattern;
|
|
||||||
if (delimiterIndex !== -1) {
|
|
||||||
name = pattern.slice(0, delimiterIndex).replace(/_/g, ' ');
|
|
||||||
regexPattern = pattern.slice(delimiterIndex + delimiter.length);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
new RegExp(regexPattern);
|
|
||||||
} catch (e) {
|
|
||||||
return createResponse(
|
|
||||||
false,
|
|
||||||
'invalidRegexSortPattern',
|
|
||||||
`Invalid regex sort pattern: ${name ? `"${name}" ` : ''}${regexPattern}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return createResponse(true, null, null);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './addon';
|
|
||||||
export * from './config';
|
|
||||||
export * from './manifest';
|
|
||||||
export * from './responses';
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Config } from '@aiostreams/types';
|
|
||||||
import { version, description } from '../package.json';
|
|
||||||
import { getTextHash, Settings } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
const manifest = (config?: Config, configPresent?: boolean) => {
|
|
||||||
let addonId = Settings.ADDON_ID;
|
|
||||||
if (config && Settings.DETERMINISTIC_ADDON_ID) {
|
|
||||||
addonId =
|
|
||||||
addonId += `.${getTextHash(JSON.stringify(config)).substring(0, 12)}`;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: config?.overrideName || Settings.ADDON_NAME,
|
|
||||||
id: addonId,
|
|
||||||
version: version,
|
|
||||||
description: description,
|
|
||||||
catalogs: [],
|
|
||||||
resources: ['stream'],
|
|
||||||
background:
|
|
||||||
'https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/background.png',
|
|
||||||
logo: 'https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/logo.png',
|
|
||||||
types: ['movie', 'series'],
|
|
||||||
behaviorHints: {
|
|
||||||
configurable: true,
|
|
||||||
configurationRequired: config || configPresent ? false : true,
|
|
||||||
},
|
|
||||||
stremioAddonsConfig:
|
|
||||||
Settings.STREMIO_ADDONS_CONFIG_ISSUER &&
|
|
||||||
Settings.STREMIO_ADDONS_CONFIG_SIGNATURE
|
|
||||||
? {
|
|
||||||
issuer: Settings.STREMIO_ADDONS_CONFIG_ISSUER,
|
|
||||||
signature: Settings.STREMIO_ADDONS_CONFIG_SIGNATURE,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default manifest;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Settings } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
export const errorResponse = (
|
|
||||||
errorMessage: string,
|
|
||||||
origin?: string,
|
|
||||||
path?: string,
|
|
||||||
externalUrl?: string
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
streams: [errorStream(errorMessage, 'Error', origin, path, externalUrl)],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const errorStream = (
|
|
||||||
errorMessage: string,
|
|
||||||
errorTitle?: string,
|
|
||||||
origin?: string,
|
|
||||||
path?: string,
|
|
||||||
externalUrl?: string
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
externalUrl:
|
|
||||||
(origin && path ? origin + path : undefined) ||
|
|
||||||
externalUrl ||
|
|
||||||
'https://github.com/Viren070/AIOStreams',
|
|
||||||
name: `[❌] ${Settings.ADDON_NAME}\n${errorTitle || 'Error'}`,
|
|
||||||
description: errorMessage,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
import express, { Request, Response } from 'express';
|
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import { AIOStreams } from './addon';
|
|
||||||
import { Config, StreamRequest } from '@aiostreams/types';
|
|
||||||
import { validateConfig } from './config';
|
|
||||||
import manifest from './manifest';
|
|
||||||
import { errorResponse } from './responses';
|
|
||||||
import {
|
|
||||||
Settings,
|
|
||||||
addonDetails,
|
|
||||||
parseAndDecryptString,
|
|
||||||
Cache,
|
|
||||||
unminifyConfig,
|
|
||||||
minifyConfig,
|
|
||||||
crushJson,
|
|
||||||
compressData,
|
|
||||||
encryptData,
|
|
||||||
decompressData,
|
|
||||||
decryptData,
|
|
||||||
uncrushJson,
|
|
||||||
loadSecretKey,
|
|
||||||
createLogger,
|
|
||||||
getTimeTakenSincePoint,
|
|
||||||
isValueEncrypted,
|
|
||||||
maskSensitiveInfo,
|
|
||||||
} from '@aiostreams/utils';
|
|
||||||
|
|
||||||
const logger = createLogger('server');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
//logger.info(`Starting server and loading settings...`);
|
|
||||||
logger.info('Starting server and loading settings...', { func: 'init' });
|
|
||||||
Object.entries(Settings).forEach(([key, value]) => {
|
|
||||||
switch (key) {
|
|
||||||
case 'SECRET_KEY':
|
|
||||||
if (value) {
|
|
||||||
logger.info(`${key} = ${value.replace(/./g, '*').slice(0, 64)}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'BRANDING':
|
|
||||||
case 'CUSTOM_CONFIGS':
|
|
||||||
// Skip CUSTOM_CONFIGS processing here, handled later
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.info(`${key} = ${value}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// attempt to load the secret key
|
|
||||||
try {
|
|
||||||
if (Settings.SECRET_KEY) loadSecretKey(true);
|
|
||||||
} catch (error: any) {
|
|
||||||
// determine command to run based on system OS
|
|
||||||
const command =
|
|
||||||
process.platform === 'win32'
|
|
||||||
? '[System.Guid]::NewGuid().ToString("N").Substring(0, 32) + [System.Guid]::NewGuid().ToString("N").Substring(0, 32)'
|
|
||||||
: 'openssl rand -hex 32';
|
|
||||||
logger.error(
|
|
||||||
`The secret key is invalid. You will not be able to generate configurations. You can generate a new secret key by running the following command\n${command}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Built-in middleware for parsing JSON
|
|
||||||
app.use(express.json());
|
|
||||||
// Built-in middleware for parsing URL-encoded data
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
// unhandled errors
|
|
||||||
app.use((err: any, req: Request, res: Response, next: any) => {
|
|
||||||
logger.error(`${err.message}`);
|
|
||||||
res.status(500).send('Internal server error');
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
res.append('Access-Control-Allow-Origin', '*');
|
|
||||||
res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
|
|
||||||
const start = Date.now();
|
|
||||||
res.on('finish', () => {
|
|
||||||
logger.info(
|
|
||||||
`${req.method} ${req.path
|
|
||||||
.replace(/\/ey[JI][\w\=]+/g, '/*******')
|
|
||||||
.replace(
|
|
||||||
/\/(E2?|B)?-[\w-\%]+/g,
|
|
||||||
'/*******'
|
|
||||||
)} - ${getIp(req) ? maskSensitiveInfo(getIp(req)!) : 'Unknown IP'} - ${res.statusCode} - ${getTimeTakenSincePoint(start)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
|
||||||
res.redirect('/configure');
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get(
|
|
||||||
['/_next/*', '/assets/*', '/icon.ico', '/configure.txt'],
|
|
||||||
(req, res) => {
|
|
||||||
res.sendFile(path.join(__dirname, '../../frontend/out', req.path));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!Settings.DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE) {
|
|
||||||
app.get('/custom-config-generator', (req, res) => {
|
|
||||||
res.sendFile(
|
|
||||||
path.join(__dirname, '../../frontend/out/custom-config-generator.html')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/configure', (req, res) => {
|
|
||||||
res.sendFile(path.join(__dirname, '../../frontend/out/configure.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/:config/configure', (req, res) => {
|
|
||||||
const config = req.params.config;
|
|
||||||
if (config.startsWith('eyJ') || config.startsWith('eyI')) {
|
|
||||||
return res.sendFile(
|
|
||||||
path.join(__dirname, '../../frontend/out/configure.html')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let configJson = extractJsonConfig(config);
|
|
||||||
let configString = config;
|
|
||||||
if (Settings.CUSTOM_CONFIGS) {
|
|
||||||
const customConfig = extractCustomConfig(config);
|
|
||||||
if (customConfig) {
|
|
||||||
configJson = customConfig;
|
|
||||||
configString = decodeURIComponent(Settings.CUSTOM_CONFIGS[config]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isValueEncrypted(configString)) {
|
|
||||||
logger.info(`Encrypted config detected, encrypting credentials`);
|
|
||||||
configJson = encryptInfoInConfig(configJson);
|
|
||||||
}
|
|
||||||
const base64Config = Buffer.from(JSON.stringify(configJson)).toString(
|
|
||||||
'base64'
|
|
||||||
);
|
|
||||||
res.redirect(`/${encodeURIComponent(base64Config)}/configure`);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to extract config: ${error.message}`);
|
|
||||||
res.status(400).send('Invalid config');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/manifest.json', (req, res) => {
|
|
||||||
res.status(200).json(manifest());
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/:config/manifest.json', (req, res) => {
|
|
||||||
const config = decodeURIComponent(req.params.config);
|
|
||||||
let configJson: Config;
|
|
||||||
try {
|
|
||||||
configJson = extractJsonConfig(config);
|
|
||||||
logger.info(`Extracted config for manifest request`);
|
|
||||||
configJson = decryptEncryptedInfoFromConfig(configJson);
|
|
||||||
if (Settings.LOG_SENSITIVE_INFO) {
|
|
||||||
logger.info(`Final config: ${JSON.stringify(configJson)}`);
|
|
||||||
}
|
|
||||||
logger.info(`Successfully removed or decrypted sensitive info`);
|
|
||||||
const { valid, errorMessage } = validateConfig(configJson);
|
|
||||||
if (!valid) {
|
|
||||||
logger.error(
|
|
||||||
`Received invalid config for manifest request: ${errorMessage}`
|
|
||||||
);
|
|
||||||
res.status(400).json({ error: 'Invalid config', message: errorMessage });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to extract config: ${error.message}`);
|
|
||||||
res.status(400).json({ error: 'Invalid config' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(200).json(manifest(configJson));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route for /stream
|
|
||||||
app.get('/stream/:type/:id', (req: Request, res: Response) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(
|
|
||||||
errorResponse(
|
|
||||||
'You must configure this addon to use it',
|
|
||||||
rootUrl(req),
|
|
||||||
'/configure'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/:config/stream/:type/:id.json', (req, res: Response): void => {
|
|
||||||
const { config, type, id } = req.params;
|
|
||||||
let configJson: Config;
|
|
||||||
try {
|
|
||||||
configJson = extractJsonConfig(config);
|
|
||||||
logger.info(`Extracted config for stream request`);
|
|
||||||
configJson = decryptEncryptedInfoFromConfig(configJson);
|
|
||||||
if (Settings.LOG_SENSITIVE_INFO) {
|
|
||||||
logger.info(`Final config: ${JSON.stringify(configJson)}`);
|
|
||||||
}
|
|
||||||
logger.info(`Successfully removed or decrypted sensitive info`);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to extract config: ${error.message}`);
|
|
||||||
res.json(
|
|
||||||
errorResponse(
|
|
||||||
`${error.message}, please check the logs or click this stream to create an issue on GitHub`,
|
|
||||||
rootUrl(req),
|
|
||||||
undefined,
|
|
||||||
'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Requesting streams for ${type} ${id}`);
|
|
||||||
|
|
||||||
if (type !== 'movie' && type !== 'series') {
|
|
||||||
logger.error(`Invalid type for stream request`);
|
|
||||||
res.json(
|
|
||||||
errorResponse(
|
|
||||||
'Invalid type for stream request, must be movie or series',
|
|
||||||
rootUrl(req),
|
|
||||||
'/'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let streamRequest: StreamRequest = { id, type };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { valid, errorCode, errorMessage } = validateConfig(configJson);
|
|
||||||
if (!valid) {
|
|
||||||
logger.error(`Received invalid config: ${errorCode} - ${errorMessage}`);
|
|
||||||
res.json(
|
|
||||||
errorResponse(errorMessage ?? 'Unknown', rootUrl(req), '/configure')
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
configJson.requestingIp = getIp(req);
|
|
||||||
const aioStreams = new AIOStreams(configJson);
|
|
||||||
aioStreams
|
|
||||||
.getStreams(streamRequest)
|
|
||||||
.then((streams) => {
|
|
||||||
res.json({ streams: streams });
|
|
||||||
})
|
|
||||||
.catch((error: any) => {
|
|
||||||
logger.error(`Internal addon error: ${error.message}`);
|
|
||||||
res.json(
|
|
||||||
errorResponse(
|
|
||||||
'An unexpected error occurred, please check the logs or create an issue on GitHub',
|
|
||||||
rootUrl(req),
|
|
||||||
undefined,
|
|
||||||
'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Internal addon error: ${error.message}`);
|
|
||||||
res.json(
|
|
||||||
errorResponse(
|
|
||||||
'An unexpected error occurred, please check the logs or create an issue on GitHub',
|
|
||||||
rootUrl(req),
|
|
||||||
undefined,
|
|
||||||
'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/encrypt-user-data', (req, res) => {
|
|
||||||
const { data } = req.body;
|
|
||||||
let finalString: string = '';
|
|
||||||
if (!data) {
|
|
||||||
logger.error('/encrypt-user-data: No data provided');
|
|
||||||
res.json({ success: false, message: 'No data provided' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// First, validate the config
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(data);
|
|
||||||
const { valid, errorCode, errorMessage } = validateConfig(config);
|
|
||||||
if (!valid) {
|
|
||||||
logger.error(
|
|
||||||
`generateConfig: Invalid config: ${errorCode} - ${errorMessage}`
|
|
||||||
);
|
|
||||||
res.json({ success: false, message: errorMessage, error: errorMessage });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`/encrypt-user-data: Invalid JSON: ${error.message}`);
|
|
||||||
res.json({ success: false, message: 'Malformed configuration' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const minified = minifyConfig(JSON.parse(data));
|
|
||||||
const crushed = crushJson(JSON.stringify(minified));
|
|
||||||
const compressed = compressData(crushed);
|
|
||||||
if (!Settings.SECRET_KEY) {
|
|
||||||
// use base64 encoding if no secret key is set
|
|
||||||
finalString = `B-${encodeURIComponent(compressed.toString('base64'))}`;
|
|
||||||
} else {
|
|
||||||
const { iv, data } = encryptData(compressed);
|
|
||||||
finalString = `E2-${encodeURIComponent(iv)}-${encodeURIComponent(data)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`|INF| server > /encrypt-user-data: Encrypted user data, compression report:`
|
|
||||||
);
|
|
||||||
logger.info(`+--------------------------------------------+`);
|
|
||||||
logger.info(`| Original: ${data.length} bytes`);
|
|
||||||
logger.info(`| URL Encoded: ${encodeURIComponent(data).length} bytes`);
|
|
||||||
logger.info(`| Minified: ${JSON.stringify(minified).length} bytes`);
|
|
||||||
logger.info(`| Crushed: ${crushed.length} bytes`);
|
|
||||||
logger.info(`| Compressed: ${compressed.length} bytes`);
|
|
||||||
logger.info(`| Final String: ${finalString.length} bytes`);
|
|
||||||
logger.info(
|
|
||||||
`| Ratio: ${((finalString.length / data.length) * 100).toFixed(2)}%`
|
|
||||||
);
|
|
||||||
logger.info(
|
|
||||||
`| Reduction: ${data.length - finalString.length} bytes (${(((data.length - finalString.length) / data.length) * 100).toFixed(2)}%)`
|
|
||||||
);
|
|
||||||
logger.info(`+--------------------------------------------+`);
|
|
||||||
|
|
||||||
res.json({ success: true, data: finalString });
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`/encrypt-user-data: ${error.message}`);
|
|
||||||
logger.error(error);
|
|
||||||
res.json({ success: false, message: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/get-addon-config', (req, res) => {
|
|
||||||
res.status(200).json({
|
|
||||||
success: true,
|
|
||||||
maxMovieSize: Settings.MAX_MOVIE_SIZE,
|
|
||||||
maxEpisodeSize: Settings.MAX_EPISODE_SIZE,
|
|
||||||
torrentioDisabled: Settings.DISABLE_TORRENTIO,
|
|
||||||
apiKeyRequired: !!Settings.API_KEY,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.status(200).json({ status: 'ok' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// define 404
|
|
||||||
app.use((req, res) => {
|
|
||||||
res.status(404).sendFile(path.join(__dirname, '../../frontend/out/404.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(Settings.PORT, () => {
|
|
||||||
logger.info(`Listening on port ${Settings.PORT}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
function getIp(req: Request): string | undefined {
|
|
||||||
return (
|
|
||||||
req.get('X-Client-IP') ||
|
|
||||||
req.get('X-Forwarded-For')?.split(',')[0].trim() ||
|
|
||||||
req.get('X-Real-IP') ||
|
|
||||||
req.get('CF-Connecting-IP') ||
|
|
||||||
req.get('True-Client-IP') ||
|
|
||||||
req.get('X-Forwarded')?.split(',')[0].trim() ||
|
|
||||||
req.get('Forwarded-For')?.split(',')[0].trim() ||
|
|
||||||
req.ip
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function extractJsonConfig(config: string): Config {
|
|
||||||
if (
|
|
||||||
config.startsWith('eyJ') ||
|
|
||||||
config.startsWith('eyI') ||
|
|
||||||
config.startsWith('B-') ||
|
|
||||||
isValueEncrypted(config)
|
|
||||||
) {
|
|
||||||
return extractEncryptedOrEncodedConfig(config, 'Config');
|
|
||||||
}
|
|
||||||
if (Settings.CUSTOM_CONFIGS) {
|
|
||||||
const customConfig = extractCustomConfig(config);
|
|
||||||
if (customConfig) return customConfig;
|
|
||||||
}
|
|
||||||
throw new Error('Config was in an unexpected format');
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCustomConfig(config: string): Config | undefined {
|
|
||||||
const customConfig = Settings.CUSTOM_CONFIGS[config];
|
|
||||||
if (!customConfig) return undefined;
|
|
||||||
logger.info(
|
|
||||||
`Found custom config for alias ${config}, attempting to extract config`
|
|
||||||
);
|
|
||||||
return extractEncryptedOrEncodedConfig(
|
|
||||||
decodeURIComponent(customConfig),
|
|
||||||
`CustomConfig ${config}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractEncryptedOrEncodedConfig(
|
|
||||||
config: string,
|
|
||||||
label: string
|
|
||||||
): Config {
|
|
||||||
let decodedConfig: Config;
|
|
||||||
try {
|
|
||||||
if (config.startsWith('E-')) {
|
|
||||||
// compressed and encrypted (hex)
|
|
||||||
logger.info(`Extracting encrypted (v1) config`);
|
|
||||||
const parts = config.split('-');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new Error('Invalid encrypted config format');
|
|
||||||
}
|
|
||||||
const iv = Buffer.from(decodeURIComponent(parts[1]), 'hex');
|
|
||||||
const data = Buffer.from(decodeURIComponent(parts[2]), 'hex');
|
|
||||||
decodedConfig = JSON.parse(decompressData(decryptData(data, iv)));
|
|
||||||
} else if (config.startsWith('E2-')) {
|
|
||||||
// minified, crushed, compressed and encrypted (base64)
|
|
||||||
logger.info(`Extracting encrypted (v2) config`);
|
|
||||||
const parts = config.split('-');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new Error('Invalid encrypted config format');
|
|
||||||
}
|
|
||||||
const iv = Buffer.from(decodeURIComponent(parts[1]), 'base64');
|
|
||||||
const data = Buffer.from(decodeURIComponent(parts[2]), 'base64');
|
|
||||||
const compressedCrushedJson = decryptData(data, iv);
|
|
||||||
const crushedJson = decompressData(compressedCrushedJson);
|
|
||||||
const minifiedConfig = uncrushJson(crushedJson);
|
|
||||||
decodedConfig = unminifyConfig(JSON.parse(minifiedConfig));
|
|
||||||
} else if (config.startsWith('B-')) {
|
|
||||||
// minifed, crushed, compressed, base64 encoded
|
|
||||||
logger.info(`Extracting base64 encoded and compressed config`);
|
|
||||||
decodedConfig = unminifyConfig(
|
|
||||||
JSON.parse(
|
|
||||||
uncrushJson(decompressData(Buffer.from(config.slice(2), 'base64')))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// plain base64 encoded
|
|
||||||
logger.info(`Extracting plain base64 encoded config`);
|
|
||||||
decodedConfig = JSON.parse(
|
|
||||||
Buffer.from(config, 'base64').toString('utf-8')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return decodedConfig;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to parse ${label}: ${error.message}`, {
|
|
||||||
func: 'extractJsonConfig',
|
|
||||||
});
|
|
||||||
logger.error(error, { func: 'extractJsonConfig' });
|
|
||||||
throw new Error(`Failed to parse ${label}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptEncryptedInfoFromConfig(config: Config): Config {
|
|
||||||
if (config.services) {
|
|
||||||
config.services.forEach(
|
|
||||||
(service) =>
|
|
||||||
service.credentials &&
|
|
||||||
processObjectValues(
|
|
||||||
service.credentials,
|
|
||||||
`service ${service.id}`,
|
|
||||||
true,
|
|
||||||
(key, value) => isValueEncrypted(value)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.mediaFlowConfig) {
|
|
||||||
decryptMediaFlowConfig(config.mediaFlowConfig);
|
|
||||||
}
|
|
||||||
if (config.stremThruConfig) {
|
|
||||||
decryptStremThruConfig(config.stremThruConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.apiKey) {
|
|
||||||
config.apiKey = decryptValue(config.apiKey, 'aioStreams apiKey');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.addons) {
|
|
||||||
config.addons.forEach((addon) => {
|
|
||||||
if (addon.options) {
|
|
||||||
processObjectValues(
|
|
||||||
addon.options,
|
|
||||||
`addon ${addon.id}`,
|
|
||||||
true,
|
|
||||||
(key, value) =>
|
|
||||||
isValueEncrypted(value) &&
|
|
||||||
// Decrypt only if the option is secret
|
|
||||||
(
|
|
||||||
addonDetails.find((addonDetail) => addonDetail.id === addon.id)
|
|
||||||
?.options ?? []
|
|
||||||
).some((option) => option.id === key && option.secret)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptMediaFlowConfig(mediaFlowConfig: {
|
|
||||||
apiPassword: string;
|
|
||||||
proxyUrl: string;
|
|
||||||
publicIp: string;
|
|
||||||
}): void {
|
|
||||||
const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig;
|
|
||||||
mediaFlowConfig.apiPassword = decryptValue(
|
|
||||||
apiPassword,
|
|
||||||
'MediaFlow apiPassword'
|
|
||||||
);
|
|
||||||
mediaFlowConfig.proxyUrl = decryptValue(proxyUrl, 'MediaFlow proxyUrl');
|
|
||||||
mediaFlowConfig.publicIp = decryptValue(publicIp, 'MediaFlow publicIp');
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptStremThruConfig(
|
|
||||||
stremThruConfig: Config['stremThruConfig']
|
|
||||||
): void {
|
|
||||||
if (!stremThruConfig) return;
|
|
||||||
const { url, credential, publicIp } = stremThruConfig;
|
|
||||||
stremThruConfig.url = decryptValue(url, 'StremThru url');
|
|
||||||
stremThruConfig.credential = decryptValue(credential, 'StremThru credential');
|
|
||||||
stremThruConfig.publicIp = decryptValue(publicIp, 'StremThru publicIp');
|
|
||||||
}
|
|
||||||
|
|
||||||
function encryptInfoInConfig(config: Config): Config {
|
|
||||||
if (config.services) {
|
|
||||||
config.services.forEach(
|
|
||||||
(service) =>
|
|
||||||
service.credentials &&
|
|
||||||
processObjectValues(
|
|
||||||
service.credentials,
|
|
||||||
`service ${service.id}`,
|
|
||||||
false,
|
|
||||||
() => true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.mediaFlowConfig) {
|
|
||||||
encryptMediaFlowConfig(config.mediaFlowConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.stremThruConfig) {
|
|
||||||
encryptStremThruConfig(config.stremThruConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.apiKey) {
|
|
||||||
// we can either remove the api key for better security or encrypt it for usability
|
|
||||||
// removing it means the user has to enter it every time upon reconfiguration.
|
|
||||||
config.apiKey = encryptValue(config.apiKey, 'aioStreams apiKey');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.addons) {
|
|
||||||
config.addons.forEach((addon) => {
|
|
||||||
if (addon.options) {
|
|
||||||
processObjectValues(
|
|
||||||
addon.options,
|
|
||||||
`addon ${addon.id}`,
|
|
||||||
false,
|
|
||||||
(key) => {
|
|
||||||
const addonDetail = addonDetails.find(
|
|
||||||
(addonDetail) => addonDetail.id === addon.id
|
|
||||||
);
|
|
||||||
if (!addonDetail) return false;
|
|
||||||
const optionDetail = addonDetail.options?.find(
|
|
||||||
(option) => option.id === key
|
|
||||||
);
|
|
||||||
// Encrypt only if the option is secret
|
|
||||||
return optionDetail?.secret ?? false;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encryptMediaFlowConfig(mediaFlowConfig: {
|
|
||||||
apiPassword: string;
|
|
||||||
proxyUrl: string;
|
|
||||||
publicIp: string;
|
|
||||||
}): void {
|
|
||||||
const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig;
|
|
||||||
mediaFlowConfig.apiPassword = encryptValue(
|
|
||||||
apiPassword,
|
|
||||||
'MediaFlow apiPassword'
|
|
||||||
);
|
|
||||||
mediaFlowConfig.proxyUrl = encryptValue(proxyUrl, 'MediaFlow proxyUrl');
|
|
||||||
mediaFlowConfig.publicIp = encryptValue(publicIp, 'MediaFlow publicIp');
|
|
||||||
}
|
|
||||||
|
|
||||||
function encryptStremThruConfig(
|
|
||||||
stremThruConfig: Config['stremThruConfig']
|
|
||||||
): void {
|
|
||||||
if (!stremThruConfig) return;
|
|
||||||
const { url, credential, publicIp } = stremThruConfig;
|
|
||||||
stremThruConfig.url = encryptValue(url, 'StremThru url');
|
|
||||||
stremThruConfig.credential = encryptValue(credential, 'StremThru credential');
|
|
||||||
stremThruConfig.publicIp = encryptValue(publicIp, 'StremThru publicIp');
|
|
||||||
}
|
|
||||||
|
|
||||||
function processObjectValues(
|
|
||||||
obj: Record<string, any>,
|
|
||||||
labelPrefix: string,
|
|
||||||
decrypt: boolean,
|
|
||||||
condition: (key: string, value: any) => boolean
|
|
||||||
): void {
|
|
||||||
Object.keys(obj).forEach((key) => {
|
|
||||||
const value = obj[key];
|
|
||||||
if (condition(key, value)) {
|
|
||||||
logger.debug(`Processing ${labelPrefix} ${key}`);
|
|
||||||
obj[key] = decrypt
|
|
||||||
? decryptValue(value, `${labelPrefix} ${key}`)
|
|
||||||
: encryptValue(value, `${labelPrefix} ${key}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function encryptValue(value: any, label: string): any {
|
|
||||||
if (value && !isValueEncrypted(value)) {
|
|
||||||
try {
|
|
||||||
const { iv, data } = encryptData(compressData(value));
|
|
||||||
return `E2-${iv}-${data}`;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to encrypt ${label}`, { func: 'encryptValue' });
|
|
||||||
logger.error(error, { func: 'encryptValue' });
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptValue(value: any, label: string): any {
|
|
||||||
try {
|
|
||||||
if (!isValueEncrypted(value)) return value;
|
|
||||||
const decrypted = parseAndDecryptString(value);
|
|
||||||
if (decrypted === null) throw new Error('Decryption failed');
|
|
||||||
return decrypted;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Failed to decrypt ${label}: ${error.message}`, {
|
|
||||||
func: 'decryptValue',
|
|
||||||
});
|
|
||||||
logger.error(error, { func: 'decryptValue' });
|
|
||||||
throw new Error('Failed to decrypt config');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootUrl = (req: Request) =>
|
|
||||||
`${req.protocol}://${req.hostname}${req.hostname === 'localhost' ? `:${Settings.PORT}` : ''}`;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "../wrappers"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../formatters"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../types"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../utils"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aiostreams/cloudflare-worker",
|
|
||||||
"version": "1.21.1",
|
|
||||||
"scripts": {
|
|
||||||
"deploy": "wrangler deploy",
|
|
||||||
"dev": "wrangler dev",
|
|
||||||
"start": "wrangler dev",
|
|
||||||
"test": "vitest",
|
|
||||||
"cf-typegen": "wrangler types"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@aiostreams/addon": "^1.0.0",
|
|
||||||
"@aiostreams/types": "^1.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@cloudflare/workers-types": "^4.20241224.0",
|
|
||||||
"typescript": "^5.5.2",
|
|
||||||
"wrangler": "^3.99.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { AIOStreams, errorResponse, validateConfig } from '@aiostreams/addon';
|
|
||||||
import manifest from '@aiostreams/addon/src/manifest';
|
|
||||||
import { Config, StreamRequest } from '@aiostreams/types';
|
|
||||||
import { Cache, unminifyConfig } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
const HEADERS = {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS',
|
|
||||||
};
|
|
||||||
|
|
||||||
function createJsonResponse(data: any): Response {
|
|
||||||
return new Response(JSON.stringify(data, null, 4), {
|
|
||||||
headers: HEADERS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createResponse(message: string, status: number): Response {
|
|
||||||
return new Response(message, {
|
|
||||||
status,
|
|
||||||
headers: HEADERS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
async fetch(request, env, ctx): Promise<Response> {
|
|
||||||
try {
|
|
||||||
const url = new URL(decodeURIComponent(request.url));
|
|
||||||
const components = url.pathname.split('/').splice(1);
|
|
||||||
|
|
||||||
// handle static asset requests
|
|
||||||
if (components.includes('_next') || components.includes('assets')) {
|
|
||||||
return env.ASSETS.fetch(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/icon.ico') {
|
|
||||||
return env.ASSETS.fetch(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// redirect to /configure if root path is requested
|
|
||||||
if (url.pathname === '/') {
|
|
||||||
return Response.redirect(url.origin + '/configure', 301);
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle /encrypt-user-data POST requests
|
|
||||||
if (components.includes('encrypt-user-data')) {
|
|
||||||
const data = (await request.json()) as { data: string };
|
|
||||||
if (!data) {
|
|
||||||
return createResponse('Invalid Request', 400);
|
|
||||||
}
|
|
||||||
const dataToEncode = data.data;
|
|
||||||
try {
|
|
||||||
console.log(
|
|
||||||
`Received /encrypt-user-data request with Data: ${dataToEncode}`
|
|
||||||
);
|
|
||||||
const encodedData = Buffer.from(dataToEncode).toString('base64');
|
|
||||||
return createJsonResponse({ data: encodedData, success: true });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
return createJsonResponse({ error: error.message, success: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// handle /configure and /:config/configure requests
|
|
||||||
if (components.includes('configure')) {
|
|
||||||
if (components.length === 1) {
|
|
||||||
return env.ASSETS.fetch(request);
|
|
||||||
} else {
|
|
||||||
// display configure page with config still in url
|
|
||||||
return env.ASSETS.fetch(
|
|
||||||
new Request(url.origin + '/configure', request)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle /manifest.json and /:config/manifest.json requests
|
|
||||||
if (components.includes('manifest.json')) {
|
|
||||||
if (components.length === 1) {
|
|
||||||
return createJsonResponse(manifest());
|
|
||||||
} else {
|
|
||||||
return createJsonResponse(manifest(undefined, true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (components.includes('stream')) {
|
|
||||||
// when /stream is requested without config
|
|
||||||
let config = decodeURIComponent(components[0]);
|
|
||||||
console.log(`components: ${components}`);
|
|
||||||
if (components.length < 4) {
|
|
||||||
return createJsonResponse(
|
|
||||||
errorResponse(
|
|
||||||
'You must configure this addon first',
|
|
||||||
url.origin,
|
|
||||||
'/configure'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`Received /stream request with Config: ${config}`);
|
|
||||||
const decodedPath = decodeURIComponent(url.pathname);
|
|
||||||
|
|
||||||
const streamMatch = /stream\/(movie|series)\/([^/]+)\.json/.exec(
|
|
||||||
decodedPath
|
|
||||||
);
|
|
||||||
if (!streamMatch) {
|
|
||||||
let path = decodedPath.replace(`/${config}`, '');
|
|
||||||
console.error(`Invalid request: ${path}`);
|
|
||||||
return createResponse('Invalid request', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, id] = streamMatch.slice(1);
|
|
||||||
console.log(`Received /stream request with Type: ${type}, ID: ${id}`);
|
|
||||||
|
|
||||||
let decodedConfig: Config;
|
|
||||||
|
|
||||||
if (config.startsWith('E-') || config.startsWith('E2-')) {
|
|
||||||
return createResponse('Encrypted Config Not Supported', 400);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
decodedConfig = unminifyConfig(
|
|
||||||
JSON.parse(Buffer.from(config, 'base64').toString('utf-8'))
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
return createJsonResponse(
|
|
||||||
errorResponse(
|
|
||||||
'Unable to parse config, please reconfigure or create an issue on GitHub',
|
|
||||||
url.origin,
|
|
||||||
'/configure'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { valid, errorMessage, errorCode } =
|
|
||||||
validateConfig(decodedConfig);
|
|
||||||
if (!valid) {
|
|
||||||
console.error(`Invalid config: ${errorMessage}`);
|
|
||||||
return createJsonResponse(
|
|
||||||
errorResponse(errorMessage ?? 'Unknown', url.origin, '/configure')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type !== 'movie' && type !== 'series') {
|
|
||||||
return createResponse('Invalid Request', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
let streamRequest: StreamRequest = { id, type };
|
|
||||||
|
|
||||||
decodedConfig.requestingIp =
|
|
||||||
request.headers.get('X-Forwarded-For') ||
|
|
||||||
request.headers.get('X-Real-IP') ||
|
|
||||||
request.headers.get('CF-Connecting-IP') ||
|
|
||||||
request.headers.get('X-Client-IP') ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
const aioStreams = new AIOStreams(decodedConfig);
|
|
||||||
const streams = await aioStreams.getStreams(streamRequest);
|
|
||||||
return createJsonResponse({ streams });
|
|
||||||
}
|
|
||||||
|
|
||||||
const notFound = await env.ASSETS.fetch(
|
|
||||||
new Request(url.origin + '/404', request)
|
|
||||||
);
|
|
||||||
return new Response(notFound.body, { ...notFound, status: 404 });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return new Response('Internal Server Error', {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/plain',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} satisfies ExportedHandler<Env>;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2021",
|
|
||||||
"lib": ["es2021"],
|
|
||||||
/* Specify what JSX code is generated. */
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Specify what module code is generated. */
|
|
||||||
"module": "es2022",
|
|
||||||
/* Specify how TypeScript looks up a file from a given module specifier. */
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
/* Specify type package names to be included without being referenced in a source file. */
|
|
||||||
"types": ["@cloudflare/workers-types"],
|
|
||||||
/* Enable importing .json files */
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
|
|
||||||
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
|
||||||
"allowJs": true,
|
|
||||||
/* Enable error reporting in type-checked JavaScript files. */
|
|
||||||
"checkJs": false,
|
|
||||||
|
|
||||||
/* Disable emitting files from a compilation. */
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Ensure that each file can be safely transpiled without relying on other imports. */
|
|
||||||
"isolatedModules": true,
|
|
||||||
/* Allow 'import x from y' when a module doesn't have a default export. */
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
/* Ensure that casing is correct in imports. */
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
|
|
||||||
/* Enable all strict type-checking options. */
|
|
||||||
"strict": true,
|
|
||||||
|
|
||||||
/* Skip type checking all .d.ts files. */
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "../addon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../types"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"exclude": ["test"],
|
|
||||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Generated by Wrangler by running `wrangler types`
|
|
||||||
|
|
||||||
interface Env {
|
|
||||||
ASSETS: Fetcher;
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
#:schema node_modules/wrangler/config-schema.json
|
|
||||||
name = "aiostreams"
|
|
||||||
main = "src/index.ts"
|
|
||||||
compatibility_date = "2024-12-24"
|
|
||||||
compatibility_flags = ["nodejs_compat"]
|
|
||||||
assets = { directory = "../frontend/out", binding = "ASSETS", experimental_serve_directly = false}
|
|
||||||
|
|
||||||
# Workers Logs
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/
|
|
||||||
# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs
|
|
||||||
[observability]
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
# Automatically place your workloads in an optimal location to minimize latency.
|
|
||||||
# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure
|
|
||||||
# rather than the end user may result in better performance.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
|
||||||
# [placement]
|
|
||||||
# mode = "smart"
|
|
||||||
|
|
||||||
# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
|
|
||||||
# Docs:
|
|
||||||
# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
|
|
||||||
# Note: Use secrets to store sensitive data.
|
|
||||||
# - https://developers.cloudflare.com/workers/configuration/secrets/
|
|
||||||
# [vars]
|
|
||||||
# MY_VARIABLE = "production_value"
|
|
||||||
|
|
||||||
# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai
|
|
||||||
# [ai]
|
|
||||||
# binding = "AI"
|
|
||||||
|
|
||||||
# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets
|
|
||||||
# [[analytics_engine_datasets]]
|
|
||||||
# binding = "MY_DATASET"
|
|
||||||
|
|
||||||
# Bind a headless browser instance running on Cloudflare's global network.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering
|
|
||||||
# [browser]
|
|
||||||
# binding = "MY_BROWSER"
|
|
||||||
|
|
||||||
# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases
|
|
||||||
# [[d1_databases]]
|
|
||||||
# binding = "MY_DB"
|
|
||||||
# database_name = "my-database"
|
|
||||||
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
||||||
|
|
||||||
# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms
|
|
||||||
# [[dispatch_namespaces]]
|
|
||||||
# binding = "MY_DISPATCHER"
|
|
||||||
# namespace = "my-namespace"
|
|
||||||
|
|
||||||
# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
|
|
||||||
# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects
|
|
||||||
# [[durable_objects.bindings]]
|
|
||||||
# name = "MY_DURABLE_OBJECT"
|
|
||||||
# class_name = "MyDurableObject"
|
|
||||||
|
|
||||||
# Durable Object migrations.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations
|
|
||||||
# [[migrations]]
|
|
||||||
# tag = "v1"
|
|
||||||
# new_classes = ["MyDurableObject"]
|
|
||||||
|
|
||||||
# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive
|
|
||||||
# [[hyperdrive]]
|
|
||||||
# binding = "MY_HYPERDRIVE"
|
|
||||||
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
||||||
|
|
||||||
# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces
|
|
||||||
# [[kv_namespaces]]
|
|
||||||
# binding = "MY_KV_NAMESPACE"
|
|
||||||
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
||||||
|
|
||||||
# Bind an mTLS certificate. Use to present a client certificate when communicating with another service.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates
|
|
||||||
# [[mtls_certificates]]
|
|
||||||
# binding = "MY_CERTIFICATE"
|
|
||||||
# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
||||||
|
|
||||||
# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues
|
|
||||||
# [[queues.producers]]
|
|
||||||
# binding = "MY_QUEUE"
|
|
||||||
# queue = "my-queue"
|
|
||||||
|
|
||||||
# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues
|
|
||||||
# [[queues.consumers]]
|
|
||||||
# queue = "my-queue"
|
|
||||||
|
|
||||||
# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets
|
|
||||||
# [[r2_buckets]]
|
|
||||||
# binding = "MY_BUCKET"
|
|
||||||
# bucket_name = "my-bucket"
|
|
||||||
|
|
||||||
# Bind another Worker service. Use this binding to call another Worker without network overhead.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
|
|
||||||
# [[services]]
|
|
||||||
# binding = "MY_SERVICE"
|
|
||||||
# service = "my-service"
|
|
||||||
|
|
||||||
# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases.
|
|
||||||
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes
|
|
||||||
# [[vectorize]]
|
|
||||||
# binding = "MY_INDEX"
|
|
||||||
# index_name = "my-index"
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@aiostreams/core",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:watch": "vitest watch",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"description": "Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"envalid": "^8.0.0",
|
||||||
|
"moment-timezone": "^0.5.48",
|
||||||
|
"parse-torrent-title": "github:TheBeastLT/parse-torrent-title",
|
||||||
|
"pg": "^8.16.0",
|
||||||
|
"sqlite": "^5.1.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"super-regex": "^1.0.0",
|
||||||
|
"undici": "^7.2.3",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"zod": "^3.24.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/pg": "^8.15.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { TABLES } from './schemas';
|
||||||
|
import { createLogger } from '../utils';
|
||||||
|
import { parseConnectionURI, adaptQuery, ConnectionURI } from './utils';
|
||||||
|
|
||||||
|
const logger = createLogger('database');
|
||||||
|
|
||||||
|
import { Pool, Client, QueryResult } from 'pg';
|
||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { open, Database } from 'sqlite';
|
||||||
|
import { URL } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
type QueryResultRow = Record<string, any>;
|
||||||
|
|
||||||
|
interface UnifiedQueryResult<T = QueryResultRow> {
|
||||||
|
rows: T[];
|
||||||
|
rowCount: number;
|
||||||
|
command?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DBDialect = 'postgres' | 'sqlite';
|
||||||
|
|
||||||
|
type DSNModifier = (url: URL, query: URLSearchParams) => void;
|
||||||
|
|
||||||
|
type Transaction = {
|
||||||
|
commit: () => Promise<void>;
|
||||||
|
rollback: () => Promise<void>;
|
||||||
|
execute: (query: string, params?: any[]) => Promise<UnifiedQueryResult<any>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DB {
|
||||||
|
private static instance: DB;
|
||||||
|
private db!: Pool | Database<any>;
|
||||||
|
private static initialised: boolean = false;
|
||||||
|
private static dialect: DBDialect;
|
||||||
|
private uri!: ConnectionURI;
|
||||||
|
private dsnModifiers: DSNModifier[] = [];
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): DB {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new DB();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
isInitialised(): boolean {
|
||||||
|
return DB.initialised;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDialect(): DBDialect {
|
||||||
|
return DB.dialect;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialise(
|
||||||
|
uri: string,
|
||||||
|
dsnModifiers: DSNModifier[] = []
|
||||||
|
): Promise<void> {
|
||||||
|
if (DB.initialised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.uri = parseConnectionURI(uri);
|
||||||
|
this.dsnModifiers = dsnModifiers;
|
||||||
|
await this.open();
|
||||||
|
await this.ping();
|
||||||
|
|
||||||
|
// create tables
|
||||||
|
for (const [name, schema] of Object.entries(TABLES)) {
|
||||||
|
const createTableQuery = `CREATE TABLE IF NOT EXISTS ${name} (${schema})`;
|
||||||
|
await this.execute(createTableQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.uri.dialect === 'sqlite') {
|
||||||
|
await this.execute('PRAGMA busy_timeout = 5000');
|
||||||
|
await this.execute('PRAGMA foreign_keys = ON');
|
||||||
|
await this.execute('PRAGMA synchronous = OFF');
|
||||||
|
await this.execute('PRAGMA journal_mode = WAL');
|
||||||
|
await this.execute('PRAGMA locking_mode = IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB.initialised = true;
|
||||||
|
DB.dialect = this.uri.dialect;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize database:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(): Promise<void> {
|
||||||
|
if (this.uri.dialect === 'postgres') {
|
||||||
|
const pool = new Pool({ connectionString: this.uri.url.toString() });
|
||||||
|
this.db = pool;
|
||||||
|
this.uri.dialect = 'postgres';
|
||||||
|
} else if (this.uri.dialect === 'sqlite') {
|
||||||
|
// make parent directory if it does not exist
|
||||||
|
const parentDir = path.dirname(this.uri.filename);
|
||||||
|
if (!parentDir) {
|
||||||
|
throw new Error('Invalid SQLite path');
|
||||||
|
}
|
||||||
|
const fs = require('fs');
|
||||||
|
if (!fs.existsSync(parentDir)) {
|
||||||
|
fs.mkdirSync(parentDir, { recursive: true });
|
||||||
|
}
|
||||||
|
logger.debug(`Opening SQLite database: ${this.uri.filename}`);
|
||||||
|
|
||||||
|
this.db = await open({
|
||||||
|
filename: this.uri.filename,
|
||||||
|
driver: sqlite3.Database,
|
||||||
|
});
|
||||||
|
this.uri.dialect = 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.uri.dialect === 'postgres') {
|
||||||
|
await (this.db as Pool).end();
|
||||||
|
} else if (this.uri.dialect === 'sqlite') {
|
||||||
|
await (this.db as Database<any>).close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<void> {
|
||||||
|
if (this.uri.dialect === 'postgres') {
|
||||||
|
await (this.db as Pool).query('SELECT 1');
|
||||||
|
} else if (this.uri.dialect === 'sqlite') {
|
||||||
|
await (this.db as Database<any>).get('SELECT 1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(query: string, params?: any[]): Promise<any> {
|
||||||
|
if (this.uri.dialect === 'postgres') {
|
||||||
|
return (this.db as Pool).query(
|
||||||
|
adaptQuery(query, this.uri.dialect),
|
||||||
|
params
|
||||||
|
);
|
||||||
|
} else if (this.uri.dialect === 'sqlite') {
|
||||||
|
return (this.db as Database<any>).run(query, params);
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported dialect');
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(query: string, params?: any[]): Promise<any[]> {
|
||||||
|
const adaptedQuery = adaptQuery(query, this.uri.dialect);
|
||||||
|
if (this.uri.dialect === 'postgres') {
|
||||||
|
const result = await (this.db as Pool).query(adaptedQuery, params);
|
||||||
|
return result.rows;
|
||||||
|
} else if (this.uri.dialect === 'sqlite') {
|
||||||
|
return (this.db as Database<any>).all(adaptedQuery, params);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async begin(): Promise<Transaction> {
|
||||||
|
if (this.uri.dialect === 'postgres') {
|
||||||
|
const client = await (this.db as Pool).connect();
|
||||||
|
await client.query('BEGIN');
|
||||||
|
return {
|
||||||
|
commit: async () => {
|
||||||
|
await client.query('COMMIT');
|
||||||
|
},
|
||||||
|
rollback: async () => {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
},
|
||||||
|
execute: async (
|
||||||
|
query: string,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<UnifiedQueryResult> => {
|
||||||
|
const result = await client.query(query, params);
|
||||||
|
return {
|
||||||
|
rows: result.rows,
|
||||||
|
rowCount: result.rowCount || 0,
|
||||||
|
command: result.command,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (this.uri.dialect === 'sqlite') {
|
||||||
|
const db = this.db as Database<any>;
|
||||||
|
await db.run('BEGIN');
|
||||||
|
return {
|
||||||
|
commit: async () => {
|
||||||
|
await db.run('COMMIT');
|
||||||
|
},
|
||||||
|
rollback: async () => {
|
||||||
|
await db.run('ROLLBACK');
|
||||||
|
},
|
||||||
|
execute: async (
|
||||||
|
query: string,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<UnifiedQueryResult> => {
|
||||||
|
const result = await db.all(query, params);
|
||||||
|
return {
|
||||||
|
rows: result,
|
||||||
|
rowCount: result.length || 0,
|
||||||
|
command: 'SELECT',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported transaction dialect');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './db';
|
||||||
|
export * from './users';
|
||||||
|
export * from './schemas';
|
||||||
|
export * from './queue';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createLogger } from '../utils';
|
||||||
|
import { DB } from './db';
|
||||||
|
const logger = createLogger('db');
|
||||||
|
const db = DB.getInstance();
|
||||||
|
|
||||||
|
// Queue for SQLite transactions
|
||||||
|
|
||||||
|
export class TransactionQueue {
|
||||||
|
private queue: Array<() => Promise<any>> = [];
|
||||||
|
private processing = false;
|
||||||
|
private static instance: TransactionQueue;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): TransactionQueue {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new TransactionQueue();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
// If using PostgreSQL, execute directly without queuing
|
||||||
|
if (db['uri']?.dialect === 'postgres') {
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.queue.push(async () => {
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.processQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue() {
|
||||||
|
if (this.processing || this.queue.length === 0) return;
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const operation = this.queue.shift();
|
||||||
|
if (operation) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing queued operation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { constants } from '../utils';
|
||||||
|
|
||||||
|
const ServiceIds = z.enum(constants.SERVICES);
|
||||||
|
|
||||||
|
const Resolutions = z.enum(constants.RESOLUTIONS);
|
||||||
|
|
||||||
|
const Qualities = z.enum(constants.QUALITIES);
|
||||||
|
|
||||||
|
const VisualTags = z.enum(constants.VISUAL_TAGS);
|
||||||
|
|
||||||
|
const AudioTags = z.enum(constants.AUDIO_TAGS);
|
||||||
|
|
||||||
|
const Encodes = z.enum(constants.ENCODES);
|
||||||
|
|
||||||
|
const SortCriteria = z.enum(constants.SORT_CRITERIA);
|
||||||
|
|
||||||
|
const SortDirections = z.enum(constants.SORT_DIRECTIONS);
|
||||||
|
const StreamTypes = z.enum(constants.STREAM_TYPES);
|
||||||
|
const Languages = z.enum(constants.LANGUAGES);
|
||||||
|
|
||||||
|
const Formatter = z.object({
|
||||||
|
id: z.enum(constants.FORMATTERS),
|
||||||
|
definition: z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const StreamProxyConfig = z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
id: z.enum(constants.PROXY_SERVICES),
|
||||||
|
url: z.string().url(),
|
||||||
|
credentials: z.string().min(1),
|
||||||
|
publicIp: z.string().min(1).optional(),
|
||||||
|
proxiedAddons: z.array(z.string().min(1)),
|
||||||
|
proxiedServices: z.array(z.string().min(1)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StreamProxyConfig = z.infer<typeof StreamProxyConfig>;
|
||||||
|
|
||||||
|
const ResultLimitOptions = z.object({
|
||||||
|
global: z.number().min(1).optional(),
|
||||||
|
service: z.number().min(1).optional(),
|
||||||
|
addon: z.number().min(1).optional(),
|
||||||
|
resolution: z.number().min(1).optional(),
|
||||||
|
quality: z.number().min(1).optional(),
|
||||||
|
language: z.number().min(1).optional(),
|
||||||
|
visualTag: z.number().min(1).optional(),
|
||||||
|
audioTag: z.number().min(1).optional(),
|
||||||
|
streamType: z.number().min(1).optional(),
|
||||||
|
encode: z.number().min(1).optional(),
|
||||||
|
regexPattern: z.number().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SizeFilter = z.object({
|
||||||
|
movies: z
|
||||||
|
.object({
|
||||||
|
min: z.number().min(1).optional(),
|
||||||
|
max: z.number().min(1).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
series: z
|
||||||
|
.object({
|
||||||
|
min: z.number().min(1).optional(),
|
||||||
|
max: z.number().min(1).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SizeFilterOptions = z.object({
|
||||||
|
global: SizeFilter.optional(),
|
||||||
|
service: SizeFilter.optional(),
|
||||||
|
addon: SizeFilter.optional(),
|
||||||
|
resolution: SizeFilter.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const Service = z.object({
|
||||||
|
id: ServiceIds,
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
credentials: z.object({
|
||||||
|
username: z.string().min(1).optional(),
|
||||||
|
password: z.string().min(1).optional(),
|
||||||
|
email: z.string().min(1).optional(),
|
||||||
|
token: z.string().min(1).optional(),
|
||||||
|
clientId: z.string().min(1).optional(),
|
||||||
|
apiKey: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ServiceList = z.array(Service);
|
||||||
|
|
||||||
|
const ResourceSchema = z.enum(constants.RESOURCES);
|
||||||
|
|
||||||
|
export type Resource = z.infer<typeof ResourceSchema>;
|
||||||
|
|
||||||
|
const ResourceList = z.array(ResourceSchema);
|
||||||
|
|
||||||
|
const AddonSchema = z.object({
|
||||||
|
manifestUrl: z.string().url(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
resources: ResourceList.optional(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
timeout: z.number().min(1),
|
||||||
|
library: z.boolean().optional(),
|
||||||
|
fromPresetId: z.string().min(1).optional(),
|
||||||
|
headers: z.record(z.string().min(1), z.string().min(1)).optional(),
|
||||||
|
ip: z.string().ip().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// preset objects are transformed into addons by a preset transformer.
|
||||||
|
const PresetSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
baseUrl: z.string().url().optional(),
|
||||||
|
timeout: z.number().min(1).optional(),
|
||||||
|
resources: ResourceList.optional(),
|
||||||
|
options: z.record(z.string().min(1), z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AddonList = z.array(AddonSchema);
|
||||||
|
const PresetList = z.array(PresetSchema);
|
||||||
|
|
||||||
|
export type Addon = z.infer<typeof AddonSchema>;
|
||||||
|
export type Preset = z.infer<typeof PresetSchema>;
|
||||||
|
|
||||||
|
const DeduplicatorKey = z.enum(constants.DEDUPLICATOR_KEYS);
|
||||||
|
|
||||||
|
// deduplicator options.
|
||||||
|
// can choose what keys to use for identifying duplicates.
|
||||||
|
// can choose how duplicates are removed specifically.
|
||||||
|
// we can either
|
||||||
|
// - keep only 1 result from the highest priority service from the highest priority addon (single_result)
|
||||||
|
// - keep 1 result for each enabled service from the higest priority addon (per_service)
|
||||||
|
// - keep 1 result from the highest priority service from each enabled addon (per_addon)
|
||||||
|
const DeduplicatorMode = z.enum(['single_result', 'per_service', 'per_addon']);
|
||||||
|
|
||||||
|
const DeduplicatorTypeOptions = z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
mode: DeduplicatorMode.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const DeduplicatorOptions = z.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
keys: z.array(DeduplicatorKey).optional(),
|
||||||
|
cached: DeduplicatorTypeOptions.optional(),
|
||||||
|
uncached: DeduplicatorTypeOptions.optional(),
|
||||||
|
p2p: DeduplicatorTypeOptions.optional(),
|
||||||
|
http: DeduplicatorTypeOptions.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const OptionDefinition = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
type: z.enum(['string', 'number', 'boolean', 'select', 'multi-select']),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
default: z.any().optional(),
|
||||||
|
sensitive: z.boolean().optional(),
|
||||||
|
options: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
value: z.any(),
|
||||||
|
label: z.string().min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
constraints: z
|
||||||
|
.object({
|
||||||
|
min: z.number().min(1).optional(),
|
||||||
|
max: z.number().min(1).optional(),
|
||||||
|
enum: z.array(z.any()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Option = z.infer<typeof OptionDefinition>;
|
||||||
|
|
||||||
|
const NameableRegex = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
pattern: z.string().regex(/^.*$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UserDataSchema = z.object({
|
||||||
|
uuid: z.string().uuid().optional(),
|
||||||
|
admin: z.boolean().optional(),
|
||||||
|
apiKey: z.string().min(1).optional(),
|
||||||
|
ip: z.string().ip().optional(),
|
||||||
|
addonName: z.string().min(1).optional(),
|
||||||
|
addonLogo: z.string().url().optional(),
|
||||||
|
addonBackground: z.string().url().optional(),
|
||||||
|
addonDescription: z.string().min(1).optional(),
|
||||||
|
excludedResolutions: z.array(Resolutions).optional(),
|
||||||
|
requiredResolutions: z.array(Resolutions).optional(),
|
||||||
|
preferredResolutions: z.array(Resolutions).optional(),
|
||||||
|
excludedQualities: z.array(Qualities).optional(),
|
||||||
|
requiredQualities: z.array(Qualities).optional(),
|
||||||
|
preferredQualities: z.array(Qualities).optional(),
|
||||||
|
excludedLanguages: z.array(Languages).optional(),
|
||||||
|
requiredLanguages: z.array(Languages).optional(),
|
||||||
|
preferredLanguages: z.array(Languages).optional(),
|
||||||
|
excludedVisualTags: z.array(VisualTags).optional(),
|
||||||
|
requiredVisualTags: z.array(VisualTags).optional(),
|
||||||
|
preferredVisualTags: z.array(VisualTags).optional(),
|
||||||
|
excludedAudioTags: z.array(AudioTags).optional(),
|
||||||
|
requiredAudioTags: z.array(AudioTags).optional(),
|
||||||
|
preferredAudioTags: z.array(AudioTags).optional(),
|
||||||
|
excludedStreamTypes: z.array(StreamTypes).optional(),
|
||||||
|
requiredStreamTypes: z.array(StreamTypes).optional(),
|
||||||
|
preferredStreamTypes: z.array(StreamTypes).optional(),
|
||||||
|
excludedEncodes: z.array(Encodes).optional(),
|
||||||
|
requiredEncodes: z.array(Encodes).optional(),
|
||||||
|
preferredEncodes: z.array(Encodes).optional(),
|
||||||
|
requiredRegexPatterns: z.array(z.string().min(1)).optional(),
|
||||||
|
excludedRegexPatterns: z.array(z.string().min(1)).optional(),
|
||||||
|
preferredRegexPatterns: z.array(NameableRegex).optional(),
|
||||||
|
|
||||||
|
sortCriteria: z
|
||||||
|
.object({
|
||||||
|
// global must be defined.
|
||||||
|
global: z.array(z.tuple([SortCriteria, SortDirections.optional()])),
|
||||||
|
// results must be from either a movie or series search, so we can safely apply different sort criteria.
|
||||||
|
movies: z
|
||||||
|
.array(z.tuple([SortCriteria, SortDirections.optional()]))
|
||||||
|
.optional(),
|
||||||
|
series: z
|
||||||
|
.array(z.tuple([SortCriteria, SortDirections.optional()]))
|
||||||
|
.optional(),
|
||||||
|
// cached and uncached results are a sort criteria themselves, so this can only be applied when cache is high enough in the global
|
||||||
|
// sort criteria, and we would have to split the results into two (cached and uncached) lists, and then apply both sort criteria below
|
||||||
|
// and then merge the results.
|
||||||
|
cached: z
|
||||||
|
.array(z.tuple([SortCriteria, SortDirections.optional()]))
|
||||||
|
.optional(),
|
||||||
|
uncached: z
|
||||||
|
.array(z.tuple([SortCriteria, SortDirections.optional()]))
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
formatter: Formatter,
|
||||||
|
proxy: StreamProxyConfig.optional(),
|
||||||
|
resultLimiterOptions: ResultLimitOptions.optional(),
|
||||||
|
sizeFilters: SizeFilterOptions.optional(),
|
||||||
|
hideErrors: z.boolean().optional(),
|
||||||
|
strictTitleMatching: z.boolean().optional(),
|
||||||
|
deduplicator: DeduplicatorOptions.optional(),
|
||||||
|
precacheNextEpisode: z.boolean().optional(),
|
||||||
|
hideZeroSeederResults: z.boolean().optional(),
|
||||||
|
services: ServiceList.optional(),
|
||||||
|
addons: AddonList,
|
||||||
|
presets: PresetList.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserData = z.infer<typeof UserDataSchema>;
|
||||||
|
|
||||||
|
export const TABLES = {
|
||||||
|
USERS: `
|
||||||
|
uuid TEXT PRIMARY KEY,
|
||||||
|
config TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
password_salt TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
accessed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const strictManifestResourceSchema = z.object({
|
||||||
|
name: z.enum(constants.RESOURCES),
|
||||||
|
types: z.array(z.string()),
|
||||||
|
idPrefixes: z.array(z.string().min(1)).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StrictManifestResource = z.infer<
|
||||||
|
typeof strictManifestResourceSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
const ManifestResourceSchema = z.union([
|
||||||
|
z.string(),
|
||||||
|
strictManifestResourceSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ManifestExtraSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
isRequired: z.boolean().optional(),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
optionsLimit: z.number().min(1).optional(),
|
||||||
|
});
|
||||||
|
const ManifestCatalogSchema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
extra: z.array(ManifestExtraSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const AddonCatalogDefinitionSchema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ManifestSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
version: z.string().min(1),
|
||||||
|
types: z.array(z.string()),
|
||||||
|
idPrefixes: z.array(z.string().min(1)).optional(),
|
||||||
|
resources: z.array(ManifestResourceSchema),
|
||||||
|
catalogs: z.array(ManifestCatalogSchema),
|
||||||
|
addonCatalogs: z.array(AddonCatalogDefinitionSchema).optional(),
|
||||||
|
background: z.string().min(1).optional(),
|
||||||
|
logo: z.string().min(1).optional(),
|
||||||
|
contactEmail: z.string().min(1).optional(),
|
||||||
|
behaviorHints: z.object({
|
||||||
|
adult: z.boolean().optional(),
|
||||||
|
p2p: z.boolean().optional(),
|
||||||
|
configurable: z.boolean().optional(),
|
||||||
|
configurationRequired: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
// not part of the manifest scheme, but needed for stremio-addons.net
|
||||||
|
stremioAddonsConfig: z
|
||||||
|
.object({
|
||||||
|
issuer: z.string().min(1),
|
||||||
|
signature: z.string().min(1),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Manifest = z.infer<typeof ManifestSchema>;
|
||||||
|
|
||||||
|
const SubtitleSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
url: z.string().url(),
|
||||||
|
lang: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SubtitleResponseSchema = z.object({
|
||||||
|
subtitles: z.array(SubtitleSchema),
|
||||||
|
});
|
||||||
|
export type SubtitleResponse = z.infer<typeof SubtitleResponseSchema>;
|
||||||
|
export type Subtitle = z.infer<typeof SubtitleSchema>;
|
||||||
|
|
||||||
|
const StreamSchema = z.object({
|
||||||
|
url: z.string().url().optional(),
|
||||||
|
ytId: z.string().min(1).optional(),
|
||||||
|
infoHash: z.string().min(1).optional(),
|
||||||
|
fileIdx: z.number().optional(),
|
||||||
|
externalUrl: z.string().min(1).optional(),
|
||||||
|
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
subtitles: z.array(SubtitleSchema).optional(),
|
||||||
|
sources: z.array(z.string().min(1)).optional(),
|
||||||
|
behaviorHints: z
|
||||||
|
.object({
|
||||||
|
countryWhitelist: z.array(z.string().length(3)).optional(),
|
||||||
|
notWebReady: z.boolean().optional(),
|
||||||
|
bingeGroup: z.string().min(1).optional(),
|
||||||
|
proxyHeaders: z
|
||||||
|
.object({
|
||||||
|
request: z.record(z.string().min(1), z.string().min(1)).optional(),
|
||||||
|
response: z.record(z.string().min(1), z.string().min(1)).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
videoHash: z.string().min(1).optional(),
|
||||||
|
videoSize: z.number().optional(),
|
||||||
|
filename: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StreamResponseSchema = z.object({
|
||||||
|
streams: z.array(StreamSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StreamResponse = z.infer<typeof StreamResponseSchema>;
|
||||||
|
|
||||||
|
export type Stream = z.infer<typeof StreamSchema>;
|
||||||
|
|
||||||
|
const TrailerSchema = z.object({
|
||||||
|
source: z.string().min(1),
|
||||||
|
type: z.enum(['Trailer']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetaLinkSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
category: z.string().min(1),
|
||||||
|
url: z.string().url().or(z.string().startsWith('stremio:///')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetaVideoSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
released: z.string().datetime(),
|
||||||
|
thumbnail: z.string().url().optional(),
|
||||||
|
streams: z.array(StreamSchema).optional(),
|
||||||
|
available: z.boolean().optional(),
|
||||||
|
episode: z.number().optional(),
|
||||||
|
season: z.number().optional(),
|
||||||
|
trailers: z.array(TrailerSchema).optional(),
|
||||||
|
overview: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetaPreviewSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
poster: z.string(),
|
||||||
|
posterShape: z.enum(['square', 'poster', 'landscape']).optional(),
|
||||||
|
// discover sidebar
|
||||||
|
//@deprecated use links instead
|
||||||
|
genres: z.array(z.string()).optional(),
|
||||||
|
imdbRating: z.string().or(z.null()).optional(),
|
||||||
|
releaseInfo: z.string().or(z.null()).optional(),
|
||||||
|
//@deprecated
|
||||||
|
director: z.array(z.string()).or(z.null()).optional(),
|
||||||
|
//@deprecated
|
||||||
|
cast: z.array(z.string()).or(z.null()).optional(),
|
||||||
|
// background: z.string().min(1).optional(),
|
||||||
|
// logo: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
trailers: z.array(TrailerSchema).optional(),
|
||||||
|
links: z.array(MetaLinkSchema).optional(),
|
||||||
|
// released: z.string().datetime().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MetaSchema = MetaPreviewSchema.extend({
|
||||||
|
poster: z.string().min(1).optional(),
|
||||||
|
background: z.string().min(1).optional(),
|
||||||
|
logo: z.string().min(1).optional(),
|
||||||
|
videos: z.array(MetaVideoSchema).optional(),
|
||||||
|
runtime: z.string().min(1).optional(),
|
||||||
|
language: z.string().min(1).optional(),
|
||||||
|
country: z.string().length(3).optional(),
|
||||||
|
awards: z.string().min(1).optional(),
|
||||||
|
website: z.string().url().optional(),
|
||||||
|
behaviorHints: z
|
||||||
|
.object({
|
||||||
|
defaultVideoId: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MetaResponseSchema = z.object({
|
||||||
|
meta: MetaSchema,
|
||||||
|
});
|
||||||
|
export const CatalogResponseSchema = z.object({
|
||||||
|
metas: z.array(MetaPreviewSchema),
|
||||||
|
});
|
||||||
|
export type MetaResponse = z.infer<typeof MetaResponseSchema>;
|
||||||
|
export type CatalogResponse = z.infer<typeof CatalogResponseSchema>;
|
||||||
|
export type Meta = z.infer<typeof MetaSchema>;
|
||||||
|
export type MetaPreview = z.infer<typeof MetaPreviewSchema>;
|
||||||
|
|
||||||
|
const AddonCatalogSchema = z.object({
|
||||||
|
transportName: z.literal('http'),
|
||||||
|
transportUrl: z.string().url(),
|
||||||
|
manifest: ManifestSchema,
|
||||||
|
});
|
||||||
|
export const AddonCatalogResponseSchema = z.object({
|
||||||
|
addons: z.array(AddonCatalogSchema),
|
||||||
|
});
|
||||||
|
export type AddonCatalogResponse = z.infer<typeof AddonCatalogResponseSchema>;
|
||||||
|
export type AddonCatalog = z.infer<typeof AddonCatalogSchema>;
|
||||||
|
|
||||||
|
const ParsedFileSchema = z.object({
|
||||||
|
releaseGroup: z.string().min(1).optional(),
|
||||||
|
resolution: z.string().min(1).optional(),
|
||||||
|
quality: z.string().min(1).optional(),
|
||||||
|
encode: z.string().min(1).optional(),
|
||||||
|
visualTags: z.array(z.string()).optional(),
|
||||||
|
audioTags: z.array(z.string()).optional(),
|
||||||
|
languages: z.array(z.string()).optional(),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
year: z.string().min(1).optional(),
|
||||||
|
season: z.number().optional(),
|
||||||
|
seasons: z.array(z.number()).optional(),
|
||||||
|
episode: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ParsedFile = z.infer<typeof ParsedFileSchema>;
|
||||||
|
|
||||||
|
export const ParsedStreamSchema = z.object({
|
||||||
|
proxied: z.boolean().optional(),
|
||||||
|
addon: AddonSchema,
|
||||||
|
parsedFile: ParsedFileSchema,
|
||||||
|
message: z.string().min(1).max(1000).optional(),
|
||||||
|
regexMatched: z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
pattern: z.string().min(1).optional(),
|
||||||
|
index: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
size: z.number().optional(),
|
||||||
|
type: StreamTypes,
|
||||||
|
indexer: z.string().min(1).optional(),
|
||||||
|
age: z.string().optional(),
|
||||||
|
torrent: z
|
||||||
|
.object({
|
||||||
|
infoHash: z.string().min(1).optional(),
|
||||||
|
fileIdx: z.number().optional(),
|
||||||
|
seeders: z.number().optional(),
|
||||||
|
sources: z.array(z.string().min(1)).optional(), // array of tracker urls and DHT nodes
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
countryWhitelist: z.array(z.string().length(3)).optional(),
|
||||||
|
notWebReady: z.boolean().optional(),
|
||||||
|
bingeGroup: z.string().min(1).optional(),
|
||||||
|
requestHeaders: z.record(z.string().min(1), z.string().min(1)).optional(),
|
||||||
|
responseHeaders: z.record(z.string().min(1), z.string().min(1)).optional(),
|
||||||
|
videoHash: z.string().min(1).optional(),
|
||||||
|
subtitles: z.array(SubtitleSchema).optional(),
|
||||||
|
filename: z.string().min(1).optional(),
|
||||||
|
folderName: z.string().min(1).optional(),
|
||||||
|
service: z
|
||||||
|
.object({
|
||||||
|
id: z.enum(constants.SERVICES),
|
||||||
|
cached: z.boolean(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
inLibrary: z.boolean().optional(),
|
||||||
|
url: z.string().url().optional(),
|
||||||
|
ytId: z.string().min(1).optional(),
|
||||||
|
externalUrl: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ParsedStream = z.infer<typeof ParsedStreamSchema>;
|
||||||
|
|
||||||
|
const PresetMetadataSchema = z.object({
|
||||||
|
ID: z.string(),
|
||||||
|
NAME: z.string(),
|
||||||
|
LOGO: z.string(),
|
||||||
|
DESCRIPTION: z.string(),
|
||||||
|
URL: z.string(),
|
||||||
|
TIMEOUT: z.number(),
|
||||||
|
USER_AGENT: z.string(),
|
||||||
|
SUPPORTED_SERVICES: z.array(z.string()),
|
||||||
|
OPTIONS: z.array(OptionDefinition),
|
||||||
|
TAGS: z.array(z.string()),
|
||||||
|
RESOURCES: z.array(ResourceSchema),
|
||||||
|
REQUIRES_SERVICE: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const StatusResponseSchema = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
commit: z.string(),
|
||||||
|
buildTime: z.string(),
|
||||||
|
commitTime: z.string(),
|
||||||
|
users: z.number(),
|
||||||
|
settings: z.object({
|
||||||
|
disabledAddons: z.array(z.string()),
|
||||||
|
disabledServices: z.array(z.string()),
|
||||||
|
forced: z.object({
|
||||||
|
proxy: z.object({
|
||||||
|
enabled: z.boolean().or(z.null()),
|
||||||
|
id: z.string().or(z.null()),
|
||||||
|
url: z.string().or(z.null()),
|
||||||
|
publicIp: z.string().or(z.null()),
|
||||||
|
credentials: z.string().or(z.null()),
|
||||||
|
proxiedAddons: z.array(z.string()).or(z.null()),
|
||||||
|
proxiedServices: z.array(z.string()).or(z.null()),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
defaults: z.object({
|
||||||
|
proxy: z.object({
|
||||||
|
enabled: z.boolean().or(z.null()),
|
||||||
|
id: z.string().or(z.null()),
|
||||||
|
url: z.string().or(z.null()),
|
||||||
|
publicIp: z.string().or(z.null()),
|
||||||
|
credentials: z.string().or(z.null()),
|
||||||
|
proxiedAddons: z.array(z.string()).or(z.null()),
|
||||||
|
proxiedServices: z.array(z.string()).or(z.null()),
|
||||||
|
}),
|
||||||
|
preferredRegex: z.array(z.string()),
|
||||||
|
requiredRegex: z.array(z.string()),
|
||||||
|
excludedRegex: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
presets: z.array(PresetMetadataSchema),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StatusResponse = z.infer<typeof StatusResponseSchema>;
|
||||||
|
export type PresetMetadata = z.infer<typeof PresetMetadataSchema>;
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
// import { UserDataSchema, UserData, DB } from '../db';
|
||||||
|
import { UserDataSchema, UserData } from './schemas';
|
||||||
|
import { TransactionQueue } from './queue';
|
||||||
|
import { DB } from './db';
|
||||||
|
import {
|
||||||
|
decryptString,
|
||||||
|
deriveKey,
|
||||||
|
encryptString,
|
||||||
|
generateUUID,
|
||||||
|
getTextHash,
|
||||||
|
maskSensitiveInfo,
|
||||||
|
createLogger,
|
||||||
|
constants,
|
||||||
|
Env,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
|
const APIError = constants.APIError;
|
||||||
|
const logger = createLogger('users');
|
||||||
|
const db = DB.getInstance();
|
||||||
|
const txQueue = TransactionQueue.getInstance();
|
||||||
|
|
||||||
|
export class UserRepository {
|
||||||
|
static async createUser(
|
||||||
|
config: UserData,
|
||||||
|
password: string
|
||||||
|
): Promise<{ uuid: string; encryptedPassword: string }> {
|
||||||
|
return txQueue.enqueue(async () => {
|
||||||
|
if (password.length < 8) {
|
||||||
|
return Promise.reject(
|
||||||
|
new APIError(constants.ErrorCode.USER_NEW_PASSWORD_TOO_SHORT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// require at least one uppercase, one lowercase, one number, and one special character
|
||||||
|
// [@$!%*?&\-\._#~^()+=<>,;:'"`{}[\]|\\]
|
||||||
|
if (
|
||||||
|
!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&\-\._#~^()+=<>,;:'"`{}[\]|\\])[A-Za-z\d@$!%*?&\-\._#~^()+=<>,;:'"`{}[\]|\\]{8,}$/.test(
|
||||||
|
password
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Promise.reject(
|
||||||
|
new APIError(constants.ErrorCode.USER_NEW_PASSWORD_TOO_SIMPLE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = await this.generateUUID();
|
||||||
|
config.uuid = uuid;
|
||||||
|
|
||||||
|
const { encryptedConfig, salt } = await this.encryptConfig(
|
||||||
|
config,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
const { hash: hashedPassword } = getTextHash(password, salt);
|
||||||
|
|
||||||
|
const { success, data } = encryptString(password);
|
||||||
|
if (success === false) {
|
||||||
|
return Promise.reject(constants.ErrorCode.USER_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedPassword = data;
|
||||||
|
const tx = await db.begin();
|
||||||
|
try {
|
||||||
|
await tx.execute(
|
||||||
|
'INSERT INTO users (uuid, password_hash, password_salt, config) VALUES (?, ?, ?, ?)',
|
||||||
|
[uuid, hashedPassword, salt, encryptedConfig]
|
||||||
|
);
|
||||||
|
await tx.commit();
|
||||||
|
logger.info(`Created a new user with UUID: ${uuid}`);
|
||||||
|
return { uuid, encryptedPassword };
|
||||||
|
} catch (error) {
|
||||||
|
await tx.rollback();
|
||||||
|
logger.error(
|
||||||
|
`Failed to create user: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async checkUserExists(uuid: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT uuid FROM users WHERE uuid = ?', [
|
||||||
|
uuid,
|
||||||
|
]);
|
||||||
|
return result.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error checking user existence: ${error}`);
|
||||||
|
return Promise.reject(constants.ErrorCode.USER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUser(
|
||||||
|
uuid: string,
|
||||||
|
encryptedPassword?: string,
|
||||||
|
password?: string
|
||||||
|
): Promise<UserData | null> {
|
||||||
|
try {
|
||||||
|
if (!encryptedPassword && !password) {
|
||||||
|
return Promise.reject(
|
||||||
|
new APIError(constants.ErrorCode.MISSING_REQUIRED_FIELDS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT config, password_salt FROM users WHERE uuid = ?',
|
||||||
|
[uuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.length || !result[0].config) {
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'UPDATE users SET accessed_at = CURRENT_TIMESTAMP WHERE uuid = ?',
|
||||||
|
[uuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
const { success, data } = decryptString(encryptedPassword!);
|
||||||
|
if (!success) {
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
password = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await this.verifyUserPassword(uuid, password);
|
||||||
|
if (!isValid) {
|
||||||
|
return Promise.reject(
|
||||||
|
new APIError(constants.ErrorCode.USER_INVALID_PASSWORD)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedConfig = await this.decryptConfig(
|
||||||
|
result[0].config,
|
||||||
|
password,
|
||||||
|
result[0].password_salt
|
||||||
|
);
|
||||||
|
|
||||||
|
decryptedConfig.admin =
|
||||||
|
Env.ADMIN_UUIDS?.split(',').some((u) => new RegExp(u).test(uuid)) ??
|
||||||
|
false;
|
||||||
|
logger.info(`Retrieved configuration for user ${uuid}`);
|
||||||
|
return decryptedConfig;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error retrieving user ${uuid}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateUser(
|
||||||
|
uuid: string,
|
||||||
|
password: string,
|
||||||
|
config: UserData
|
||||||
|
): Promise<void> {
|
||||||
|
return txQueue.enqueue(async () => {
|
||||||
|
const tx = await db.begin();
|
||||||
|
try {
|
||||||
|
const currentUser = await tx.execute(
|
||||||
|
'SELECT password_salt FROM users WHERE uuid = ?',
|
||||||
|
[uuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentUser.rows.length) {
|
||||||
|
await tx.rollback();
|
||||||
|
return Promise.reject(
|
||||||
|
new APIError(constants.ErrorCode.USER_NOT_FOUND)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await this.verifyUserPassword(uuid, password);
|
||||||
|
if (!isValid) {
|
||||||
|
await tx.rollback();
|
||||||
|
return Promise.reject(
|
||||||
|
new APIError(constants.ErrorCode.USER_INVALID_PASSWORD)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { encryptedConfig } = await this.encryptConfig(
|
||||||
|
config,
|
||||||
|
password,
|
||||||
|
currentUser.rows[0].password_salt
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.execute(
|
||||||
|
'UPDATE users SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE uuid = ?',
|
||||||
|
[encryptedConfig, uuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.commit();
|
||||||
|
logger.info(`Updated user ${uuid} with an updated configuration`);
|
||||||
|
} catch (error) {
|
||||||
|
await tx.rollback();
|
||||||
|
logger.error(
|
||||||
|
`Failed to update user ${uuid}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT * FROM users');
|
||||||
|
return result.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error getting user count: ${error}`);
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteUser(uuid: string): Promise<void> {
|
||||||
|
return txQueue.enqueue(async () => {
|
||||||
|
const tx = await db.begin();
|
||||||
|
try {
|
||||||
|
const result = await tx.execute('DELETE FROM users WHERE uuid = ?', [
|
||||||
|
uuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
await tx.rollback();
|
||||||
|
throw new APIError(constants.ErrorCode.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.commit();
|
||||||
|
logger.info(`Deleted user ${uuid}`);
|
||||||
|
} catch (error) {
|
||||||
|
await tx.rollback();
|
||||||
|
logger.error(
|
||||||
|
`Failed to delete user ${uuid}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
throw new APIError(constants.ErrorCode.USER_ERROR);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async pruneUsers(maxDays: number = 30): Promise<void> {
|
||||||
|
try {
|
||||||
|
const query =
|
||||||
|
db.getDialect() === 'postgres'
|
||||||
|
? `DELETE FROM users WHERE accessed_at < NOW() - INTERVAL ${maxDays} DAY`
|
||||||
|
: `DELETE FROM users WHERE accessed_at < datetime('now', '-' || ${maxDays} || ' days')`;
|
||||||
|
await db.execute(query);
|
||||||
|
logger.info(`Pruned users older than ${maxDays} days`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to prune users:', error);
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async verifyUserPassword(
|
||||||
|
uuid: string,
|
||||||
|
password: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT password_hash, password_salt FROM users WHERE uuid = ?',
|
||||||
|
[uuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password_hash: hashedPassword, password_salt: salt } = result[0];
|
||||||
|
const { hash } = getTextHash(password, salt);
|
||||||
|
return hash === hashedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async encryptConfig(
|
||||||
|
config: UserData,
|
||||||
|
password: string,
|
||||||
|
salt?: string
|
||||||
|
): Promise<{
|
||||||
|
encryptedConfig: string;
|
||||||
|
salt: string;
|
||||||
|
}> {
|
||||||
|
const { key, salt: saltUsed } = deriveKey(password, salt);
|
||||||
|
const configString = JSON.stringify(config);
|
||||||
|
const { success, data, error } = encryptString(configString, key);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { encryptedConfig: data, salt: saltUsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async decryptConfig(
|
||||||
|
encryptedConfig: string,
|
||||||
|
password: string,
|
||||||
|
salt: string
|
||||||
|
): Promise<UserData> {
|
||||||
|
const { key } = deriveKey(password, salt);
|
||||||
|
const {
|
||||||
|
success,
|
||||||
|
data: decryptedString,
|
||||||
|
error,
|
||||||
|
} = decryptString(encryptedConfig, key);
|
||||||
|
|
||||||
|
if (!success || !decryptedString) {
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserDataSchema.parse(JSON.parse(decryptedString));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async generateUUID(count: number = 1): Promise<string> {
|
||||||
|
if (count > 10) {
|
||||||
|
return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = generateUUID();
|
||||||
|
const existingUser = await this.checkUserExists(uuid);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return this.generateUUID(count + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { URL } from 'url';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
type BaseConnectionURI = {
|
||||||
|
url: URL;
|
||||||
|
driverName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostgresConnectionURI = BaseConnectionURI & {
|
||||||
|
dialect: 'postgres';
|
||||||
|
};
|
||||||
|
|
||||||
|
type SQLiteConnectionURI = BaseConnectionURI & {
|
||||||
|
filename: string;
|
||||||
|
dialect: 'sqlite';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConnectionURI = PostgresConnectionURI | SQLiteConnectionURI;
|
||||||
|
|
||||||
|
type DBDialect = 'postgres' | 'sqlite';
|
||||||
|
|
||||||
|
type DSNModifier = (url: URL, query: URLSearchParams) => void;
|
||||||
|
const logger = createLogger('database');
|
||||||
|
|
||||||
|
function parseConnectionURI(uri: string): ConnectionURI {
|
||||||
|
const url = new URL(uri);
|
||||||
|
let driverName: string;
|
||||||
|
let dialect: DBDialect;
|
||||||
|
|
||||||
|
switch (url.protocol) {
|
||||||
|
case 'sqlite:':
|
||||||
|
driverName = 'sqlite3';
|
||||||
|
dialect = 'sqlite';
|
||||||
|
let filename = url.pathname;
|
||||||
|
if (url.hostname && url.hostname !== '.') {
|
||||||
|
throw new Error("Invalid path, must start with '/' or './'");
|
||||||
|
}
|
||||||
|
if (!url.pathname) {
|
||||||
|
throw new Error('Invalid path, must be absolute');
|
||||||
|
}
|
||||||
|
if (url.hostname === '.') {
|
||||||
|
// resolve relative path using process.cwd()
|
||||||
|
filename = path.join(process.cwd(), url.pathname.replace(/^\//, ''));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
driverName,
|
||||||
|
filename: filename,
|
||||||
|
dialect,
|
||||||
|
};
|
||||||
|
case 'postgres:':
|
||||||
|
driverName = 'pg';
|
||||||
|
dialect = 'postgres';
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
|
||||||
|
driverName,
|
||||||
|
dialect,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported scheme: ' + url.protocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adaptQuery(query: string, dialect: DBDialect): string {
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
let position = 1;
|
||||||
|
return query.replace(/\?/g, () => `$${position++}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseConnectionURI, adaptQuery };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// vs code...
|
||||||
|
declare namespace Intl {
|
||||||
|
type Key =
|
||||||
|
| 'calendar'
|
||||||
|
| 'collation'
|
||||||
|
| 'currency'
|
||||||
|
| 'numberingSystem'
|
||||||
|
| 'timeZone'
|
||||||
|
| 'unit';
|
||||||
|
|
||||||
|
function supportedValuesOf(input: Key): string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import { ParsedStream } from '../db';
|
||||||
|
// import { constants, Env, createLogger } from '../utils';
|
||||||
|
import * as constants from '../utils/constants';
|
||||||
|
import { createLogger } from '../utils/logger';
|
||||||
|
import { formatBytes, formatDuration, languageToEmoji } from './utils';
|
||||||
|
import { Env } from '../utils/env';
|
||||||
|
|
||||||
|
const logger = createLogger('formatter');
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* The custom formatter code in this file was adapted from https://github.com/diced/zipline/blob/trunk/src/lib/parser/index.ts
|
||||||
|
*
|
||||||
|
* The original code is licensed under the MIT License.
|
||||||
|
*
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Copyright (c) 2023 dicedtomato
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
* SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FormatterConfig {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseValue {
|
||||||
|
config?: {
|
||||||
|
addonName: string | null;
|
||||||
|
};
|
||||||
|
stream?: {
|
||||||
|
filename: string | null;
|
||||||
|
folderName: string | null;
|
||||||
|
size: number | null;
|
||||||
|
inLibrary: boolean | null;
|
||||||
|
quality: string | null;
|
||||||
|
resolution: string | null;
|
||||||
|
languages: string[] | null;
|
||||||
|
languageEmojis: string[] | null;
|
||||||
|
visualTags: string[] | null;
|
||||||
|
audioTags: string[] | null;
|
||||||
|
releaseGroup: string | null;
|
||||||
|
regexMatched: string | null;
|
||||||
|
encode: string | null;
|
||||||
|
indexer: string | null;
|
||||||
|
year: string | null;
|
||||||
|
title: string | null;
|
||||||
|
season: number | null;
|
||||||
|
seasons: number[] | null;
|
||||||
|
episode: number | null;
|
||||||
|
seeders: number | null;
|
||||||
|
age: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
infoHash: string | null;
|
||||||
|
type: string | null;
|
||||||
|
message: string | null;
|
||||||
|
proxied: boolean | null;
|
||||||
|
};
|
||||||
|
service?: {
|
||||||
|
id: string | null;
|
||||||
|
shortName: string | null;
|
||||||
|
name: string | null;
|
||||||
|
cached: boolean | null;
|
||||||
|
};
|
||||||
|
addon?: {
|
||||||
|
name: string;
|
||||||
|
manifestUrl: string;
|
||||||
|
};
|
||||||
|
debug?: {
|
||||||
|
json: string | null;
|
||||||
|
jsonf: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseFormatter {
|
||||||
|
protected config: FormatterConfig;
|
||||||
|
|
||||||
|
constructor(config: FormatterConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public format(stream: ParsedStream): { name: string; description: string } {
|
||||||
|
const parseValue = this.convertStreamToParseValue(stream);
|
||||||
|
return {
|
||||||
|
name: this.parseString(this.config.name, parseValue) || '',
|
||||||
|
description: this.parseString(this.config.description, parseValue) || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected convertStreamToParseValue(stream: ParsedStream): ParseValue {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
addonName: Env.ADDON_NAME,
|
||||||
|
},
|
||||||
|
stream: {
|
||||||
|
filename: stream.filename || null,
|
||||||
|
folderName: stream.folderName || null,
|
||||||
|
size: stream.size || null,
|
||||||
|
inLibrary: stream.inLibrary !== undefined ? stream.inLibrary : null,
|
||||||
|
quality: stream.parsedFile.quality || null,
|
||||||
|
resolution: stream.parsedFile.resolution || null,
|
||||||
|
languages: stream.parsedFile.languages || null,
|
||||||
|
languageEmojis: stream.parsedFile.languages
|
||||||
|
? stream.parsedFile.languages
|
||||||
|
.map((lang) => languageToEmoji(lang) || lang)
|
||||||
|
.filter((value, index, self) => self.indexOf(value) === index)
|
||||||
|
: null,
|
||||||
|
visualTags: stream.parsedFile.visualTags || null,
|
||||||
|
audioTags: stream.parsedFile.audioTags || null,
|
||||||
|
releaseGroup: stream.parsedFile.releaseGroup || null,
|
||||||
|
regexMatched: stream.regexMatched?.name || null,
|
||||||
|
encode: stream.parsedFile.encode || null,
|
||||||
|
indexer: stream.indexer || null,
|
||||||
|
seeders: stream.torrent?.seeders || null,
|
||||||
|
year: stream.parsedFile.year || null,
|
||||||
|
type: stream.type || null,
|
||||||
|
title: stream.parsedFile.title || null,
|
||||||
|
season: stream.parsedFile.season || null,
|
||||||
|
seasons: stream.parsedFile.seasons || null,
|
||||||
|
episode: stream.parsedFile.episode || null,
|
||||||
|
duration: stream.duration || null,
|
||||||
|
infoHash: stream.torrent?.infoHash || null,
|
||||||
|
age: stream.age || null,
|
||||||
|
message: stream.message || null,
|
||||||
|
proxied: stream.proxied !== undefined ? stream.proxied : null,
|
||||||
|
},
|
||||||
|
addon: {
|
||||||
|
name: stream.addon.name,
|
||||||
|
manifestUrl: stream.addon.manifestUrl,
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
id: stream.service?.id || null,
|
||||||
|
shortName: stream.service?.id
|
||||||
|
? Object.values(constants.SERVICE_DETAILS).find(
|
||||||
|
(service) => service.id === stream.service?.id
|
||||||
|
)?.shortName || null
|
||||||
|
: null,
|
||||||
|
name: stream.service?.id
|
||||||
|
? Object.values(constants.SERVICE_DETAILS).find(
|
||||||
|
(service) => service.id === stream.service?.id
|
||||||
|
)?.name || null
|
||||||
|
: null,
|
||||||
|
cached:
|
||||||
|
stream.service?.cached !== undefined ? stream.service?.cached : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseString(str: string, value: ParseValue): string | null {
|
||||||
|
if (!str) return null;
|
||||||
|
|
||||||
|
const replacer = (key: string, value: unknown) => {
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
stream: value.stream,
|
||||||
|
service: value.service,
|
||||||
|
addon: value.addon,
|
||||||
|
config: value.config,
|
||||||
|
};
|
||||||
|
|
||||||
|
value.debug = {
|
||||||
|
json: JSON.stringify(data, replacer),
|
||||||
|
jsonf: JSON.stringify(data, replacer, 2),
|
||||||
|
};
|
||||||
|
|
||||||
|
const re =
|
||||||
|
/\{(?<type>stream|service|addon|config|debug)\.(?<prop>\w+)(::(?<mod>(\w+(\([^)]*\))?|<|<=|=|>=|>|\^|\$|~|\/)+))?((::(?<mod_tzlocale>\S+?))|(?<mod_check>\[(?<mod_check_true>".*?")\|\|(?<mod_check_false>".*?")\]))?\}/gi;
|
||||||
|
let matches: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((matches = re.exec(str))) {
|
||||||
|
if (!matches.groups) continue;
|
||||||
|
|
||||||
|
const index = matches.index as number;
|
||||||
|
|
||||||
|
const getV = value[matches.groups.type as keyof ParseValue];
|
||||||
|
|
||||||
|
if (!getV) {
|
||||||
|
str = this.replaceCharsFromString(
|
||||||
|
str,
|
||||||
|
'{unknown_type}',
|
||||||
|
index,
|
||||||
|
re.lastIndex
|
||||||
|
);
|
||||||
|
re.lastIndex = index;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const v =
|
||||||
|
getV[
|
||||||
|
matches.groups.prop as
|
||||||
|
| keyof ParseValue['stream']
|
||||||
|
| keyof ParseValue['service']
|
||||||
|
| keyof ParseValue['addon']
|
||||||
|
];
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
str = this.replaceCharsFromString(
|
||||||
|
str,
|
||||||
|
'{unknown_value}',
|
||||||
|
index,
|
||||||
|
re.lastIndex
|
||||||
|
);
|
||||||
|
re.lastIndex = index;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.groups.mod) {
|
||||||
|
str = this.replaceCharsFromString(
|
||||||
|
str,
|
||||||
|
this.modifier(
|
||||||
|
matches.groups.mod,
|
||||||
|
v,
|
||||||
|
matches.groups.mod_tzlocale ?? undefined,
|
||||||
|
matches.groups.mod_check_true ?? undefined,
|
||||||
|
matches.groups.mod_check_false ?? undefined,
|
||||||
|
value
|
||||||
|
),
|
||||||
|
index,
|
||||||
|
re.lastIndex
|
||||||
|
);
|
||||||
|
re.lastIndex = index;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
str = this.replaceCharsFromString(str, v, index, re.lastIndex);
|
||||||
|
re.lastIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.split('\n')
|
||||||
|
.filter(
|
||||||
|
(line) => line.trim() !== '' && !line.includes('{tools.removeLine}')
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\{tools.newLine\}/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected modifier(
|
||||||
|
mod: string,
|
||||||
|
value: unknown,
|
||||||
|
tzlocale?: string,
|
||||||
|
check_true?: string,
|
||||||
|
check_false?: string,
|
||||||
|
_value?: ParseValue
|
||||||
|
): string {
|
||||||
|
mod = mod.toLowerCase();
|
||||||
|
check_true = check_true?.slice(1, -1);
|
||||||
|
check_false = check_false?.slice(1, -1);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
switch (true) {
|
||||||
|
case mod === 'join':
|
||||||
|
return value.join(', ');
|
||||||
|
case mod.startsWith('join(') && mod.endsWith(')'):
|
||||||
|
// Extract the separator from join(separator)
|
||||||
|
// e.g. join(' - ')
|
||||||
|
const separator = mod
|
||||||
|
.substring(5, mod.length - 1)
|
||||||
|
.replace(/^['"]|['"]$/g, '');
|
||||||
|
return value.join(separator);
|
||||||
|
case mod == 'length':
|
||||||
|
return value.length.toString();
|
||||||
|
case mod == 'first':
|
||||||
|
return value.length > 0 ? String(value[0]) : '';
|
||||||
|
case mod == 'last':
|
||||||
|
return value.length > 0 ? String(value[value.length - 1]) : '';
|
||||||
|
case mod == 'random':
|
||||||
|
return value.length > 0
|
||||||
|
? String(value[Math.floor(Math.random() * value.length)])
|
||||||
|
: '';
|
||||||
|
case mod == 'sort':
|
||||||
|
return [...value].sort().join(', ');
|
||||||
|
case mod == 'reverse':
|
||||||
|
return [...value].reverse().join(', ');
|
||||||
|
case mod == 'exists': {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_array_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value.length > 0
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.length > 0 ? check_true : check_false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `{unknown_array_modifier(${mod})}`;
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
switch (true) {
|
||||||
|
case mod == 'upper':
|
||||||
|
return value.toUpperCase();
|
||||||
|
case mod == 'lower':
|
||||||
|
return value.toLowerCase();
|
||||||
|
case mod == 'title':
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
case mod == 'length':
|
||||||
|
return value.length.toString();
|
||||||
|
case mod == 'reverse':
|
||||||
|
return value.split('').reverse().join('');
|
||||||
|
case mod == 'base64':
|
||||||
|
return btoa(value);
|
||||||
|
case mod == 'string':
|
||||||
|
return value;
|
||||||
|
case mod == 'exists': {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value != 'null' && value
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value != 'null' && value ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('='): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = mod.replace('=', '');
|
||||||
|
|
||||||
|
if (!check) return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value.toLowerCase() == check
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toLowerCase() == check ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('$'): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = mod.replace('$', '');
|
||||||
|
|
||||||
|
if (!check) return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value.toLowerCase().startsWith(check)
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toLowerCase().startsWith(check)
|
||||||
|
? check_true
|
||||||
|
: check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('^'): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = mod.replace('^', '');
|
||||||
|
|
||||||
|
if (!check) return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value.toLowerCase().endsWith(check)
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toLowerCase().endsWith(check) ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('~'): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = mod.replace('~', '');
|
||||||
|
|
||||||
|
if (!check) return `{unknown_str_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value.toLowerCase().includes(check)
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toLowerCase().includes(check) ? check_true : check_false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `{unknown_str_modifier(${mod})}`;
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
switch (true) {
|
||||||
|
case mod == 'comma':
|
||||||
|
return value.toLocaleString();
|
||||||
|
case mod == 'hex':
|
||||||
|
return value.toString(16);
|
||||||
|
case mod == 'octal':
|
||||||
|
return value.toString(8);
|
||||||
|
case mod == 'binary':
|
||||||
|
return value.toString(2);
|
||||||
|
case mod == 'bytes10' || mod == 'bytes':
|
||||||
|
return formatBytes(value, 1000);
|
||||||
|
case mod == 'bytes2':
|
||||||
|
return formatBytes(value, 1024);
|
||||||
|
case mod == 'string':
|
||||||
|
return value.toString();
|
||||||
|
case mod == 'time':
|
||||||
|
return formatDuration(value);
|
||||||
|
case mod.startsWith('>='): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = Number(mod.replace('>=', ''));
|
||||||
|
|
||||||
|
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value >= check
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value >= check ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('>'): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = Number(mod.replace('>', ''));
|
||||||
|
|
||||||
|
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value > check
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value > check ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('='): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = Number(mod.replace('=', ''));
|
||||||
|
|
||||||
|
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value == check
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value == check ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('<='): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = Number(mod.replace('<=', ''));
|
||||||
|
|
||||||
|
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value <= check
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value <= check ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod.startsWith('<'): {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
const check = Number(mod.replace('<', ''));
|
||||||
|
|
||||||
|
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value < check
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value < check ? check_true : check_false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `{unknown_int_modifier(${mod})}`;
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
switch (true) {
|
||||||
|
case mod == 'istrue': {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_bool_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return value
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ? check_true : check_false;
|
||||||
|
}
|
||||||
|
case mod == 'isfalse': {
|
||||||
|
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
||||||
|
return `{unknown_bool_modifier(${mod})}`;
|
||||||
|
|
||||||
|
if (_value) {
|
||||||
|
return !value
|
||||||
|
? this.parseString(check_true, _value) || check_true
|
||||||
|
: this.parseString(check_false, _value) || check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !value ? check_true : check_false;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `{unknown_bool_modifier(${mod})}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof check_false == 'string' &&
|
||||||
|
(['>', '>=', '=', '<=', '<', '~', '$', '^'].some((modif) =>
|
||||||
|
mod.startsWith(modif)
|
||||||
|
) ||
|
||||||
|
['istrue', 'exists', 'isfalse'].includes(mod))
|
||||||
|
) {
|
||||||
|
if (_value) return this.parseString(check_false, _value) || check_false;
|
||||||
|
return check_false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `{unknown_modifier(${mod})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleComparison(mod: string, value: unknown): boolean {
|
||||||
|
const operator = mod.match(/^[<>=]+/)?.[0];
|
||||||
|
const compareValue = mod.slice(operator?.length || 0);
|
||||||
|
|
||||||
|
if (!operator) return false;
|
||||||
|
|
||||||
|
const numValue = Number(value);
|
||||||
|
const numCompare = Number(compareValue);
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case '<':
|
||||||
|
return numValue < numCompare;
|
||||||
|
case '<=':
|
||||||
|
return numValue <= numCompare;
|
||||||
|
case '>':
|
||||||
|
return numValue > numCompare;
|
||||||
|
case '>=':
|
||||||
|
return numValue >= numCompare;
|
||||||
|
case '=':
|
||||||
|
return numValue === numCompare;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected replaceCharsFromString(
|
||||||
|
str: string,
|
||||||
|
replace: string,
|
||||||
|
start: number,
|
||||||
|
end: number
|
||||||
|
): string {
|
||||||
|
return str.slice(0, start) + replace + str.slice(end);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { BaseFormatter, FormatterConfig } from './base';
|
||||||
|
|
||||||
|
export class CustomFormatter extends BaseFormatter {
|
||||||
|
constructor(nameTemplate: string, descriptionTemplate: string) {
|
||||||
|
super({
|
||||||
|
name: nameTemplate,
|
||||||
|
description: descriptionTemplate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static fromConfig(config: FormatterConfig): CustomFormatter {
|
||||||
|
return new CustomFormatter(config.name, config.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateTemplate(
|
||||||
|
nameTemplate: string,
|
||||||
|
descriptionTemplate: string
|
||||||
|
): void {
|
||||||
|
this.config = {
|
||||||
|
name: nameTemplate,
|
||||||
|
description: descriptionTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTemplate(): FormatterConfig {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export * from './base';
|
||||||
|
export * from './predefined';
|
||||||
|
export * from './custom';
|
||||||
|
export * from './utils';
|
||||||
|
|
||||||
|
import { BaseFormatter, FormatterConfig } from './base';
|
||||||
|
import {
|
||||||
|
TorrentioFormatter,
|
||||||
|
TorboxFormatter,
|
||||||
|
GDriveFormatter,
|
||||||
|
LightGDriveFormatter,
|
||||||
|
MinimalisticGdriveFormatter,
|
||||||
|
} from './predefined';
|
||||||
|
import { CustomFormatter } from './custom';
|
||||||
|
import { FormatterType } from '../utils/constants';
|
||||||
|
|
||||||
|
export function createFormatter(
|
||||||
|
type: FormatterType,
|
||||||
|
config?: FormatterConfig
|
||||||
|
): BaseFormatter {
|
||||||
|
switch (type) {
|
||||||
|
case 'torrentio':
|
||||||
|
return new TorrentioFormatter();
|
||||||
|
case 'torbox':
|
||||||
|
return new TorboxFormatter();
|
||||||
|
case 'gdrive':
|
||||||
|
return new GDriveFormatter();
|
||||||
|
case 'lightgdrive':
|
||||||
|
return new LightGDriveFormatter();
|
||||||
|
case 'minimalisticgdrive':
|
||||||
|
return new MinimalisticGdriveFormatter();
|
||||||
|
case 'custom':
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Config is required for custom formatter');
|
||||||
|
}
|
||||||
|
return CustomFormatter.fromConfig(config);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown formatter type: ${type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BaseFormatter, FormatterConfig } from './base';
|
||||||
|
|
||||||
|
export class TorrentioFormatter extends BaseFormatter {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
name: '{stream.title} {stream.quality}',
|
||||||
|
description: '{stream.size::bytes} {stream.seeders} seeders',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TorboxFormatter extends BaseFormatter {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
name: '{stream.title} {stream.quality}',
|
||||||
|
description: '{stream.size::bytes} {stream.seeders} seeders',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GDriveFormatter extends BaseFormatter {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
name: '{stream.title} {stream.quality}',
|
||||||
|
description: '{stream.size::bytes} {stream.seeders} seeders',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LightGDriveFormatter extends BaseFormatter {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
name: '{stream.title} {stream.quality}',
|
||||||
|
description: '{stream.size::bytes} {stream.seeders} seeders',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MinimalisticGdriveFormatter extends BaseFormatter {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
name: '{stream.title} {stream.quality}',
|
||||||
|
description: '{stream.size::bytes} {stream.seeders} seeders',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
export function formatSize(bytes: number): string {
|
export function formatBytes(bytes: number, k: 1024 | 1000): string {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const sizes =
|
||||||
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
|
k === 1024
|
||||||
|
? ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||||
|
: ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './utils';
|
||||||
|
export * from './db';
|
||||||
|
export * from './main';
|
||||||
|
export * from './parser';
|
||||||
|
export * from './formatters';
|
||||||
|
export { PresetManager } from './presets';
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
import {
|
||||||
|
Addon,
|
||||||
|
Manifest,
|
||||||
|
Resource,
|
||||||
|
StrictManifestResource,
|
||||||
|
UserData,
|
||||||
|
} from './db';
|
||||||
|
import {
|
||||||
|
constants,
|
||||||
|
createErrorStream,
|
||||||
|
createLogger,
|
||||||
|
getTimeTakenSincePoint,
|
||||||
|
} from './utils';
|
||||||
|
import { Wrapper } from './wrapper';
|
||||||
|
import { PresetManager } from './presets';
|
||||||
|
import { AddonCatalog, ParsedStream, Stream, Subtitle } from './db/schemas';
|
||||||
|
import { createProxy } from './proxy';
|
||||||
|
import { createFormatter } from './formatters';
|
||||||
|
const logger = createLogger('core');
|
||||||
|
|
||||||
|
export class AIOStreams {
|
||||||
|
private readonly userData: UserData;
|
||||||
|
private manifests: Record<number, Manifest>;
|
||||||
|
private supportedResources: Record<number, StrictManifestResource[]>;
|
||||||
|
private finalResources: StrictManifestResource[] = [];
|
||||||
|
private finalCatalogs: Manifest['catalogs'] = [];
|
||||||
|
private finalAddonCatalogs: Manifest['addonCatalogs'] = [];
|
||||||
|
private isInitialised: boolean = false;
|
||||||
|
|
||||||
|
constructor(userData: UserData) {
|
||||||
|
this.userData = userData;
|
||||||
|
this.manifests = {};
|
||||||
|
this.supportedResources = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialise() {
|
||||||
|
if (this.isInitialised) return;
|
||||||
|
await this.applyPresets();
|
||||||
|
await this.fetchManifests();
|
||||||
|
await this.fetchResources();
|
||||||
|
this.isInitialised = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkInitialised() {
|
||||||
|
if (!this.isInitialised) {
|
||||||
|
throw new Error(
|
||||||
|
'AIOStreams is not initialised. Call initialise() first.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getStreams(
|
||||||
|
id: string,
|
||||||
|
type: string
|
||||||
|
): Promise<{
|
||||||
|
streams: ParsedStream[];
|
||||||
|
errors: { addon: Addon; error: string }[];
|
||||||
|
}> {
|
||||||
|
logger.info(`getStreams: ${id}`);
|
||||||
|
|
||||||
|
// step 1
|
||||||
|
// get the public IP of the requesting user, using the proxy server if configured
|
||||||
|
// if a proxy server is configured, and we fail to get the IP, return an error.
|
||||||
|
// however, note that some addons may not be configured to use a proxy,
|
||||||
|
// so we should attach an IP to each addon depending on if it would be using the proxy or not.
|
||||||
|
|
||||||
|
this.assignPublicIps();
|
||||||
|
|
||||||
|
// step 2
|
||||||
|
// get all parsed stream objects and errors from all addons that have the stream resource.
|
||||||
|
// and that support the type and match the id prefix
|
||||||
|
|
||||||
|
const { streams, errors } = await this.getStreamsFromAddons(type, id);
|
||||||
|
|
||||||
|
// step 3
|
||||||
|
// apply all filters to the streams.
|
||||||
|
|
||||||
|
const filteredStreams = this.applyFilters(streams);
|
||||||
|
|
||||||
|
// step 4
|
||||||
|
// deduplicate streams based on the depuplicatoroptions
|
||||||
|
|
||||||
|
const deduplicatedStreams = this.deduplicateStreams(filteredStreams);
|
||||||
|
|
||||||
|
// step 5
|
||||||
|
// sort the streams based on the sort criteria.
|
||||||
|
|
||||||
|
const sortedStreams = this.sortStreams(deduplicatedStreams);
|
||||||
|
|
||||||
|
// step 6
|
||||||
|
// limit the number of streams based on the limit criteria.
|
||||||
|
|
||||||
|
const limitedStreams = this.limitStreams(sortedStreams);
|
||||||
|
|
||||||
|
// step 7
|
||||||
|
// proxify streaming links if a proxy is provided
|
||||||
|
|
||||||
|
const proxifiedStreams = await this.proxifyStreams(limitedStreams);
|
||||||
|
|
||||||
|
// step 8
|
||||||
|
// if this.userData.precacheNextEpisode is true, start a new thread to request the next episode, check if
|
||||||
|
// all provider streams are uncached, and only if so, then send a request to the first uncached stream in the list.
|
||||||
|
|
||||||
|
// step 9
|
||||||
|
// return the final list of streams, followed by the error streams.
|
||||||
|
return {
|
||||||
|
streams: proxifiedStreams,
|
||||||
|
errors: errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async transformStreams({
|
||||||
|
streams,
|
||||||
|
errors,
|
||||||
|
}: {
|
||||||
|
streams: ParsedStream[];
|
||||||
|
errors: { addon: Addon; error: string }[];
|
||||||
|
}): Promise<Stream[]> {
|
||||||
|
const transformedStreams: Stream[] = [];
|
||||||
|
// need to generate a name, description, and other stremio-specific fields
|
||||||
|
// use the configured formatter to generate the name and description.
|
||||||
|
let formatter;
|
||||||
|
if (this.userData.formatter.id === constants.CUSTOM_FORMATTER) {
|
||||||
|
const template = this.userData.formatter.definition;
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('No template defined for custom formatter');
|
||||||
|
}
|
||||||
|
formatter = createFormatter(this.userData.formatter.id, template);
|
||||||
|
} else {
|
||||||
|
formatter = createFormatter(this.userData.formatter.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
streams.map(async (stream: ParsedStream): Promise<Stream> => {
|
||||||
|
const { name, description } = formatter.format(stream);
|
||||||
|
const bingeGroup = `${stream.proxied ? 'proxied.' : ''}${stream.parsedFile.resolution}|${stream.parsedFile.quality}|${stream.parsedFile.encode}`;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
url: ['http', 'usenet', 'debrid', 'live'].includes(stream.type)
|
||||||
|
? stream.url
|
||||||
|
: undefined,
|
||||||
|
infoHash:
|
||||||
|
stream.type === 'p2p' ? stream.torrent?.infoHash : undefined,
|
||||||
|
ytId: stream.type === 'youtube' ? stream.ytId : undefined,
|
||||||
|
externalUrl:
|
||||||
|
stream.type === 'external' ? stream.externalUrl : undefined,
|
||||||
|
sources: stream.type === 'p2p' ? stream.torrent?.sources : undefined,
|
||||||
|
subtitles: stream.subtitles,
|
||||||
|
behaviorHints: {
|
||||||
|
countryWhitelist: stream.countryWhitelist,
|
||||||
|
notWebReady: stream.notWebReady,
|
||||||
|
bingeGroup: bingeGroup,
|
||||||
|
proxyHeaders:
|
||||||
|
stream.requestHeaders || stream.responseHeaders
|
||||||
|
? {
|
||||||
|
request: stream.requestHeaders,
|
||||||
|
response: stream.responseHeaders,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
videoHash: stream.videoHash,
|
||||||
|
videoSize: stream.size,
|
||||||
|
filename: stream.filename,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// add errors to the end (if this.userData.hideErrors is false )
|
||||||
|
if (!this.userData.hideErrors) {
|
||||||
|
transformedStreams.push(
|
||||||
|
...errors.map((error) =>
|
||||||
|
createErrorStream({
|
||||||
|
description: error.error,
|
||||||
|
name: `[❌] ${error.addon.name}`,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformedStreams;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCatalog(type: string, id: string, extras?: string) {
|
||||||
|
// step 1
|
||||||
|
// get the addon index from the id
|
||||||
|
logger.info(`Handling catalog request`, { type, id, extras });
|
||||||
|
const start = Date.now();
|
||||||
|
const addonIndex = id.split('.', 2)[0];
|
||||||
|
const addon = this.getAddon(Number(addonIndex));
|
||||||
|
if (!addon) {
|
||||||
|
logger.error(`Addon ${addonIndex} not found`);
|
||||||
|
throw new Error(`Addon ${addonIndex} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 2
|
||||||
|
// get the actual catalog id from the id
|
||||||
|
const actualCatalogId = id.split('.', 2)[1];
|
||||||
|
// step 3
|
||||||
|
// get the catalog from the addon
|
||||||
|
const catalog = await new Wrapper(addon).getCatalog(
|
||||||
|
type,
|
||||||
|
actualCatalogId,
|
||||||
|
extras
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Received catalog ${actualCatalogId} of type ${type} from ${addon.name} in ${getTimeTakenSincePoint(start)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// step 4
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMeta(type: string, id: string) {
|
||||||
|
logger.info(`getMeta: ${id}`);
|
||||||
|
// step 1
|
||||||
|
// determine what addon has a meta resource with an id prefix that matches this id
|
||||||
|
|
||||||
|
for (const [index, resources] of Object.entries(this.supportedResources)) {
|
||||||
|
const resource = resources.find((r) =>
|
||||||
|
r.name === 'meta' && r.types.includes(type) && r.idPrefixes
|
||||||
|
? r.idPrefixes.some((prefix) => id.startsWith(prefix))
|
||||||
|
: true
|
||||||
|
);
|
||||||
|
if (resource) {
|
||||||
|
const addon = this.getAddon(Number(index));
|
||||||
|
logger.info(`Found addon that supports the requested meta resource`, {
|
||||||
|
addonName: addon.name,
|
||||||
|
addonIndex: index,
|
||||||
|
});
|
||||||
|
return new Wrapper(addon).getMeta(type, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subtitle resource
|
||||||
|
public async getSubtitles(type: string, id: string, extras?: string) {
|
||||||
|
logger.info(`getSubtitles: ${id}`);
|
||||||
|
|
||||||
|
// Find all addons that support subtitles for this type and id prefix
|
||||||
|
const supportedAddons = [];
|
||||||
|
for (const [addonIndex, addonResources] of Object.entries(
|
||||||
|
this.supportedResources
|
||||||
|
)) {
|
||||||
|
const resource = addonResources.find((r) =>
|
||||||
|
r.name === 'subtitles' && r.types.includes(type) && r.idPrefixes
|
||||||
|
? r.idPrefixes.some((prefix) => id.startsWith(prefix))
|
||||||
|
: true
|
||||||
|
);
|
||||||
|
if (resource) {
|
||||||
|
const addon = this.getAddon(Number(addonIndex));
|
||||||
|
if (addon) {
|
||||||
|
supportedAddons.push(addon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request subtitles from all supported addons in parallel
|
||||||
|
let errors: { addon: Addon; error: string }[] = [];
|
||||||
|
let allSubtitles: Subtitle[] = [];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
supportedAddons.map(async (addon) => {
|
||||||
|
try {
|
||||||
|
const subtitles = await new Wrapper(addon).getSubtitles(
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
extras
|
||||||
|
);
|
||||||
|
if (subtitles) {
|
||||||
|
allSubtitles.push(...subtitles);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
addon: addon,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtitles: allSubtitles,
|
||||||
|
errors: errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// addon_catalog resource
|
||||||
|
public async getAddonCatalog(type: string, id: string) {
|
||||||
|
logger.info(`getAddonCatalog: ${id}`);
|
||||||
|
// step 1
|
||||||
|
// get the addon index from the id
|
||||||
|
const addonIndex = id.split('.', 2)[0];
|
||||||
|
const addon = this.getAddon(Number(addonIndex));
|
||||||
|
if (!addon) {
|
||||||
|
throw new Error(`Addon ${addonIndex} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 2
|
||||||
|
// get the actual addon catalog id from the id
|
||||||
|
const actualAddonCatalogId = id.split('.', 2)[1];
|
||||||
|
|
||||||
|
// step 3
|
||||||
|
// get the addon catalog from the addon
|
||||||
|
const addonCatalogs: AddonCatalog[] = await new Wrapper(
|
||||||
|
addon
|
||||||
|
).getAddonCatalog(type, actualAddonCatalogId);
|
||||||
|
|
||||||
|
// step 4
|
||||||
|
return addonCatalogs;
|
||||||
|
}
|
||||||
|
// convers presets to addons
|
||||||
|
private async applyPresets() {
|
||||||
|
if (!this.userData.presets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preset of this.userData.presets) {
|
||||||
|
const addons = await PresetManager.fromId(preset.id).generateAddons(
|
||||||
|
this.userData,
|
||||||
|
preset.options,
|
||||||
|
preset.baseUrl,
|
||||||
|
preset.name,
|
||||||
|
preset.timeout,
|
||||||
|
preset.resources
|
||||||
|
);
|
||||||
|
this.userData.addons.push(...addons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchManifests() {
|
||||||
|
this.manifests = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
this.userData.addons.map(async (addon, index) => [
|
||||||
|
index,
|
||||||
|
await new Wrapper(addon).getManifest(),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchResources() {
|
||||||
|
for (const [index, manifest] of Object.entries(this.manifests)) {
|
||||||
|
if (!manifest) continue;
|
||||||
|
|
||||||
|
// Convert string resources to StrictManifestResource objects
|
||||||
|
const addonResources = manifest.resources.map((resource) => {
|
||||||
|
if (typeof resource === 'string') {
|
||||||
|
return {
|
||||||
|
name: resource as Resource,
|
||||||
|
types: manifest.types,
|
||||||
|
idPrefixes: manifest.idPrefixes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return resource;
|
||||||
|
});
|
||||||
|
|
||||||
|
const addon = this.userData.addons[Number(index)];
|
||||||
|
|
||||||
|
// Filter and merge resources
|
||||||
|
for (const resource of addonResources) {
|
||||||
|
if (addon.resources && !addon.resources.includes(resource.name))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const existing = this.finalResources.find(
|
||||||
|
(r) => r.name === resource.name
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.types = [...new Set([...existing.types, ...resource.types])];
|
||||||
|
if (resource.idPrefixes) {
|
||||||
|
existing.idPrefixes = existing.idPrefixes || [];
|
||||||
|
existing.idPrefixes = [
|
||||||
|
...new Set([...existing.idPrefixes, ...resource.idPrefixes]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.finalResources.push({
|
||||||
|
...resource,
|
||||||
|
idPrefixes: resource.idPrefixes
|
||||||
|
? [...resource.idPrefixes]
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add catalogs with prefixed IDs (ensure to check that if addon.resources is defined and does not have catalog
|
||||||
|
// then we do not add the catalogs)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!addon.resources ||
|
||||||
|
(addon.resources && addon.resources.includes('catalog'))
|
||||||
|
) {
|
||||||
|
this.finalCatalogs.push(
|
||||||
|
...manifest.catalogs.map((catalog) => ({
|
||||||
|
...catalog,
|
||||||
|
id: `${index}.${catalog.id}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add all addon catalogs, prefixing id with index
|
||||||
|
if (manifest.addonCatalogs) {
|
||||||
|
this.finalAddonCatalogs!.push(
|
||||||
|
...(manifest.addonCatalogs || []).map((catalog) => ({
|
||||||
|
...catalog,
|
||||||
|
id: `${index}.${catalog.id}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.supportedResources[Number(index)] = addonResources;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getResources(): StrictManifestResource[] {
|
||||||
|
this.checkInitialised();
|
||||||
|
return this.finalResources;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCatalogs(): Manifest['catalogs'] {
|
||||||
|
this.checkInitialised();
|
||||||
|
return this.finalCatalogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAddonCatalogs(): Manifest['addonCatalogs'] {
|
||||||
|
this.checkInitialised();
|
||||||
|
return this.finalAddonCatalogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAddon(index: number): Addon {
|
||||||
|
return this.userData.addons[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldProxyAddon(addon: Addon): boolean {
|
||||||
|
let proxyConfig = this.userData.proxy;
|
||||||
|
if (!proxyConfig) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proxyConfig.proxiedAddons) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyConfig.proxiedAddons.includes(addon.manifestUrl)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
addon.fromPresetId &&
|
||||||
|
proxyConfig.proxiedAddons.includes(addon.fromPresetId)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldProxyStream(stream: ParsedStream): boolean {
|
||||||
|
const streamService = stream.service ? stream.service.id : 'none';
|
||||||
|
const proxy = this.userData.proxy;
|
||||||
|
if (!stream.url || !proxy?.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyAddon =
|
||||||
|
!proxy.proxiedAddons?.length ||
|
||||||
|
proxy.proxiedAddons.includes(stream.addon.manifestUrl) ||
|
||||||
|
(stream.addon.fromPresetId &&
|
||||||
|
proxy.proxiedAddons.includes(stream.addon.fromPresetId));
|
||||||
|
const proxyService =
|
||||||
|
!proxy.proxiedServices?.length ||
|
||||||
|
proxy.proxiedServices.includes(streamService);
|
||||||
|
|
||||||
|
if (proxy.enabled && proxyAddon && proxyService) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProxyIp() {
|
||||||
|
let userIp = this.userData.ip;
|
||||||
|
const PRIVATE_IP_REGEX =
|
||||||
|
/^(::1|::ffff:(10|127|192|172)\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|10\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|192\.168\.(\d{1,3})\.(\d{1,3})|172\.(1[6-9]|2[0-9]|3[0-1])\.(\d{1,3})\.(\d{1,3}))$/;
|
||||||
|
|
||||||
|
if (userIp && PRIVATE_IP_REGEX.test(userIp)) {
|
||||||
|
userIp = undefined;
|
||||||
|
}
|
||||||
|
if (!this.userData.proxy) {
|
||||||
|
return userIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy = createProxy(this.userData.proxy);
|
||||||
|
if (proxy.getConfig().enabled) {
|
||||||
|
userIp = await this.retryGetIp(
|
||||||
|
() => proxy.getPublicIp(),
|
||||||
|
'Proxy public IP'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return userIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retryGetIp<T>(
|
||||||
|
getter: () => Promise<T | null>,
|
||||||
|
label: string,
|
||||||
|
maxRetries: number = 3
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
const result = await getter();
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
`Failed to get ${label}, retrying... (${attempt}/${maxRetries})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to get ${label} after ${maxRetries} attempts`);
|
||||||
|
}
|
||||||
|
// stream utility functions
|
||||||
|
private async assignPublicIps() {
|
||||||
|
let userIp = this.userData.ip;
|
||||||
|
let proxyIp = undefined;
|
||||||
|
if (this.userData.proxy) {
|
||||||
|
proxyIp = await this.getProxyIp();
|
||||||
|
}
|
||||||
|
for (const addon of this.userData.addons) {
|
||||||
|
const proxy = this.shouldProxyAddon(addon);
|
||||||
|
if (proxy) {
|
||||||
|
addon.ip = proxyIp;
|
||||||
|
} else {
|
||||||
|
addon.ip = userIp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStreamsFromAddons(type: string, id: string) {
|
||||||
|
// get a list of all addons that support the stream resource with the given type and id.
|
||||||
|
const supportedAddons = [];
|
||||||
|
for (const [index, addonResources] of Object.entries(
|
||||||
|
this.supportedResources
|
||||||
|
)) {
|
||||||
|
const resource = addonResources.find(
|
||||||
|
(r) =>
|
||||||
|
r.name === 'stream' && r.types.includes(type) && r.idPrefixes
|
||||||
|
? r.idPrefixes?.some((prefix) => id.startsWith(prefix))
|
||||||
|
: true // if no id prefixes are defined, assume it supports all IDs
|
||||||
|
);
|
||||||
|
if (resource) {
|
||||||
|
const addon = this.getAddon(Number(index));
|
||||||
|
if (addon) {
|
||||||
|
supportedAddons.push(addon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch all streams in parallel, maintaining a list of errors too,
|
||||||
|
let errors: { addon: Addon; error: string }[] = [];
|
||||||
|
let parsedStreams: ParsedStream[] = [];
|
||||||
|
await Promise.all(
|
||||||
|
supportedAddons.map(async (addon) => {
|
||||||
|
try {
|
||||||
|
const streams = await new Wrapper(addon).getStreams(type, id);
|
||||||
|
parsedStreams.push(...streams);
|
||||||
|
return streams;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
addon: addon,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { streams: parsedStreams, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFilters(streams: ParsedStream[]): ParsedStream[] {
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private deduplicateStreams(streams: ParsedStream[]): ParsedStream[] {
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortStreams(streams: ParsedStream[]): ParsedStream[] {
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private limitStreams(streams: ParsedStream[]): ParsedStream[] {
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async proxifyStreams(
|
||||||
|
streams: ParsedStream[]
|
||||||
|
): Promise<ParsedStream[]> {
|
||||||
|
if (!this.userData.proxy?.enabled) {
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamsToProxy = streams
|
||||||
|
.map((stream, index) => ({ stream, index }))
|
||||||
|
.filter(({ stream }) => stream.url && this.shouldProxyStream(stream));
|
||||||
|
|
||||||
|
const proxy = createProxy(this.userData.proxy);
|
||||||
|
|
||||||
|
const proxiedUrls = streamsToProxy.length
|
||||||
|
? await proxy.generateUrls(
|
||||||
|
streamsToProxy.map(({ stream }) => ({
|
||||||
|
url: stream.url!,
|
||||||
|
filename: stream.filename,
|
||||||
|
headers: {
|
||||||
|
request: stream.requestHeaders,
|
||||||
|
response: stream.responseHeaders,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const removeIndexes = new Set<number>();
|
||||||
|
|
||||||
|
streamsToProxy.forEach(({ stream, index }, i) => {
|
||||||
|
const proxiedUrl = proxiedUrls?.[i];
|
||||||
|
if (proxiedUrl) {
|
||||||
|
stream.url = proxiedUrl;
|
||||||
|
stream.proxied = true;
|
||||||
|
} else {
|
||||||
|
removeIndexes.add(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (removeIndexes.size > 0) {
|
||||||
|
streams = streams.filter((_, index) => !removeIndexes.has(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { PARSE_REGEX } from './regex';
|
||||||
|
import * as PTT from 'parse-torrent-title';
|
||||||
|
import { ParsedFile } from '../db';
|
||||||
|
|
||||||
|
function matchPattern(
|
||||||
|
filename: string,
|
||||||
|
patterns: Record<string, RegExp>
|
||||||
|
): string | undefined {
|
||||||
|
return Object.entries(patterns).find(([_, pattern]) =>
|
||||||
|
pattern.test(filename)
|
||||||
|
)?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchMultiplePatterns(
|
||||||
|
filename: string,
|
||||||
|
patterns: Record<string, RegExp>
|
||||||
|
): string[] {
|
||||||
|
return Object.entries(patterns)
|
||||||
|
.filter(([_, pattern]) => pattern.test(filename))
|
||||||
|
.map(([tag]) => tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileParser {
|
||||||
|
static parse(filename: string): ParsedFile {
|
||||||
|
const resolution = matchPattern(filename, PARSE_REGEX.resolutions);
|
||||||
|
const quality = matchPattern(filename, PARSE_REGEX.qualities);
|
||||||
|
const encode = matchPattern(filename, PARSE_REGEX.encodes);
|
||||||
|
const visualTags = matchMultiplePatterns(filename, PARSE_REGEX.visualTags);
|
||||||
|
const audioTags = matchMultiplePatterns(filename, PARSE_REGEX.audioTags);
|
||||||
|
const languages = matchMultiplePatterns(filename, PARSE_REGEX.languages);
|
||||||
|
|
||||||
|
const parsed = PTT.parse(filename);
|
||||||
|
const releaseGroup = parsed.group;
|
||||||
|
const title = parsed.title;
|
||||||
|
const year = parsed.year ? parsed.year.toString() : undefined;
|
||||||
|
const season = parsed.season;
|
||||||
|
const seasons = parsed.seasons;
|
||||||
|
const episode = parsed.episode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolution,
|
||||||
|
quality,
|
||||||
|
languages,
|
||||||
|
encode,
|
||||||
|
audioTags,
|
||||||
|
visualTags,
|
||||||
|
releaseGroup,
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
season,
|
||||||
|
seasons,
|
||||||
|
episode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileParser;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as FileParser } from './file';
|
||||||
|
export { default as StreamParser } from './streams';
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { Stream, ParsedStream, Addon } from '../db';
|
||||||
|
import { constants } from '../utils';
|
||||||
|
import FileParser from './file';
|
||||||
|
|
||||||
|
class StreamParser {
|
||||||
|
constructor(private readonly addon: Addon) {}
|
||||||
|
|
||||||
|
parse(stream: Stream): ParsedStream {
|
||||||
|
let parsedStream: ParsedStream = {
|
||||||
|
addon: this.addon,
|
||||||
|
type: this.getStreamType(stream, undefined),
|
||||||
|
parsedFile: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.raiseErrorIfNecessary(stream);
|
||||||
|
|
||||||
|
parsedStream.filename = this.getFilename(stream);
|
||||||
|
parsedStream.folderName = this.getFolder(stream);
|
||||||
|
parsedStream.size = this.getSize(stream);
|
||||||
|
parsedStream.indexer = this.getIndexer(stream);
|
||||||
|
parsedStream.service = this.getService(stream);
|
||||||
|
parsedStream.duration = this.getDuration(stream);
|
||||||
|
parsedStream.type = this.getStreamType(stream, parsedStream.service);
|
||||||
|
|
||||||
|
if (parsedStream.filename) {
|
||||||
|
parsedStream.parsedFile = FileParser.parse(parsedStream.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedStream.torrent = {
|
||||||
|
infoHash: stream.infoHash,
|
||||||
|
seeders: this.getSeeders(stream),
|
||||||
|
sources: stream.sources,
|
||||||
|
fileIdx: stream.fileIdx,
|
||||||
|
};
|
||||||
|
|
||||||
|
return parsedStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected raiseErrorIfNecessary(stream: Stream) {
|
||||||
|
const errorRegex = /invalid\s+\w+\s+(account|apikey|token)/i;
|
||||||
|
if (errorRegex.test(stream.description || stream.title || '')) {
|
||||||
|
throw new Error('Invalid account or apikey or token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getFilename(stream: Stream): string | undefined {
|
||||||
|
let filename = stream.behaviorHints?.filename;
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
return this.normaliseFilename(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = stream.description || stream.title;
|
||||||
|
if (!description) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to find a filename by finding the most suitable line that has more info
|
||||||
|
const potentialFilenames = description
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim() !== '')
|
||||||
|
.splice(0, 5);
|
||||||
|
|
||||||
|
for (const line of potentialFilenames) {
|
||||||
|
const parsed = FileParser.parse(line);
|
||||||
|
if (parsed.year || (parsed.season && parsed.episode) || parsed.episode) {
|
||||||
|
filename = line;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filename) {
|
||||||
|
filename = description.split('\n')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normaliseFilename(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getFolder(stream: Stream): string | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSize(stream: Stream): number | undefined {
|
||||||
|
const description = stream.description || stream.title;
|
||||||
|
let size =
|
||||||
|
stream.behaviorHints?.videoSize ||
|
||||||
|
(stream as any).size ||
|
||||||
|
(stream as any).sizeBytes ||
|
||||||
|
(stream as any).sizebytes ||
|
||||||
|
(description && this.calculateBytesFromSizeString(description)) ||
|
||||||
|
(stream.name && this.calculateBytesFromSizeString(stream.name));
|
||||||
|
|
||||||
|
if (typeof size === 'string') {
|
||||||
|
size = parseInt(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSeeders(stream: Stream): number | undefined {
|
||||||
|
const regex = /[👥👤]\s*(\d+)/u;
|
||||||
|
const match = stream.title?.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return parseInt(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getIndexer(stream: Stream): string | undefined {
|
||||||
|
const regex =
|
||||||
|
/(?:🌐|⚙️|🔗|🔎|☁️)\s?(.*?)(?=[\p{Emoji_Presentation}]|$|\n)/u;
|
||||||
|
const match = stream.title?.match(regex);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getService(stream: Stream): ParsedStream['service'] | undefined {
|
||||||
|
return this.parseServiceData(stream.name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getDuration(stream: Stream): number | undefined {
|
||||||
|
// Regular expression to match different formats of time durations
|
||||||
|
const regex =
|
||||||
|
/(?<![^\s\[(_\-,.])(?:(\d+)h[:\s]?(\d+)m[:\s]?(\d+)s|(\d+)h[:\s]?(\d+)m|(\d+)h|(\d+)m|(\d+)s)(?=[\s\)\]_.\-,]|$)/gi;
|
||||||
|
|
||||||
|
const match = regex.exec(stream.description || stream.title || '');
|
||||||
|
if (!match) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = parseInt(match[1] || match[4] || match[6] || '0', 10);
|
||||||
|
const minutes = parseInt(match[2] || match[5] || match[7] || '0', 10);
|
||||||
|
const seconds = parseInt(match[3] || match[8] || '0', 10);
|
||||||
|
|
||||||
|
// Convert to milliseconds
|
||||||
|
const totalMilliseconds = (hours * 3600 + minutes * 60 + seconds) * 1000;
|
||||||
|
|
||||||
|
return totalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getStreamType(
|
||||||
|
stream: Stream,
|
||||||
|
service: ParsedStream['service']
|
||||||
|
): ParsedStream['type'] {
|
||||||
|
if (stream.infoHash) {
|
||||||
|
return 'p2p';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.url?.endsWith('.m3u8')) {
|
||||||
|
return 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service?.id === constants.EASYNEWS_SERVICE) {
|
||||||
|
return 'usenet';
|
||||||
|
} else if (service) {
|
||||||
|
return 'debrid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// return 'http';
|
||||||
|
if (stream.url) {
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.externalUrl) {
|
||||||
|
return 'external';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.ytId) {
|
||||||
|
return 'youtube';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid stream, missing a required stream property');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normaliseFilename(filename: string): string {
|
||||||
|
// remove all non-alphanumeric characters, replace spaces with . and remove trailing and leading . or spaces
|
||||||
|
return filename
|
||||||
|
.replace(/[^a-zA-Z0-9]/g, '')
|
||||||
|
.replace(/\s+/g, '.')
|
||||||
|
.replace(/^\.+|\.+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateBytesFromSizeString(size: string, k: number = 1024): number {
|
||||||
|
const sizePattern = /(\d+(\.\d+)?)\s?(KB|MB|GB)/i;
|
||||||
|
const match = size.match(sizePattern);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[3];
|
||||||
|
|
||||||
|
switch (unit.toUpperCase()) {
|
||||||
|
case 'TB':
|
||||||
|
return value * k * k * k * k;
|
||||||
|
case 'GB':
|
||||||
|
return value * k * k * k;
|
||||||
|
case 'MB':
|
||||||
|
return value * k * k;
|
||||||
|
case 'KB':
|
||||||
|
return value * k;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected parseServiceData(
|
||||||
|
string: string
|
||||||
|
): ParsedStream['service'] | undefined {
|
||||||
|
const cleanString = string.replace(/web-?dl/i, '');
|
||||||
|
const services = constants.SERVICE_DETAILS;
|
||||||
|
const cachedSymbols = ['+', '⚡', '🚀', 'cached'];
|
||||||
|
const uncachedSymbols = ['⏳', 'download', 'UNCACHED'];
|
||||||
|
let streamService: ParsedStream['service'] | undefined;
|
||||||
|
Object.values(services).forEach((service) => {
|
||||||
|
// for each service, generate a regexp which creates a regex with all known names separated by |
|
||||||
|
const regex = new RegExp(
|
||||||
|
`(^|(?<![^ |[(_\\/\\-.]))(${service.knownNames.join('|')})(?=[ ⬇️⏳⚡+/|\\)\\]_.-]|$|\n)`,
|
||||||
|
'i'
|
||||||
|
);
|
||||||
|
// check if the string contains the regex
|
||||||
|
if (regex.test(cleanString)) {
|
||||||
|
let cached: boolean = false;
|
||||||
|
// check if any of the uncachedSymbols are in the string
|
||||||
|
if (uncachedSymbols.some((symbol) => string.includes(symbol))) {
|
||||||
|
cached = false;
|
||||||
|
}
|
||||||
|
// check if any of the cachedSymbols are in the string
|
||||||
|
else if (cachedSymbols.some((symbol) => string.includes(symbol))) {
|
||||||
|
cached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
streamService = {
|
||||||
|
id: service.id,
|
||||||
|
cached: cached,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return streamService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StreamParser;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './preset';
|
||||||
|
export * from './presetManager';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { constants, Env } from '../utils';
|
||||||
|
import { Preset } from './preset';
|
||||||
|
|
||||||
|
// export class OpenSubtitlesPreset extends Preset {
|
||||||
|
// static override get METADATA() {
|
||||||
|
// return {
|
||||||
|
// ID: 'opensubtitles',
|
||||||
|
// NAME: 'OpenSubtitles',
|
||||||
|
// LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png',
|
||||||
|
// DESCRIPTION: 'OpenSubtitles preset',
|
||||||
|
// OPTIONS: [],
|
||||||
|
// TAGS: [],
|
||||||
|
// RESOURCES: [constants.SUBTITLES_RESOURCE],
|
||||||
|
// URL: 'https://www.opensubtitles.org/',
|
||||||
|
// TIMEOUT: 10000,
|
||||||
|
// USER_AGENT: Env.DEFAULT_USER_AGENT,
|
||||||
|
// SUPPORTED_SERVICES: [],
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Option,
|
||||||
|
Resource,
|
||||||
|
Stream,
|
||||||
|
ParsedStream,
|
||||||
|
UserData,
|
||||||
|
PresetMetadata,
|
||||||
|
} from '../db';
|
||||||
|
import { StreamParser } from '../parser';
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* What modifications are needed for each preset:
|
||||||
|
*
|
||||||
|
* comet: apply FORCE_COMET_HOSTNAME, FORCE_COMET_PORT, FORCE_COMET_PROTOCOl to stream urls if they are defined
|
||||||
|
* dmm cast: need to split title by newline, replace trailing dashes, excluding lines with box emoji, and
|
||||||
|
* then joining the array back together.
|
||||||
|
* easynews,easynews+,easynews++: need to set type as usenet
|
||||||
|
* jackettio: apply FORCE_JACKETTIO_HOSTNAME, FORCE_JACKETTIO_PORT, FORCE_JACKETTIO_PROTOCOL to stream urls if they are defined
|
||||||
|
* mediafusion: need to add hint for folder name, 📁 emoji, and split on arrow, take last index.
|
||||||
|
* stremio-jacektt: need to inspect stream urls to extract service info.
|
||||||
|
* stremthruStore: need to mark each stream as 'inLibrary' and unset any parsed 'indexer'
|
||||||
|
* torbox: need to use different regex for probably everything.
|
||||||
|
* torrentio: extract folder name from first line
|
||||||
|
*/
|
||||||
|
|
||||||
|
export abstract class Preset {
|
||||||
|
static get METADATA(): PresetMetadata {
|
||||||
|
throw new Error('METADATA must be implemented by derived classes');
|
||||||
|
}
|
||||||
|
|
||||||
|
static getParser(): typeof StreamParser {
|
||||||
|
return StreamParser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a preset from a preset id.
|
||||||
|
* @param presetId - The id of the preset to create.
|
||||||
|
* @returns The preset.
|
||||||
|
*/
|
||||||
|
|
||||||
|
static generateAddons(
|
||||||
|
userData: UserData,
|
||||||
|
options?: Record<string, any>,
|
||||||
|
baseUrl?: string,
|
||||||
|
name?: string,
|
||||||
|
timeout?: number,
|
||||||
|
resources?: Resource[]
|
||||||
|
) {
|
||||||
|
throw new Error('generateAddons must be implemented by derived classes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions for generating config strings
|
||||||
|
/**
|
||||||
|
* Encodes a JSON object into a base64 encoded string.
|
||||||
|
* @param json - The JSON object to encode.
|
||||||
|
* @returns The base64 encoded string.
|
||||||
|
*/
|
||||||
|
protected static base64EncodeJSON(
|
||||||
|
json: any,
|
||||||
|
urlEncode: boolean = false, // url encode the string
|
||||||
|
makeUrlSafe: boolean = false // replace + with -, / with _ and = with nothing
|
||||||
|
) {
|
||||||
|
let encoded = Buffer.from(JSON.stringify(json)).toString('base64');
|
||||||
|
if (makeUrlSafe) {
|
||||||
|
encoded = encoded
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
} else if (urlEncode) {
|
||||||
|
encoded = encodeURIComponent(encoded);
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static urlEncodeJSON(json: any) {
|
||||||
|
return encodeURIComponent(JSON.stringify(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms key-value pairs into a url encoded string
|
||||||
|
* @param options - The key-value pair object to encode.
|
||||||
|
* @returns The encoded string.
|
||||||
|
*/
|
||||||
|
protected static urlEncodeKeyValuePairs(
|
||||||
|
options: Record<string, string> | string[][]
|
||||||
|
) {
|
||||||
|
return encodeURIComponent(
|
||||||
|
(Array.isArray(options) ? options : Object.entries(options))
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('|')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { PresetMetadata } from '../db';
|
||||||
|
import { Preset } from './preset';
|
||||||
|
import { StremthruStorePreset } from './stremthruStore';
|
||||||
|
import { TorrentioPreset } from './torrentio';
|
||||||
|
|
||||||
|
const PRESET_LIST: string[] = ['torrentio', 'stremthruStore'];
|
||||||
|
|
||||||
|
export class PresetManager {
|
||||||
|
static getPresetList(): PresetMetadata[] {
|
||||||
|
return PRESET_LIST.map((presetId) => this.fromId(presetId).METADATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromId(id: string) {
|
||||||
|
switch (id) {
|
||||||
|
case 'torrentio':
|
||||||
|
return TorrentioPreset;
|
||||||
|
case 'stremthruStore':
|
||||||
|
return StremthruStorePreset;
|
||||||
|
default:
|
||||||
|
throw new Error(`Preset ${id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { Addon, Option, UserData, Resource } from '../db';
|
||||||
|
import { Preset } from './preset';
|
||||||
|
import { Env } from '../utils';
|
||||||
|
import { constants, ServiceId } from '../utils';
|
||||||
|
|
||||||
|
export class StremthruStorePreset extends Preset {
|
||||||
|
static override get METADATA() {
|
||||||
|
const supportedServices = [
|
||||||
|
constants.REALDEBRID_SERVICE,
|
||||||
|
constants.PREMIUMIZE_SERVICE,
|
||||||
|
constants.ALLEDEBRID_SERVICE,
|
||||||
|
constants.TORBOX_SERVICE,
|
||||||
|
constants.EASYDEBRID_SERVICE,
|
||||||
|
constants.PUTIO_SERVICE,
|
||||||
|
constants.OFFCLOUD_SERVICE,
|
||||||
|
];
|
||||||
|
|
||||||
|
const options: Option[] = [
|
||||||
|
{
|
||||||
|
id: 'services',
|
||||||
|
name: 'Services',
|
||||||
|
description: 'The services to use',
|
||||||
|
type: 'multi-select',
|
||||||
|
required: true,
|
||||||
|
options: supportedServices.map((service) => ({
|
||||||
|
value: service,
|
||||||
|
label: service,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideCatalog',
|
||||||
|
name: 'Hide Catalog',
|
||||||
|
description: 'Hide the catalog',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hideStream',
|
||||||
|
name: 'Hide Stream',
|
||||||
|
description: 'Hide the stream',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'webDl',
|
||||||
|
name: 'Web DL',
|
||||||
|
description: 'Enable web DL',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ID: 'stremthruStore',
|
||||||
|
NAME: 'Stremthru Store',
|
||||||
|
LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png',
|
||||||
|
URL: Env.STREMTHRU_STORE_URL,
|
||||||
|
TIMEOUT: Env.DEFAULT_STREMTHRU_STORE_TIMEOUT || Env.DEFAULT_TIMEOUT,
|
||||||
|
USER_AGENT:
|
||||||
|
Env.DEFAULT_STREMTHRU_STORE_USER_AGENT || Env.DEFAULT_USER_AGENT,
|
||||||
|
SUPPORTED_SERVICES: supportedServices,
|
||||||
|
REQUIRES_SERVICE: true,
|
||||||
|
DESCRIPTION: 'Stremthru Store preset',
|
||||||
|
OPTIONS: options,
|
||||||
|
TAGS: [constants.P2P_TAG, constants.DEBRID_TAG],
|
||||||
|
RESOURCES: [
|
||||||
|
constants.STREAM_RESOURCE,
|
||||||
|
constants.CATALOG_RESOURCE,
|
||||||
|
constants.META_RESOURCE,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async generateAddons(
|
||||||
|
userData: UserData,
|
||||||
|
options?: Record<string, any>,
|
||||||
|
baseUrl?: string,
|
||||||
|
name?: string,
|
||||||
|
timeout?: number,
|
||||||
|
resources?: Resource[]
|
||||||
|
): Promise<Addon[]> {
|
||||||
|
// baseUrl can either be something like https://torrentio.com/ or it can be a custom manifest url.
|
||||||
|
// if it is a custom manifest url, return a single addon with the custom manifest url.
|
||||||
|
if (baseUrl?.endsWith('/manifest.json')) {
|
||||||
|
return [
|
||||||
|
this.generateAddon(
|
||||||
|
userData,
|
||||||
|
undefined,
|
||||||
|
baseUrl,
|
||||||
|
timeout,
|
||||||
|
name,
|
||||||
|
resources
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all services that are supported by the preset and enabled
|
||||||
|
let usableServices = userData.services?.filter(
|
||||||
|
(service) =>
|
||||||
|
this.METADATA.SUPPORTED_SERVICES.includes(service.id) && service.enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
// if user has specified services, filter the usable services to only include the specified services
|
||||||
|
if (options?.services) {
|
||||||
|
usableServices = usableServices?.filter((service) =>
|
||||||
|
options.services.includes(service.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no services are usable, throw an error
|
||||||
|
if (!usableServices || usableServices.length === 0) {
|
||||||
|
throw new Error('No services are usable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return usableServices.map((service) =>
|
||||||
|
this.generateAddon(
|
||||||
|
userData,
|
||||||
|
service.id,
|
||||||
|
baseUrl,
|
||||||
|
timeout,
|
||||||
|
name,
|
||||||
|
resources,
|
||||||
|
options?.hideCatalog,
|
||||||
|
options?.hideStream,
|
||||||
|
options?.webDl
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static generateAddon(
|
||||||
|
userData: UserData,
|
||||||
|
serviceId: ServiceId | undefined,
|
||||||
|
baseUrl?: string,
|
||||||
|
timeout?: number,
|
||||||
|
name?: string,
|
||||||
|
resources?: Resource[],
|
||||||
|
hideCatalog?: boolean,
|
||||||
|
hideStream?: boolean,
|
||||||
|
webDl?: boolean
|
||||||
|
): Addon {
|
||||||
|
return {
|
||||||
|
name: name || this.METADATA.NAME,
|
||||||
|
manifestUrl: this.generateManifestUrl(
|
||||||
|
userData,
|
||||||
|
serviceId,
|
||||||
|
baseUrl,
|
||||||
|
hideCatalog,
|
||||||
|
hideStream,
|
||||||
|
webDl
|
||||||
|
),
|
||||||
|
enabled: true,
|
||||||
|
resources: resources || this.METADATA.RESOURCES,
|
||||||
|
timeout: timeout || this.METADATA.TIMEOUT,
|
||||||
|
fromPresetId: this.METADATA.ID,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': this.METADATA.USER_AGENT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static generateManifestUrl(
|
||||||
|
userData: UserData,
|
||||||
|
serviceId: ServiceId | undefined,
|
||||||
|
baseUrl?: string,
|
||||||
|
hideCatalog: boolean = false,
|
||||||
|
hideStream: boolean = false,
|
||||||
|
webDl: boolean = false
|
||||||
|
) {
|
||||||
|
const url = baseUrl || this.METADATA.URL;
|
||||||
|
if (url.endsWith('/manifest.json')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
if (!serviceId) {
|
||||||
|
throw new Error('Service is required');
|
||||||
|
}
|
||||||
|
const configString = this.base64EncodeJSON({
|
||||||
|
store_name: serviceId,
|
||||||
|
store_token: this.getServiceCredential(serviceId, userData),
|
||||||
|
hide_catalog: hideCatalog,
|
||||||
|
hide_stream: hideStream,
|
||||||
|
web_dl: webDl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${url}${configString ? '/' + configString : ''}/manifest.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getServiceCredential(
|
||||||
|
serviceId: ServiceId,
|
||||||
|
userData: UserData
|
||||||
|
): string {
|
||||||
|
// Validate service exists
|
||||||
|
const service = constants.SERVICE_DETAILS[serviceId];
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service ${serviceId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get credentials for service
|
||||||
|
const serviceCredentials = userData.services?.find(
|
||||||
|
(service) => service.id === serviceId
|
||||||
|
)?.credentials;
|
||||||
|
|
||||||
|
if (!serviceCredentials) {
|
||||||
|
throw new Error(`No credentials found for service ${serviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle put.io special case which requires both clientId and token
|
||||||
|
if (
|
||||||
|
serviceId === constants.OFFCLOUD_SERVICE ||
|
||||||
|
serviceId === constants.PIKPAK_SERVICE
|
||||||
|
) {
|
||||||
|
const { email, password } = serviceCredentials;
|
||||||
|
if (!email || !password) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing credentials for ${serviceId}. Please add an email and password.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${email}:${password}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle default case which requires just an API key
|
||||||
|
const { apiKey } = serviceCredentials;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing credentials for ${serviceId}. Please add an API key.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { Addon, Option, UserData, Resource, Stream } from '../db';
|
||||||
|
import { Preset } from './preset';
|
||||||
|
import { Env } from '../utils';
|
||||||
|
import { constants, ServiceId } from '../utils';
|
||||||
|
import { StreamParser } from '../parser';
|
||||||
|
|
||||||
|
export class TorrentioParser extends StreamParser {
|
||||||
|
override getFolder(stream: Stream): string | undefined {
|
||||||
|
const description = stream.description || stream.title;
|
||||||
|
if (!description) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const folderName = description.split('\n')[0];
|
||||||
|
return folderName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TorrentioPreset extends Preset {
|
||||||
|
static override getParser(): typeof StreamParser {
|
||||||
|
return TorrentioParser;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override get METADATA() {
|
||||||
|
const supportedServices = [
|
||||||
|
constants.REALDEBRID_SERVICE,
|
||||||
|
constants.PREMIUMIZE_SERVICE,
|
||||||
|
constants.ALLEDEBRID_SERVICE,
|
||||||
|
constants.TORBOX_SERVICE,
|
||||||
|
constants.EASYDEBRID_SERVICE,
|
||||||
|
constants.PUTIO_SERVICE,
|
||||||
|
constants.OFFCLOUD_SERVICE,
|
||||||
|
];
|
||||||
|
|
||||||
|
const options: Option[] = [
|
||||||
|
{
|
||||||
|
id: 'services',
|
||||||
|
name: 'Services',
|
||||||
|
description: 'The services to use',
|
||||||
|
type: 'multi-select',
|
||||||
|
required: true,
|
||||||
|
options: supportedServices.map((service) => ({
|
||||||
|
value: service,
|
||||||
|
label: service,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'useMultipleInstances',
|
||||||
|
name: 'Use Multiple Instances',
|
||||||
|
description:
|
||||||
|
'When using multiple services, use a different Torrentio addon for each service, rather than using one instance for all services',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ID: 'torrentio',
|
||||||
|
NAME: 'Torrentio',
|
||||||
|
LOGO: `${Env.TORRENTIO_URL}/images/logo_v1.png`,
|
||||||
|
URL: Env.TORRENTIO_URL,
|
||||||
|
TIMEOUT: Env.DEFAULT_TORRENTIO_TIMEOUT || Env.DEFAULT_TIMEOUT,
|
||||||
|
USER_AGENT: Env.DEFAULT_TORRENTIO_USER_AGENT || Env.DEFAULT_USER_AGENT,
|
||||||
|
SUPPORTED_SERVICES: supportedServices,
|
||||||
|
REQUIRES_SERVICE: false,
|
||||||
|
DESCRIPTION: 'Torrentio preset',
|
||||||
|
OPTIONS: options,
|
||||||
|
TAGS: [constants.P2P_TAG, constants.DEBRID_TAG],
|
||||||
|
RESOURCES: [
|
||||||
|
constants.STREAM_RESOURCE,
|
||||||
|
constants.META_RESOURCE,
|
||||||
|
constants.CATALOG_RESOURCE,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async generateAddons(
|
||||||
|
userData: UserData,
|
||||||
|
options?: Record<string, any>,
|
||||||
|
baseUrl?: string,
|
||||||
|
name?: string,
|
||||||
|
timeout?: number,
|
||||||
|
resources?: Resource[]
|
||||||
|
): Promise<Addon[]> {
|
||||||
|
// baseUrl can either be something like https://torrentio.com/ or it can be a custom manifest url.
|
||||||
|
// if it is a custom manifest url, return a single addon with the custom manifest url.
|
||||||
|
if (baseUrl?.endsWith('/manifest.json')) {
|
||||||
|
return [
|
||||||
|
this.generateAddon(userData, [], baseUrl, name, timeout, resources),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all services that are supported by the preset and enabled
|
||||||
|
let usableServices = userData.services?.filter(
|
||||||
|
(service) =>
|
||||||
|
this.METADATA.SUPPORTED_SERVICES.includes(service.id) && service.enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
// if user has specified services, filter the usable services to only include the specified services
|
||||||
|
if (options?.services) {
|
||||||
|
usableServices = usableServices?.filter((service) =>
|
||||||
|
options.services.includes(service.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if no services are usable, return a single addon with no services
|
||||||
|
if (!usableServices || usableServices.length === 0) {
|
||||||
|
return [
|
||||||
|
this.generateAddon(userData, [], baseUrl, name, timeout, resources),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// if user has specified useMultipleInstances, return a single addon for each service
|
||||||
|
if (options?.useMultipleInstances) {
|
||||||
|
return usableServices.map((service) =>
|
||||||
|
this.generateAddon(
|
||||||
|
userData,
|
||||||
|
[service.id],
|
||||||
|
baseUrl,
|
||||||
|
name,
|
||||||
|
timeout,
|
||||||
|
resources
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// return a single addon with all usable services
|
||||||
|
return [
|
||||||
|
this.generateAddon(
|
||||||
|
userData,
|
||||||
|
usableServices.map((service) => service.id),
|
||||||
|
baseUrl,
|
||||||
|
name,
|
||||||
|
timeout
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static generateAddon(
|
||||||
|
userData: UserData,
|
||||||
|
services: ServiceId[],
|
||||||
|
baseUrl?: string,
|
||||||
|
name?: string,
|
||||||
|
timeout?: number,
|
||||||
|
resources?: Resource[]
|
||||||
|
): Addon {
|
||||||
|
return {
|
||||||
|
name: name || this.METADATA.NAME,
|
||||||
|
manifestUrl: this.generateManifestUrl(userData, services, baseUrl),
|
||||||
|
enabled: true,
|
||||||
|
resources: resources || this.METADATA.RESOURCES,
|
||||||
|
timeout: timeout || this.METADATA.TIMEOUT,
|
||||||
|
fromPresetId: this.METADATA.ID,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': this.METADATA.USER_AGENT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static generateManifestUrl(
|
||||||
|
userData: UserData,
|
||||||
|
services: ServiceId[],
|
||||||
|
baseUrl?: string
|
||||||
|
) {
|
||||||
|
const url = baseUrl || this.METADATA.URL;
|
||||||
|
if (url.endsWith('/manifest.json')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
const configString = services.length
|
||||||
|
? this.urlEncodeKeyValuePairs(
|
||||||
|
services.map((service) => [
|
||||||
|
service,
|
||||||
|
this.getServiceCredential(service, userData),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `${url}${configString ? '/' + configString : ''}/manifest.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getServiceCredential(
|
||||||
|
serviceId: ServiceId,
|
||||||
|
userData: UserData
|
||||||
|
): string {
|
||||||
|
// Validate service exists
|
||||||
|
const service = constants.SERVICE_DETAILS[serviceId];
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service ${serviceId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get credentials for service
|
||||||
|
const serviceCredentials = userData.services?.find(
|
||||||
|
(service) => service.id === serviceId
|
||||||
|
)?.credentials;
|
||||||
|
|
||||||
|
if (!serviceCredentials) {
|
||||||
|
throw new Error(`No credentials found for service ${serviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle put.io special case which requires both clientId and token
|
||||||
|
if (serviceId === constants.PUTIO_SERVICE) {
|
||||||
|
const { clientId, token } = serviceCredentials;
|
||||||
|
if (!clientId || !token) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing credentials for ${serviceId}. Please add a client ID and token.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${clientId}@${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle default case which requires just an API key
|
||||||
|
const { apiKey } = serviceCredentials;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing credentials for ${serviceId}. Please add an API key.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { StreamProxyConfig } from '../db';
|
||||||
|
import { Cache, createLogger, maskSensitiveInfo, Env } from '../utils';
|
||||||
|
|
||||||
|
const logger = createLogger('proxy');
|
||||||
|
const cache = Cache.getInstance<string, string>('publicIp');
|
||||||
|
|
||||||
|
export interface ProxyStream {
|
||||||
|
url: string;
|
||||||
|
filename?: string;
|
||||||
|
headers?: {
|
||||||
|
request?: Record<string, string>;
|
||||||
|
response?: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseProxy {
|
||||||
|
protected readonly config: StreamProxyConfig;
|
||||||
|
private readonly PRIVATE_CIDR =
|
||||||
|
/^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
|
||||||
|
|
||||||
|
constructor(config: StreamProxyConfig) {
|
||||||
|
// Apply any forced environment variables
|
||||||
|
this.config = {
|
||||||
|
enabled:
|
||||||
|
Env.FORCE_PROXY_ENABLED !== undefined
|
||||||
|
? Env.FORCE_PROXY_ENABLED
|
||||||
|
: config.enabled,
|
||||||
|
id: Env.FORCE_PROXY_ID !== undefined ? Env.FORCE_PROXY_ID : config.id,
|
||||||
|
url: Env.FORCE_PROXY_URL !== undefined ? Env.FORCE_PROXY_URL : config.url,
|
||||||
|
credentials:
|
||||||
|
Env.FORCE_PROXY_CREDENTIALS !== undefined
|
||||||
|
? Env.FORCE_PROXY_CREDENTIALS
|
||||||
|
: config.credentials,
|
||||||
|
publicIp:
|
||||||
|
Env.FORCE_PROXY_PUBLIC_IP !== undefined
|
||||||
|
? Env.FORCE_PROXY_PUBLIC_IP
|
||||||
|
: config.publicIp,
|
||||||
|
proxiedAddons:
|
||||||
|
Env.FORCE_PROXY_PROXIED_ADDONS !== undefined
|
||||||
|
? Env.FORCE_PROXY_PROXIED_ADDONS
|
||||||
|
: config.proxiedAddons,
|
||||||
|
proxiedServices:
|
||||||
|
Env.FORCE_PROXY_PROXIED_SERVICES !== undefined
|
||||||
|
? Env.FORCE_PROXY_PROXIED_SERVICES
|
||||||
|
: config.proxiedServices,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getConfig(): StreamProxyConfig {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract generateProxyUrl(endpoint: string): URL;
|
||||||
|
protected abstract getPublicIpEndpoint(): string;
|
||||||
|
protected abstract getPublicIpFromResponse(data: any): string | null;
|
||||||
|
protected abstract generateStreamUrls(
|
||||||
|
streams: ProxyStream[]
|
||||||
|
): Promise<string[] | null>;
|
||||||
|
|
||||||
|
public async getPublicIp(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
if (!this.config.url) {
|
||||||
|
logger.error('Proxy URL is missing');
|
||||||
|
throw new Error('Proxy URL is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.publicIp) {
|
||||||
|
return this.config.publicIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyUrl = new URL(this.config.url.replace(/\/$/, ''));
|
||||||
|
if (this.PRIVATE_CIDR.test(proxyUrl.hostname)) {
|
||||||
|
logger.error('Proxy URL is a private IP address, returning null');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${this.config.id}:${this.config.url}:${this.config.credentials}`;
|
||||||
|
const cachedPublicIp = cache ? cache.get(cacheKey) : null;
|
||||||
|
if (cachedPublicIp) {
|
||||||
|
logger.debug('Returning cached public IP');
|
||||||
|
return cachedPublicIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipUrl = this.generateProxyUrl(this.getPublicIpEndpoint());
|
||||||
|
|
||||||
|
if (Env.LOG_SENSITIVE_INFO) {
|
||||||
|
logger.debug(`GET ${ipUrl.toString()}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`GET ${ipUrl.protocol}://${maskSensitiveInfo(ipUrl.hostname)}${ipUrl.port ? `:${ipUrl.port}` : ''}${ipUrl.pathname}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(ipUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const publicIp = this.getPublicIpFromResponse(data);
|
||||||
|
|
||||||
|
if (publicIp && cache) {
|
||||||
|
cache.set(cacheKey, publicIp, 900); // 15 minute cache
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`Proxy did not respond with a public IP. Response: ${JSON.stringify(data)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicIp;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Failed to get public IP: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getHeaders(): Record<string, string>;
|
||||||
|
|
||||||
|
public async generateUrls(streams: ProxyStream[]): Promise<string[] | null> {
|
||||||
|
if (!streams.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.config) {
|
||||||
|
throw new Error('Proxy configuration is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.generateStreamUrls(streams);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to generate proxy URLs: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export * from './base';
|
||||||
|
export * from './mediaflow';
|
||||||
|
export * from './stremthru';
|
||||||
|
|
||||||
|
import { constants } from '../utils';
|
||||||
|
import { BaseProxy } from './base';
|
||||||
|
import { MediaFlowProxy } from './mediaflow';
|
||||||
|
import { StremThruProxy } from './stremthru';
|
||||||
|
import { StreamProxyConfig } from '../db';
|
||||||
|
|
||||||
|
export function createProxy(config: StreamProxyConfig): BaseProxy {
|
||||||
|
switch (config.id) {
|
||||||
|
case constants.MEDIAFLOW_SERVICE:
|
||||||
|
return new MediaFlowProxy(config);
|
||||||
|
case constants.STREMTHRU_SERVICE:
|
||||||
|
return new StremThruProxy(config);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown proxy type: ${config.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { BaseProxy, ProxyStream } from './base';
|
||||||
|
import { createLogger, maskSensitiveInfo, Env } from '../utils';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const logger = createLogger('mediaflow');
|
||||||
|
|
||||||
|
export class MediaFlowProxy extends BaseProxy {
|
||||||
|
protected generateProxyUrl(endpoint: string): URL {
|
||||||
|
const proxyUrl = new URL(this.config.url.replace(/\/$/, ''));
|
||||||
|
proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${endpoint}`;
|
||||||
|
return proxyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPublicIpEndpoint(): string {
|
||||||
|
return '/proxy/ip';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPublicIpFromResponse(data: any): string | null {
|
||||||
|
return data.ip || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async generateStreamUrls(
|
||||||
|
streams: ProxyStream[]
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const proxyUrl = this.generateProxyUrl('/generate_urls');
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
mediaflow_proxy_url: this.config.url.replace(/\/$/, ''),
|
||||||
|
api_password: Env.ENCRYPT_MEDIAFLOW_URLS
|
||||||
|
? this.config.credentials
|
||||||
|
: undefined,
|
||||||
|
urls: streams.map((stream) => ({
|
||||||
|
endpoint: '/proxy/stream',
|
||||||
|
filename: stream.filename || path.basename(stream.url),
|
||||||
|
query_params: Env.ENCRYPT_MEDIAFLOW_URLS
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
api_password: this.config.credentials,
|
||||||
|
},
|
||||||
|
destination_url: stream.url,
|
||||||
|
request_headers: stream.headers?.request,
|
||||||
|
response_headers: stream.headers?.response,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Env.LOG_SENSITIVE_INFO) {
|
||||||
|
logger.debug(`POST ${proxyUrl.toString()}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`POST ${proxyUrl.protocol}://${maskSensitiveInfo(proxyUrl.hostname)}${proxyUrl.port ? `:${proxyUrl.port}` : ''}/generate_urls`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(proxyUrl.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseData: any;
|
||||||
|
try {
|
||||||
|
responseData = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
const text = await response.text();
|
||||||
|
logger.debug(`Response body: ${text}`);
|
||||||
|
throw new Error('Failed to parse JSON response from MediaFlow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData.error) {
|
||||||
|
throw new Error(responseData.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData.urls) {
|
||||||
|
return responseData.urls;
|
||||||
|
} else {
|
||||||
|
throw new Error('No URLs were returned from MediaFlow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { BaseProxy, ProxyStream } from './base';
|
||||||
|
import { createLogger, maskSensitiveInfo, Env } from '../utils';
|
||||||
|
|
||||||
|
const logger = createLogger('stremthru');
|
||||||
|
|
||||||
|
export class StremThruProxy extends BaseProxy {
|
||||||
|
protected generateProxyUrl(endpoint: string): URL {
|
||||||
|
const proxyUrl = new URL(this.config.url.replace(/\/$/, ''));
|
||||||
|
proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${endpoint}`;
|
||||||
|
return proxyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPublicIpEndpoint(): string {
|
||||||
|
return '/v0/health/__debug__';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getPublicIpFromResponse(data: any): string | null {
|
||||||
|
return typeof data.data?.ip?.exposed === 'object'
|
||||||
|
? data.data.ip.exposed['*'] || data.data.ip.machine
|
||||||
|
: data.data?.ip?.machine || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Env.ENCRYPT_STREMTHRU_URLS) {
|
||||||
|
headers['X-StremThru-Authorization'] = `Basic ${this.config.credentials}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async generateStreamUrls(
|
||||||
|
streams: ProxyStream[]
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const proxyUrl = this.generateProxyUrl('/v0/proxy');
|
||||||
|
|
||||||
|
if (!Env.ENCRYPT_STREMTHRU_URLS) {
|
||||||
|
proxyUrl.searchParams.set('token', this.config.credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
|
streams.forEach((stream, i) => {
|
||||||
|
data.append('url', stream.url);
|
||||||
|
let req_headers = '';
|
||||||
|
if (stream.headers?.request) {
|
||||||
|
for (const [key, value] of Object.entries(stream.headers.request)) {
|
||||||
|
req_headers += `${key}: ${value}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.append(`req_headers[${i}]`, req_headers);
|
||||||
|
if (stream.filename) {
|
||||||
|
data.append(`filename[${i}]`, stream.filename);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Env.LOG_SENSITIVE_INFO) {
|
||||||
|
logger.debug(`POST ${proxyUrl.toString()}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`POST ${proxyUrl.protocol}://${maskSensitiveInfo(proxyUrl.hostname)}${proxyUrl.port ? `:${proxyUrl.port}` : ''}/v0/proxy`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(proxyUrl.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
body: data,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseData: any;
|
||||||
|
try {
|
||||||
|
responseData = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
const text = await response.text();
|
||||||
|
logger.debug(`Response body: ${text}`);
|
||||||
|
throw new Error('Failed to parse JSON response from StremThru');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData.error) {
|
||||||
|
throw new Error(responseData.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData.data?.items) {
|
||||||
|
return responseData.data.items;
|
||||||
|
} else {
|
||||||
|
throw new Error('No URLs were returned from StremThru');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Env } from './env';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
import { createErrorStream } from './stremio';
|
||||||
|
|
||||||
|
const logger = createLogger('server');
|
||||||
|
|
||||||
|
type ApiResponseOptions = {
|
||||||
|
success: boolean;
|
||||||
|
/**
|
||||||
|
* @deprecated Use detail instead
|
||||||
|
*/
|
||||||
|
message?: string;
|
||||||
|
detail?: string;
|
||||||
|
data?: any;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createResponse(
|
||||||
|
options: ApiResponseOptions,
|
||||||
|
path?: string,
|
||||||
|
adaptResponses?: boolean
|
||||||
|
) {
|
||||||
|
const { success, data, error } = options;
|
||||||
|
let type: string = 'api';
|
||||||
|
// adapt responses for path specific response types
|
||||||
|
if (adaptResponses && path) {
|
||||||
|
if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/stream/)) {
|
||||||
|
type = 'stream';
|
||||||
|
} else if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/catalog/)) {
|
||||||
|
type = 'catalog';
|
||||||
|
} else if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/subtitles/)) {
|
||||||
|
type = 'subtitles';
|
||||||
|
} else if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/meta/)) {
|
||||||
|
type = 'meta';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug('createResponse', {
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
type,
|
||||||
|
path,
|
||||||
|
adaptResponses,
|
||||||
|
});
|
||||||
|
// return type === 'stream' && success === false
|
||||||
|
// ? {
|
||||||
|
// streams: [createErrorStream({ description: error?.message })],
|
||||||
|
// }
|
||||||
|
// : {
|
||||||
|
// success,
|
||||||
|
// message: options.message || null,
|
||||||
|
// data: data || null,
|
||||||
|
// error: error || null,
|
||||||
|
// };
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'stream':
|
||||||
|
if (success) {
|
||||||
|
return {
|
||||||
|
streams: data,
|
||||||
|
};
|
||||||
|
} else if (error) {
|
||||||
|
return {
|
||||||
|
streams: [createErrorStream({ description: error?.message })],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'subtitles':
|
||||||
|
if (success) {
|
||||||
|
return {
|
||||||
|
subtitles: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'catalog':
|
||||||
|
if (success) {
|
||||||
|
return {
|
||||||
|
metas: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'meta':
|
||||||
|
if (success) {
|
||||||
|
return {
|
||||||
|
meta: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
detail: options.message || null,
|
||||||
|
data: data || null,
|
||||||
|
error: error || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
import { Settings } from './settings';
|
import { Env } from './env';
|
||||||
|
|
||||||
const logger = createLogger('cache');
|
const logger = createLogger('cache');
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export class Cache<K, V> {
|
|||||||
*/
|
*/
|
||||||
public static getInstance<K, V>(
|
public static getInstance<K, V>(
|
||||||
name: string,
|
name: string,
|
||||||
maxSize: number = Settings.MAX_CACHE_SIZE
|
maxSize: number = Env.MAX_CACHE_SIZE
|
||||||
): Cache<K, V> {
|
): Cache<K, V> {
|
||||||
if (!this.instances.has(name)) {
|
if (!this.instances.has(name)) {
|
||||||
logger.debug(`Creating new cache instance: ${name}`);
|
logger.debug(`Creating new cache instance: ${name}`);
|
||||||
@@ -77,6 +77,12 @@ export class Cache<K, V> {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache with a specific TTL
|
||||||
|
* @param key The key to set the value for
|
||||||
|
* @param value The value to set
|
||||||
|
* @param ttl The TTL in seconds
|
||||||
|
*/
|
||||||
set(key: K, value: V, ttl: number): void {
|
set(key: K, value: V, ttl: number): void {
|
||||||
if (this.cache.size >= this.maxSize) {
|
if (this.cache.size >= this.maxSize) {
|
||||||
this.evict();
|
this.evict();
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
import { Option } from '../db';
|
||||||
|
|
||||||
|
export enum ErrorCode {
|
||||||
|
// User API
|
||||||
|
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||||
|
USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS',
|
||||||
|
USER_INVALID_PASSWORD = 'USER_INVALID_PASSWORD',
|
||||||
|
USER_INVALID_CONFIG = 'USER_INVALID_CONFIG',
|
||||||
|
USER_ERROR = 'USER_ERROR',
|
||||||
|
USER_NEW_PASSWORD_TOO_SHORT = 'USER_NEW_PASSWORD_TOO_SHORT',
|
||||||
|
USER_NEW_PASSWORD_TOO_SIMPLE = 'USER_NEW_PASSWORD_TOO_SIMPLE',
|
||||||
|
// Format API
|
||||||
|
FORMAT_INVALID_FORMATTER = 'FORMAT_INVALID_FORMATTER',
|
||||||
|
FORMAT_INVALID_STREAM = 'FORMAT_INVALID_STREAM',
|
||||||
|
FORMAT_ERROR = 'FORMAT_ERROR',
|
||||||
|
// Other
|
||||||
|
MISSING_REQUIRED_FIELDS = 'MISSING_REQUIRED_FIELDS',
|
||||||
|
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||||
|
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
|
||||||
|
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorDetails {
|
||||||
|
statusCode: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorMap: Record<ErrorCode, ErrorDetails> = {
|
||||||
|
[ErrorCode.MISSING_REQUIRED_FIELDS]: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Required fields are missing',
|
||||||
|
},
|
||||||
|
[ErrorCode.USER_NOT_FOUND]: {
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'User not found',
|
||||||
|
},
|
||||||
|
[ErrorCode.USER_ALREADY_EXISTS]: {
|
||||||
|
statusCode: 409,
|
||||||
|
message: 'User already exists',
|
||||||
|
},
|
||||||
|
[ErrorCode.USER_INVALID_PASSWORD]: {
|
||||||
|
statusCode: 401,
|
||||||
|
message: 'Invalid password',
|
||||||
|
},
|
||||||
|
[ErrorCode.USER_INVALID_CONFIG]: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'The config for this user is invalid',
|
||||||
|
},
|
||||||
|
[ErrorCode.USER_ERROR]: {
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'A generic error while processing the user request',
|
||||||
|
},
|
||||||
|
[ErrorCode.USER_NEW_PASSWORD_TOO_SHORT]: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'New password is too short',
|
||||||
|
},
|
||||||
|
[ErrorCode.USER_NEW_PASSWORD_TOO_SIMPLE]: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'New password is too simple',
|
||||||
|
},
|
||||||
|
[ErrorCode.INTERNAL_SERVER_ERROR]: {
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'An unexpected error occurred',
|
||||||
|
},
|
||||||
|
[ErrorCode.METHOD_NOT_ALLOWED]: {
|
||||||
|
statusCode: 405,
|
||||||
|
message: 'Method not allowed',
|
||||||
|
},
|
||||||
|
[ErrorCode.RATE_LIMIT_EXCEEDED]: {
|
||||||
|
statusCode: 429,
|
||||||
|
message: 'Too many requests from this IP, please try again later.',
|
||||||
|
},
|
||||||
|
[ErrorCode.FORMAT_INVALID_FORMATTER]: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid formatter',
|
||||||
|
},
|
||||||
|
[ErrorCode.FORMAT_INVALID_STREAM]: {
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid stream',
|
||||||
|
},
|
||||||
|
[ErrorCode.FORMAT_ERROR]: {
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'An error occurred while formatting the stream',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class APIError extends Error {
|
||||||
|
constructor(
|
||||||
|
public code: ErrorCode,
|
||||||
|
public statusCode: number = ErrorMap[code].statusCode,
|
||||||
|
message?: string
|
||||||
|
) {
|
||||||
|
super(message || ErrorMap[code].message);
|
||||||
|
this.name = 'APIError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADERS_FOR_IP_FORWARDING = [
|
||||||
|
'X-Client-IP',
|
||||||
|
'X-Forwarded-For',
|
||||||
|
'X-Real-IP',
|
||||||
|
'True-Client-IP',
|
||||||
|
'X-Forwarded',
|
||||||
|
'Forwarded-For',
|
||||||
|
];
|
||||||
|
|
||||||
|
const API_VERSION = 1;
|
||||||
|
|
||||||
|
export const GDRIVE_FORMATTER = 'gdrive';
|
||||||
|
export const LIGHT_GDRIVE_FORMATTER = 'lightgdrive';
|
||||||
|
export const MINIMALISTIC_GDRIVE_FORMATTER = 'minimalisticgdrive';
|
||||||
|
export const TORRENTIO_FORMATTER = 'torrentio';
|
||||||
|
export const TORBOX_FORMATTER = 'torbox';
|
||||||
|
export const CUSTOM_FORMATTER = 'custom';
|
||||||
|
|
||||||
|
export const FORMATTERS = [
|
||||||
|
GDRIVE_FORMATTER,
|
||||||
|
LIGHT_GDRIVE_FORMATTER,
|
||||||
|
MINIMALISTIC_GDRIVE_FORMATTER,
|
||||||
|
TORRENTIO_FORMATTER,
|
||||||
|
TORBOX_FORMATTER,
|
||||||
|
CUSTOM_FORMATTER,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FormatterDetail = {
|
||||||
|
id: FormatterType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FORMATTER_DETAILS: Record<FormatterType, FormatterDetail> = {
|
||||||
|
[GDRIVE_FORMATTER]: {
|
||||||
|
id: GDRIVE_FORMATTER,
|
||||||
|
name: 'Google Drive',
|
||||||
|
description: 'Uses the formatting from the Stremio GDrive addon',
|
||||||
|
},
|
||||||
|
[LIGHT_GDRIVE_FORMATTER]: {
|
||||||
|
id: LIGHT_GDRIVE_FORMATTER,
|
||||||
|
name: 'Light Google Drive',
|
||||||
|
description:
|
||||||
|
'A lighter version of the GDrive formatter, focused on asthetics',
|
||||||
|
},
|
||||||
|
[MINIMALISTIC_GDRIVE_FORMATTER]: {
|
||||||
|
id: MINIMALISTIC_GDRIVE_FORMATTER,
|
||||||
|
name: 'Minimalistic Google Drive',
|
||||||
|
description:
|
||||||
|
'A minimalistic formatter for Google Drive which shows only the bare minimum',
|
||||||
|
},
|
||||||
|
[TORRENTIO_FORMATTER]: {
|
||||||
|
id: TORRENTIO_FORMATTER,
|
||||||
|
name: 'Torrentio',
|
||||||
|
description: 'Uses the formatting from the Torrentio addon',
|
||||||
|
},
|
||||||
|
[TORBOX_FORMATTER]: {
|
||||||
|
id: TORBOX_FORMATTER,
|
||||||
|
name: 'Torbox',
|
||||||
|
description: 'Uses the formatting from the TorBox Stremio addon',
|
||||||
|
},
|
||||||
|
[CUSTOM_FORMATTER]: {
|
||||||
|
id: CUSTOM_FORMATTER,
|
||||||
|
name: 'Custom',
|
||||||
|
description: 'Define your own formatter',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormatterType = (typeof FORMATTERS)[number];
|
||||||
|
|
||||||
|
const REALDEBRID_SERVICE = 'realdebrid';
|
||||||
|
const DEBRIDLINK_SERVICE = 'debridlink';
|
||||||
|
const PREMIUMIZE_SERVICE = 'premiumize';
|
||||||
|
const ALLEDEBRID_SERVICE = 'alldebrid';
|
||||||
|
const TORBOX_SERVICE = 'torbox';
|
||||||
|
const EASYDEBRID_SERVICE = 'easydebrid';
|
||||||
|
const PUTIO_SERVICE = 'putio';
|
||||||
|
const PIKPAK_SERVICE = 'pikpak';
|
||||||
|
const OFFCLOUD_SERVICE = 'offcloud';
|
||||||
|
const SEEDR_SERVICE = 'seedr';
|
||||||
|
const EASYNEWS_SERVICE = 'easynews';
|
||||||
|
|
||||||
|
const SERVICES = [
|
||||||
|
REALDEBRID_SERVICE,
|
||||||
|
DEBRIDLINK_SERVICE,
|
||||||
|
PREMIUMIZE_SERVICE,
|
||||||
|
ALLEDEBRID_SERVICE,
|
||||||
|
TORBOX_SERVICE,
|
||||||
|
EASYDEBRID_SERVICE,
|
||||||
|
PUTIO_SERVICE,
|
||||||
|
PIKPAK_SERVICE,
|
||||||
|
OFFCLOUD_SERVICE,
|
||||||
|
SEEDR_SERVICE,
|
||||||
|
EASYNEWS_SERVICE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ServiceId = (typeof SERVICES)[number];
|
||||||
|
|
||||||
|
export const MEDIAFLOW_SERVICE = 'mediaflow' as const;
|
||||||
|
export const STREMTHRU_SERVICE = 'stremthru' as const;
|
||||||
|
|
||||||
|
export const PROXY_SERVICES = [MEDIAFLOW_SERVICE, STREMTHRU_SERVICE] as const;
|
||||||
|
export type ProxyServiceId = (typeof PROXY_SERVICES)[number];
|
||||||
|
|
||||||
|
export const PROXY_SERVICE_DETAILS: Record<
|
||||||
|
ProxyServiceId,
|
||||||
|
{
|
||||||
|
id: ProxyServiceId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
[MEDIAFLOW_SERVICE]: {
|
||||||
|
id: MEDIAFLOW_SERVICE,
|
||||||
|
name: 'MediaFlow',
|
||||||
|
description:
|
||||||
|
'MediaFlow is a proxy service that allows you to proxy your requests to the server.',
|
||||||
|
url: 'https://github.com/mhdzumair/mediaflow-proxy',
|
||||||
|
},
|
||||||
|
[STREMTHRU_SERVICE]: {
|
||||||
|
id: STREMTHRU_SERVICE,
|
||||||
|
name: 'StremThru',
|
||||||
|
description:
|
||||||
|
'StremThru is a proxy service that allows you to proxy your requests to the server.',
|
||||||
|
url: 'https://github.com/MunifTanjim/stremthru',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERVICE_DETAILS: Record<
|
||||||
|
ServiceId,
|
||||||
|
{
|
||||||
|
id: ServiceId;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
knownNames: string[];
|
||||||
|
signUpText: string;
|
||||||
|
credentials: Option[];
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
[REALDEBRID_SERVICE]: {
|
||||||
|
id: REALDEBRID_SERVICE,
|
||||||
|
name: 'Real-Debrid',
|
||||||
|
shortName: 'RD',
|
||||||
|
knownNames: ['RD', 'Real Debrid', 'RealDebrid', 'Real-Debrid'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://real-debrid.com/signup?aid=9483829)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'API Key',
|
||||||
|
description:
|
||||||
|
'The API key for the Real-Debrid service. Obtain it from [here](https://real-debrid.com/settings/api)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[ALLEDEBRID_SERVICE]: {
|
||||||
|
id: ALLEDEBRID_SERVICE,
|
||||||
|
name: 'All-Debrid',
|
||||||
|
shortName: 'AD',
|
||||||
|
knownNames: ['AD', 'All Debrid', 'AllDebrid', 'All-Debrid'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://alldebrid.com/?uid=3n8qa&lang=en)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'API Key',
|
||||||
|
description:
|
||||||
|
'The API key for the All-Debrid service. Create one [here](https://alldebrid.com/apikeys)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[PREMIUMIZE_SERVICE]: {
|
||||||
|
id: PREMIUMIZE_SERVICE,
|
||||||
|
name: 'Premiumize',
|
||||||
|
shortName: 'PZ',
|
||||||
|
knownNames: ['PM', 'Premiumize'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://www.premiumize.me/register)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'API Key',
|
||||||
|
description:
|
||||||
|
'Your Premiumize API key. Obtain it from [here](https://www.premiumize.me/account)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[DEBRIDLINK_SERVICE]: {
|
||||||
|
id: DEBRIDLINK_SERVICE,
|
||||||
|
name: 'Debridlink',
|
||||||
|
shortName: 'DL',
|
||||||
|
knownNames: ['DL', 'Debrid Link', 'DebridLink', 'Debrid-Link'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://debrid-link.com/id/EY0JO)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'API Key',
|
||||||
|
description:
|
||||||
|
'Your Debrid-Link API key. Obtain it from [here](https://debrid-link.com/webapp/apikey)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[TORBOX_SERVICE]: {
|
||||||
|
id: TORBOX_SERVICE,
|
||||||
|
name: 'TorBox',
|
||||||
|
shortName: 'TB',
|
||||||
|
knownNames: ['TB', 'TorBox', 'Torbox'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://torbox.app/subscription?referral=9ca21adb-dbcb-4fb0-9195-412a5f3519bc) or use my referral code `9ca21adb-dbcb-4fb0-9195-412a5f3519bc`.",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'API Key',
|
||||||
|
description:
|
||||||
|
'Your Torbox API key. Obtain it from [here](https://torbox.app/settings)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[OFFCLOUD_SERVICE]: {
|
||||||
|
id: OFFCLOUD_SERVICE,
|
||||||
|
name: 'Offcloud',
|
||||||
|
shortName: 'OC',
|
||||||
|
knownNames: ['OC', 'Offcloud'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://offcloud.com/?=06202a3d)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'API Key',
|
||||||
|
description:
|
||||||
|
'Your Offcloud API key. Obtain it from [here](https://offcloud.com/settings)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
name: 'Email',
|
||||||
|
description:
|
||||||
|
'Your Offcloud email. (These credentials are necessary for some addons)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
name: 'Password',
|
||||||
|
description:
|
||||||
|
'Your Offcloud password. (These credentials are necessary for some addons)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[PUTIO_SERVICE]: {
|
||||||
|
id: PUTIO_SERVICE,
|
||||||
|
name: 'put.io',
|
||||||
|
shortName: 'P.IO',
|
||||||
|
knownNames: ['PO', 'put.io', 'putio'],
|
||||||
|
signUpText: "Don't have an account? [Sign up here](https://put.io/)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'clientId',
|
||||||
|
name: 'Client ID',
|
||||||
|
description:
|
||||||
|
'Your put.io Client ID. Obtain it from [here](https://put.io/oauth)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'token',
|
||||||
|
name: 'Token',
|
||||||
|
description:
|
||||||
|
'Your put.io Token. Obtain it from [here](https://put.io/oauth)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[EASYNEWS_SERVICE]: {
|
||||||
|
id: EASYNEWS_SERVICE,
|
||||||
|
name: 'Easynews',
|
||||||
|
shortName: 'EN',
|
||||||
|
knownNames: ['EN', 'Easynews'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://www.easynews.com/)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
name: 'Username',
|
||||||
|
description: 'Your Easynews username',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
name: 'Password',
|
||||||
|
description: 'Your Easynews password',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[EASYDEBRID_SERVICE]: {
|
||||||
|
id: EASYDEBRID_SERVICE,
|
||||||
|
name: 'EasyDebrid',
|
||||||
|
shortName: 'ED',
|
||||||
|
knownNames: ['ED', 'EasyDebrid'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://paradise-cloud.com/products/easydebrid)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'API Key',
|
||||||
|
description:
|
||||||
|
'Your EasyDebrid API key. Obtain it from [here](https://paradise-cloud.com/products/easydebrid)',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[PIKPAK_SERVICE]: {
|
||||||
|
id: PIKPAK_SERVICE,
|
||||||
|
name: 'PikPak',
|
||||||
|
shortName: 'PKP',
|
||||||
|
knownNames: ['PP', 'PikPak', 'PKP'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://mypikpak.com/drive/activity/invited?invitation-code=72822731)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
name: 'Email',
|
||||||
|
description: 'Your PikPak email address',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'password',
|
||||||
|
name: 'Password',
|
||||||
|
description: 'Your PikPak password',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[SEEDR_SERVICE]: {
|
||||||
|
id: SEEDR_SERVICE,
|
||||||
|
name: 'Seedr',
|
||||||
|
shortName: 'SDR',
|
||||||
|
knownNames: ['SR', 'Seedr', 'SDR'],
|
||||||
|
signUpText:
|
||||||
|
"Don't have an account? [Sign up here](https://www.seedr.cc/?r=6542079)",
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 'apiKey',
|
||||||
|
name: 'Encoded Token',
|
||||||
|
description:
|
||||||
|
'Please authorise at MediaFusion and copy the token into here.',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
sensitive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRING_OPTION_TYPE = 'string';
|
||||||
|
const NUMBER_OPTION_TYPE = 'number';
|
||||||
|
const BOOLEAN_OPTION_TYPE = 'boolean';
|
||||||
|
const SELECT_OPTION_TYPE = 'select';
|
||||||
|
const MULTI_SELECT_OPTION_TYPE = 'multi-select';
|
||||||
|
|
||||||
|
const OPTION_TYPES = [
|
||||||
|
STRING_OPTION_TYPE,
|
||||||
|
NUMBER_OPTION_TYPE,
|
||||||
|
BOOLEAN_OPTION_TYPE,
|
||||||
|
SELECT_OPTION_TYPE,
|
||||||
|
MULTI_SELECT_OPTION_TYPE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const P2P_TAG = 'p2p';
|
||||||
|
const DIRECT_TAG = 'direct';
|
||||||
|
const DEBRID_TAG = 'debrid';
|
||||||
|
const USENET_TAG = 'usenet';
|
||||||
|
const PRESET_TAGS = [P2P_TAG, DIRECT_TAG, DEBRID_TAG, USENET_TAG] as const;
|
||||||
|
|
||||||
|
export const DEDUPLICATOR_KEYS = ['filename', 'infoHash'] as const;
|
||||||
|
|
||||||
|
const RESOLUTIONS = [
|
||||||
|
'2160p',
|
||||||
|
'1440p',
|
||||||
|
'1080p',
|
||||||
|
'720p',
|
||||||
|
'576p',
|
||||||
|
'480p',
|
||||||
|
'360p',
|
||||||
|
'240p',
|
||||||
|
'144p',
|
||||||
|
'Unknown',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const QUALITIES = [
|
||||||
|
'Bluray REMUX',
|
||||||
|
'Bluray',
|
||||||
|
'WEB-DL',
|
||||||
|
'WEBRip',
|
||||||
|
'HDRip',
|
||||||
|
'HC HD-Rip',
|
||||||
|
'DVDRip',
|
||||||
|
'HDTV',
|
||||||
|
'CAM',
|
||||||
|
'TS',
|
||||||
|
'TC',
|
||||||
|
'SCR',
|
||||||
|
'Unknown',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const VISUAL_TAGS = [
|
||||||
|
'HDR+DV',
|
||||||
|
'HDR10+',
|
||||||
|
'HDR10',
|
||||||
|
'DV',
|
||||||
|
'HDR',
|
||||||
|
'10bit',
|
||||||
|
'3D',
|
||||||
|
'IMAX',
|
||||||
|
'AI',
|
||||||
|
'SDR',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const AUDIO_TAGS = [
|
||||||
|
'Atmos',
|
||||||
|
'DD+',
|
||||||
|
'DD',
|
||||||
|
'DTS-HD MA',
|
||||||
|
'DTS',
|
||||||
|
'TrueHD',
|
||||||
|
'5.1',
|
||||||
|
'7.1',
|
||||||
|
'FLAC',
|
||||||
|
'AAC',
|
||||||
|
'Unknown',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ENCODES = [
|
||||||
|
'AV1',
|
||||||
|
'HEVC',
|
||||||
|
'AVC',
|
||||||
|
'XviD',
|
||||||
|
'DivX',
|
||||||
|
'H-OU',
|
||||||
|
'H-SBS',
|
||||||
|
'Unknown',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SORT_CRITERIA = [
|
||||||
|
'quality',
|
||||||
|
'resolution',
|
||||||
|
'language',
|
||||||
|
'visualTag',
|
||||||
|
'audioTag',
|
||||||
|
'streamType',
|
||||||
|
'size',
|
||||||
|
'service',
|
||||||
|
'seeders',
|
||||||
|
'addon',
|
||||||
|
'regexPatterns',
|
||||||
|
'cached',
|
||||||
|
'personal',
|
||||||
|
] as const;
|
||||||
|
const SORT_DIRECTIONS = ['asc', 'desc'] as const;
|
||||||
|
|
||||||
|
const STREAM_TYPES = [
|
||||||
|
'p2p',
|
||||||
|
'live',
|
||||||
|
'usenet',
|
||||||
|
'debrid',
|
||||||
|
'http',
|
||||||
|
'external',
|
||||||
|
'youtube',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const STREAM_RESOURCE = 'stream' as const;
|
||||||
|
const SUBTITLES_RESOURCE = 'subtitles' as const;
|
||||||
|
const CATALOG_RESOURCE = 'catalog' as const;
|
||||||
|
const META_RESOURCE = 'meta' as const;
|
||||||
|
const ADDON_CATALOG_RESOURCE = 'addon_catalog' as const;
|
||||||
|
|
||||||
|
export const MOVIE_TYPE = 'movie' as const;
|
||||||
|
export const SERIES_TYPE = 'series' as const;
|
||||||
|
export const CHANNEL_TYPE = 'channel' as const;
|
||||||
|
export const TV_TYPE = 'tv' as const;
|
||||||
|
|
||||||
|
export const TYPES = [MOVIE_TYPE, SERIES_TYPE, CHANNEL_TYPE, TV_TYPE] as const;
|
||||||
|
|
||||||
|
const RESOURCES = [
|
||||||
|
STREAM_RESOURCE,
|
||||||
|
SUBTITLES_RESOURCE,
|
||||||
|
CATALOG_RESOURCE,
|
||||||
|
META_RESOURCE,
|
||||||
|
ADDON_CATALOG_RESOURCE,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// const LANGUAGE_EMOJI_MAPPING = {
|
||||||
|
// multi: '🌎',
|
||||||
|
// english: '🇬🇧',
|
||||||
|
// japanese: '🇯🇵',
|
||||||
|
// chinese: '🇨🇳',
|
||||||
|
// russian: '🇷🇺',
|
||||||
|
// arabic: '🇸🇦',
|
||||||
|
// portuguese: '🇵🇹',
|
||||||
|
// spanish: '🇪��',
|
||||||
|
// french: '🇫🇷',
|
||||||
|
// german: '🇩🇪',
|
||||||
|
// italian: '🇮🇹',
|
||||||
|
// korean: '🇰🇷',
|
||||||
|
// hindi: '🇮🇳',
|
||||||
|
// bengali: '🇧🇩',
|
||||||
|
// punjabi: '🇵🇰',
|
||||||
|
// marathi: '🇮🇳',
|
||||||
|
// gujarati: '🇮🇳',
|
||||||
|
// tamil: '🇮🇳',
|
||||||
|
// telugu: '🇮🇳',
|
||||||
|
// kannada: '🇮🇳',
|
||||||
|
// malayalam: '🇮🇳',
|
||||||
|
// thai: '🇹🇭',
|
||||||
|
// vietnamese: '🇻🇳',
|
||||||
|
// indonesian: '🇮🇩',
|
||||||
|
// turkish: '🇹🇷',
|
||||||
|
// hebrew: '🇮🇱',
|
||||||
|
// persian: '🇮🇷',
|
||||||
|
// ukrainian: '🇺🇦',
|
||||||
|
// greek: '🇬🇷',
|
||||||
|
// lithuanian: '🇱🇹',
|
||||||
|
// latvian: '🇱🇻',
|
||||||
|
// estonian: '🇪🇪',
|
||||||
|
// polish: '🇵🇱',
|
||||||
|
// czech: '🇨🇿',
|
||||||
|
// slovak: '🇸🇰',
|
||||||
|
// hungarian: '🇭🇺',
|
||||||
|
// romanian: '🇷🇴',
|
||||||
|
// bulgarian: '🇧🇬',
|
||||||
|
// serbian: '🇷🇸',
|
||||||
|
// croatian: '🇭🇷',
|
||||||
|
// slovenian: '🇸🇮',
|
||||||
|
// dutch: '🇳🇱',
|
||||||
|
// danish: '🇩🇰',
|
||||||
|
// finnish: '🇫🇮',
|
||||||
|
// swedish: '🇸🇪',
|
||||||
|
// norwegian: '🇳🇴',
|
||||||
|
// malay: '🇲🇾',
|
||||||
|
// latino: '💃🏻',
|
||||||
|
// Latino: '🇲🇽',
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const ISO_639_1_LANGUAGE_MAPPING: Record<string, string> = {
|
||||||
|
// EN: 'english',
|
||||||
|
// JA: 'japanese',
|
||||||
|
// ZH: 'chinese',
|
||||||
|
// RU: 'russian',
|
||||||
|
// AR: 'arabic',
|
||||||
|
// PT: 'portuguese',
|
||||||
|
// ES: 'spanish',
|
||||||
|
// FR: 'french',
|
||||||
|
// DE: 'german',
|
||||||
|
// IT: 'italian',
|
||||||
|
// KO: 'korean',
|
||||||
|
// HI: 'hindi',
|
||||||
|
// BN: 'bengali',
|
||||||
|
// PA: 'punjabi',
|
||||||
|
// MR: 'marathi',
|
||||||
|
// GU: 'gujarati',
|
||||||
|
// TA: 'tamil',
|
||||||
|
// TE: 'telugu',
|
||||||
|
// KN: 'kannada',
|
||||||
|
// ML: 'malayalam',
|
||||||
|
// TH: 'thai',
|
||||||
|
// VI: 'vietnamese',
|
||||||
|
// ID: 'indonesian',
|
||||||
|
// TR: 'turkish',
|
||||||
|
// HE: 'hebrew',
|
||||||
|
// FA: 'persian',
|
||||||
|
// UK: 'ukrainian',
|
||||||
|
// EL: 'greek',
|
||||||
|
// LT: 'lithuanian',
|
||||||
|
// LV: 'latvian',
|
||||||
|
// ET: 'estonian',
|
||||||
|
// PL: 'polish',
|
||||||
|
// CS: 'czech',
|
||||||
|
// SK: 'slovak',
|
||||||
|
// HU: 'hungarian',
|
||||||
|
// RO: 'romanian',
|
||||||
|
// BG: 'bulgarian',
|
||||||
|
// SR: 'serbian',
|
||||||
|
// HR: 'croatian',
|
||||||
|
// SL: 'slovenian',
|
||||||
|
// NL: 'dutch',
|
||||||
|
// DA: 'danish',
|
||||||
|
// FI: 'finnish',
|
||||||
|
// SV: 'swedish',
|
||||||
|
// NO: 'norwegian',
|
||||||
|
// MS: 'malay',
|
||||||
|
// LA: 'latino',
|
||||||
|
// MX: 'Latino',
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // const LANGUAGES = Object.keys(LANGUAGE_EMOJI_MAPPING).map(
|
||||||
|
// // (lang) => lang.charAt(0).toUpperCase() + lang.slice(1)
|
||||||
|
// // );
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
'english',
|
||||||
|
'japanese',
|
||||||
|
'chinese',
|
||||||
|
'russian',
|
||||||
|
'arabic',
|
||||||
|
'portuguese',
|
||||||
|
'spanish',
|
||||||
|
'french',
|
||||||
|
'german',
|
||||||
|
'italian',
|
||||||
|
'korean',
|
||||||
|
'hindi',
|
||||||
|
'bengali',
|
||||||
|
'punjabi',
|
||||||
|
'marathi',
|
||||||
|
'gujarati',
|
||||||
|
'tamil',
|
||||||
|
'telugu',
|
||||||
|
'kannada',
|
||||||
|
'malayalam',
|
||||||
|
'thai',
|
||||||
|
'vietnamese',
|
||||||
|
'indonesian',
|
||||||
|
'turkish',
|
||||||
|
'hebrew',
|
||||||
|
'persian',
|
||||||
|
'ukrainian',
|
||||||
|
'greek',
|
||||||
|
'lithuanian',
|
||||||
|
'latvian',
|
||||||
|
'estonian',
|
||||||
|
'polish',
|
||||||
|
'czech',
|
||||||
|
'slovak',
|
||||||
|
'hungarian',
|
||||||
|
'romanian',
|
||||||
|
'bulgarian',
|
||||||
|
'serbian',
|
||||||
|
'croatian',
|
||||||
|
'slovenian',
|
||||||
|
'dutch',
|
||||||
|
'danish',
|
||||||
|
'finnish',
|
||||||
|
'swedish',
|
||||||
|
'norwegian',
|
||||||
|
'malay',
|
||||||
|
'latino',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SNIPPETS = [
|
||||||
|
{
|
||||||
|
name: 'Title Only',
|
||||||
|
description: 'Just the title of the stream',
|
||||||
|
value: '{title}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Title + Year',
|
||||||
|
description: 'Title and year',
|
||||||
|
value: '{title} ({year})',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Full Info',
|
||||||
|
description: 'Title, year, resolution, quality',
|
||||||
|
value: '{title} ({year}) [{resolution}] [{quality}]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export {
|
||||||
|
API_VERSION,
|
||||||
|
SERVICES,
|
||||||
|
RESOLUTIONS,
|
||||||
|
QUALITIES,
|
||||||
|
VISUAL_TAGS,
|
||||||
|
AUDIO_TAGS,
|
||||||
|
ENCODES,
|
||||||
|
SORT_CRITERIA,
|
||||||
|
SORT_DIRECTIONS,
|
||||||
|
STREAM_TYPES,
|
||||||
|
LANGUAGES,
|
||||||
|
RESOURCES,
|
||||||
|
OPTION_TYPES,
|
||||||
|
PRESET_TAGS,
|
||||||
|
STREAM_RESOURCE,
|
||||||
|
SUBTITLES_RESOURCE,
|
||||||
|
CATALOG_RESOURCE,
|
||||||
|
META_RESOURCE,
|
||||||
|
ADDON_CATALOG_RESOURCE,
|
||||||
|
REALDEBRID_SERVICE,
|
||||||
|
PREMIUMIZE_SERVICE,
|
||||||
|
ALLEDEBRID_SERVICE,
|
||||||
|
TORBOX_SERVICE,
|
||||||
|
EASYDEBRID_SERVICE,
|
||||||
|
PUTIO_SERVICE,
|
||||||
|
PIKPAK_SERVICE,
|
||||||
|
OFFCLOUD_SERVICE,
|
||||||
|
SEEDR_SERVICE,
|
||||||
|
EASYNEWS_SERVICE,
|
||||||
|
SERVICE_DETAILS,
|
||||||
|
STRING_OPTION_TYPE,
|
||||||
|
NUMBER_OPTION_TYPE,
|
||||||
|
BOOLEAN_OPTION_TYPE,
|
||||||
|
SELECT_OPTION_TYPE,
|
||||||
|
MULTI_SELECT_OPTION_TYPE,
|
||||||
|
P2P_TAG,
|
||||||
|
DIRECT_TAG,
|
||||||
|
DEBRID_TAG,
|
||||||
|
USENET_TAG,
|
||||||
|
HEADERS_FOR_IP_FORWARDING,
|
||||||
|
};
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import {
|
||||||
|
randomBytes,
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
pbkdf2Sync,
|
||||||
|
randomUUID,
|
||||||
|
} from 'crypto';
|
||||||
|
import { deflateSync, inflateSync } from 'zlib';
|
||||||
|
import { Env } from './env';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
|
||||||
|
const logger = createLogger('crypto');
|
||||||
|
|
||||||
|
const compressData = (data: string): Buffer => {
|
||||||
|
return deflateSync(Buffer.from(data, 'utf-8'), {
|
||||||
|
level: 9,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const decompressData = (data: Buffer): string => {
|
||||||
|
return inflateSync(data).toString('utf-8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const encryptData = (
|
||||||
|
secretKey: Buffer,
|
||||||
|
data: Buffer
|
||||||
|
): { iv: string; data: string } => {
|
||||||
|
// Then encrypt the compressed data
|
||||||
|
const iv = randomBytes(16);
|
||||||
|
const cipher = createCipheriv('aes-256-cbc', secretKey, iv);
|
||||||
|
|
||||||
|
const encryptedData = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
data: encryptedData.toString('base64'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptData = (
|
||||||
|
secretKey: Buffer,
|
||||||
|
encryptedData: Buffer,
|
||||||
|
iv: Buffer
|
||||||
|
): Buffer => {
|
||||||
|
const decipher = createDecipheriv('aes-256-cbc', secretKey, iv);
|
||||||
|
|
||||||
|
// Decrypt the data
|
||||||
|
const decryptedData = Buffer.concat([
|
||||||
|
decipher.update(encryptedData),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decryptedData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SuccessResponse = {
|
||||||
|
success: true;
|
||||||
|
data: string;
|
||||||
|
error: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorResponse = {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
data: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Response = SuccessResponse | ErrorResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts a string using AES-256-CBC encryption, returns a string in the format "iv:encrypted" where
|
||||||
|
* iv and encrypted are url encoded.
|
||||||
|
* @param data Data to encrypt
|
||||||
|
* @param secretKey Secret key used for encryption
|
||||||
|
* @returns Encrypted data or error message
|
||||||
|
*/
|
||||||
|
export function encryptString(data: string, secretKey?: Buffer): Response {
|
||||||
|
if (!secretKey) {
|
||||||
|
secretKey = Buffer.from(Env.SECRET_KEY, 'hex');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const compressed = compressData(data);
|
||||||
|
const { iv, data: encrypted } = encryptData(secretKey, compressed);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: `${encodeURIComponent(iv)}:${encodeURIComponent(encrypted)}`,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Failed to encrypt data: ${error.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a string using AES-256-CBC encryption
|
||||||
|
* @param data Encrypted data to decrypt
|
||||||
|
* @param secretKey Secret key used for encryption
|
||||||
|
* @returns Decrypted data or error message
|
||||||
|
*/
|
||||||
|
export function decryptString(data: string, secretKey?: Buffer): Response {
|
||||||
|
if (!secretKey) {
|
||||||
|
secretKey = Buffer.from(Env.SECRET_KEY, 'hex');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [ivHex, encryptedHex] = data.split(':').map(decodeURIComponent);
|
||||||
|
const iv = Buffer.from(ivHex, 'base64');
|
||||||
|
const encrypted = Buffer.from(encryptedHex, 'base64');
|
||||||
|
const decrypted = decryptData(secretKey, encrypted, iv);
|
||||||
|
const decompressed = decompressData(decrypted);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: decompressed,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`Failed to decrypt data: ${error.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSimpleTextHash(text: string): string {
|
||||||
|
// Use SHA-256 for a simple hash for non-sensitive data
|
||||||
|
const hash = createHash('sha256').update(text).digest('hex');
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a secure hash of text using PBKDF2
|
||||||
|
* @param text Text to hash
|
||||||
|
* @param salt Optional salt, will be generated if not provided
|
||||||
|
* @param iterations Number of iterations for PBKDF2, defaults to 10000
|
||||||
|
* @returns Object containing the hash and salt used
|
||||||
|
*/
|
||||||
|
export function getTextHash(
|
||||||
|
text: string,
|
||||||
|
salt?: string,
|
||||||
|
iterations: number = 10000
|
||||||
|
): { hash: string; salt: string } {
|
||||||
|
// Generate a random salt if not provided
|
||||||
|
const usedSalt = salt || randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
// Use PBKDF2 with SHA-512
|
||||||
|
const hash = createHash('sha512')
|
||||||
|
.update(text + usedSalt)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
// Apply multiple iterations
|
||||||
|
let result = hash;
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
result = createHash('sha512')
|
||||||
|
.update(result + usedSalt)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hash: result, salt: usedSalt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies if the provided text matches a previously generated hash
|
||||||
|
* @param text Text to verify
|
||||||
|
* @param storedHash Previously generated hash
|
||||||
|
* @param salt Salt used in the original hash
|
||||||
|
* @param iterations Number of iterations used in the original hash
|
||||||
|
* @returns Boolean indicating if the text matches the hash
|
||||||
|
*/
|
||||||
|
export function verifyHash(
|
||||||
|
text: string,
|
||||||
|
storedHash: string,
|
||||||
|
salt: string,
|
||||||
|
iterations: number = 10000
|
||||||
|
): boolean {
|
||||||
|
const { hash } = getTextHash(text, salt, iterations);
|
||||||
|
return hash === storedHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a 64 character hex string from a password using PBKDF2
|
||||||
|
* @param password Password to derive key from
|
||||||
|
* @param salt Optional salt, will be generated if not provided
|
||||||
|
* @returns Object containing the key and salt used
|
||||||
|
*/
|
||||||
|
export function deriveKey(
|
||||||
|
password: string,
|
||||||
|
salt?: string
|
||||||
|
): { key: Buffer; salt: string } {
|
||||||
|
salt = salt || randomBytes(16).toString('hex');
|
||||||
|
const key = pbkdf2Sync(
|
||||||
|
Buffer.from(password, 'utf-8'),
|
||||||
|
Buffer.from(salt, 'hex'),
|
||||||
|
100000,
|
||||||
|
32,
|
||||||
|
'sha512'
|
||||||
|
);
|
||||||
|
return { key, salt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUUID(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import p from '../package.json';
|
|
||||||
import {
|
import {
|
||||||
cleanEnv,
|
cleanEnv,
|
||||||
str,
|
str,
|
||||||
@@ -12,73 +11,69 @@ import {
|
|||||||
EnvError,
|
EnvError,
|
||||||
port,
|
port,
|
||||||
} from 'envalid';
|
} from 'envalid';
|
||||||
|
import { ResourceManager } from './resources';
|
||||||
|
import * as constants from './constants';
|
||||||
try {
|
try {
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading .env file', error);
|
console.error('Error loading .env file', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// define default timeouts and urls here so we can use them in the validators
|
let metadata: any = undefined;
|
||||||
// so we can use them in the validators
|
try {
|
||||||
const DEFAULT_TIMEOUT = 15000; // 15 seconds
|
metadata = ResourceManager.getResource('metadata.json') || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading metadata.json file', error);
|
||||||
|
}
|
||||||
|
|
||||||
const secretKey = makeValidator((x) => {
|
const secretKey = makeValidator((x) => {
|
||||||
if (typeof x !== 'string') {
|
if (!/^[0-9a-fA-F]{64}$/.test(x)) {
|
||||||
throw new Error('Secret key must be a string');
|
throw new EnvError('Secret key must be a 64-character hex string');
|
||||||
}
|
}
|
||||||
// backwards compatibility for 32 character secret keys
|
return x;
|
||||||
if (x.length === 32) {
|
});
|
||||||
return x;
|
|
||||||
|
const regexes = makeValidator((x) => {
|
||||||
|
// json array of string
|
||||||
|
const parsed = JSON.parse(x);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new EnvError('Regexes must be an array');
|
||||||
}
|
}
|
||||||
if (x.length === 64) {
|
// each element must be a string
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(x)) {
|
parsed.forEach((x) => {
|
||||||
throw new EnvError('64-character secret key must be a hex string');
|
if (typeof x !== 'string') {
|
||||||
|
throw new EnvError('Regexes must be an array of strings');
|
||||||
}
|
}
|
||||||
return x;
|
|
||||||
}
|
|
||||||
throw new EnvError('Secret key must be a 64-character hex string');
|
|
||||||
});
|
|
||||||
|
|
||||||
const regex = makeValidator((x) => {
|
|
||||||
if (typeof x !== 'string') {
|
|
||||||
throw new Error('Regex pattern must be a string');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
new RegExp(x);
|
|
||||||
return x;
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`Invalid regex pattern: ${x}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const multipleRegex = makeValidator((x) => {
|
|
||||||
if (typeof x !== 'string') {
|
|
||||||
throw new EnvError('Regex pattern must be a string');
|
|
||||||
}
|
|
||||||
|
|
||||||
const patterns = x.split(/\s+/).filter(Boolean);
|
|
||||||
if (patterns.length > parseInt(process.env.MAX_REGEX_SORT_PATTERNS || '30')) {
|
|
||||||
throw new EnvError(
|
|
||||||
`Too many regex sort patterns in environment variables (max is ${process.env.MAX_REGEX_SORT_PATTERNS || '30'})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// try compiling each pattern to check for validity
|
|
||||||
// each "pattern" is regexName::regexPattern, where regexName is optional
|
|
||||||
// we need to only extract the regexPattern part,
|
|
||||||
patterns.forEach((p) => {
|
|
||||||
try {
|
try {
|
||||||
const delimiterIndex = p.indexOf('<::>');
|
new RegExp(x);
|
||||||
if (delimiterIndex !== -1) {
|
|
||||||
p = p.slice(delimiterIndex + 2);
|
|
||||||
}
|
|
||||||
new RegExp(p);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new EnvError(`Invalid regex pattern: ${p}`);
|
throw new EnvError(`Invalid regex pattern: ${x}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return parsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const namedRegexes = makeValidator((x) => {
|
||||||
|
// array of objects with properties name and pattern
|
||||||
|
const parsed = JSON.parse(x);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new EnvError('Named regexes must be an array');
|
||||||
|
}
|
||||||
|
// each element must be an object with properties name and pattern
|
||||||
|
parsed.forEach((x) => {
|
||||||
|
if (typeof x !== 'object' || !x.name || !x.pattern) {
|
||||||
|
throw new EnvError(
|
||||||
|
'Named regexes must be an array of objects with properties name and pattern'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new RegExp(x.pattern);
|
||||||
|
} catch (e) {
|
||||||
|
throw new EnvError(`Invalid regex pattern: ${x.pattern}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// return normal input
|
return parsed;
|
||||||
return x;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = makeValidator((x) => {
|
const url = makeValidator((x) => {
|
||||||
@@ -90,7 +85,8 @@ const url = makeValidator((x) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new EnvError(`Invalid URL: ${x}`);
|
throw new EnvError(`Invalid URL: ${x}`);
|
||||||
}
|
}
|
||||||
return x.endsWith('/') ? x : `${x}/`;
|
// remove trailing slash
|
||||||
|
return x.endsWith('/') ? x.slice(0, -1) : x;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const forcedPort = makeValidator<string>((input: string) => {
|
export const forcedPort = makeValidator<string>((input: string) => {
|
||||||
@@ -116,36 +112,88 @@ const userAgent = makeValidator((x) => {
|
|||||||
throw new Error('User agent must be a string');
|
throw new Error('User agent must be a string');
|
||||||
}
|
}
|
||||||
// replace {version} with the version of the addon
|
// replace {version} with the version of the addon
|
||||||
return x.replace(/{version}/g, p.version);
|
return x.replace(/{version}/g, metadata?.version || 'unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
const customConfigs = makeValidator((x) => {
|
// comma separated list of alias:uuid
|
||||||
|
const aliasedUUIDs = makeValidator((x) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(x);
|
// const parsed = JSON.parse(x);
|
||||||
if (typeof parsed !== 'object') {
|
// if (typeof parsed !== 'object') {
|
||||||
throw new Error('Custom configs must be an object');
|
// throw new Error('Custom configs must be an object');
|
||||||
}
|
// }
|
||||||
// must be a simple key-value object, of string-string
|
// // must be a simple key-value object, of string-string
|
||||||
for (const key in parsed) {
|
// for (const key in parsed) {
|
||||||
if (typeof parsed[key] !== 'string') {
|
// if (typeof parsed[key] !== 'string') {
|
||||||
throw new Error(
|
// throw new Error(
|
||||||
`Custom config ${key} must be a string, got ${typeof parsed[key]}`
|
// `Custom config ${key} must be a string, got ${typeof parsed[key]}`
|
||||||
);
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return parsed;
|
||||||
|
const aliases: Record<string, string> = {};
|
||||||
|
const parsed = x.split(',').map((x) => {
|
||||||
|
const [alias, uuid] = x.split(':');
|
||||||
|
if (!alias || !uuid) {
|
||||||
|
throw new Error('Invalid alias:uuid pair');
|
||||||
|
} else if (
|
||||||
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||||||
|
uuid
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid UUID');
|
||||||
}
|
}
|
||||||
}
|
aliases[alias] = uuid;
|
||||||
return parsed;
|
});
|
||||||
|
return aliases;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('Custom configs must be a valid JSON string');
|
throw new Error('Custom configs must be a valid JSON string');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Settings = cleanEnv(process.env, {
|
const readonly = makeValidator((x) => {
|
||||||
|
if (x) {
|
||||||
|
throw new EnvError('Readonly environment variable, cannot be set');
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Env = cleanEnv(process.env, {
|
||||||
|
VERSION: readonly({
|
||||||
|
default: metadata?.version || 'unknown',
|
||||||
|
desc: 'Version of the addon',
|
||||||
|
}),
|
||||||
|
DESCRIPTION: readonly({
|
||||||
|
default: metadata?.description || 'unknown',
|
||||||
|
desc: 'Description of the addon',
|
||||||
|
}),
|
||||||
|
NODE_ENV: str({
|
||||||
|
default: 'production',
|
||||||
|
desc: 'Node environment of the addon',
|
||||||
|
choices: ['production', 'development', 'test'],
|
||||||
|
}),
|
||||||
|
GIT_COMMIT: readonly({
|
||||||
|
default: metadata?.commitHash || 'unknown',
|
||||||
|
desc: 'Git commit hash of the addon',
|
||||||
|
}),
|
||||||
|
GIT_TAG: readonly({
|
||||||
|
default: metadata?.latestTag || 'unknown',
|
||||||
|
desc: 'Git tag of the addon',
|
||||||
|
}),
|
||||||
|
BUILD_TIME: readonly({
|
||||||
|
default: metadata?.buildTime || 'unknown',
|
||||||
|
desc: 'Build time of the addon',
|
||||||
|
}),
|
||||||
|
BUILD_COMMIT_TIME: readonly({
|
||||||
|
default: metadata?.commitTime || 'unknown',
|
||||||
|
desc: 'Build commit time of the addon',
|
||||||
|
}),
|
||||||
ADDON_NAME: str({
|
ADDON_NAME: str({
|
||||||
default: 'AIOStreams',
|
default: 'AIOStreams',
|
||||||
desc: 'Name of the addon',
|
desc: 'Name of the addon',
|
||||||
}),
|
}),
|
||||||
ADDON_ID: str({
|
ADDON_ID: str({
|
||||||
default: 'aiostreams.viren070.com',
|
default: 'com.aiostreams.viren070',
|
||||||
desc: 'ID of the addon',
|
desc: 'ID of the addon',
|
||||||
}),
|
}),
|
||||||
PORT: port({
|
PORT: port({
|
||||||
@@ -157,8 +205,8 @@ export const Settings = cleanEnv(process.env, {
|
|||||||
desc: 'Branding for the addon',
|
desc: 'Branding for the addon',
|
||||||
}),
|
}),
|
||||||
SECRET_KEY: secretKey({
|
SECRET_KEY: secretKey({
|
||||||
default: '',
|
|
||||||
desc: 'Secret key for the addon, used for encryption and must be 64 characters of hex',
|
desc: 'Secret key for the addon, used for encryption and must be 64 characters of hex',
|
||||||
|
default: '',
|
||||||
}),
|
}),
|
||||||
API_KEY: str({
|
API_KEY: str({
|
||||||
default: '',
|
default: '',
|
||||||
@@ -172,19 +220,29 @@ export const Settings = cleanEnv(process.env, {
|
|||||||
default: true,
|
default: true,
|
||||||
desc: 'Deterministic addon ID',
|
desc: 'Deterministic addon ID',
|
||||||
}),
|
}),
|
||||||
|
DATABASE_URI: str({
|
||||||
|
default: 'sqlite://./data/db.sqlite',
|
||||||
|
desc: 'Database URI for the addon',
|
||||||
|
}),
|
||||||
ADDON_PROXY: url({
|
ADDON_PROXY: url({
|
||||||
default: '',
|
default: undefined,
|
||||||
desc: 'Proxy URL for the addon',
|
desc: 'Proxy URL for the addon',
|
||||||
}),
|
}),
|
||||||
ADDON_PROXY_CONFIG: str({
|
ADDON_PROXY_CONFIG: str({
|
||||||
default: undefined,
|
default: undefined,
|
||||||
desc: 'Proxy config for the addon in format of comma separated hostname:boolean',
|
desc: 'Proxy config for the addon in format of comma separated hostname:boolean',
|
||||||
}),
|
}),
|
||||||
|
ALIASED_UUIDS: aliasedUUIDs({
|
||||||
CUSTOM_CONFIGS: customConfigs<Record<string, string>>({
|
|
||||||
default: {},
|
default: {},
|
||||||
desc: 'Custom configs for the addon in JSON key value format, using the key as the config name and the value as the config string',
|
desc: 'Comma separated list of alias:uuid pairs.',
|
||||||
|
}),
|
||||||
|
ADMIN_UUIDS: str({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Comma separated list of admin UUIDs. Admin UUIDs can use regex features.',
|
||||||
|
}),
|
||||||
|
TMDB_ACCESS_TOKEN: str({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'TMDB Read Access Token. Used for fetching metadata for the strict title matching option.',
|
||||||
}),
|
}),
|
||||||
DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE: bool({
|
DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE: bool({
|
||||||
default: false,
|
default: false,
|
||||||
@@ -199,22 +257,16 @@ export const Settings = cleanEnv(process.env, {
|
|||||||
LOG_LEVEL: str({
|
LOG_LEVEL: str({
|
||||||
default: 'info',
|
default: 'info',
|
||||||
desc: 'Log level for the addon',
|
desc: 'Log level for the addon',
|
||||||
choices: ['info', 'debug', 'warn', 'error'],
|
choices: ['info', 'debug', 'warn', 'error', 'verbose', 'silly', 'http'],
|
||||||
}),
|
}),
|
||||||
LOG_FORMAT: str({
|
LOG_FORMAT: str({
|
||||||
default: 'text',
|
default: 'text',
|
||||||
desc: 'Log format for the addon',
|
desc: 'Log format for the addon',
|
||||||
choices: ['text', 'json'],
|
choices: ['text', 'json'],
|
||||||
}),
|
}),
|
||||||
|
LOG_TIMEZONE: str({
|
||||||
DISABLE_TORRENTIO: bool({
|
default: 'UTC',
|
||||||
default: false,
|
desc: 'Timezone for log timestamps (e.g., America/New_York, Europe/London)',
|
||||||
desc: 'Disable Torrentio addon',
|
|
||||||
}),
|
|
||||||
DISABLE_TORRENTIO_MESSAGE: str({
|
|
||||||
default:
|
|
||||||
'The Torrentio addon has been disabled, please remove it to use this addon.',
|
|
||||||
desc: 'Message to show when the Torrentio addon is disabled',
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
STREMIO_ADDONS_CONFIG_ISSUER: url({
|
STREMIO_ADDONS_CONFIG_ISSUER: url({
|
||||||
@@ -227,7 +279,7 @@ export const Settings = cleanEnv(process.env, {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
DEFAULT_USER_AGENT: userAgent({
|
DEFAULT_USER_AGENT: userAgent({
|
||||||
default: `AIOStreams/${p.version}`,
|
default: `AIOStreams/${metadata?.version || 'unknown'}`,
|
||||||
desc: 'Default user agent for the addon',
|
desc: 'Default user agent for the addon',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -281,58 +333,141 @@ export const Settings = cleanEnv(process.env, {
|
|||||||
desc: 'Default timeout for the addon',
|
desc: 'Default timeout for the addon',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
DEFAULT_REGEX_EXCLUDE_PATTERN: regex({
|
DEFAULT_REQUIRED_REGEX_PATTERNS: regexes({
|
||||||
default: undefined,
|
default: [],
|
||||||
desc: 'Default regex exclude pattern',
|
desc: 'Default regex exclude pattern',
|
||||||
}),
|
}),
|
||||||
DEFAULT_REGEX_INCLUDE_PATTERN: regex({
|
DEFAULT_EXCLUDED_REGEX_PATTERNS: regexes({
|
||||||
default: undefined,
|
default: [],
|
||||||
desc: 'Default regex include pattern',
|
desc: 'Default regex include pattern',
|
||||||
}),
|
}),
|
||||||
DEFAULT_REGEX_SORT_PATTERNS: multipleRegex({
|
DEFAULT_PREFERRED_REGEX_PATTERNS: namedRegexes({
|
||||||
default: undefined,
|
default: [],
|
||||||
desc: 'Default regex sort patterns',
|
desc: 'Default regex sort patterns',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// MediaFlow settings
|
FORCE_PROXY_ENABLED: bool({
|
||||||
DEFAULT_MEDIAFLOW_URL: url({
|
default: undefined,
|
||||||
default: '',
|
desc: 'Force proxy enabled',
|
||||||
desc: 'Default MediaFlow URL',
|
|
||||||
}),
|
}),
|
||||||
DEFAULT_MEDIAFLOW_API_PASSWORD: str({
|
FORCE_PROXY_ID: str({
|
||||||
default: '',
|
default: undefined,
|
||||||
desc: 'Default MediaFlow API password',
|
desc: 'Force proxy id',
|
||||||
|
choices: constants.PROXY_SERVICES,
|
||||||
}),
|
}),
|
||||||
DEFAULT_MEDIAFLOW_PUBLIC_IP: str({
|
FORCE_PROXY_URL: url({
|
||||||
default: '',
|
default: undefined,
|
||||||
desc: 'Default MediaFlow public IP',
|
desc: 'Force proxy url',
|
||||||
}),
|
}),
|
||||||
MEDIAFLOW_IP_TIMEOUT: num({
|
FORCE_PROXY_CREDENTIALS: str({
|
||||||
default: 30000,
|
default: undefined,
|
||||||
desc: 'MediaFlow IP timeout',
|
desc: 'Force proxy credentials',
|
||||||
}),
|
}),
|
||||||
|
FORCE_PROXY_PUBLIC_IP: str({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Force proxy public ip',
|
||||||
|
}),
|
||||||
|
FORCE_PROXY_PROXIED_ADDONS: json({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Force proxy proxied addons',
|
||||||
|
}),
|
||||||
|
FORCE_PROXY_PROXIED_SERVICES: json({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Force proxy proxied services',
|
||||||
|
}),
|
||||||
|
|
||||||
|
DEFAULT_PROXY_ENABLED: bool({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Default proxy enabled',
|
||||||
|
}),
|
||||||
|
DEFAULT_PROXY_ID: str({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Default proxy id',
|
||||||
|
}),
|
||||||
|
DEFAULT_PROXY_URL: url({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Default proxy url',
|
||||||
|
}),
|
||||||
|
DEFAULT_PROXY_CREDENTIALS: str({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Default proxy credentials',
|
||||||
|
}),
|
||||||
|
DEFAULT_PROXY_PUBLIC_IP: str({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Default proxy public ip',
|
||||||
|
}),
|
||||||
|
DEFAULT_PROXY_PROXIED_ADDONS: json({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Default proxy proxied addons',
|
||||||
|
}),
|
||||||
|
DEFAULT_PROXY_PROXIED_SERVICES: json({
|
||||||
|
default: undefined,
|
||||||
|
desc: 'Default proxy proxied services',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// // MediaFlow settings
|
||||||
|
// FORCE_MEDIAFLOW_URL: url({
|
||||||
|
// default: undefined,
|
||||||
|
// desc: 'Force MediaFlow URL',
|
||||||
|
// }),
|
||||||
|
// FORCE_MEDIAFLOW_API_PASSWORD: str({
|
||||||
|
// default: undefined,
|
||||||
|
// desc: 'Force MediaFlow API password',
|
||||||
|
// }),
|
||||||
|
// FORCE_MEDIAFLOW_PUBLIC_IP: str({
|
||||||
|
// default: undefined,
|
||||||
|
// desc: 'Force MediaFlow public IP',
|
||||||
|
// }),
|
||||||
|
// DEFAULT_MEDIAFLOW_URL: url({
|
||||||
|
// default: '',
|
||||||
|
// desc: 'Default MediaFlow URL',
|
||||||
|
// }),
|
||||||
|
// DEFAULT_MEDIAFLOW_API_PASSWORD: str({
|
||||||
|
// default: '',
|
||||||
|
// desc: 'Default MediaFlow API password',
|
||||||
|
// }),
|
||||||
|
// DEFAULT_MEDIAFLOW_PUBLIC_IP: str({
|
||||||
|
// default: '',
|
||||||
|
// desc: 'Default MediaFlow public IP',
|
||||||
|
// }),
|
||||||
|
// MEDIAFLOW_IP_TIMEOUT: num({
|
||||||
|
// default: 30000,
|
||||||
|
// desc: 'MediaFlow IP timeout',
|
||||||
|
// }),
|
||||||
ENCRYPT_MEDIAFLOW_URLS: bool({
|
ENCRYPT_MEDIAFLOW_URLS: bool({
|
||||||
default: true,
|
default: true,
|
||||||
desc: 'Encrypt MediaFlow URLs',
|
desc: 'Encrypt MediaFlow URLs',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// StremThru settings
|
// // StremThru settings
|
||||||
DEFAULT_STREMTHRU_URL: url({
|
// FORCE_STREMTHRU_URL: url({
|
||||||
default: '',
|
// default: undefined,
|
||||||
desc: 'Default StremThru URL',
|
// desc: 'Force StremThru URL',
|
||||||
}),
|
// }),
|
||||||
DEFAULT_STREMTHRU_CREDENTIAL: str({
|
// FORCE_STREMTHRU_CREDENTIAL: str({
|
||||||
default: '',
|
// default: undefined,
|
||||||
desc: 'Default StremThru credential',
|
// desc: 'Force StremThru credential',
|
||||||
}),
|
// }),
|
||||||
DEFAULT_STREMTHRU_PUBLIC_IP: str({
|
// FORCE_STREMTHRU_PUBLIC_IP: str({
|
||||||
default: '',
|
// default: undefined,
|
||||||
desc: 'Default StremThru public IP',
|
// desc: 'Force StremThru public IP',
|
||||||
}),
|
// }),
|
||||||
STREMTHRU_TIMEOUT: num({
|
// DEFAULT_STREMTHRU_URL: url({
|
||||||
default: 30000,
|
// default: '',
|
||||||
desc: 'StremThru timeout',
|
// desc: 'Default StremThru URL',
|
||||||
}),
|
// }),
|
||||||
|
// DEFAULT_STREMTHRU_CREDENTIAL: str({
|
||||||
|
// default: '',
|
||||||
|
// desc: 'Default StremThru credential',
|
||||||
|
// }),
|
||||||
|
// DEFAULT_STREMTHRU_PUBLIC_IP: str({
|
||||||
|
// default: '',
|
||||||
|
// desc: 'Default StremThru public IP',
|
||||||
|
// }),
|
||||||
|
// STREMTHRU_TIMEOUT: num({
|
||||||
|
// default: 30000,
|
||||||
|
// desc: 'StremThru timeout',
|
||||||
|
// }),
|
||||||
ENCRYPT_STREMTHRU_URLS: bool({
|
ENCRYPT_STREMTHRU_URLS: bool({
|
||||||
default: true,
|
default: true,
|
||||||
desc: 'Encrypt StremThru URLs',
|
desc: 'Encrypt StremThru URLs',
|
||||||
@@ -595,4 +730,56 @@ export const Settings = cleanEnv(process.env, {
|
|||||||
default: undefined,
|
default: undefined,
|
||||||
desc: 'Default GDrive user agent',
|
desc: 'Default GDrive user agent',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Rate limiting settings
|
||||||
|
DISABLE_RATE_LIMITS: bool({
|
||||||
|
default: false,
|
||||||
|
desc: 'Disable rate limiting',
|
||||||
|
}),
|
||||||
|
// RATE_LIMIT_WINDOW: num({
|
||||||
|
// default: 60, // 1 minute
|
||||||
|
// desc: 'Time window for rate limiting in seconds',
|
||||||
|
// }),
|
||||||
|
// RATE_LIMIT_MAX_REQUESTS: num({
|
||||||
|
// default: 20, // allow 100 requests per IP per minute
|
||||||
|
// desc: 'Maximum number of requests allowed per IP within the time window',
|
||||||
|
// })
|
||||||
|
STATIC_RATE_LIMIT_WINDOW: num({
|
||||||
|
default: 60, // 1 minute
|
||||||
|
desc: 'Time window for static file serving rate limiting in seconds',
|
||||||
|
}),
|
||||||
|
STATIC_RATE_LIMIT_MAX_REQUESTS: num({
|
||||||
|
default: 100, // allow 100 requests per IP per minute
|
||||||
|
desc: 'Maximum number of requests allowed per IP within the time window',
|
||||||
|
}),
|
||||||
|
USER_API_RATE_LIMIT_WINDOW: num({
|
||||||
|
default: 60, // 1 minute
|
||||||
|
desc: 'Time window for user API rate limiting in seconds',
|
||||||
|
}),
|
||||||
|
USER_API_RATE_LIMIT_MAX_REQUESTS: num({
|
||||||
|
default: 20, // allow 100 requests per IP per minute
|
||||||
|
}),
|
||||||
|
STREAM_API_RATE_LIMIT_WINDOW: num({
|
||||||
|
default: 60, // 1 minute
|
||||||
|
desc: 'Time window for stream API rate limiting in seconds',
|
||||||
|
}),
|
||||||
|
STREAM_API_RATE_LIMIT_MAX_REQUESTS: num({
|
||||||
|
default: 20, // allow 100 requests per IP per minute
|
||||||
|
}),
|
||||||
|
STREMIO_STREAM_RATE_LIMIT_WINDOW: num({
|
||||||
|
default: 60, // 1 minute
|
||||||
|
desc: 'Time window for Stremio stream rate limiting in seconds',
|
||||||
|
}),
|
||||||
|
STREMIO_STREAM_RATE_LIMIT_MAX_REQUESTS: num({
|
||||||
|
default: 100, // allow 100 requests per IP per minute
|
||||||
|
desc: 'Maximum number of requests allowed per IP within the time window',
|
||||||
|
}),
|
||||||
|
STREMIO_CATALOG_RATE_LIMIT_WINDOW: num({
|
||||||
|
default: 60, // 1 minute
|
||||||
|
desc: 'Time window for Stremio catalog rate limiting in seconds',
|
||||||
|
}),
|
||||||
|
STREMIO_CATALOG_RATE_LIMIT_MAX_REQUESTS: num({
|
||||||
|
default: 100, // allow 100 requests per IP per minute
|
||||||
|
desc: 'Maximum number of requests allowed per IP within the time window',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { HEADERS_FOR_IP_FORWARDING } from './constants';
|
||||||
|
import { Env } from './env';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
import { fetch, ProxyAgent } from 'undici';
|
||||||
|
|
||||||
|
const logger = createLogger('http');
|
||||||
|
|
||||||
|
export function makeRequest(
|
||||||
|
url: string,
|
||||||
|
timeout: number,
|
||||||
|
headers: HeadersInit = {},
|
||||||
|
forwardIp?: string
|
||||||
|
) {
|
||||||
|
const useProxy = shouldProxy(url);
|
||||||
|
headers = new Headers(headers);
|
||||||
|
if (forwardIp) {
|
||||||
|
for (const header of HEADERS_FOR_IP_FORWARDING) {
|
||||||
|
headers.set(header, forwardIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = fetch(url, {
|
||||||
|
dispatcher: useProxy ? new ProxyAgent(Env.ADDON_PROXY!) : undefined,
|
||||||
|
method: 'GET',
|
||||||
|
headers: headers,
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldProxy(url: string) {
|
||||||
|
let shouldProxy = false;
|
||||||
|
let hostname: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hostname = new URL(url).hostname;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Env.ADDON_PROXY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldProxy = true;
|
||||||
|
if (Env.ADDON_PROXY_CONFIG) {
|
||||||
|
for (const rule of Env.ADDON_PROXY_CONFIG.split(',')) {
|
||||||
|
const [ruleHostname, ruleShouldProxy] = rule.split(':');
|
||||||
|
if (['true', 'false'].includes(ruleShouldProxy) === false) {
|
||||||
|
logger.error(`Invalid proxy config: ${rule}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ruleHostname === '*') {
|
||||||
|
shouldProxy = !(ruleShouldProxy === 'false');
|
||||||
|
} else if (ruleHostname.startsWith('*')) {
|
||||||
|
if (hostname.endsWith(ruleHostname.slice(1))) {
|
||||||
|
shouldProxy = !(ruleShouldProxy === 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hostname === ruleHostname) {
|
||||||
|
shouldProxy = !(ruleShouldProxy === 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldProxy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export * from './cache';
|
||||||
|
export * from './constants';
|
||||||
|
export * from './env';
|
||||||
|
export * from './logger';
|
||||||
|
export * from './resources';
|
||||||
|
export * from './stremio';
|
||||||
|
export * from './crypto';
|
||||||
|
export * from './http';
|
||||||
|
// export * from './metadata';
|
||||||
|
export * as constants from './constants';
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
import moment from 'moment-timezone';
|
||||||
|
import { Env } from './env';
|
||||||
|
|
||||||
|
// Map log levels to their full names
|
||||||
|
const levelMap: { [key: string]: string } = {
|
||||||
|
error: 'ERROR',
|
||||||
|
warn: 'WARNING',
|
||||||
|
info: 'INFO',
|
||||||
|
debug: 'DEBUG',
|
||||||
|
verbose: 'VERBOSE',
|
||||||
|
silly: 'SILLY',
|
||||||
|
http: 'HTTP',
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleMap: { [key: string]: string } = {
|
||||||
|
server: '🌐 SERVER',
|
||||||
|
wrappers: '📦 WRAPPERS',
|
||||||
|
crypto: '🔒 CRYPTO',
|
||||||
|
core: '⚡ CORE',
|
||||||
|
parser: '🔍 PARSER',
|
||||||
|
mediaflow: '🌊 MEDIAFLOW',
|
||||||
|
stremthru: '✨ STREMTHRU',
|
||||||
|
cache: '🗄️ CACHE',
|
||||||
|
regex: '🅰️ REGEX',
|
||||||
|
database: '🗃️ DATABASE',
|
||||||
|
users: '👤 USERS',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define colors for each log level using full names
|
||||||
|
const levelColors: { [key: string]: string } = {
|
||||||
|
ERROR: 'red',
|
||||||
|
WARNING: 'yellow',
|
||||||
|
INFO: 'cyan',
|
||||||
|
DEBUG: 'magenta',
|
||||||
|
HTTP: 'green',
|
||||||
|
VERBOSE: 'blue',
|
||||||
|
SILLY: 'grey',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emojiLevelMap: { [key: string]: string } = {
|
||||||
|
error: '❌',
|
||||||
|
warn: '⚠️ ',
|
||||||
|
info: '🔵',
|
||||||
|
debug: '🐞',
|
||||||
|
verbose: '🔍',
|
||||||
|
silly: '🤪',
|
||||||
|
http: '🌐',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate the maximum level name length for padding
|
||||||
|
const MAX_LEVEL_LENGTH = Math.max(
|
||||||
|
...Object.values(levelMap).map((level) => level.length)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply colors to Winston
|
||||||
|
winston.addColors(levelColors);
|
||||||
|
|
||||||
|
export const createLogger = (module: string) => {
|
||||||
|
const isJsonFormat = Env.LOG_FORMAT === 'json';
|
||||||
|
const timezone = Env.LOG_TIMEZONE;
|
||||||
|
|
||||||
|
const timestampFormat = winston.format((info) => {
|
||||||
|
info.timestamp = moment().tz(timezone).format('YYYY-MM-DD HH:mm:ss.SSS z');
|
||||||
|
return info;
|
||||||
|
});
|
||||||
|
|
||||||
|
return winston.createLogger({
|
||||||
|
level: Env.LOG_LEVEL,
|
||||||
|
format: isJsonFormat
|
||||||
|
? winston.format.combine(timestampFormat(), winston.format.json())
|
||||||
|
: winston.format.combine(
|
||||||
|
timestampFormat(),
|
||||||
|
winston.format.printf(({ timestamp, level, message, ...rest }) => {
|
||||||
|
const emoji = emojiLevelMap[level] || '';
|
||||||
|
const formattedModule = moduleMap[module] || module;
|
||||||
|
// Get full level name and pad it for centering
|
||||||
|
const fullLevel = levelMap[level] || level.toUpperCase();
|
||||||
|
const padding = Math.floor(
|
||||||
|
(MAX_LEVEL_LENGTH - fullLevel.length) / 2
|
||||||
|
);
|
||||||
|
const paddedLevel =
|
||||||
|
' '.repeat(padding) +
|
||||||
|
fullLevel +
|
||||||
|
' '.repeat(MAX_LEVEL_LENGTH - fullLevel.length - padding);
|
||||||
|
|
||||||
|
// Apply color to the padded level
|
||||||
|
const coloredLevel = winston.format
|
||||||
|
.colorize()
|
||||||
|
.colorize(fullLevel, paddedLevel);
|
||||||
|
|
||||||
|
const formatLine = (line: unknown) => {
|
||||||
|
return `${emoji} | ${coloredLevel} | ${timestamp} | ${formattedModule} | ${line} ${
|
||||||
|
rest ? `${formatJsonToStyledString(rest)}` : ''
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
return message.split('\n').map(formatLine).join('\n');
|
||||||
|
} else if (typeof message === 'object') {
|
||||||
|
return formatLine(formatJsonToStyledString(message));
|
||||||
|
}
|
||||||
|
return formatLine(message);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
transports: [new winston.transports.Console()],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatJsonToStyledString(json: any) {
|
||||||
|
// return json.formatted
|
||||||
|
if (json.formatted) {
|
||||||
|
return json.formatted;
|
||||||
|
}
|
||||||
|
// extract keys and values, display space separated key=value pairs
|
||||||
|
const keys = Object.keys(json);
|
||||||
|
const values = keys.map((key) => `${key}=${json[key]}`);
|
||||||
|
return values.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskSensitiveInfo(message: string) {
|
||||||
|
if (Env.LOG_SENSITIVE_INFO) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return '<redacted>';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTimeTakenSincePoint = (point: number) => {
|
||||||
|
const timeNow = new Date().getTime();
|
||||||
|
const duration = timeNow - point;
|
||||||
|
// format duration and choose unit and return
|
||||||
|
const nanos = duration * 1_000_000; // Convert to nanoseconds
|
||||||
|
const micros = duration * 1_000; // Convert to microseconds
|
||||||
|
|
||||||
|
if (nanos < 1) {
|
||||||
|
return `${nanos.toFixed(2)}ns`;
|
||||||
|
} else if (micros < 1) {
|
||||||
|
return `${micros.toFixed(2)}µs`;
|
||||||
|
} else if (duration < 1000) {
|
||||||
|
return `${duration.toFixed(2)}ms`;
|
||||||
|
} else {
|
||||||
|
return `${(duration / 1000).toFixed(2)}s`;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// import { Env } from './env';
|
||||||
|
// import { Cache } from './cache';
|
||||||
|
|
||||||
|
// export type Metadata = {
|
||||||
|
// title: string;
|
||||||
|
// year: string;
|
||||||
|
// imdbId: string;
|
||||||
|
// tmdbId: string;
|
||||||
|
// type: string;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const metadataCache = Cache.getInstance<string, Metadata>('metadata');
|
||||||
|
|
||||||
|
// const API_BASE_URL = 'https://api.themoviedb.org/3';
|
||||||
|
// const FIND_BY_ID_PATH = '/find';
|
||||||
|
// const MOVIE_DETAILS_PATH = '/movie';
|
||||||
|
// const TV_DETAILS_PATH = '/tv';
|
||||||
|
|
||||||
|
// export class TMDBMetadata {
|
||||||
|
// private readonly TMDB_ID_REGEX = /^(?:tmdb):(\d+)$/;
|
||||||
|
// private readonly IMDB_ID_REGEX = /^(?:tt)\d+$/;
|
||||||
|
// private readonly SUPPORTED_ID_REGEXES = [this.TMDB_ID_REGEX, this.IMDB_ID_REGEX];
|
||||||
|
|
||||||
|
// public constructor(private readonly type: 'movie' | 'tv', private readonly id: string) {
|
||||||
|
// if (!Env.TMDB_ACCESS_TOKEN) {
|
||||||
|
// throw new Error('TMDB Access Token is not set');
|
||||||
|
// }
|
||||||
|
// if (!this.isSupportedId(this.id)) {
|
||||||
|
// throw new Error('Invalid metadata id');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private isSupportedId(id: string): boolean {
|
||||||
|
// return this.SUPPORTED_ID_REGEXES.some((regex) => regex.test(id));
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public async fetch(): Promise<Metadata> {
|
||||||
|
// if (this.isTmdbId()) {
|
||||||
|
// return this.getTmdbMetadata();
|
||||||
|
// }
|
||||||
|
// return this.getExternalMetadata();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private isTmdbId(): boolean {
|
||||||
|
// return this.TMDB_ID_REGEX.test(this.id);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private getExternalMetadata(): Promise<Metadata> {
|
||||||
|
// const url = new URL(API_BASE_URL + FIND_BY_ID_PATH + `/${this.id}`);
|
||||||
|
// url.searchParams.set('external_source', 'imdb_id')
|
||||||
|
|
||||||
|
// const response = await fetch(url, {
|
||||||
|
// headers: {
|
||||||
|
// Authorization: `Bearer ${Env.TMDB_ACCESS_TOKEN}`,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (!response.ok) {
|
||||||
|
// throw new Error('Failed to fetch metadata');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const data = await response.json();
|
||||||
|
|
||||||
|
// }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export class ResourceManager {
|
||||||
|
static getResource(resourceName: string) {
|
||||||
|
// check existence
|
||||||
|
const filePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../../../',
|
||||||
|
'resources',
|
||||||
|
resourceName
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`Resource ${resourceName} not found at ${filePath}`);
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Env } from './env';
|
||||||
|
|
||||||
|
type ErrorStreamOptions = {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
externalUrl?: string;
|
||||||
|
};
|
||||||
|
export function createErrorStream(options: ErrorStreamOptions = {}) {
|
||||||
|
const {
|
||||||
|
name = `[❌] ${Env.ADDON_NAME}`,
|
||||||
|
description = 'Unknown error',
|
||||||
|
externalUrl = 'https://github.com/Viren070/AIOStreams',
|
||||||
|
} = options;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
externalUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
Addon,
|
||||||
|
AddonCatalog,
|
||||||
|
AddonCatalogResponse,
|
||||||
|
AddonCatalogResponseSchema,
|
||||||
|
CatalogResponse,
|
||||||
|
CatalogResponseSchema,
|
||||||
|
Manifest,
|
||||||
|
ManifestSchema,
|
||||||
|
Meta,
|
||||||
|
MetaPreview,
|
||||||
|
MetaResponse,
|
||||||
|
MetaResponseSchema,
|
||||||
|
ParsedStream,
|
||||||
|
Resource,
|
||||||
|
Stream,
|
||||||
|
StreamResponse,
|
||||||
|
StreamResponseSchema,
|
||||||
|
Subtitle,
|
||||||
|
SubtitleResponse,
|
||||||
|
SubtitleResponseSchema,
|
||||||
|
} from './db/schemas';
|
||||||
|
import {
|
||||||
|
Cache,
|
||||||
|
makeRequest,
|
||||||
|
createLogger,
|
||||||
|
constants,
|
||||||
|
maskSensitiveInfo,
|
||||||
|
} from './utils';
|
||||||
|
import { PresetManager } from './presets';
|
||||||
|
import { StreamParser } from './parser';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const logger = createLogger('wrappers');
|
||||||
|
// const cache = Cache.getInstance<string, any>('wrappers');
|
||||||
|
const manifestCache = Cache.getInstance<string, Manifest>('manifest');
|
||||||
|
const resourceCache = Cache.getInstance<string, any>('resources');
|
||||||
|
|
||||||
|
const RESOURCE_TTL = 5 * 60;
|
||||||
|
const MANIFEST_TTL = 10 * 60;
|
||||||
|
|
||||||
|
type ResourceParams = {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
extras?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Wrapper {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly addon: Addon;
|
||||||
|
|
||||||
|
constructor(addon: Addon) {
|
||||||
|
this.addon = addon;
|
||||||
|
this.baseUrl = this.addon.manifestUrl.split('/').slice(0, -1).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeLogSafeUrl(url: string): string {
|
||||||
|
// make the url safe for logging.
|
||||||
|
// use maskSensitiveInfo on the componenent that is the longest
|
||||||
|
// and only mask the longest if it is longer than 10 characters
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const path = urlObj.pathname;
|
||||||
|
// get the longest component
|
||||||
|
const longestComponent = path
|
||||||
|
.split('/')
|
||||||
|
.reduce((a, b) => (a.length > b.length ? a : b));
|
||||||
|
if (longestComponent.length > 10) {
|
||||||
|
return `${urlObj.protocol}//${urlObj.host}${urlObj.port ? `:${urlObj.port}` : ''}${path.replace(longestComponent, maskSensitiveInfo(longestComponent))}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getManifest(): Promise<Manifest> {
|
||||||
|
return manifestCache.wrap(
|
||||||
|
async () => {
|
||||||
|
logger.debug(
|
||||||
|
`Fetching manifest for ${this.addon.name} (${this.makeLogSafeUrl(this.addon.manifestUrl)})`
|
||||||
|
);
|
||||||
|
const res = await makeRequest(
|
||||||
|
this.addon.manifestUrl,
|
||||||
|
this.addon.timeout,
|
||||||
|
this.addon.headers
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to fetch manifest for ${this.addon.name}: ${res.status} - ${res.statusText}`
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to fetch manifest for ${this.addon.name}`);
|
||||||
|
}
|
||||||
|
const manifest = ManifestSchema.safeParse(await res.json());
|
||||||
|
if (!manifest.success) {
|
||||||
|
logger.error(`Manifest response was unexpected`);
|
||||||
|
logger.error(manifest.error);
|
||||||
|
throw new Error(`Failed to parse manifest for ${this.addon.name}`);
|
||||||
|
}
|
||||||
|
return manifest.data;
|
||||||
|
},
|
||||||
|
this.addon.manifestUrl,
|
||||||
|
MANIFEST_TTL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStreams(type: string, id: string): Promise<ParsedStream[]> {
|
||||||
|
const streams: StreamResponse = await this.makeResourceRequest(
|
||||||
|
'stream',
|
||||||
|
{ type, id },
|
||||||
|
StreamResponseSchema
|
||||||
|
);
|
||||||
|
const Parser = this.addon.fromPresetId
|
||||||
|
? PresetManager.fromId(this.addon.fromPresetId).getParser()
|
||||||
|
: StreamParser;
|
||||||
|
const parser = new Parser(this.addon);
|
||||||
|
return streams.streams.map((stream: Stream) => parser.parse(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCatalog(
|
||||||
|
type: string,
|
||||||
|
id: string,
|
||||||
|
extras?: string
|
||||||
|
): Promise<MetaPreview[]> {
|
||||||
|
const catalog: CatalogResponse = await this.makeResourceRequest(
|
||||||
|
'catalog',
|
||||||
|
{ type, id, extras },
|
||||||
|
CatalogResponseSchema,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return catalog.metas;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMeta(type: string, id: string): Promise<Meta> {
|
||||||
|
const meta: MetaResponse = await this.makeResourceRequest(
|
||||||
|
'meta',
|
||||||
|
{ type, id },
|
||||||
|
MetaResponseSchema,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return meta.meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubtitles(
|
||||||
|
type: string,
|
||||||
|
id: string,
|
||||||
|
extras?: string
|
||||||
|
): Promise<Subtitle[]> {
|
||||||
|
const subtitles: SubtitleResponse = await this.makeResourceRequest(
|
||||||
|
'subtitles',
|
||||||
|
{ type, id, extras },
|
||||||
|
SubtitleResponseSchema,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return subtitles.subtitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAddonCatalog(type: string, id: string): Promise<AddonCatalog[]> {
|
||||||
|
const addonCatalog: AddonCatalogResponse = await this.makeResourceRequest(
|
||||||
|
'addon_catalog',
|
||||||
|
{ type, id },
|
||||||
|
AddonCatalogResponseSchema
|
||||||
|
);
|
||||||
|
return addonCatalog.addons;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async makeResourceRequest(
|
||||||
|
resource: Resource,
|
||||||
|
params: ResourceParams,
|
||||||
|
schema: z.ZodSchema,
|
||||||
|
cache: boolean = false
|
||||||
|
) {
|
||||||
|
const { type, id, extras } = params;
|
||||||
|
const url = this.buildResourceUrl(resource, type, id, extras);
|
||||||
|
if (cache) {
|
||||||
|
const cached = resourceCache.get(url);
|
||||||
|
if (cached) {
|
||||||
|
logger.debug(
|
||||||
|
`Returning cached ${resource} for ${this.addon.name} (${this.makeLogSafeUrl(url)})`
|
||||||
|
);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`Fetching ${resource} of type ${type} with id ${id} and extras ${extras} (${this.makeLogSafeUrl(url)})`
|
||||||
|
);
|
||||||
|
const res = await makeRequest(
|
||||||
|
url,
|
||||||
|
this.addon.timeout,
|
||||||
|
this.addon.headers,
|
||||||
|
this.addon.ip
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to fetch ${resource} resource for ${this.addon.name}: ${res.status} - ${res.statusText}`
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch ${resource} resource for ${this.addon.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const parsed = schema.safeParse(data);
|
||||||
|
if (!parsed.success) {
|
||||||
|
logger.error(`Resource response was unexpected`);
|
||||||
|
logger.error(JSON.stringify(parsed.error, null, 2));
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse ${resource} resource for ${this.addon.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cache) {
|
||||||
|
resourceCache.set(url, parsed.data, RESOURCE_TTL);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildResourceUrl(
|
||||||
|
resource: Resource,
|
||||||
|
type: string,
|
||||||
|
id: string,
|
||||||
|
extras?: string
|
||||||
|
): string {
|
||||||
|
const extrasPath = extras ? `/${extras}` : '';
|
||||||
|
return `${this.baseUrl}/${resource}/${type}/${encodeURIComponent(id)}${extrasPath}.json`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aiostreams/formatters",
|
|
||||||
"version": "1.21.1",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run --passWithNoTests",
|
|
||||||
"build": "tsc"
|
|
||||||
},
|
|
||||||
"description": "Library to take parsed information and return a formatted Stremio stream name and description",
|
|
||||||
"dependencies": {
|
|
||||||
"@aiostreams/types": "^1.0.0",
|
|
||||||
"@aiostreams/utils": "^1.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
import { Config, CustomFormatter, ParsedStream } from '@aiostreams/types';
|
|
||||||
import { serviceDetails, Settings } from '@aiostreams/utils';
|
|
||||||
import { formatDuration, formatSize, languageToEmoji } from './utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* The custom formatter code in this file was adapted from https://github.com/diced/zipline/blob/trunk/src/lib/parser/index.ts
|
|
||||||
*
|
|
||||||
* The original code is licensed under the MIT License.
|
|
||||||
*
|
|
||||||
* MIT License
|
|
||||||
*
|
|
||||||
* Copyright (c) 2023 dicedtomato
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
|
||||||
* in the Software without restriction, including without limitation the rights
|
|
||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
|
||||||
* furnished to do so, subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in all
|
|
||||||
* copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
* SOFTWARE.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function customFormat(
|
|
||||||
stream: ParsedStream,
|
|
||||||
customFormatter: CustomFormatter
|
|
||||||
): {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
} {
|
|
||||||
let name: string = '';
|
|
||||||
let description: string = '';
|
|
||||||
|
|
||||||
// name
|
|
||||||
|
|
||||||
const templateName =
|
|
||||||
parseString(
|
|
||||||
customFormatter.name || '',
|
|
||||||
convertStreamToParseValue(stream)
|
|
||||||
) || '';
|
|
||||||
|
|
||||||
// description
|
|
||||||
const templateDescription =
|
|
||||||
parseString(
|
|
||||||
customFormatter.description || '',
|
|
||||||
convertStreamToParseValue(stream)
|
|
||||||
) || '';
|
|
||||||
|
|
||||||
// Replace placeholders in the template with actual values
|
|
||||||
name = templateName;
|
|
||||||
|
|
||||||
description = templateDescription;
|
|
||||||
|
|
||||||
return { name, description };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ParseValue = {
|
|
||||||
config?: {
|
|
||||||
addonName: string | null;
|
|
||||||
showDie: boolean | null;
|
|
||||||
};
|
|
||||||
stream?: {
|
|
||||||
/** @deprecated Use filename instead */
|
|
||||||
name: string | null;
|
|
||||||
filename: string | null;
|
|
||||||
folderName: string | null;
|
|
||||||
size: number | null;
|
|
||||||
personal: boolean | null;
|
|
||||||
quality: string | null;
|
|
||||||
resolution: string | null;
|
|
||||||
languages: string[] | null;
|
|
||||||
languageEmojis: string[] | null;
|
|
||||||
visualTags: string[] | null;
|
|
||||||
audioTags: string[] | null;
|
|
||||||
releaseGroup: string | null;
|
|
||||||
regexMatched: string | null;
|
|
||||||
encode: string | null;
|
|
||||||
indexer: string | null;
|
|
||||||
year: string | null;
|
|
||||||
title: string | null;
|
|
||||||
season: number | null;
|
|
||||||
seasons: number[] | null;
|
|
||||||
episode: number | null;
|
|
||||||
seeders: number | null;
|
|
||||||
age: string | null;
|
|
||||||
duration: number | null;
|
|
||||||
infoHash: string | null;
|
|
||||||
message: string | null;
|
|
||||||
proxied: boolean | null;
|
|
||||||
};
|
|
||||||
provider?: {
|
|
||||||
id: string | null;
|
|
||||||
shortName: string | null;
|
|
||||||
name: string | null;
|
|
||||||
cached: boolean | null;
|
|
||||||
};
|
|
||||||
addon?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
debug?: {
|
|
||||||
json: string | null;
|
|
||||||
jsonf: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const convertStreamToParseValue = (stream: ParsedStream): ParseValue => {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
addonName: Settings.ADDON_NAME,
|
|
||||||
showDie: Settings.SHOW_DIE,
|
|
||||||
},
|
|
||||||
stream: {
|
|
||||||
filename: stream.filename || null,
|
|
||||||
name: stream.filename || null,
|
|
||||||
folderName: stream.folderName || null,
|
|
||||||
size: stream.size || null,
|
|
||||||
personal: stream.personal !== undefined ? stream.personal : null,
|
|
||||||
quality: stream.quality === 'Unknown' ? null : stream.quality,
|
|
||||||
resolution: stream.resolution === 'Unknown' ? null : stream.resolution,
|
|
||||||
languages: stream.languages || null,
|
|
||||||
languageEmojis: stream.languages
|
|
||||||
? stream.languages
|
|
||||||
.map((lang) => languageToEmoji(lang) || lang)
|
|
||||||
.filter((value, index, self) => self.indexOf(value) === index)
|
|
||||||
: null,
|
|
||||||
visualTags: stream.visualTags,
|
|
||||||
audioTags: stream.audioTags,
|
|
||||||
releaseGroup:
|
|
||||||
stream.releaseGroup === 'Unknown' ? null : stream.releaseGroup,
|
|
||||||
regexMatched: stream.regexMatched?.name || null,
|
|
||||||
encode: stream.encode === 'Unknown' ? null : stream.encode,
|
|
||||||
indexer: stream.indexers || null,
|
|
||||||
seeders: stream.torrent?.seeders || null,
|
|
||||||
year: stream.year || null,
|
|
||||||
title: stream.title || null,
|
|
||||||
season: stream.season || null,
|
|
||||||
seasons: stream.seasons || null,
|
|
||||||
episode: stream.episode || null,
|
|
||||||
age: stream.usenet?.age || null,
|
|
||||||
duration: stream.duration || null,
|
|
||||||
infoHash: stream.torrent?.infoHash || null,
|
|
||||||
message: stream.message || null,
|
|
||||||
proxied: stream.proxied !== undefined ? stream.proxied : null,
|
|
||||||
},
|
|
||||||
addon: {
|
|
||||||
id: stream.addon.id,
|
|
||||||
name: stream.addon.name,
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
id: stream.provider?.id || null,
|
|
||||||
shortName: stream.provider?.id
|
|
||||||
? serviceDetails.find((service) => service.id === stream.provider?.id)
|
|
||||||
?.shortName || null
|
|
||||||
: null,
|
|
||||||
name: stream.provider?.id
|
|
||||||
? serviceDetails.find((service) => service.id === stream.provider?.id)
|
|
||||||
?.name || null
|
|
||||||
: null,
|
|
||||||
cached:
|
|
||||||
stream.provider?.cached !== undefined ? stream.provider?.cached : null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseString(str: string, value: ParseValue) {
|
|
||||||
if (!str) return null;
|
|
||||||
|
|
||||||
const replacer = (key: string, value: unknown) => {
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
stream: value.stream,
|
|
||||||
provider: value.provider,
|
|
||||||
addon: value.addon,
|
|
||||||
config: value.config,
|
|
||||||
};
|
|
||||||
|
|
||||||
value.debug = {
|
|
||||||
json: JSON.stringify(data, replacer),
|
|
||||||
jsonf: JSON.stringify(data, replacer, 2),
|
|
||||||
};
|
|
||||||
|
|
||||||
const re =
|
|
||||||
/\{(?<type>stream|provider|debug|addon|config)\.(?<prop>\w+)(::(?<mod>(\w+(\([^)]*\))?|<|<=|=|>=|>|\^|\$|~|\/)+))?((::(?<mod_tzlocale>\S+?))|(?<mod_check>\[(?<mod_check_true>".*?")\|\|(?<mod_check_false>".*?")\]))?\}/gi;
|
|
||||||
let matches: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((matches = re.exec(str))) {
|
|
||||||
if (!matches.groups) continue;
|
|
||||||
|
|
||||||
const index = matches.index as number;
|
|
||||||
|
|
||||||
const getV = value[matches.groups.type as keyof ParseValue];
|
|
||||||
|
|
||||||
if (!getV) {
|
|
||||||
str = replaceCharsFromString(str, '{unknown_type}', index, re.lastIndex);
|
|
||||||
re.lastIndex = index;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const v =
|
|
||||||
getV[
|
|
||||||
matches.groups.prop as
|
|
||||||
| keyof ParseValue['stream']
|
|
||||||
| keyof ParseValue['provider']
|
|
||||||
| keyof ParseValue['addon']
|
|
||||||
];
|
|
||||||
|
|
||||||
if (v === undefined) {
|
|
||||||
str = replaceCharsFromString(str, '{unknown_value}', index, re.lastIndex);
|
|
||||||
re.lastIndex = index;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.groups.mod) {
|
|
||||||
str = replaceCharsFromString(
|
|
||||||
str,
|
|
||||||
modifier(
|
|
||||||
matches.groups.mod,
|
|
||||||
v,
|
|
||||||
matches.groups.mod_tzlocale ?? undefined,
|
|
||||||
matches.groups.mod_check_true ?? undefined,
|
|
||||||
matches.groups.mod_check_false ?? undefined,
|
|
||||||
value
|
|
||||||
),
|
|
||||||
index,
|
|
||||||
re.lastIndex
|
|
||||||
);
|
|
||||||
re.lastIndex = index;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
str = replaceCharsFromString(str, v, index, re.lastIndex);
|
|
||||||
re.lastIndex = index;
|
|
||||||
}
|
|
||||||
|
|
||||||
return str
|
|
||||||
.replace(/\\n/g, '\n')
|
|
||||||
.split('\n')
|
|
||||||
.filter(
|
|
||||||
(line) => line.trim() !== '' && !line.includes('{tools.removeLine}')
|
|
||||||
)
|
|
||||||
.join('\n')
|
|
||||||
.replace(/\{tools.newLine\}/g, '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function modifier(
|
|
||||||
mod: string,
|
|
||||||
value: unknown,
|
|
||||||
tzlocale?: string,
|
|
||||||
check_true?: string,
|
|
||||||
check_false?: string,
|
|
||||||
_value?: ParseValue
|
|
||||||
): string {
|
|
||||||
mod = mod.toLowerCase();
|
|
||||||
check_true = check_true?.slice(1, -1);
|
|
||||||
check_false = check_false?.slice(1, -1);
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
switch (true) {
|
|
||||||
case mod === 'join':
|
|
||||||
return value.join(', ');
|
|
||||||
case mod.startsWith('join(') && mod.endsWith(')'):
|
|
||||||
// Extract the separator from join(separator)
|
|
||||||
// e.g. join(' - ')
|
|
||||||
const separator = mod
|
|
||||||
.substring(5, mod.length - 1)
|
|
||||||
.replace(/^['"]|['"]$/g, '');
|
|
||||||
return value.join(separator);
|
|
||||||
case mod == 'length':
|
|
||||||
return value.length.toString();
|
|
||||||
case mod == 'first':
|
|
||||||
return value.length > 0 ? String(value[0]) : '';
|
|
||||||
case mod == 'last':
|
|
||||||
return value.length > 0 ? String(value[value.length - 1]) : '';
|
|
||||||
case mod == 'random':
|
|
||||||
return value.length > 0
|
|
||||||
? String(value[Math.floor(Math.random() * value.length)])
|
|
||||||
: '';
|
|
||||||
case mod == 'sort':
|
|
||||||
return [...value].sort().join(', ');
|
|
||||||
case mod == 'reverse':
|
|
||||||
return [...value].reverse().join(', ');
|
|
||||||
case mod == 'exists': {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_array_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value.length > 0
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.length > 0 ? check_true : check_false;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return `{unknown_array_modifier(${mod})}`;
|
|
||||||
}
|
|
||||||
} else if (typeof value === 'string') {
|
|
||||||
switch (true) {
|
|
||||||
case mod == 'upper':
|
|
||||||
return value.toUpperCase();
|
|
||||||
case mod == 'lower':
|
|
||||||
return value.toLowerCase();
|
|
||||||
case mod == 'title':
|
|
||||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
||||||
case mod == 'length':
|
|
||||||
return value.length.toString();
|
|
||||||
case mod == 'reverse':
|
|
||||||
return value.split('').reverse().join('');
|
|
||||||
case mod == 'base64':
|
|
||||||
return btoa(value);
|
|
||||||
case mod == 'string':
|
|
||||||
return value;
|
|
||||||
case mod == 'exists': {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value != 'null' && value
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value != 'null' && value ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('='): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = mod.replace('=', '');
|
|
||||||
|
|
||||||
if (!check) return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value.toLowerCase() == check
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.toLowerCase() == check ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('$'): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = mod.replace('$', '');
|
|
||||||
|
|
||||||
if (!check) return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value.toLowerCase().startsWith(check)
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.toLowerCase().startsWith(check) ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('^'): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = mod.replace('^', '');
|
|
||||||
|
|
||||||
if (!check) return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value.toLowerCase().endsWith(check)
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.toLowerCase().endsWith(check) ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('~'): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = mod.replace('~', '');
|
|
||||||
|
|
||||||
if (!check) return `{unknown_str_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value.toLowerCase().includes(check)
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.toLowerCase().includes(check) ? check_true : check_false;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return `{unknown_str_modifier(${mod})}`;
|
|
||||||
}
|
|
||||||
} else if (typeof value === 'number') {
|
|
||||||
switch (true) {
|
|
||||||
case mod == 'comma':
|
|
||||||
return value.toLocaleString();
|
|
||||||
case mod == 'hex':
|
|
||||||
return value.toString(16);
|
|
||||||
case mod == 'octal':
|
|
||||||
return value.toString(8);
|
|
||||||
case mod == 'binary':
|
|
||||||
return value.toString(2);
|
|
||||||
case mod == 'bytes':
|
|
||||||
return formatSize(value);
|
|
||||||
case mod == 'string':
|
|
||||||
return value.toString();
|
|
||||||
case mod == 'time':
|
|
||||||
return formatDuration(value);
|
|
||||||
case mod.startsWith('>='): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = Number(mod.replace('>=', ''));
|
|
||||||
|
|
||||||
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value >= check
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value >= check ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('>'): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = Number(mod.replace('>', ''));
|
|
||||||
|
|
||||||
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value > check
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value > check ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('='): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = Number(mod.replace('=', ''));
|
|
||||||
|
|
||||||
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value == check
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value == check ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('<='): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = Number(mod.replace('<=', ''));
|
|
||||||
|
|
||||||
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value <= check
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value <= check ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod.startsWith('<'): {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
const check = Number(mod.replace('<', ''));
|
|
||||||
|
|
||||||
if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value < check
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value < check ? check_true : check_false;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return `{unknown_int_modifier(${mod})}`;
|
|
||||||
}
|
|
||||||
} else if (typeof value === 'boolean') {
|
|
||||||
switch (true) {
|
|
||||||
case mod == 'istrue': {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_bool_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return value
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value ? check_true : check_false;
|
|
||||||
}
|
|
||||||
case mod == 'isfalse': {
|
|
||||||
if (typeof check_true !== 'string' || typeof check_false !== 'string')
|
|
||||||
return `{unknown_bool_modifier(${mod})}`;
|
|
||||||
|
|
||||||
if (_value) {
|
|
||||||
return !value
|
|
||||||
? parseString(check_true, _value) || check_true
|
|
||||||
: parseString(check_false, _value) || check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return !value ? check_true : check_false;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return `{unknown_bool_modifier(${mod})}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof check_false == 'string' &&
|
|
||||||
(['>', '>=', '=', '<=', '<', '~', '$', '^'].some((modif) =>
|
|
||||||
mod.startsWith(modif)
|
|
||||||
) ||
|
|
||||||
['istrue', 'exists', 'isfalse'].includes(mod))
|
|
||||||
) {
|
|
||||||
if (_value) return parseString(check_false, _value) || check_false;
|
|
||||||
return check_false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `{unknown_modifier(${mod})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceCharsFromString(
|
|
||||||
str: string,
|
|
||||||
replace: string,
|
|
||||||
start: number,
|
|
||||||
end: number
|
|
||||||
): string {
|
|
||||||
return str.slice(0, start) + replace + str.slice(end);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { ParsedStream } from '@aiostreams/types';
|
|
||||||
import { formatDuration, formatSize, languageToEmoji } from './utils';
|
|
||||||
import { serviceDetails, Settings } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
export function gdriveFormat(
|
|
||||||
stream: ParsedStream,
|
|
||||||
minimalistic: boolean = false
|
|
||||||
): {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
} {
|
|
||||||
let name: string = '';
|
|
||||||
|
|
||||||
if (stream.provider) {
|
|
||||||
const cacheStatus = stream.provider.cached
|
|
||||||
? '⚡'
|
|
||||||
: stream.provider.cached === undefined
|
|
||||||
? '❓'
|
|
||||||
: '⏳';
|
|
||||||
const serviceShortName =
|
|
||||||
serviceDetails.find((service) => service.id === stream.provider!.id)
|
|
||||||
?.shortName || stream.provider.id;
|
|
||||||
name += `[${serviceShortName}${cacheStatus}] `;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.torrent?.infoHash) {
|
|
||||||
name += `[P2P] `;
|
|
||||||
}
|
|
||||||
|
|
||||||
name += `${stream.addon.name} ${stream.personal ? '(Your Media) ' : ''}`;
|
|
||||||
if (!minimalistic) {
|
|
||||||
name += stream.resolution;
|
|
||||||
} else {
|
|
||||||
name += stream.resolution !== 'Unknown' ? stream.resolution + '' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// let description: string = `${stream.quality !== 'Unknown' ? '🎥 ' + stream.quality + ' ' : ''}${stream.encode !== 'Unknown' ? '🎞️ ' + stream.encode : ''}`;
|
|
||||||
let description: string = '';
|
|
||||||
if (
|
|
||||||
stream.quality ||
|
|
||||||
stream.encode ||
|
|
||||||
(stream.releaseGroup && !minimalistic)
|
|
||||||
) {
|
|
||||||
description += stream.quality !== 'Unknown' ? `🎥 ${stream.quality} ` : '';
|
|
||||||
description += stream.encode !== 'Unknown' ? `🎞️ ${stream.encode} ` : '';
|
|
||||||
description +=
|
|
||||||
stream.releaseGroup !== 'Unknown' && !minimalistic
|
|
||||||
? `🏷️ ${stream.releaseGroup}`
|
|
||||||
: '';
|
|
||||||
description += '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.visualTags.length > 0 || stream.audioTags.length > 0) {
|
|
||||||
description +=
|
|
||||||
stream.visualTags.length > 0
|
|
||||||
? `📺 ${stream.visualTags.join(' | ')} `
|
|
||||||
: '';
|
|
||||||
description +=
|
|
||||||
stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : '';
|
|
||||||
description += '\n';
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stream.size ||
|
|
||||||
(stream.torrent?.seeders && !minimalistic) ||
|
|
||||||
(minimalistic && stream.torrent?.seeders && !stream.provider?.cached) ||
|
|
||||||
stream.usenet?.age ||
|
|
||||||
stream.duration
|
|
||||||
) {
|
|
||||||
description += `📦 ${formatSize(stream.size || 0)} `;
|
|
||||||
description += stream.duration
|
|
||||||
? `⏱️ ${formatDuration(stream.duration)} `
|
|
||||||
: '';
|
|
||||||
description +=
|
|
||||||
(stream.torrent?.seeders !== undefined && !minimalistic) ||
|
|
||||||
(minimalistic && stream.torrent?.seeders && !stream.provider?.cached)
|
|
||||||
? `👥 ${stream.torrent.seeders} `
|
|
||||||
: '';
|
|
||||||
|
|
||||||
description += stream.usenet?.age ? `📅 ${stream.usenet.age} ` : '';
|
|
||||||
description +=
|
|
||||||
stream.indexers && !minimalistic ? `🔍 ${stream.indexers}` : '';
|
|
||||||
description += '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.languages.length !== 0) {
|
|
||||||
let languages = stream.languages;
|
|
||||||
if (minimalistic) {
|
|
||||||
languages = languages.map(
|
|
||||||
(language) => languageToEmoji(language) || language
|
|
||||||
);
|
|
||||||
}
|
|
||||||
description += `🌎 ${languages.join(minimalistic ? ' / ' : ' | ')}`;
|
|
||||||
description += '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!minimalistic && (stream.filename || stream.folderName)) {
|
|
||||||
description += stream.folderName ? `📁 ${stream.folderName}\n` : '';
|
|
||||||
description += stream.filename ? `📄 ${stream.filename}\n` : '📄 Unknown\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.message) {
|
|
||||||
description += `📢 ${stream.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.proxied) {
|
|
||||||
name = `🕵️♂️ ${name}`;
|
|
||||||
} else if (Settings.SHOW_DIE) {
|
|
||||||
name = `🎲 ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
description = description.trim();
|
|
||||||
name = name.trim();
|
|
||||||
return { name, description };
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { ParsedStream } from '@aiostreams/types';
|
|
||||||
import { formatDuration, formatSize } from './utils';
|
|
||||||
|
|
||||||
const imposters = [
|
|
||||||
'Disney+',
|
|
||||||
'Netflix',
|
|
||||||
'HBO',
|
|
||||||
'Amazon Prime Video',
|
|
||||||
'Hulu',
|
|
||||||
'Apple TV+',
|
|
||||||
'Peacock',
|
|
||||||
'Paramount+',
|
|
||||||
];
|
|
||||||
|
|
||||||
export function imposterFormat(stream: ParsedStream): {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
} {
|
|
||||||
let name: string = '';
|
|
||||||
|
|
||||||
if (stream.torrent?.infoHash) {
|
|
||||||
name += `[P2P] `;
|
|
||||||
}
|
|
||||||
const chosenImposter =
|
|
||||||
imposters[Math.floor(Math.random() * imposters.length)];
|
|
||||||
name += `${chosenImposter} ${stream.personal ? '(Your Media) ' : ''}`;
|
|
||||||
|
|
||||||
name += stream.resolution !== 'Unknown' ? stream.resolution + '' : '';
|
|
||||||
|
|
||||||
let description: string = `${stream.quality !== 'Unknown' ? '🎥 ' + stream.quality + ' ' : ''}${stream.encode !== 'Unknown' ? '🎞️ ' + stream.encode : ''}`;
|
|
||||||
|
|
||||||
if (stream.visualTags.length > 0 || stream.audioTags.length > 0) {
|
|
||||||
description += '\n';
|
|
||||||
|
|
||||||
description +=
|
|
||||||
stream.visualTags.length > 0
|
|
||||||
? `📺 ${stream.visualTags.join(' | ')} `
|
|
||||||
: '';
|
|
||||||
description +=
|
|
||||||
stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : '';
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stream.size ||
|
|
||||||
stream.torrent?.seeders ||
|
|
||||||
stream.usenet?.age ||
|
|
||||||
stream.duration
|
|
||||||
) {
|
|
||||||
description += '\n';
|
|
||||||
|
|
||||||
description += `📦 ${formatSize(stream.size || 0)} `;
|
|
||||||
description += stream.duration
|
|
||||||
? `⏱️ ${formatDuration(stream.duration)} `
|
|
||||||
: '';
|
|
||||||
description += stream.torrent?.seeders
|
|
||||||
? `👥 ${stream.torrent.seeders}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
description += stream.usenet?.age ? `📅 ${stream.usenet.age}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.languages.length !== 0) {
|
|
||||||
let languages = stream.languages;
|
|
||||||
description += `\n🔊 ${languages.join(' | ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
description += `\n📄 ${stream.filename ? stream.filename : 'Unknown'}`;
|
|
||||||
if (stream.message) {
|
|
||||||
description += `\n📢${stream.message}`;
|
|
||||||
}
|
|
||||||
return { name, description };
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from './gdrive';
|
|
||||||
export * from './utils';
|
|
||||||
export * from './torrentio';
|
|
||||||
export * from './torbox';
|
|
||||||
export * from './imposter';
|
|
||||||
export * from './custom';
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { ParsedStream } from '@aiostreams/types';
|
|
||||||
import { formatSize } from './utils';
|
|
||||||
import { serviceDetails, Settings } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
export function torboxFormat(stream: ParsedStream): {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
} {
|
|
||||||
let name: string = '';
|
|
||||||
|
|
||||||
name += `${stream.addon.name} `;
|
|
||||||
if (stream.provider) {
|
|
||||||
const serviceShortName =
|
|
||||||
serviceDetails.find((service) => service.id === stream.provider!.id)
|
|
||||||
?.shortName || stream.provider.id;
|
|
||||||
name += `(${stream.provider.cached === undefined ? 'Unknown' : stream.provider.cached ? 'Instant' : ''} ${serviceShortName}) `;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.torrent?.infoHash) {
|
|
||||||
name += `(P2P) `;
|
|
||||||
}
|
|
||||||
|
|
||||||
name += `${stream.personal ? '(Your Media) ' : ''}(${stream.resolution})`;
|
|
||||||
|
|
||||||
let description: string = '';
|
|
||||||
|
|
||||||
let streamType = '';
|
|
||||||
if (stream?.torrent?.seeders) {
|
|
||||||
streamType = 'Torrent';
|
|
||||||
} else if (stream?.usenet?.age) {
|
|
||||||
streamType = 'Usenet';
|
|
||||||
}
|
|
||||||
|
|
||||||
description += `Quality: ${stream.quality}\nName: ${stream.filename || 'Unknown'}\nSize: ${stream.size ? formatSize(stream.size) : 'Unknown'}${stream.indexers ? ` | Source: ${stream.indexers}` : ''}\nLanguage: ${stream.languages.length > 0 ? stream.languages.join(', ') : 'Unknown'}`;
|
|
||||||
|
|
||||||
if (streamType === 'Torrent' || streamType === 'Usenet') {
|
|
||||||
description += `\nType: ${streamType} | ${streamType === 'Torrent' ? 'Seeders' : 'Age'}: ${streamType === 'Torrent' ? stream.torrent?.seeders : stream.usenet?.age}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.message) {
|
|
||||||
description += `\n${stream.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.proxied) {
|
|
||||||
name = `🕵️♂️ ${name}`;
|
|
||||||
} else if (Settings.SHOW_DIE) {
|
|
||||||
name = `🎲 ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name, description };
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { ParsedStream } from '@aiostreams/types';
|
|
||||||
import { formatSize, languageToEmoji } from './utils';
|
|
||||||
import { serviceDetails, Settings } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
export function torrentioFormat(stream: ParsedStream): {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
} {
|
|
||||||
let name: string = '';
|
|
||||||
|
|
||||||
if (stream.provider) {
|
|
||||||
const cacheStatus = stream.provider.cached
|
|
||||||
? '+'
|
|
||||||
: stream.provider.cached === undefined
|
|
||||||
? ' Unknown'
|
|
||||||
: ' download';
|
|
||||||
const serviceShortName =
|
|
||||||
serviceDetails.find((service) => service.id === stream.provider!.id)
|
|
||||||
?.shortName || stream.provider.id;
|
|
||||||
name += `[${serviceShortName}${cacheStatus}] `;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.torrent?.infoHash) {
|
|
||||||
name += '[P2P] ';
|
|
||||||
}
|
|
||||||
|
|
||||||
name += `${stream.addon.name} ${stream.personal ? '(Your Media) ' : ''}${stream.resolution} `;
|
|
||||||
|
|
||||||
if (stream.visualTags.length > 0) {
|
|
||||||
name += stream.visualTags.join(' | ');
|
|
||||||
}
|
|
||||||
let description = '';
|
|
||||||
|
|
||||||
if (stream.message) {
|
|
||||||
description += `\n${stream.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.filename || stream.folderName) {
|
|
||||||
description += `\n${stream.folderName ? stream.folderName : ''}/${stream.filename ? stream.filename : ''}`;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stream.size ||
|
|
||||||
stream.torrent?.seeders ||
|
|
||||||
stream.usenet?.age ||
|
|
||||||
stream.indexers
|
|
||||||
) {
|
|
||||||
description += '\n';
|
|
||||||
|
|
||||||
description +=
|
|
||||||
stream.torrent?.seeders !== undefined
|
|
||||||
? `👤 ${stream.torrent.seeders} `
|
|
||||||
: '';
|
|
||||||
|
|
||||||
description += stream.usenet?.age ? `📅 ${stream.usenet.age} ` : '';
|
|
||||||
|
|
||||||
description += `💾 ${formatSize(stream.size || 0)} `;
|
|
||||||
|
|
||||||
description += stream.indexers ? `⚙️ ${stream.indexers} ` : '';
|
|
||||||
}
|
|
||||||
const languageEmojis = stream.languages.map((lang) => {
|
|
||||||
const emoji = languageToEmoji(lang);
|
|
||||||
return emoji ? emoji : lang;
|
|
||||||
});
|
|
||||||
if (languageEmojis.length > 0) {
|
|
||||||
description += `\n${languageEmojis.join(' / ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream.proxied) {
|
|
||||||
name = `🕵️♂️ ${name}`;
|
|
||||||
} else if (Settings.SHOW_DIE) {
|
|
||||||
name = `🎲 ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name, description };
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "../types"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../utils"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app).
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -16,16 +16,20 @@ bun dev
|
|||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||||
|
|
||||||
|
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
## Learn More
|
## Learn More
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
@@ -33,4 +37,4 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
|||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details.
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { FlatCompat } from '@eslint/eslintrc';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
|
||||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
|
||||||
];
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
@@ -1,35 +1,16 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
try {
|
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading .env file:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const branding =
|
|
||||||
process.env.NEXT_PUBLIC_ELFHOSTED_BRANDING ?? process.env.BRANDING;
|
|
||||||
|
|
||||||
if (branding) {
|
|
||||||
console.log(`Branding set`);
|
|
||||||
} else {
|
|
||||||
console.log('No branding was set');
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
reactStrictMode: false,
|
||||||
output: 'export',
|
output: 'export',
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
env: {
|
|
||||||
NEXT_PUBLIC_BRANDING: branding,
|
|
||||||
},
|
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.resolve.fallback = {
|
config.resolve.fallback = {
|
||||||
...config.resolve.fallback, // if you miss it, all the other options in fallback, specified
|
...config.resolve.fallback,
|
||||||
// by next.js will be dropped. Doesn't make much sense, but how it is
|
fs: false,
|
||||||
fs: false, // the solution
|
|
||||||
};
|
};
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,31 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "@aiostreams/frontend",
|
"name": "@aiostreams/frontend",
|
||||||
"version": "1.21.1",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aiostreams/addon": "^1.0.0",
|
"@aiostreams/core": "^2",
|
||||||
"@aiostreams/formatters": "^1.0.0",
|
"@headlessui/react": "^2.2.4",
|
||||||
"@aiostreams/types": "^1.0.0",
|
"@headlessui/tailwindcss": "^0.2.2",
|
||||||
"@aiostreams/wrappers": "^1.0.0",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"dotenv": "^16.4.7",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"next": "15.1.2",
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.12.2",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"next": "15.3.2",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-select": "^5.9.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-toastify": "^11.0.2"
|
"sonner": "^2.0.3",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"tailwind-scrollbar-hide": "~1.1.7",
|
||||||
|
"tailwindcss-animate": "~1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.2",
|
"eslint-config-next": "15.3.2",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"postcss-import": "^16.1.0",
|
||||||
|
"postcss-nesting": "^13.0.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-import': {},
|
||||||
|
'tailwindcss/nesting': 'postcss-nesting',
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ['@tailwindcss/postcss', 'autoprefixer', 'tailwindcss'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
Before Width: | Height: | Size: 227 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -1,276 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 800px;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
margin: 0 auto; /* Center the header within the content */
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: relative;
|
|
||||||
background-color: #000000;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
box-shadow: 0 8px 16px rgb(0, 0, 0);
|
|
||||||
padding: 20px;
|
|
||||||
width: 80%;
|
|
||||||
max-width: 700px;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.branding {
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
.content {
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
padding: 10px;
|
|
||||||
border-color: #777777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingDescription {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingInput {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: flex-end;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingInput select {
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slidersContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slidersSetting {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sliderValue {
|
|
||||||
margin-right: auto;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.installButtons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.installButton {
|
|
||||||
background-color: #ffffff; /* Green */
|
|
||||||
border: none;
|
|
||||||
color: black;
|
|
||||||
padding: 15px 32px;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 4px 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
box-shadow: 0 4px 8px rgba(255, 255, 255, 0.111);
|
|
||||||
transition:
|
|
||||||
transform 0.2s,
|
|
||||||
box-shadow 0.2s,
|
|
||||||
opacity 0.2s,
|
|
||||||
background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.installButton:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
box-shadow: 0 8px 16px rgba(255, 255, 255, 0.225);
|
|
||||||
}
|
|
||||||
|
|
||||||
.installButton:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.installButton:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
background-color: #ddddddbe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version {
|
|
||||||
position: absolute;
|
|
||||||
top: -5px;
|
|
||||||
right: -60px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: black;
|
|
||||||
background-color: rgb(241, 241, 241);
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 5px;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
transition:
|
|
||||||
scale 0.2s ease,
|
|
||||||
background-color 0.2s ease,
|
|
||||||
transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (pointer: fine) {
|
|
||||||
.version:hover {
|
|
||||||
background-color: rgb(221, 221, 221);
|
|
||||||
transform: scale(1.1);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.version:active {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mediaFlowConfig {
|
|
||||||
transition:
|
|
||||||
margin-top 0.6s,
|
|
||||||
opacity 0.6s,
|
|
||||||
transform 1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mediaFlowConfig.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
margin-top: -655px;
|
|
||||||
transform: scale(0);
|
|
||||||
transition:
|
|
||||||
margin-top 0.5s,
|
|
||||||
opacity 0.2s,
|
|
||||||
transform 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mediaFlowSection {
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
border-color: #777777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stremThruConfig {
|
|
||||||
transition:
|
|
||||||
margin-top 0.6s,
|
|
||||||
opacity 0.6s,
|
|
||||||
transform 1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stremThruConfig.hidden {
|
|
||||||
opacity: 0;
|
|
||||||
margin-top: -626px;
|
|
||||||
transform: scale(0);
|
|
||||||
transition:
|
|
||||||
margin-top 0.5s,
|
|
||||||
opacity 0.2s,
|
|
||||||
transform 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stremThruSection {
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
border-color: #777777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.supportMeButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background-color: #080808;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 2px;
|
|
||||||
box-shadow: none;
|
|
||||||
margin: 1px;
|
|
||||||
color: white;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px; /* Space between the heart and text */
|
|
||||||
padding: 8px 16px; /* Adjust padding as needed */
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2); /* Subtle border */
|
|
||||||
font-size: 14px; /* Text size */
|
|
||||||
font-family: Arial, sans-serif; /* Font family */
|
|
||||||
transition:
|
|
||||||
background-color 0.3s,
|
|
||||||
color 0.3s,
|
|
||||||
border-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (pointer: fine) {
|
|
||||||
.supportMeButton:hover {
|
|
||||||
background-color: rgba(
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
0.1
|
|
||||||
); /* Slight background highlight on hover */
|
|
||||||
border-color: rgba(255, 255, 255, 0.815); /* More visible border on hover */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.supportMeButton:active {
|
|
||||||
background-color: rgba(
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
255,
|
|
||||||
0.2
|
|
||||||
); /* Slight background highlight on click */
|
|
||||||
border-color: rgba(255, 255, 255, 0.815); /* More visible border on click */
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
margin: 4px 0;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.helpText {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: #666;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 800px;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
margin: 0 auto; /* Center the header within the content */
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
background-color: #000000; /* Slightly lighter black */
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
box-shadow: 0 8px 16px rgb(0, 0, 0);
|
|
||||||
padding: 20px;
|
|
||||||
width: 80%;
|
|
||||||
max-width: 700px;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
padding: 10px;
|
|
||||||
border-color: #777777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyInput {
|
|
||||||
flex: 0 0 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valueInput {
|
|
||||||
flex: 0 0 70%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex: 1;
|
|
||||||
margin-right: 10px;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
border: 1px solid #777777;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
background-color: transparent;
|
|
||||||
padding-top: 5px;
|
|
||||||
padding-left: 5px;
|
|
||||||
border: none;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteButton {
|
|
||||||
background-color: #ff4d4d;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteButton:hover {
|
|
||||||
background-color: #ff1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help {
|
|
||||||
background-color: #121212;
|
|
||||||
border: 1px solid #333333;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
padding: 10px;
|
|
||||||
margin: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background-color: #ffffff; /* Green */
|
|
||||||
border: none;
|
|
||||||
color: black;
|
|
||||||
padding: 15px 32px;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 4px 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
box-shadow: 0 4px 8px rgba(255, 255, 255, 0.111);
|
|
||||||
transition:
|
|
||||||
transform 0.2s,
|
|
||||||
box-shadow 0.2s,
|
|
||||||
opacity 0.2s,
|
|
||||||
background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outputContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.output {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
padding: 10px;
|
|
||||||
color: white;
|
|
||||||
resize: vertical;
|
|
||||||
width: 99%;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.envSection {
|
|
||||||
padding: 15px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.envCommand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.envInput {
|
|
||||||
flex: 1;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
border: 1px solid #777777;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: white;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import styles from './page.module.css';
|
|
||||||
import { Slide, ToastContainer, toast, ToastOptions } from 'react-toastify';
|
|
||||||
import { isValueEncrypted } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
interface CustomConfig {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastOptions: ToastOptions = {
|
|
||||||
autoClose: 5000,
|
|
||||||
hideProgressBar: true,
|
|
||||||
closeOnClick: false,
|
|
||||||
pauseOnHover: true,
|
|
||||||
draggable: 'touch',
|
|
||||||
style: {
|
|
||||||
borderRadius: '8px',
|
|
||||||
backgroundColor: '#ededed',
|
|
||||||
color: 'black',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const showToast = (
|
|
||||||
message: string,
|
|
||||||
type: 'success' | 'error' | 'info' | 'warning',
|
|
||||||
id?: string
|
|
||||||
) => {
|
|
||||||
toast[type](message, {
|
|
||||||
...toastOptions,
|
|
||||||
toastId: id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidBase64 = (value: string): boolean => {
|
|
||||||
try {
|
|
||||||
JSON.parse(atob(value));
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidBase64Compressed = (value: string): boolean => {
|
|
||||||
if (value.startsWith('B-')) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidConfigFormat = (value: string): boolean => {
|
|
||||||
return value
|
|
||||||
? isValueEncrypted(value) ||
|
|
||||||
isValidBase64(value) ||
|
|
||||||
isValidBase64Compressed(value)
|
|
||||||
: false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyEvent = (text: string) => {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(text)
|
|
||||||
.then(() => {
|
|
||||||
showToast('Copied to clipboard!', 'success');
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
console.error(error);
|
|
||||||
showToast('Failed to copy to clipboard.', 'error');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const CopyButton = ({ text }: { text: string }) => (
|
|
||||||
<button
|
|
||||||
className={styles.icon}
|
|
||||||
onClick={() => {
|
|
||||||
handleCopyEvent(text);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="24px"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="0.00024000000000000003"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
{' '}
|
|
||||||
<path
|
|
||||||
d="M19.53 8L14 2.47C13.8595 2.32931 13.6688 2.25018 13.47 2.25H11C10.2707 2.25 9.57118 2.53973 9.05546 3.05546C8.53973 3.57118 8.25 4.27065 8.25 5V6.25H7C6.27065 6.25 5.57118 6.53973 5.05546 7.05546C4.53973 7.57118 4.25 8.27065 4.25 9V19C4.25 19.7293 4.53973 20.4288 5.05546 20.9445C5.57118 21.4603 6.27065 21.75 7 21.75H14C14.7293 21.75 15.4288 21.4603 15.9445 20.9445C16.4603 20.4288 16.75 19.7293 16.75 19V17.75H17C17.7293 17.75 18.4288 17.4603 18.9445 16.9445C19.4603 16.4288 19.75 15.7293 19.75 15V8.5C19.7421 8.3116 19.6636 8.13309 19.53 8ZM14.25 4.81L17.19 7.75H14.25V4.81ZM15.25 19C15.25 19.3315 15.1183 19.6495 14.8839 19.8839C14.6495 20.1183 14.3315 20.25 14 20.25H7C6.66848 20.25 6.35054 20.1183 6.11612 19.8839C5.8817 19.6495 5.75 19.3315 5.75 19V9C5.75 8.66848 5.8817 8.35054 6.11612 8.11612C6.35054 7.8817 6.66848 7.75 7 7.75H8.25V15C8.25 15.7293 8.53973 16.4288 9.05546 16.9445C9.57118 17.4603 10.2707 17.75 11 17.75H15.25V19ZM17 16.25H11C10.6685 16.25 10.3505 16.1183 10.1161 15.8839C9.8817 15.6495 9.75 15.3315 9.75 15V5C9.75 4.66848 9.8817 4.35054 10.1161 4.11612C10.3505 3.8817 10.6685 3.75 11 3.75H12.75V8.5C12.7526 8.69811 12.8324 8.88737 12.9725 9.02747C13.1126 9.16756 13.3019 9.24741 13.5 9.25H18.25V15C18.25 15.3315 18.1183 15.6495 17.8839 15.8839C17.6495 16.1183 17.3315 16.25 17 16.25Z"
|
|
||||||
fill="#ffffff"
|
|
||||||
></path>{' '}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function CustomConfigGenerator() {
|
|
||||||
const [configs, setConfigs] = useState<CustomConfig[]>([]);
|
|
||||||
const [newConfig, setNewConfig] = useState<CustomConfig>({
|
|
||||||
key: '',
|
|
||||||
value: '',
|
|
||||||
});
|
|
||||||
const [output, setOutput] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const extractConfigValue = (value: string): string => {
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
const pathParts = url.pathname.split('/');
|
|
||||||
const longUniqueId = pathParts[pathParts.length - 2];
|
|
||||||
return longUniqueId;
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateKeyValuePair = (key: string, value: string) => {
|
|
||||||
if (!key || !value) {
|
|
||||||
showToast('Both key and value are required.', 'error', 'requiredFields');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!isValidConfigFormat(value)) {
|
|
||||||
showToast(
|
|
||||||
'Invalid configuration format.',
|
|
||||||
'error',
|
|
||||||
'invalidConfigFormat'
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddRow = () => {
|
|
||||||
if (
|
|
||||||
!validateKeyValuePair(newConfig.key, extractConfigValue(newConfig.value))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
if (configs.some((config) => config.key === newConfig.key)) {
|
|
||||||
showToast('Key already exists.', 'error', 'uniqueKeyConstraintViolation');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
newConfig.value = extractConfigValue(newConfig.value);
|
|
||||||
setConfigs([...configs, newConfig]);
|
|
||||||
setNewConfig({ key: '', value: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteRow = (index: number) => {
|
|
||||||
const newConfigs = configs.filter((_, i) => i !== index);
|
|
||||||
setConfigs(newConfigs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (
|
|
||||||
index: number,
|
|
||||||
field: 'key' | 'value',
|
|
||||||
value: string
|
|
||||||
) => {
|
|
||||||
const newConfigs = configs.map((config, i) =>
|
|
||||||
i === index ? { ...config, [field]: value } : config
|
|
||||||
);
|
|
||||||
setConfigs(newConfigs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateJson = () => {
|
|
||||||
if (configs.length === 0) {
|
|
||||||
showToast('No configurations to generate.', 'error', 'noConfigs');
|
|
||||||
setOutput(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
configs.forEach(({ key, value }) => {
|
|
||||||
if (!validateKeyValuePair(key, value)) {
|
|
||||||
setOutput(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = configs.reduce(
|
|
||||||
(acc, { key, value }) => {
|
|
||||||
if (key) acc[key] = value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as { [key: string]: string }
|
|
||||||
);
|
|
||||||
// double stringify to escape the quotes
|
|
||||||
setOutput(JSON.stringify(json));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.content}>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
|
||||||
<h1 style={{ textAlign: 'center' }}>AIOStreams</h1>
|
|
||||||
</div>
|
|
||||||
<p style={{ textAlign: 'center', padding: '15px' }}>
|
|
||||||
This tool allows you to generate the value needed for the{' '}
|
|
||||||
<code>CUSTOM_CONFIGS</code> environment variable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.section}>
|
|
||||||
<h2 style={{ padding: '5px' }}>Your Configurations</h2>
|
|
||||||
<p style={{ padding: '5px' }}>
|
|
||||||
Add your configurations below. Put the name of the configuration in
|
|
||||||
the key field and the manifest URL in the value field.
|
|
||||||
</p>
|
|
||||||
<div className={styles.help}>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: '1.2em',
|
|
||||||
marginBottom: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
How to get the config value?
|
|
||||||
</h3>
|
|
||||||
<p style={{ textAlign: 'left' }}>
|
|
||||||
Once you have configured AIOStreams, upon clicking the{' '}
|
|
||||||
<code>Generate Manifest URL</code> button, make sure to click the{' '}
|
|
||||||
<code>Copy URL</code> button at the configuration page. You then
|
|
||||||
paste that URL in the value field below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{configs.map((config, index) => (
|
|
||||||
<div key={index} className={styles.row}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Key"
|
|
||||||
value={config.key}
|
|
||||||
onChange={(e) => handleChange(index, 'key', e.target.value)}
|
|
||||||
className={styles.keyInput}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Value"
|
|
||||||
value={config.value}
|
|
||||||
onChange={(e) => handleChange(index, 'value', e.target.value)}
|
|
||||||
className={styles.valueInput}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteRow(index)}
|
|
||||||
className={styles.icon}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
fill="#ffffff"
|
|
||||||
height="24px"
|
|
||||||
width="24px"
|
|
||||||
version="1.1"
|
|
||||||
id="Layer_1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
||||||
viewBox="0 0 457.503 457.503"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
stroke="#ffffff"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
{' '}
|
|
||||||
<g>
|
|
||||||
{' '}
|
|
||||||
<g>
|
|
||||||
{' '}
|
|
||||||
<path d="M381.575,57.067h-90.231C288.404,25.111,261.461,0,228.752,0C196.043,0,169.1,25.111,166.16,57.067H75.929 c-26.667,0-48.362,21.695-48.362,48.362c0,26.018,20.655,47.292,46.427,48.313v246.694c0,31.467,25.6,57.067,57.067,57.067 h195.381c31.467,0,57.067-25.6,57.067-57.067V153.741c25.772-1.02,46.427-22.294,46.427-48.313 C429.936,78.761,408.242,57.067,381.575,57.067z M165.841,376.817c0,8.013-6.496,14.509-14.508,14.509 c-8.013,0-14.508-6.496-14.508-14.509V186.113c0-8.013,6.496-14.508,14.508-14.508c8.013,0,14.508,6.496,14.508,14.508V376.817z M243.26,376.817c0,8.013-6.496,14.509-14.508,14.509c-8.013,0-14.508-6.496-14.508-14.509V186.113 c0-8.013,6.496-14.508,14.508-14.508c8.013,0,14.508,6.496,14.508,14.508V376.817z M320.679,376.817 c0,8.013-6.496,14.509-14.508,14.509c-8.013,0-14.509-6.496-14.509-14.509V186.113c0-8.013,6.496-14.508,14.509-14.508 s14.508,6.496,14.508,14.508V376.817z"></path>{' '}
|
|
||||||
</g>{' '}
|
|
||||||
</g>{' '}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className={styles.row}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Config Name"
|
|
||||||
value={newConfig.key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewConfig({ ...newConfig, key: e.target.value })
|
|
||||||
}
|
|
||||||
className={styles.keyInput}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Value"
|
|
||||||
value={newConfig.value}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewConfig({ ...newConfig, value: e.target.value })
|
|
||||||
}
|
|
||||||
className={styles.valueInput}
|
|
||||||
/>
|
|
||||||
<button onClick={handleAddRow} className={styles.icon}>
|
|
||||||
<svg
|
|
||||||
fill="#ffffff"
|
|
||||||
version="1.1"
|
|
||||||
id="Capa_1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
||||||
width="24px"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 45.402 45.402"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
stroke="#ffffff"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
{' '}
|
|
||||||
<g>
|
|
||||||
{' '}
|
|
||||||
<path d="M41.267,18.557H26.832V4.134C26.832,1.851,24.99,0,22.707,0c-2.283,0-4.124,1.851-4.124,4.135v14.432H4.141 c-2.283,0-4.139,1.851-4.138,4.135c-0.001,1.141,0.46,2.187,1.207,2.934c0.748,0.749,1.78,1.222,2.92,1.222h14.453V41.27 c0,1.142,0.453,2.176,1.201,2.922c0.748,0.748,1.777,1.211,2.919,1.211c2.282,0,4.129-1.851,4.129-4.133V26.857h14.435 c2.283,0,4.134-1.867,4.133-4.15C45.399,20.425,43.548,18.557,41.267,18.557z"></path>{' '}
|
|
||||||
</g>{' '}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.section}>
|
|
||||||
<h2 style={{ padding: '5px' }}>Generate JSON Output</h2>
|
|
||||||
<p style={{ padding: '5px' }}>
|
|
||||||
Click the <code>Generate</code> button to generate the JSON output.
|
|
||||||
This is the value you need to set for the{' '}
|
|
||||||
<code>CUSTOM_CONFIGS</code> environment variable.
|
|
||||||
</p>
|
|
||||||
<div className={styles.buttonContainer}>
|
|
||||||
<button onClick={generateJson} className={styles.button}>
|
|
||||||
Generate
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{output && (
|
|
||||||
<div className={styles.outputContainer}>
|
|
||||||
<h3 style={{ padding: '5px' }}>Output</h3>
|
|
||||||
<textarea
|
|
||||||
style={{ height: '200px', margin: '5px' }}
|
|
||||||
value={output}
|
|
||||||
readOnly
|
|
||||||
className={styles.output}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{output && (
|
|
||||||
<div className={styles.buttonContainer}>
|
|
||||||
<button
|
|
||||||
className={styles.button}
|
|
||||||
onClick={() => handleCopyEvent(output)}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{output && (
|
|
||||||
<div className={`${styles.section} ${styles.envSection}`}>
|
|
||||||
<h2>Setting the environment variable</h2>
|
|
||||||
<p>
|
|
||||||
Set the <code>CUSTOM_CONFIGS</code> environment variable to the
|
|
||||||
value generated above. You can either manually use the value above
|
|
||||||
or use the following commands to set it in a <code>.env</code>{' '}
|
|
||||||
file. Ensure you are running these commands in the root directory
|
|
||||||
of AIOStreams.
|
|
||||||
</p>
|
|
||||||
<p style={{ marginTop: '10px' }}>
|
|
||||||
<strong>Windows:</strong>
|
|
||||||
</p>
|
|
||||||
<div className={styles.envCommand}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readOnly
|
|
||||||
value={`Add-Content -Path .env -Value 'CUSTOM_CONFIGS=${output}'`}
|
|
||||||
className={styles.envInput}
|
|
||||||
/>
|
|
||||||
<CopyButton
|
|
||||||
text={`Add-Content -Path .env -Value 'CUSTOM_CONFIGS=${output}'`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p style={{ marginTop: '10px' }}>
|
|
||||||
<strong>Linux/Mac:</strong>
|
|
||||||
</p>
|
|
||||||
<div className={styles.envCommand}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readOnly
|
|
||||||
value={`echo CUSTOM_CONFIGS=${JSON.stringify(output).slice(1, -1)} >> .env`}
|
|
||||||
className={styles.envInput}
|
|
||||||
/>
|
|
||||||
<CopyButton
|
|
||||||
text={`echo CUSTOM_CONFIGS=${JSON.stringify(output).slice(1, -1)} >> .env`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ToastContainer
|
|
||||||
stacked
|
|
||||||
position="top-center"
|
|
||||||
transition={Slide}
|
|
||||||
draggablePercent={30}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -1,68 +1,327 @@
|
|||||||
:root {
|
@tailwind base;
|
||||||
--background: linear-gradient(200deg, #181818, #111111);
|
@tailwind components;
|
||||||
--foreground: #ededed;
|
@tailwind utilities;
|
||||||
--borderRadius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
@layer base {
|
||||||
body {
|
:root {
|
||||||
max-width: 100vw;
|
--media-cue-backdrop: blur(0px);
|
||||||
overflow-x: hidden;
|
--media-captions-padding: 0%;
|
||||||
}
|
--video-captions-offset: 0px;
|
||||||
|
/*--brand-color-50: #f2f0ff;*/
|
||||||
|
/*--brand-color-100: #eeebff;*/
|
||||||
|
/*--brand-color-200: #d4d0ff;*/
|
||||||
|
/*--brand-color-300: #c7c2ff;*/
|
||||||
|
/*--brand-color-400: #9f92ff;*/
|
||||||
|
/*--brand-color-500: #6152df;*/
|
||||||
|
/*--brand-color-600: #5243cb;*/
|
||||||
|
/*--brand-color-700: #3f2eb2;*/
|
||||||
|
/*--brand-color-800: #312887;*/
|
||||||
|
/*--brand-color-900: #231c6b;*/
|
||||||
|
/*--brand-color-950: #1a144f;*/
|
||||||
|
/*--brand-color-default: #6152df;*/
|
||||||
|
|
||||||
body {
|
--color-brand-50: 242 240 255;
|
||||||
color: var(--foreground);
|
--color-brand-100: 238 235 255;
|
||||||
background: var(--background);
|
--color-brand-200: 212 208 255;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
--color-brand-300: 199 194 255;
|
||||||
-webkit-font-smoothing: antialiased;
|
--color-brand-400: 159 146 255;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
--color-brand-500: 97 82 223;
|
||||||
}
|
--color-brand-600: 82 67 203;
|
||||||
|
--color-brand-700: 63 46 178;
|
||||||
|
--color-brand-800: 49 40 135;
|
||||||
|
--color-brand-900: 35 28 107;
|
||||||
|
--color-brand-950: 26 20 79;
|
||||||
|
--color-brand-default: 97 82 223;
|
||||||
|
|
||||||
* {
|
/*--gray-color-50: #FAFAFA;*/
|
||||||
box-sizing: border-box;
|
/*--gray-color-100: #F5F5F5;*/
|
||||||
padding: 0;
|
/*--gray-color-200: #E5E5E5;*/
|
||||||
margin: 0;
|
/*--gray-color-300: #D4D4D4;*/
|
||||||
}
|
/*--gray-color-400: #A3A3A3;*/
|
||||||
|
/*--gray-color-500: #737373;*/
|
||||||
|
/*--gray-color-600: #525252;*/
|
||||||
|
/*--gray-color-700: #404040;*/
|
||||||
|
/*--gray-color-800: #262626;*/
|
||||||
|
/*--gray-color-900: #171717;*/
|
||||||
|
/*--gray-color-950: #101010;*/
|
||||||
|
/*--gray-color-default: #737373;*/
|
||||||
|
|
||||||
a {
|
/* --color-gray-50: 250 250 250;
|
||||||
color: #979797;
|
--color-gray-100: 245 245 245;
|
||||||
text-decoration: underline;
|
--color-gray-200: 229 229 229;
|
||||||
}
|
--color-gray-300: 212 212 212;
|
||||||
|
--color-gray-400: 163 163 163;
|
||||||
|
--color-gray-500: 115 115 115;
|
||||||
|
--color-gray-600: 82 82 82;
|
||||||
|
--color-gray-700: 64 64 64;
|
||||||
|
--color-gray-800: 38 38 38;
|
||||||
|
--color-gray-900: 23 23 23;
|
||||||
|
--color-gray-950: 16 16 16;
|
||||||
|
--color-gray-default: 115 115 115;*/
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
--color-gray-50: 230 230 230;
|
||||||
html {
|
--color-gray-100: 225 225 225;
|
||||||
color-scheme: dark;
|
--color-gray-200: 209 209 209;
|
||||||
|
--color-gray-300: 202 202 202;
|
||||||
|
--color-gray-400: 143 143 143;
|
||||||
|
--color-gray-500: 90 90 90;
|
||||||
|
--color-gray-600: 72 72 72;
|
||||||
|
--color-gray-700: 54 54 54;
|
||||||
|
--color-gray-800: 28 28 28;
|
||||||
|
--color-gray-900: 16 16 16;
|
||||||
|
--color-gray-950: 11 11 11;
|
||||||
|
--color-gray-default: 105 105 105;
|
||||||
|
|
||||||
|
/*--radius: 0.375rem;*/
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--radius-md: 0.5rem;
|
||||||
|
|
||||||
|
--titlebar-h: theme('height.10');
|
||||||
|
|
||||||
|
--foreground: theme('colors.gray.800');
|
||||||
|
--background: white;
|
||||||
|
|
||||||
|
--brand: theme('colors.brand.300');
|
||||||
|
--slate: theme('colors.slate.500');
|
||||||
|
--gray: theme('colors.gray.500');
|
||||||
|
--zinc: theme('colors.zinc.500');
|
||||||
|
--neutral: theme('colors.neutral.500');
|
||||||
|
--stone: theme('colors.stone.500');
|
||||||
|
--red: theme('colors.red.500');
|
||||||
|
--orange: theme('colors.orange.500');
|
||||||
|
--amber: theme('colors.amber.500');
|
||||||
|
--yellow: theme('colors.yellow.500');
|
||||||
|
--lime: theme('colors.lime.500');
|
||||||
|
--green: theme('colors.green.500');
|
||||||
|
--emerald: theme('colors.emerald.500');
|
||||||
|
--teal: theme('colors.teal.500');
|
||||||
|
--cyan: theme('colors.cyan.500');
|
||||||
|
--sky: theme('colors.sky.500');
|
||||||
|
--blue: theme('colors.blue.500');
|
||||||
|
--indigo: theme('colors.indigo.500');
|
||||||
|
--violet: theme('colors.violet.500');
|
||||||
|
--purple: theme('colors.purple.500');
|
||||||
|
--fuchsia: theme('colors.fuchsia.500');
|
||||||
|
--pink: theme('colors.pink.500');
|
||||||
|
--rose: theme('colors.rose.500');
|
||||||
|
|
||||||
|
--border: theme('colors.gray.200');
|
||||||
|
--ring: theme('colors.brand.500');
|
||||||
|
|
||||||
|
--muted: theme('colors.gray.500');
|
||||||
|
--muted-highlight: theme('colors.gray.700');
|
||||||
|
|
||||||
|
--paper: theme('colors.white');
|
||||||
|
--subtle: rgba(0, 0, 0, 0.04);
|
||||||
|
--subtle-highlight: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
--media-card-popup-background: theme('colors.gray.950');
|
||||||
|
--hover-from-background-color: theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark,
|
||||||
|
[data-mode='dark'] {
|
||||||
|
--foreground: theme('colors.gray.200');
|
||||||
|
--background: #070707;
|
||||||
|
|
||||||
|
/*--brand: theme('colors.brand.300');*/
|
||||||
|
--slate: theme('colors.slate.300');
|
||||||
|
--gray: theme('colors.gray.300');
|
||||||
|
--zinc: theme('colors.zinc.300');
|
||||||
|
--neutral: theme('colors.neutral.300');
|
||||||
|
--stone: theme('colors.stone.300');
|
||||||
|
--red: theme('colors.red.300');
|
||||||
|
--orange: theme('colors.orange.300');
|
||||||
|
--amber: theme('colors.amber.300');
|
||||||
|
--yellow: theme('colors.yellow.300');
|
||||||
|
--lime: theme('colors.lime.300');
|
||||||
|
--green: theme('colors.green.300');
|
||||||
|
--emerald: theme('colors.emerald.300');
|
||||||
|
--teal: theme('colors.teal.300');
|
||||||
|
--cyan: theme('colors.cyan.300');
|
||||||
|
--sky: theme('colors.sky.300');
|
||||||
|
--blue: theme('colors.blue.300');
|
||||||
|
--indigo: theme('colors.indigo.300');
|
||||||
|
--violet: theme('colors.violet.300');
|
||||||
|
--purple: theme('colors.purple.300');
|
||||||
|
--fuchsia: theme('colors.fuchsia.300');
|
||||||
|
--pink: theme('colors.pink.300');
|
||||||
|
--rose: theme('colors.rose.300');
|
||||||
|
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--ring: theme('colors.brand.200');
|
||||||
|
|
||||||
|
--muted: rgba(255, 255, 255, 0.4);
|
||||||
|
--muted-highlight: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
--paper: theme('colors.gray.950');
|
||||||
|
--paper-highlighter: theme('colors.gray.950');
|
||||||
|
--subtle: rgba(255, 255, 255, 0.06);
|
||||||
|
--subtle-highlight: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
html {
|
||||||
-webkit-tap-highlight-color: transparent;
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='checkbox'] {
|
html * {
|
||||||
margin-right: 10px;
|
border-color: var(--border);
|
||||||
margin-left: 10px;
|
|
||||||
width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: #ffffff; /* White color */
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='url'],
|
/*h1, h2, h3, h4, h5, h6 {*/
|
||||||
input[type='text'],
|
/* @apply text-gray-800 dark:text-gray-100*/
|
||||||
input[type='number'],
|
/*}*/
|
||||||
input[type='password'],
|
|
||||||
select {
|
h1 {
|
||||||
padding: 5px;
|
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||||
border-radius: var(--borderRadius);
|
}
|
||||||
margin: 4px 0 0px 0;
|
|
||||||
border: 1px solid #555;
|
h2 {
|
||||||
height: 30px;
|
@apply scroll-m-20 text-3xl font-bold tracking-tight first:mt-0;
|
||||||
background-color: #eeeeee;
|
}
|
||||||
font-size: 16px;
|
|
||||||
|
h3 {
|
||||||
|
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
@apply scroll-m-20 text-lg font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
@apply scroll-m-20 text-base font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* width */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle */
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-700 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle on hover */
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
@apply bg-gray-800 rounded-[--radius-md] px-2 py-1 text-sm font-mono border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.JASSUB {
|
||||||
|
position: absolute !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: rgb(0, 0, 0);
|
}
|
||||||
cursor: pointer;
|
|
||||||
|
.force-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*body[data-scroll-locked] {*/
|
||||||
|
/* --removed-body-scroll-bar-size: 0 !important;*/
|
||||||
|
/* margin-right: 0 !important;*/
|
||||||
|
/* overflow-y: auto !important;*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
body[data-scroll-locked] .scroll-locked-offset {
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-scroll-locked] .media-page-header-scroll-locked {
|
||||||
|
/*right: 10px;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
/**/
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*div[data-media-player][data-controls]:not([data-hocus]) .vds-controls {*/
|
||||||
|
/* @apply lg:opacity-0*/
|
||||||
|
/*}*/
|
||||||
|
|
||||||
|
.discrete-controls[data-media-player][data-playing][data-controls]:not(
|
||||||
|
:has(.vds-controls-group:hover)
|
||||||
|
)
|
||||||
|
.vds-controls {
|
||||||
|
@apply lg:opacity-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discrete-controls[data-media-player][data-buffering][data-controls]:not(
|
||||||
|
:has(.vds-controls-group:hover)
|
||||||
|
)
|
||||||
|
.vds-controls {
|
||||||
|
@apply lg:opacity-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discrete-controls[data-media-player][data-playing][data-seeking][data-controls]:not(
|
||||||
|
:has(.vds-controls-group:hover)
|
||||||
|
) {
|
||||||
|
@apply cursor-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.halo:after {
|
||||||
|
opacity: 0.1;
|
||||||
|
background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 50%),
|
||||||
|
radial-gradient(at 97% 21%, #9772fe 0, transparent 70%),
|
||||||
|
radial-gradient(at 52% 99%, #fd3a4e 0, transparent 50%),
|
||||||
|
radial-gradient(at 10% 29%, #fc5ab0 0, transparent 50%),
|
||||||
|
radial-gradient(at 97% 96%, #e4c795 0, transparent 50%),
|
||||||
|
radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%),
|
||||||
|
radial-gradient(at 79% 53%, #eea5ba 0, transparent 50%);
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: blur(100px) saturate(150%);
|
||||||
|
z-index: -1;
|
||||||
|
top: 50px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.halo-2:after {
|
||||||
|
opacity: 0.1;
|
||||||
|
background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 30%),
|
||||||
|
radial-gradient(at 97% 21%, #9772fe 0, transparent 70%),
|
||||||
|
radial-gradient(at 52% 99%, #fd3a4e 0, transparent 20%),
|
||||||
|
radial-gradient(at 10% 29%, #fc5ab0 0, transparent 20%),
|
||||||
|
radial-gradient(at 97% 96%, #e4c795 0, transparent 20%),
|
||||||
|
radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%),
|
||||||
|
radial-gradient(at 79% 53%, #eea5ba 0, transparent 20%);
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: blur(100px) saturate(150%);
|
||||||
|
z-index: -1;
|
||||||
|
top: 50px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.hide-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 182 KiB |
@@ -1,33 +1,18 @@
|
|||||||
import type { Metadata } from 'next';
|
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const geistSans = Geist({
|
export const metadata = {
|
||||||
variable: '--font-geist-sans',
|
|
||||||
subsets: ['latin'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: '--font-geist-mono',
|
|
||||||
subsets: ['latin'],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'AIOStreams',
|
title: 'AIOStreams',
|
||||||
description:
|
description: 'The all in one addon for Stremio.',
|
||||||
'Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body>{children}</body>
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AppSidebar, useAppSidebarContext } from '@/components/ui/app-layout';
|
||||||
|
import { cn } from '@/components/ui/core/styling';
|
||||||
|
import { VerticalMenu, VerticalMenuItem } from '@/components/ui/vertical-menu';
|
||||||
|
import { useStatus } from '@/context/status';
|
||||||
|
import { useMenu, MenuId } from '@/context/menu';
|
||||||
|
import { useUserData } from '@/context/userData';
|
||||||
|
import { ConfigModal } from '@/components/config-modal';
|
||||||
|
import {
|
||||||
|
CloudIcon,
|
||||||
|
FunnelIcon,
|
||||||
|
HeartIcon,
|
||||||
|
ImportIcon,
|
||||||
|
InfoIcon,
|
||||||
|
PuzzleIcon,
|
||||||
|
SaveIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
SortAscIcon,
|
||||||
|
KeyIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
LogInIcon,
|
||||||
|
PenIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { useDisclosure } from '@/hooks/disclosure';
|
||||||
|
import {
|
||||||
|
ConfirmationDialog,
|
||||||
|
useConfirmationDialog,
|
||||||
|
} from '@/components/shared/confirmation-dialog';
|
||||||
|
import { Modal } from '@/components/ui/modal';
|
||||||
|
import { TextInput } from '@/components/ui/text-input';
|
||||||
|
|
||||||
|
type MenuItem = VerticalMenuItem & {
|
||||||
|
id: MenuId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MainSidebar() {
|
||||||
|
const ctx = useAppSidebarContext();
|
||||||
|
const [expandedSidebar, setExpandSidebar] = React.useState(false);
|
||||||
|
const isCollapsed = !ctx.isBelowBreakpoint && !expandedSidebar;
|
||||||
|
const { selectedMenu, setSelectedMenu } = useMenu();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const user = useUserData();
|
||||||
|
const signInModal = useDisclosure(false);
|
||||||
|
const [initialUuid, setInitialUuid] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const uuidMatch = pathname.match(
|
||||||
|
/stremio\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/.*\/configure/
|
||||||
|
);
|
||||||
|
if (uuidMatch) {
|
||||||
|
const extractedUuid = uuidMatch[1];
|
||||||
|
setInitialUuid(extractedUuid);
|
||||||
|
signInModal.open();
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const { status, error, loading } = useStatus();
|
||||||
|
|
||||||
|
const confirmClearConfig = useConfirmationDialog({
|
||||||
|
title: 'Sign Out',
|
||||||
|
description: 'Are you sure you want to sign out?',
|
||||||
|
onConfirm: () => {
|
||||||
|
user.setUserData(null);
|
||||||
|
user.setUuid(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const topMenuItems: MenuItem[] = [
|
||||||
|
{
|
||||||
|
name: 'About',
|
||||||
|
iconType: InfoIcon,
|
||||||
|
isCurrent: selectedMenu === 'about',
|
||||||
|
id: 'about',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Services',
|
||||||
|
iconType: CloudIcon,
|
||||||
|
isCurrent: selectedMenu === 'services',
|
||||||
|
id: 'services',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Addons',
|
||||||
|
iconType: PuzzleIcon,
|
||||||
|
isCurrent: selectedMenu === 'addons',
|
||||||
|
id: 'addons',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Filters',
|
||||||
|
iconType: FunnelIcon,
|
||||||
|
isCurrent: selectedMenu === 'filters',
|
||||||
|
id: 'filters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sorting',
|
||||||
|
iconType: SortAscIcon,
|
||||||
|
isCurrent: selectedMenu === 'sorting',
|
||||||
|
id: 'sorting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Formatter',
|
||||||
|
iconType: PenIcon,
|
||||||
|
isCurrent: selectedMenu === 'formatter',
|
||||||
|
id: 'formatter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Miscellaneous',
|
||||||
|
iconType: SettingsIcon,
|
||||||
|
isCurrent: selectedMenu === 'miscellaneous',
|
||||||
|
id: 'miscellaneous',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Save & Install',
|
||||||
|
iconType: SaveIcon,
|
||||||
|
isCurrent: selectedMenu === 'save-install',
|
||||||
|
id: 'save-install',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bottomMenuItems: MenuItem[] = [
|
||||||
|
...(user.uuid && user.password
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Sign Out',
|
||||||
|
iconType: LogOutIcon,
|
||||||
|
isCurrent: false,
|
||||||
|
id: 'unload-config' as MenuId,
|
||||||
|
onClick: () => {
|
||||||
|
confirmClearConfig.open();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: 'Login',
|
||||||
|
iconType: LogInIcon,
|
||||||
|
isCurrent: signInModal.isOpen,
|
||||||
|
id: 'sign-in' as MenuId,
|
||||||
|
onClick: () => {
|
||||||
|
signInModal.open();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleExpandSidebar = () => {
|
||||||
|
if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) {
|
||||||
|
setExpandSidebar(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleUnexpandedSidebar = () => {
|
||||||
|
if (expandedSidebar && ts.expandSidebarOnHover) {
|
||||||
|
setExpandSidebar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ts = {
|
||||||
|
expandSidebarOnHover: false,
|
||||||
|
disableSidebarTransparency: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppSidebar
|
||||||
|
className={cn(
|
||||||
|
'group/main-sidebar h-full flex flex-col justify-between transition-gpu w-full transition-[width] duration-300',
|
||||||
|
!ctx.isBelowBreakpoint && expandedSidebar && 'w-[260px]',
|
||||||
|
!ctx.isBelowBreakpoint &&
|
||||||
|
!ts.disableSidebarTransparency &&
|
||||||
|
'bg-transparent',
|
||||||
|
!ctx.isBelowBreakpoint &&
|
||||||
|
!ts.disableSidebarTransparency &&
|
||||||
|
ts.expandSidebarOnHover &&
|
||||||
|
'hover:bg-[--background]'
|
||||||
|
)}
|
||||||
|
onMouseEnter={handleExpandSidebar}
|
||||||
|
onMouseLeave={handleUnexpandedSidebar}
|
||||||
|
>
|
||||||
|
{!ctx.isBelowBreakpoint &&
|
||||||
|
ts.expandSidebarOnHover &&
|
||||||
|
ts.disableSidebarTransparency && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed h-full translate-x-0 w-[50px] bg-gradient bg-gradient-to-r via-[--background] from-[--background] to-transparent',
|
||||||
|
'group-hover/main-sidebar:translate-x-[250px] transition opacity-0 duration-300 group-hover/main-sidebar:opacity-100'
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 p-4 pb-0 flex flex-col items-center w-full">
|
||||||
|
<img src="/logo.png" alt="logo" className="w-22.5 h-15" />
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{status ? `v${status.version}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<VerticalMenu
|
||||||
|
className="px-4"
|
||||||
|
collapsed={isCollapsed}
|
||||||
|
itemClass="relative"
|
||||||
|
items={topMenuItems}
|
||||||
|
onItemSelect={(item) => {
|
||||||
|
setSelectedMenu((item as MenuItem).id);
|
||||||
|
ctx.setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full gap-2 flex-col px-4">
|
||||||
|
<div>
|
||||||
|
<VerticalMenu
|
||||||
|
collapsed={isCollapsed}
|
||||||
|
itemClass="relative"
|
||||||
|
onMouseEnter={() => {}}
|
||||||
|
onMouseLeave={() => {}}
|
||||||
|
onItemSelect={(item) => {
|
||||||
|
const menuItem = item as MenuItem;
|
||||||
|
if (menuItem.onClick) {
|
||||||
|
menuItem.onClick();
|
||||||
|
} else {
|
||||||
|
setSelectedMenu(menuItem.id);
|
||||||
|
ctx.setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
items={bottomMenuItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppSidebar>
|
||||||
|
|
||||||
|
<ConfigModal
|
||||||
|
open={signInModal.isOpen}
|
||||||
|
onSuccess={() => {
|
||||||
|
signInModal.close();
|
||||||
|
}}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) {
|
||||||
|
signInModal.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialUuid={initialUuid || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationDialog {...confirmClearConfig} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useStatus } from '@/context/status';
|
||||||
|
import { StatusProvider } from '@/context/status';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
import {
|
||||||
|
AppLayout,
|
||||||
|
AppLayoutContent,
|
||||||
|
AppLayoutSidebar,
|
||||||
|
AppSidebarProvider,
|
||||||
|
} from '@/components/ui/app-layout';
|
||||||
|
import { MainSidebar } from './main-sidebar';
|
||||||
|
import { LoadingOverlayWithLogo } from '@/components/shared/loading-overlay';
|
||||||
|
import { MenuProvider } from '@/context/menu';
|
||||||
|
import { MenuContent } from '../components/menu-content';
|
||||||
|
import { ThemeProvider } from 'next-themes';
|
||||||
|
import { LoadingOverlay } from '@/components/ui/loading-spinner';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { TopNavbar } from './top-navbar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { UserDataProvider } from '@/context/userData';
|
||||||
|
import { LuffyError } from '@/components/shared/luffy-error';
|
||||||
|
import { TextGenerateEffect } from '@/components/shared/text-generate-effect';
|
||||||
|
|
||||||
|
function ErrorOverlay({ error }: { error: string | null }) {
|
||||||
|
return (
|
||||||
|
<LoadingOverlay showSpinner={false}>
|
||||||
|
<LuffyError title="Something went wrong!" showRefreshButton>
|
||||||
|
<p>{error}</p>
|
||||||
|
</LuffyError>
|
||||||
|
</LoadingOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { status, loading, error } = useStatus();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<LoadingOverlay showSpinner>
|
||||||
|
<TextGenerateEffect words="Launching..." className="text-2xl" />
|
||||||
|
</LoadingOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !status) {
|
||||||
|
return <ErrorOverlay error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuProvider>
|
||||||
|
<AppSidebarProvider>
|
||||||
|
<AppLayout withSidebar sidebarSize="slim">
|
||||||
|
<AppLayoutSidebar>
|
||||||
|
<MainSidebar />
|
||||||
|
</AppLayoutSidebar>
|
||||||
|
<AppLayout>
|
||||||
|
<AppLayoutContent>
|
||||||
|
<div data-main-layout-container className="h-auto">
|
||||||
|
<TopNavbar />
|
||||||
|
<div data-main-layout-content>
|
||||||
|
<MenuContent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayoutContent>
|
||||||
|
</AppLayout>
|
||||||
|
</AppLayout>
|
||||||
|
</AppSidebarProvider>
|
||||||
|
<Toaster />
|
||||||
|
</MenuProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="dark" forcedTheme="dark">
|
||||||
|
<StatusProvider>
|
||||||
|
<UserDataProvider>
|
||||||
|
<AppContent />
|
||||||
|
</UserDataProvider>
|
||||||
|
</StatusProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { LoadingOverlay } from '@/components/ui/loading-spinner';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<LoadingOverlay showSpinner={false}>
|
||||||
|
<Image
|
||||||
|
src="/logo_2.png"
|
||||||
|
alt="Launching..."
|
||||||
|
priority
|
||||||
|
width={180}
|
||||||
|
height={180}
|
||||||
|
className="animate-pulse"
|
||||||
|
/>
|
||||||
|
Launching...
|
||||||
|
</LoadingOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// import { OfflineTopMenu } from '@/app/(main)/(offline)/offline/_components/offline-top-menu';
|
||||||
|
import { LayoutHeaderBackground } from '@/components/layout-header-background';
|
||||||
|
import { useStatus } from '@/context/status';
|
||||||
|
import { AppSidebarTrigger } from '@/components/ui/app-layout';
|
||||||
|
import { cn } from '@/components/ui/core/styling';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
type TopNavbarProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopNavbar(props: TopNavbarProps) {
|
||||||
|
const { children, ...rest } = props;
|
||||||
|
|
||||||
|
const serverStatus = useStatus();
|
||||||
|
const isOffline = !serverStatus.status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-top-navbar
|
||||||
|
className={cn(
|
||||||
|
'w-full h-[5rem] relative overflow-hidden flex items-center',
|
||||||
|
'lg:hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-top-navbar-content-container
|
||||||
|
className="relative z-10 px-4 w-full flex flex-row md:items-center overflow-x-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-top-navbar-content
|
||||||
|
className="flex items-center w-full gap-3"
|
||||||
|
>
|
||||||
|
<AppSidebarTrigger />
|
||||||
|
<div
|
||||||
|
data-top-navbar-content-separator
|
||||||
|
className="flex flex-1"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LayoutHeaderBackground />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addonSelector {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addonSelector button {
|
|
||||||
padding: 5px 10px;
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
border: none;
|
|
||||||
background-color: #ffffff;
|
|
||||||
color: #000;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 8px rgba(255, 255, 255, 0.041);
|
|
||||||
transition:
|
|
||||||
background-color 0.3s,
|
|
||||||
transform 0.2s,
|
|
||||||
box-shadow 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.addonSelector button:hover {
|
|
||||||
background-color: #f1f1f1;
|
|
||||||
transform: scale(1.01);
|
|
||||||
box-shadow: 0 4px 8px rgba(206, 206, 206, 0.19);
|
|
||||||
}
|
|
||||||
|
|
||||||
.addonSelector button:active {
|
|
||||||
transform: scale(0.99);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: #1a1a1a; /* Slightly lighter black */
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cardBody {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: 1px solid #a0a0a0;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option label {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option small {
|
|
||||||
color: #aaa;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.required {
|
|
||||||
color: #ffffff;
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionButton {
|
|
||||||
background-color: transparent;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
display: flex;
|
|
||||||
margin: 5px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
align-items: center;
|
|
||||||
border-width: 0;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionButton:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionButton:active {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import styles from './AddonsList.module.css';
|
|
||||||
import { AddonDetail, Config } from '@aiostreams/types';
|
|
||||||
import CredentialInput from './CredentialInput';
|
|
||||||
import MultiSelect from './MutliSelect';
|
|
||||||
|
|
||||||
interface AddonsListProps {
|
|
||||||
choosableAddons: string[];
|
|
||||||
addonDetails: AddonDetail[];
|
|
||||||
addons: Config['addons'];
|
|
||||||
setAddons: (addons: Config['addons']) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddonsList: React.FC<AddonsListProps> = ({
|
|
||||||
choosableAddons,
|
|
||||||
addonDetails,
|
|
||||||
addons,
|
|
||||||
setAddons,
|
|
||||||
}) => {
|
|
||||||
const [selectedAddon, setSelectedAddon] = useState<string>('');
|
|
||||||
|
|
||||||
const addAddon = () => {
|
|
||||||
if (selectedAddon) {
|
|
||||||
setAddons([...addons, { id: selectedAddon, options: {} }]);
|
|
||||||
setSelectedAddon('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAddon = (index: number) => {
|
|
||||||
const newAddons = [...addons];
|
|
||||||
newAddons.splice(index, 1);
|
|
||||||
setAddons(newAddons);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateOption = (
|
|
||||||
addonIndex: number,
|
|
||||||
optionKey: string,
|
|
||||||
value?: string
|
|
||||||
) => {
|
|
||||||
const newAddons = [...addons];
|
|
||||||
newAddons[addonIndex].options[optionKey] = value;
|
|
||||||
setAddons(newAddons);
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveAddon = (index: number, direction: 'up' | 'down') => {
|
|
||||||
const newAddons = [...addons];
|
|
||||||
const [movedAddon] = newAddons.splice(index, 1);
|
|
||||||
newAddons.splice(direction === 'up' ? index - 1 : index + 1, 0, movedAddon);
|
|
||||||
setAddons(newAddons);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.addonSelector}>
|
|
||||||
<select
|
|
||||||
value={selectedAddon}
|
|
||||||
onChange={(e) => setSelectedAddon(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Select an addon</option>
|
|
||||||
{choosableAddons.map((addon) => {
|
|
||||||
const addonDetail = addonDetails.find(
|
|
||||||
(detail) => detail.id === addon
|
|
||||||
);
|
|
||||||
if (addonDetail) {
|
|
||||||
return (
|
|
||||||
<option key={addon} value={addon}>
|
|
||||||
{addonDetail.name}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
<button onClick={addAddon}>Add Addon</button>
|
|
||||||
</div>
|
|
||||||
{addons.map((addon, index) => {
|
|
||||||
const details = addonDetails.find((detail) => detail.id === addon.id);
|
|
||||||
return (
|
|
||||||
<div key={index} className={styles.card}>
|
|
||||||
<div className={styles.cardHeader}>
|
|
||||||
<span style={{ fontSize: '1.2em', fontWeight: 'bold' }}>
|
|
||||||
{details?.name}
|
|
||||||
</span>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
{index > 0 && (
|
|
||||||
<button
|
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={() => moveAddon(index, 'up')}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
fill="#ffffff"
|
|
||||||
version="1.1"
|
|
||||||
id="Capa_1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
||||||
width="24px"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 96.154 96.154"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
stroke="#ffffff"
|
|
||||||
transform="matrix(1, 0, 0, -1, 0, 0)"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
{' '}
|
|
||||||
<g>
|
|
||||||
{' '}
|
|
||||||
<path d="M0.561,20.971l45.951,57.605c0.76,0.951,2.367,0.951,3.127,0l45.956-57.609c0.547-0.689,0.709-1.716,0.414-2.61 c-0.061-0.187-0.129-0.33-0.186-0.437c-0.351-0.65-1.025-1.056-1.765-1.056H2.093c-0.736,0-1.414,0.405-1.762,1.056 c-0.059,0.109-0.127,0.253-0.184,0.426C-0.15,19.251,0.011,20.28,0.561,20.971z"></path>{' '}
|
|
||||||
</g>{' '}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{index < addons.length - 1 && (
|
|
||||||
<button
|
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={() => moveAddon(index, 'down')}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
fill="#ffffff"
|
|
||||||
version="1.1"
|
|
||||||
id="Capa_1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24px"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 96.154 96.154"
|
|
||||||
stroke="#ffffff"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
{' '}
|
|
||||||
<g>
|
|
||||||
{' '}
|
|
||||||
<path d="M0.561,20.971l45.951,57.605c0.76,0.951,2.367,0.951,3.127,0l45.956-57.609c0.547-0.689,0.709-1.716,0.414-2.61 c-0.061-0.187-0.129-0.33-0.186-0.437c-0.351-0.65-1.025-1.056-1.765-1.056H2.093c-0.736,0-1.414,0.405-1.762,1.056 c-0.059,0.109-0.127,0.253-0.184,0.426C-0.15,19.251,0.011,20.28,0.561,20.971z"></path>{' '}
|
|
||||||
</g>{' '}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => removeAddon(index)}
|
|
||||||
className={styles.actionButton}
|
|
||||||
>
|
|
||||||
✖
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.cardBody}>
|
|
||||||
{details?.options
|
|
||||||
?.filter((option) => option.type !== 'deprecated')
|
|
||||||
?.map((option) => (
|
|
||||||
<div key={option.id} className={styles.option}>
|
|
||||||
<label>
|
|
||||||
{option.label}
|
|
||||||
{option.required && (
|
|
||||||
<span className={styles.required}>
|
|
||||||
<small>
|
|
||||||
<em>*Required Field</em>
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{option.type === 'checkbox' && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={addon.options[option.id] === 'true'}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateOption(
|
|
||||||
index,
|
|
||||||
option.id,
|
|
||||||
e.target.checked ? 'true' : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={styles.checkbox}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
{option.description && <small>{option.description}</small>}
|
|
||||||
{option.type === 'text' &&
|
|
||||||
(option.secret ? (
|
|
||||||
<CredentialInput
|
|
||||||
credential={addon.options[option.id] || ''}
|
|
||||||
setCredential={(value) =>
|
|
||||||
updateOption(
|
|
||||||
index,
|
|
||||||
option.id,
|
|
||||||
value ? value : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={(addon.options[option.id] as string) || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateOption(
|
|
||||||
index,
|
|
||||||
option.id,
|
|
||||||
e.target.value ? e.target.value : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={styles.textInput}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{option.type === 'select' && (
|
|
||||||
<select
|
|
||||||
value={addon.options[option.id] || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateOption(
|
|
||||||
index,
|
|
||||||
option.id,
|
|
||||||
e.target.value ? e.target.value : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={styles.textInput}
|
|
||||||
>
|
|
||||||
<option value="">None</option>
|
|
||||||
{option.options.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{option.type === 'number' && (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={addon.options[option.id] || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateOption(
|
|
||||||
index,
|
|
||||||
option.id,
|
|
||||||
e.target.value ? e.target.value : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={styles.textInput}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{option.type === 'multiSelect' && (
|
|
||||||
<MultiSelect
|
|
||||||
options={option.options}
|
|
||||||
setValues={(values) =>
|
|
||||||
updateOption(index, option.id, values.join(','))
|
|
||||||
}
|
|
||||||
values={addon.options[option.id]?.split(',') || []}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddonsList;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import React, { KeyboardEventHandler } from 'react';
|
|
||||||
|
|
||||||
import CreatableSelect from 'react-select/creatable';
|
|
||||||
import showToast from './Toasts';
|
|
||||||
import { selectStyles } from './MutliSelect';
|
|
||||||
import FakeSelect from './FakeSelect';
|
|
||||||
|
|
||||||
const components = {
|
|
||||||
DropdownIndicator: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Option {
|
|
||||||
readonly label: string;
|
|
||||||
readonly value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createOption = (label: string) => ({
|
|
||||||
label,
|
|
||||||
value: label,
|
|
||||||
});
|
|
||||||
|
|
||||||
interface CreateableSelectProps {
|
|
||||||
value: readonly Option[];
|
|
||||||
setValue: React.Dispatch<React.SetStateAction<readonly Option[]>>;
|
|
||||||
}
|
|
||||||
const CreateableSelect: React.FC<CreateableSelectProps> = ({
|
|
||||||
value,
|
|
||||||
setValue,
|
|
||||||
}) => {
|
|
||||||
const [inputValue, setInputValue] = React.useState('');
|
|
||||||
const [isClient, setIsClient] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setIsClient(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = (event) => {
|
|
||||||
if (!inputValue) return;
|
|
||||||
if (inputValue.length > 20) {
|
|
||||||
showToast('Value is too long', 'error', 'longValue');
|
|
||||||
setInputValue('');
|
|
||||||
}
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Enter':
|
|
||||||
case 'Tab':
|
|
||||||
const cleanedInputValue = inputValue;
|
|
||||||
if (!cleanedInputValue.length) {
|
|
||||||
showToast('Invalid value', 'error', 'invalidValue');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (value.find((v) => v.label === cleanedInputValue)) {
|
|
||||||
showToast('Value already exists', 'error', 'existingValue');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cleanedInputValue.length > 50) {
|
|
||||||
showToast('Value is too long', 'error', 'longValue');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setValue((prev) => [...prev, createOption(cleanedInputValue)]);
|
|
||||||
setInputValue('');
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isClient ? (
|
|
||||||
<CreatableSelect
|
|
||||||
components={components}
|
|
||||||
inputValue={inputValue}
|
|
||||||
isClearable
|
|
||||||
isMulti
|
|
||||||
menuIsOpen={false}
|
|
||||||
onChange={(newValue) => setValue(newValue as readonly Option[])}
|
|
||||||
onInputChange={(newValue) => setInputValue(newValue)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Type something and press enter..."
|
|
||||||
value={value}
|
|
||||||
styles={selectStyles}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FakeSelect innerText="Type something and press enter..." />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateableSelect;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
.credentialsInputContainer {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.resetCredentialButton {
|
|
||||||
margin-top: 5px;
|
|
||||||
margin-left: 5px;
|
|
||||||
top: 6px;
|
|
||||||
padding: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
color: rgb(255, 255, 255);
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resetCredentialButton:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resetCredentialButton:active {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.showHideButton {
|
|
||||||
background-color: transparent;
|
|
||||||
padding-top: 5px;
|
|
||||||
padding-left: 5px;
|
|
||||||
border: none;
|
|
||||||
transform: scale(1);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showHideButton:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.showHideButton:active {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import styles from './CredentialInput.module.css';
|
|
||||||
import { isValueEncrypted } from '@aiostreams/utils';
|
|
||||||
|
|
||||||
interface CredentialInputProps {
|
|
||||||
credential: string;
|
|
||||||
setCredential: (credential: string) => void;
|
|
||||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CredentialInput: React.FC<CredentialInputProps> = ({
|
|
||||||
credential,
|
|
||||||
setCredential,
|
|
||||||
inputProps = {},
|
|
||||||
}) => {
|
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
|
||||||
return (
|
|
||||||
<div className={styles.credentialsInputContainer}>
|
|
||||||
<input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={
|
|
||||||
isValueEncrypted(credential) ? '••••••••••••••••••••••••' : credential
|
|
||||||
}
|
|
||||||
onChange={(e) => setCredential(e.target.value.trim())}
|
|
||||||
className={styles.credentialInput}
|
|
||||||
{...inputProps}
|
|
||||||
disabled={
|
|
||||||
isValueEncrypted(credential) ? true : inputProps.disabled || false
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{!isValueEncrypted(credential) && (
|
|
||||||
<button
|
|
||||||
className={styles.showHideButton}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isValueEncrypted(credential)) {
|
|
||||||
setShowPassword(!showPassword);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<svg
|
|
||||||
width="24px"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
<path
|
|
||||||
d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M1 12C1 12 5 20 12 20C19 20 23 12 23 12"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></path>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="3"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></circle>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
width="24px"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
<path
|
|
||||||
d="M2 2L22 22"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M6.71277 6.7226C3.66479 8.79527 2 12 2 12C2 12 5.63636 19 12 19C14.0503 19 15.8174 18.2734 17.2711 17.2884M11 5.05822C11.3254 5.02013 11.6588 5 12 5C18.3636 5 22 12 22 12C22 12 21.3082 13.3317 20 14.8335"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
d="M14 14.2362C13.4692 14.7112 12.7684 15.0001 12 15.0001C10.3431 15.0001 9 13.657 9 12.0001C9 11.1764 9.33193 10.4303 9.86932 9.88818"
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></path>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isValueEncrypted(credential) && (
|
|
||||||
<button
|
|
||||||
className={styles.resetCredentialButton}
|
|
||||||
onClick={() => setCredential('')}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
fill="#ffffff"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24px"
|
|
||||||
height="24px"
|
|
||||||
viewBox="0 0 52 52"
|
|
||||||
enableBackground="new 0 0 52 52"
|
|
||||||
xmlSpace="preserve"
|
|
||||||
stroke="#ffffff"
|
|
||||||
>
|
|
||||||
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
|
|
||||||
<g
|
|
||||||
id="SVGRepo_tracerCarrier"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
></g>
|
|
||||||
<g id="SVGRepo_iconCarrier">
|
|
||||||
{' '}
|
|
||||||
<g>
|
|
||||||
{' '}
|
|
||||||
<path d="M42,23H10c-2.2,0-4,1.8-4,4v19c0,2.2,1.8,4,4,4h32c2.2,0,4-1.8,4-4V27C46,24.8,44.2,23,42,23z M31,44.5 c-1.5,1-3.2,1.5-5,1.5c-0.6,0-1.2-0.1-1.8-0.2c-2.4-0.5-4.4-1.8-5.7-3.8l3.3-2.2c0.7,1.1,1.9,1.9,3.2,2.1c1.3,0.3,2.6,0,3.8-0.8 c2.3-1.5,2.9-4.7,1.4-6.9c-0.7-1.1-1.9-1.9-3.2-2.1c-1.3-0.3-2.6,0-3.8,0.8c-0.3,0.2-0.5,0.4-0.7,0.6L26,37h-9v-9l2.6,2.6 c0.4-0.4,0.9-0.8,1.3-1.1c2-1.3,4.4-1.8,6.8-1.4c2.4,0.5,4.4,1.8,5.7,3.8C36.2,36.1,35.1,41.7,31,44.5z"></path>{' '}
|
|
||||||
<path d="M10,18.1v0.4C10,18.4,10,18.3,10,18.1C10,18.1,10,18.1,10,18.1z"></path>{' '}
|
|
||||||
<path d="M11,19h4c0.6,0,1-0.3,1-0.9V18c0-5.7,4.9-10.4,10.7-10C32,8.4,36,13,36,18.4v-0.3c0,0.6,0.4,0.9,1,0.9h4 c0.6,0,1-0.3,1-0.9V18c0-9.1-7.6-16.4-16.8-16c-8.5,0.4-15,7.6-15.2,16.1C10.1,18.6,10.5,19,11,19z"></path>{' '}
|
|
||||||
</g>{' '}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CredentialInput;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
.customFormatterContainer {
|
|
||||||
background-color: #121212;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
border: 1px solid #333;
|
|
||||||
margin-top: 15px;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customFormatterTitle {
|
|
||||||
padding: 12px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #aaa;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customFormatterTitle:hover {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandIcon {
|
|
||||||
font-size: 12px;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customFormatterContent {
|
|
||||||
padding: 15px;
|
|
||||||
border-top: 1px solid #333;
|
|
||||||
background-color: #121212;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customFormatterDescription {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #aaa;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customFormatterDescription code {
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin: 0 2px;
|
|
||||||
font-family: monospace;
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formGroup {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #aaa;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.syntaxInput {
|
|
||||||
width: 100%;
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: var(--borderRadius);
|
|
||||||
color: white;
|
|
||||||
padding: 10px;
|
|
||||||
font-family: monospace;
|
|
||||||
resize: vertical;
|
|
||||||
transition:
|
|
||||||
border-color 0.2s ease,
|
|
||||||
box-shadow 0.2s ease,
|
|
||||||
background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.syntaxInput:focus {
|
|
||||||
border-color: #555;
|
|
||||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.syntaxInput:hover {
|
|
||||||
background-color: #252525;
|
|
||||||
border-color: #444;
|
|
||||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.syntaxInput::placeholder {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import styles from './CustomFormatter.module.css';
|
|
||||||
|
|
||||||
interface CustomFormatterProps {
|
|
||||||
formatter: string;
|
|
||||||
setFormatter: (formatter: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomFormatter: React.FC<CustomFormatterProps> = ({
|
|
||||||
formatter,
|
|
||||||
setFormatter,
|
|
||||||
}) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
let initialName = '';
|
|
||||||
let initialDesc = '';
|
|
||||||
if (formatter.startsWith('custom:') && formatter.length > 7) {
|
|
||||||
const formatterData = JSON.parse(formatter.substring(7));
|
|
||||||
initialName = formatterData.name || '';
|
|
||||||
initialDesc = formatterData.description || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const [customNameSyntax, setCustomNameSyntax] = useState(initialName);
|
|
||||||
const [customDescSyntax, setCustomDescSyntax] = useState(initialDesc);
|
|
||||||
|
|
||||||
// Load the existing formatter on component mount
|
|
||||||
useEffect(() => {
|
|
||||||
const formatterData = {
|
|
||||||
name: customNameSyntax,
|
|
||||||
description: customDescSyntax,
|
|
||||||
};
|
|
||||||
setFormatter(`custom:${JSON.stringify(formatterData)}`);
|
|
||||||
}, [customNameSyntax, customDescSyntax, setFormatter]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.customFormatterContainer}>
|
|
||||||
<h3
|
|
||||||
className={styles.customFormatterTitle}
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
>
|
|
||||||
Custom Formatter
|
|
||||||
<span className={styles.expandIcon}>{isExpanded ? '▼' : '►'}</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className={styles.customFormatterContent}>
|
|
||||||
<p className={styles.customFormatterDescription}>
|
|
||||||
Define a custom formatter syntax. Write
|
|
||||||
<code>{'{debug.jsonf}'}</code> to see the available variables.
|
|
||||||
<br />
|
|
||||||
For a more detailed explanation, check the{' '}
|
|
||||||
<a href="https://github.com/Viren070/AIOStreams/wiki/Custom-Formatter">
|
|
||||||
wiki
|
|
||||||
</a>
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label className={styles.label}>Name Format:</label>
|
|
||||||
<textarea
|
|
||||||
className={styles.syntaxInput}
|
|
||||||
value={customNameSyntax}
|
|
||||||
onChange={(e) => setCustomNameSyntax(e.target.value)}
|
|
||||||
placeholder="E.g.: {addon.name}"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label className={styles.label}>Description Format:</label>
|
|
||||||
<textarea
|
|
||||||
className={styles.syntaxInput}
|
|
||||||
value={customDescSyntax}
|
|
||||||
onChange={(e) => setCustomDescSyntax(e.target.value)}
|
|
||||||
placeholder="E.g.: {stream.name}"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomFormatter;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
const FakeSelect: React.FC<
|
|
||||||
Readonly<{
|
|
||||||
innerText: string;
|
|
||||||
}>
|
|
||||||
> = ({ innerText }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '42px',
|
|
||||||
margin: '0',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: 'var(--borderRadius)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ margin: '10px', color: '#808090' }}>{innerText}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FakeSelect;
|
|
||||||