mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat: update
feat: add gdrive builtin addon feat: add torbox search builtin addon feat: allow setting allowed regexes for all users closes #244 feat: allow deleting user feat: make `BASE_URL` required fix: simplify and fix bluray remux detection fix: override type to live for usa tv, argentina tv, and debridio tv fix: throw error on rate limit exceeded streams fix: skip mediafusion found but filtered message stream fix(stremthru-store): ensure release groups don't get parsed as indexer feat: add configurable year tolerance during year matching fix: ensure forced proxy information is always checked/used when provided
This commit is contained in:
+46
@@ -19,6 +19,10 @@ PORT=3000
|
||||
# Example: https://aiostreams.yourdomain.com
|
||||
BASE_URL=
|
||||
|
||||
# Optionally override the internal URL. This URL is used when communicating with built-in addons.
|
||||
# In most cases, the default is fine.
|
||||
# INTERNAL_URL=http://localhost:${PORT}
|
||||
|
||||
# --- Security ---
|
||||
# CRITICAL: Secret key for encrypting addon configuration.
|
||||
# MUST be a 64-character hex string.
|
||||
@@ -47,6 +51,36 @@ ADDON_PASSWORD=
|
||||
DATABASE_URI=sqlite://./data/db.sqlite
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# BUILT-IN ADDON CONFIGURATION
|
||||
# ==============================================================================
|
||||
# AIOStreams provides some built-in addons that you can configure here.
|
||||
# These require BASE_URL to be set above.
|
||||
# Some of these built-in integrations require StremThru for Debrid integration.
|
||||
# Set the URL to your StremThru instance here:
|
||||
BUILTIN_STREMTHRU_URL=https://stremthru.13377001.xyz
|
||||
|
||||
|
||||
# ---- Stremio GDrive -----
|
||||
# Client ID and Secret generated following this guide: https://guides.viren070.me/stremio/addons/stremio-gdrive
|
||||
# Follow only:
|
||||
# - All steps from 'Setting up our Google App' (but set redirect URL to Set the redirect URL to ${BASE_URL}/oauth/callback/gdrive)
|
||||
# - Step 7 from 'Setting up the cloudflare worker'
|
||||
# And then paste in the Client ID and Secret here.
|
||||
# BUILTIN_GDRIVE_CLIENT_ID=
|
||||
# BUILTIN_GDRIVE_CLIENT_SECRET=
|
||||
|
||||
# The limit to the amount of items that can possibly be queried from the Google Drive API in a given request.
|
||||
BUILTIN_GDRIVE_PAGE_SIZE_LIMIT=1000
|
||||
# BUILTIN_GDRIVE_TIMEOUT=
|
||||
|
||||
# ---- TorBox Search -----
|
||||
# Has no required configuration apart from BASE_URL
|
||||
# The amount of time to wait for requests to the TorBox Search API.
|
||||
BUILTIN_TORBOX_SEARCH_SEARCH_API_TIMEOUT=30000
|
||||
# BUILTIN_TORBOX_SEARCH_SEARCH_API_CACHE_TTL=3600
|
||||
# BUILTIN_TORBOX_SEARCH_METADATA_CACHE_TTL=604800
|
||||
# BUILTIN_TORBOX_SEARCH_INSTANT_AVAILABILITY_CACHE_TTL=900
|
||||
# ==============================================================================
|
||||
# DEBRID & OTHER SERVICE API KEYS
|
||||
# ==============================================================================
|
||||
@@ -136,6 +170,13 @@ CUSTOM_HTML=
|
||||
# Default: trusted
|
||||
REGEX_FILTER_ACCESS=trusted
|
||||
|
||||
# --- Allowed Regexes ----
|
||||
# Set allowed regexes which are available to all users, regardless of the setting of REGEX_FILTER_ACCESS.
|
||||
# Format: e.g. ["/(\\[(Aergia|smol|SoM|Vodes)\\]|-(Aergia(?!-raws)|smol|SoM)\\b|\\b(Arg0|LYS1TH3A|OZR|SCY|ZeroBuild)\\b)|(?<!Not)-Vodes\\b/i", "/(\\[(Legion|sam|Vanilla)\\]|-(Legion|sam|Vanilla)\\b)/"]
|
||||
# Description supports markdown format for links and code only. (i.e. [linktext](link) and `code`)
|
||||
# ALLOWED_REGEX_PATTERNS=
|
||||
# ALLOWED_REGEX_PATTERNS_DESCRIPTION=
|
||||
|
||||
# --- Aliased Configurations (Vanity URLs) ---
|
||||
# Create shorter, memorable installation URLs.
|
||||
# Format: aliasName1:uuid1:encryptedPassword1,aliasName2:uuid2:encryptedPassword2
|
||||
@@ -598,5 +639,10 @@ PRUNE_MAX_DAYS=-1
|
||||
# ---------------- Subhero -----------------
|
||||
# SUBHERO_URL=https://subhero.onrender.com
|
||||
|
||||
# --------------- StreamAsia ---------------
|
||||
# STREAMASIA_URL=https://stremio-dramacool-addon.xyz
|
||||
|
||||
# --------------- More Like This -------------
|
||||
# MORE_LIKE_THIS_URL=https://bbab4a35b833-more-like-this.baby-beamup.club
|
||||
|
||||
# ==============================================================================
|
||||
Generated
+104
-22
@@ -904,19 +904,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
|
||||
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
|
||||
"integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.14.0",
|
||||
"@eslint/core": "^0.15.1",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
||||
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
|
||||
@@ -3634,6 +3647,24 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@torbox/torbox-api": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@torbox/torbox-api/-/torbox-api-1.0.9.tgz",
|
||||
"integrity": "sha512-Lle2A5RU/Rps6aBqv1/QYtfEr6/wvC7rFYi7taoBYj11HlfK74Gj1YM46dfRk/xqgoUfv6OHv0IIO/f3y7R6wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "3.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@torbox/torbox-api/node_modules/zod": {
|
||||
"version": "3.22.0",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz",
|
||||
"integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
@@ -3810,12 +3841,21 @@
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.17.10",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.15.2",
|
||||
"dev": true,
|
||||
@@ -5016,10 +5056,7 @@
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
@@ -5718,10 +5755,7 @@
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -6058,10 +6092,7 @@
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -6427,7 +6458,6 @@
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -7307,15 +7337,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"dev": true,
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
@@ -7706,7 +7736,6 @@
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -10176,6 +10205,48 @@
|
||||
"version": "7.1.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "8.4.1",
|
||||
"license": "MIT",
|
||||
@@ -12166,6 +12237,16 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stremthru": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/stremthru/-/stremthru-0.8.0.tgz",
|
||||
"integrity": "sha512-Bv17o2mmj3B7NMaJEPYlMYiSgWChGx5FkjYzlaLqwTv8RFeP2wIWwKCHGjLYhDn1EsTvmnv7vh+rdQT/6MxavQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"node-fetch": "release-2.x"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
@@ -13113,7 +13194,6 @@
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
@@ -14433,6 +14513,7 @@
|
||||
"name": "@aiostreams/core",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@torbox/torbox-api": "^1.0.9",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bytes": "^3.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -14444,6 +14525,7 @@
|
||||
"pg": "^8.16.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stremthru": "^0.8.0",
|
||||
"super-regex": "^1.0.0",
|
||||
"undici": "^7.2.3",
|
||||
"winston": "^3.17.0",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"description": "Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.",
|
||||
"dependencies": {
|
||||
"@torbox/torbox-api": "^1.0.9",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bytes": "^3.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -20,6 +21,7 @@
|
||||
"pg": "^8.16.0",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stremthru": "^0.8.0",
|
||||
"super-regex": "^1.0.0",
|
||||
"undici": "^7.2.3",
|
||||
"winston": "^3.17.0",
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { Manifest, Meta, MetaPreview, Stream, Subtitle } from '../../db';
|
||||
import { Env, ExtrasParser, createLogger } from '../../utils';
|
||||
import {
|
||||
GDriveAPI,
|
||||
GoogleOAuth,
|
||||
GoogleOAuthError,
|
||||
GoogleOAuthErrorCode,
|
||||
} from './api';
|
||||
import { GDriveFile, UserData } from './schemas';
|
||||
import { IMDBMetadata } from '../../metadata/imdb';
|
||||
import { TMDBMetadata } from '../../metadata/tmdb';
|
||||
import { KitsuMetadata } from '../../metadata/kitsu';
|
||||
import { formatBytes, formatDuration } from '../../formatters';
|
||||
import { IdParser, ParsedId } from '../utils/id-parser';
|
||||
import { TorboxSearchApiIdType } from '../torbox-search/search-api';
|
||||
|
||||
const logger = createLogger('gdrive');
|
||||
|
||||
export class GDriveAddon {
|
||||
private userData: UserData;
|
||||
private api: GDriveAPI;
|
||||
private oauth: GoogleOAuth;
|
||||
private manifest: Manifest;
|
||||
private static supportedIdTypes: TorboxSearchApiIdType[] = [
|
||||
'imdb_id',
|
||||
'tmdb',
|
||||
'thetvdb_id',
|
||||
'kitsu_id',
|
||||
];
|
||||
private static readonly idParser: IdParser = new IdParser(
|
||||
GDriveAddon.supportedIdTypes
|
||||
);
|
||||
|
||||
constructor(userData: UserData) {
|
||||
this.userData = UserData.parse(userData);
|
||||
this.manifest = GDriveAddon.getManifest();
|
||||
this.manifest.behaviorHints = {
|
||||
...this.manifest.behaviorHints,
|
||||
configurationRequired: false,
|
||||
};
|
||||
|
||||
if (this.userData.metadataSource === 'imdb') {
|
||||
const streamResource = this.manifest.resources.find(
|
||||
(resource) => typeof resource !== 'string' && resource.name === 'stream'
|
||||
);
|
||||
if (streamResource && typeof streamResource !== 'string') {
|
||||
streamResource.idPrefixes = streamResource.idPrefixes!.filter(
|
||||
(prefix) => prefix !== 'tmdb' && prefix !== 'tvdb'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.oauth = new GoogleOAuth(this.userData.refreshToken);
|
||||
this.api = new GDriveAPI(this.oauth);
|
||||
}
|
||||
|
||||
private getMimeTypes() {
|
||||
const mimeTypes = ['video/'];
|
||||
if (this.userData.includeAudioFiles) {
|
||||
mimeTypes.push('audio/');
|
||||
}
|
||||
return mimeTypes;
|
||||
}
|
||||
|
||||
static getManifest(): Manifest {
|
||||
return {
|
||||
id: 'com.gdrive.viren070',
|
||||
version: '1.0.0',
|
||||
name: 'Stremio GDrive',
|
||||
description: 'Stream your files from Google Drive within Stremio!',
|
||||
catalogs: [
|
||||
{
|
||||
name: 'Google Drive',
|
||||
id: 'gdrive.videos',
|
||||
type: 'movie',
|
||||
extra: [
|
||||
{
|
||||
name: 'search',
|
||||
isRequired: false,
|
||||
},
|
||||
{
|
||||
name: 'skip',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
name: 'stream',
|
||||
types: ['movie', 'series', 'anime'],
|
||||
idPrefixes: GDriveAddon.idParser.supportedPrefixes,
|
||||
},
|
||||
{
|
||||
name: 'catalog',
|
||||
types: ['movie'],
|
||||
idPrefixes: ['gdrive'],
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
types: ['movie'],
|
||||
idPrefixes: ['gdrive'],
|
||||
},
|
||||
],
|
||||
types: ['movie', 'series', 'anime'],
|
||||
behaviorHints: {
|
||||
adult: false,
|
||||
p2p: false,
|
||||
configurable: true,
|
||||
configurationRequired: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public getManifest(): Manifest {
|
||||
return this.manifest;
|
||||
}
|
||||
|
||||
public async getStreams(type: string, id: string): Promise<Stream[]> {
|
||||
const parsedId = GDriveAddon.idParser.parse(id);
|
||||
if (!parsedId) {
|
||||
throw new Error(`Requested ID ${id} is not a valid or supported ID`);
|
||||
}
|
||||
logger.debug(`Parsed ID: ${id}`, parsedId);
|
||||
const { id: titleId, season, episode } = parsedId;
|
||||
let searchQuery: string;
|
||||
try {
|
||||
const { titles, year } = await this.getMetadata(parsedId, type);
|
||||
searchQuery = this.buildSearchQuery(titles, year, season, episode);
|
||||
logger.debug(`Search query: ${searchQuery}`);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to get metadata for ${titleId}: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
throw new Error('Failed to get metadata');
|
||||
}
|
||||
|
||||
const queryParams = {
|
||||
q: searchQuery,
|
||||
corpora: 'allDrives',
|
||||
includeItemsFromAllDrives: true,
|
||||
supportsAllDrives: true,
|
||||
pageSize: Env.BUILTIN_GDRIVE_PAGE_SIZE_LIMIT,
|
||||
fields:
|
||||
'files(id,name,description,size,createdTime,modifiedTime,thumbnailLink,iconLink,videoMediaMetadata,fileExtension)',
|
||||
};
|
||||
try {
|
||||
const { files } = await this.api.listFiles(queryParams);
|
||||
const accessToken = await this.oauth.getAccessToken();
|
||||
return files.map((file) => this.createStream(file, accessToken));
|
||||
} catch (error) {
|
||||
if (error instanceof GoogleOAuthError) {
|
||||
logger.error(
|
||||
`Google OAuth Error during stream retrieval: ${error.errorCode} - ${error.errorDescription}`
|
||||
);
|
||||
switch (error.errorCode) {
|
||||
case GoogleOAuthErrorCode.INVALID_GRANT:
|
||||
return [this.createErrorStream(`Invalid Refresh Token`)];
|
||||
case GoogleOAuthErrorCode.UNAUTHORIZED_CLIENT:
|
||||
case GoogleOAuthErrorCode.INVALID_CLIENT:
|
||||
return [this.createErrorStream(`Invalid Client ID or Secret`)];
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private createErrorStream(message: string): Stream {
|
||||
return {
|
||||
name: `[❌] Stremio GDrive`,
|
||||
description: message,
|
||||
url: 'stremio://',
|
||||
};
|
||||
}
|
||||
|
||||
private async getMetadata(parsedId: ParsedId, type: string) {
|
||||
let titles: string[];
|
||||
let year: number;
|
||||
|
||||
switch (true) {
|
||||
case parsedId.type === 'kitsu_id': {
|
||||
const kitsuMetadata = new KitsuMetadata();
|
||||
const metadata = await kitsuMetadata.getMetadata(
|
||||
`kitsu:${parsedId.id}`,
|
||||
type
|
||||
);
|
||||
titles = metadata.titles ?? [metadata.title];
|
||||
year = metadata.year;
|
||||
break;
|
||||
}
|
||||
case this.userData.metadataSource === 'imdb': {
|
||||
const imdbMetadata = new IMDBMetadata();
|
||||
const metadata = await imdbMetadata.getTitleAndYear(parsedId.id, type);
|
||||
titles = metadata.titles ?? [metadata.title];
|
||||
year = metadata.year;
|
||||
break;
|
||||
}
|
||||
case this.userData.metadataSource === 'tmdb': {
|
||||
if (!this.userData.tmdbReadAccessToken) {
|
||||
throw new Error('TMDB API Key is not set');
|
||||
}
|
||||
const tmdbMetadata = new TMDBMetadata({
|
||||
accessToken: this.userData.tmdbReadAccessToken,
|
||||
});
|
||||
const metadata = await tmdbMetadata.getMetadata(
|
||||
parsedId.id,
|
||||
type as any
|
||||
);
|
||||
titles = metadata.titles;
|
||||
year = Number(metadata.year);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error('Metadata source is not set');
|
||||
}
|
||||
return { titles, year };
|
||||
}
|
||||
|
||||
private buildSearchQuery(
|
||||
titles: string[],
|
||||
year: number,
|
||||
season?: string,
|
||||
episode?: string
|
||||
) {
|
||||
const isShow = season || episode;
|
||||
let query =
|
||||
"trashed=false and not name contains 'trailer' and not name contains 'sample'";
|
||||
|
||||
const mimeTypes = this.getMimeTypes();
|
||||
query += ` and (${mimeTypes.map((mimeType) => `mimeType contains '${mimeType}'`).join(' or ')})`;
|
||||
|
||||
// look for season x in any title and extract the season number, and also remove the season x from the title
|
||||
titles = titles.map((title) => {
|
||||
const seasonMatch = title.match(/season\s+(\d+)/i);
|
||||
if (seasonMatch) {
|
||||
season = seasonMatch[1];
|
||||
title = title.replace(/season\s+\d+/i, '').trim();
|
||||
}
|
||||
return title;
|
||||
});
|
||||
|
||||
const sanitisedTitles = titles.map((title) =>
|
||||
title.replace(/[^\p{L}\p{N}\s]/gu, '')
|
||||
);
|
||||
const possibleTitles = Array.from(
|
||||
new Set([
|
||||
...sanitisedTitles,
|
||||
...sanitisedTitles.map((title) => title.replace(/'/g, "\\'")),
|
||||
])
|
||||
);
|
||||
|
||||
if (isShow) {
|
||||
query += ` and (${possibleTitles.map((title) => `name contains '${title}'`).join(' or ')})`;
|
||||
} else {
|
||||
query += ` and (${possibleTitles.map((title) => `name contains '${title} ${year}'`).join(' or ')})`;
|
||||
}
|
||||
|
||||
if (!isShow) {
|
||||
return query;
|
||||
}
|
||||
|
||||
const seasonNum = season ? parseInt(season, 10) : undefined;
|
||||
const episodeNum = episode ? parseInt(episode, 10) : undefined;
|
||||
|
||||
const getFormats = (s: number | undefined, e: number | undefined) => {
|
||||
const sPad = s ? s.toString().padStart(2, '0') : undefined;
|
||||
const ePad = e ? e.toString().padStart(2, '0') : undefined;
|
||||
if (!sPad && !ePad) return new Set();
|
||||
|
||||
if (e && s) {
|
||||
return new Set([
|
||||
`s${s}e${e}`,
|
||||
`s${sPad}e${ePad}`,
|
||||
`s${s}.e${e}`,
|
||||
`s${sPad}.e${ePad}`,
|
||||
`${s}x${e}`,
|
||||
`${s}x${ePad}`,
|
||||
`s${s}xe${e}`,
|
||||
`s${sPad}xe${ePad}`,
|
||||
`season ${s} episode ${e}`,
|
||||
`season ${sPad} episode ${ePad}`,
|
||||
`s${s} ep${e}`,
|
||||
`s${sPad} ep${ePad}`,
|
||||
]);
|
||||
} else if (e) {
|
||||
return new Set([
|
||||
`e${e}`,
|
||||
`e${ePad}`,
|
||||
`ep${e}`,
|
||||
`ep${ePad}`,
|
||||
`episode ${e}`,
|
||||
]);
|
||||
} else if (s) {
|
||||
return new Set([`s${s}`, `s${sPad}`, `season ${s}`, `season ${sPad}`]);
|
||||
}
|
||||
return new Set();
|
||||
};
|
||||
|
||||
const formats = Array.from(getFormats(seasonNum, episodeNum));
|
||||
|
||||
query += ` and (${formats
|
||||
.map((format) => `fullText contains '${format}'`)
|
||||
.join(' or ')})`;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
public async getCatalog(
|
||||
type: string,
|
||||
id: string,
|
||||
extras?: string
|
||||
): Promise<MetaPreview[]> {
|
||||
const parsedExtras = extras ? new ExtrasParser(extras) : undefined;
|
||||
const sort = (this.userData.catalogSort || ['createdTime ↓'])
|
||||
.map((sort) => sort.replace(' ↑', '').replace(' ↓', ' desc'))
|
||||
.join(',');
|
||||
if (type !== 'movie' || id !== 'gdrive.videos') {
|
||||
throw new Error('Unsupported type or ID for Catalog request');
|
||||
}
|
||||
let filesToFetch = 100;
|
||||
let filesToSkip = 0;
|
||||
if (parsedExtras?.skip) {
|
||||
filesToFetch = parsedExtras.skip + 100;
|
||||
filesToSkip = parsedExtras.skip;
|
||||
}
|
||||
if (filesToFetch > Env.BUILTIN_GDRIVE_PAGE_SIZE_LIMIT) {
|
||||
filesToFetch = Env.BUILTIN_GDRIVE_PAGE_SIZE_LIMIT;
|
||||
}
|
||||
|
||||
const sanitiseQuery = (query: string) => {
|
||||
return decodeURIComponent(query).replace(/'/g, "\\'");
|
||||
};
|
||||
let query = '';
|
||||
if (parsedExtras?.search && parsedExtras.search.startsWith('rawQuery:')) {
|
||||
const rawQuery = parsedExtras.search.split('rawQuery:')[1];
|
||||
query = rawQuery;
|
||||
} else {
|
||||
query = `(${this.getMimeTypes()
|
||||
.map((mimeType) => `mimeType contains '${mimeType}'`)
|
||||
.join(
|
||||
' or '
|
||||
)}) and trashed=false and not name contains 'trailer' and not name contains 'sample'`;
|
||||
|
||||
if (parsedExtras?.search) {
|
||||
query += ` and name contains '${sanitiseQuery(parsedExtras.search)}'`;
|
||||
}
|
||||
}
|
||||
logger.debug(`Catalog query: ${query}`);
|
||||
|
||||
const { files } = await this.api.listFiles({
|
||||
fields:
|
||||
'files(id,name,description,size,createdTime,modifiedTime,thumbnailLink,iconLink,videoMediaMetadata,fileExtension)',
|
||||
orderBy: sort,
|
||||
pageSize: filesToFetch,
|
||||
corpora: 'allDrives',
|
||||
includeItemsFromAllDrives: true,
|
||||
supportsAllDrives: true,
|
||||
q: query,
|
||||
});
|
||||
|
||||
const accessToken = await this.oauth.getAccessToken();
|
||||
return files
|
||||
.slice(filesToSkip, filesToFetch)
|
||||
.map((file) => this.createMeta(file, accessToken));
|
||||
}
|
||||
|
||||
public async getMeta(type: string, id: string): Promise<Meta> {
|
||||
if (type !== 'movie' || !id.startsWith('gdrive.')) {
|
||||
throw new Error('Unsupported type or ID for Meta request');
|
||||
}
|
||||
|
||||
const fileId = id.split('.')[1];
|
||||
const file = await this.api.getFile(fileId, {
|
||||
supportsAllDrives: true,
|
||||
fields:
|
||||
'id,name,description,size,createdTime,modifiedTime,thumbnailLink,iconLink,videoMediaMetadata,fileExtension',
|
||||
});
|
||||
|
||||
const accessToken = await this.oauth.getAccessToken();
|
||||
return this.createMeta(file, accessToken);
|
||||
}
|
||||
|
||||
private createMeta(
|
||||
file: GDriveFile,
|
||||
accessToken?: string,
|
||||
subtitles?: Subtitle[]
|
||||
): Meta {
|
||||
const descriptionParts = [];
|
||||
if (file.description) descriptionParts.push(file.description);
|
||||
descriptionParts.push(
|
||||
`📅 ${new Date(file.createdTime).toLocaleDateString()}`
|
||||
);
|
||||
if (file.modifiedTime !== file.createdTime) {
|
||||
descriptionParts.push(
|
||||
`(Modified: ${new Date(file.modifiedTime).toLocaleDateString()})`
|
||||
);
|
||||
}
|
||||
descriptionParts.push(`📦 ${formatBytes(Number(file.size), 1000)}`);
|
||||
if (file.videoMediaMetadata?.durationMs) {
|
||||
descriptionParts.push(
|
||||
`⏱️ ${formatDuration(file.videoMediaMetadata.durationMs)}`
|
||||
);
|
||||
}
|
||||
if (file.videoMediaMetadata) {
|
||||
descriptionParts.push(
|
||||
`🎬 ${file.videoMediaMetadata.width}x${file.videoMediaMetadata.height}`
|
||||
);
|
||||
}
|
||||
if (file.fileExtension) {
|
||||
descriptionParts.push(`📄 ${file.fileExtension.toUpperCase()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `gdrive.${file.id}`,
|
||||
name: file.name,
|
||||
description: descriptionParts.join(' • '),
|
||||
poster: file.thumbnailLink,
|
||||
posterShape: 'landscape',
|
||||
background: file.thumbnailLink,
|
||||
type: 'movie',
|
||||
videos: accessToken
|
||||
? [
|
||||
{
|
||||
id: file.id,
|
||||
title: file.name,
|
||||
released: file.createdTime,
|
||||
thumbnail: file.thumbnailLink,
|
||||
streams: [this.createStream(file, accessToken)],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
private createStream(
|
||||
file: GDriveFile,
|
||||
accessToken: string
|
||||
): Stream & { duration: number | undefined } {
|
||||
return {
|
||||
name: `Stremio GDrive`,
|
||||
subtitles: [],
|
||||
url: `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`,
|
||||
duration: file.videoMediaMetadata?.durationMs,
|
||||
behaviorHints: {
|
||||
filename: file.name,
|
||||
videoSize: file.size ? Number(file.size) : undefined,
|
||||
notWebReady: true,
|
||||
proxyHeaders: {
|
||||
request: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Cache, createLogger, Env, makeRequest } from '../../utils';
|
||||
import {
|
||||
GDriveFile,
|
||||
GDriveFileGetResponseSchema,
|
||||
GDriveFileQueryResponseSchema,
|
||||
RefreshTokenResponse,
|
||||
RefreshTokenResponseSchema,
|
||||
} from './schemas';
|
||||
|
||||
const accessTokenCache = Cache.getInstance<string, string>(
|
||||
'gdrive-access-token'
|
||||
);
|
||||
const logger = createLogger('gdrive');
|
||||
|
||||
interface GDriveFileQueryParams {
|
||||
q?: string;
|
||||
pageSize?: number;
|
||||
pageToken?: string;
|
||||
fields?: string;
|
||||
includeItemsFromAllDrives?: boolean;
|
||||
supportsAllDrives?: boolean;
|
||||
corpora?: string;
|
||||
driveId?: string;
|
||||
orderBy?: string;
|
||||
includeLabels?: string;
|
||||
}
|
||||
|
||||
interface GDriveFileGetParams {
|
||||
acknowledgeAbuse?: boolean;
|
||||
supportsAllDrives?: boolean;
|
||||
includeLabels?: string;
|
||||
includePermissionsForView?: string;
|
||||
fields?: string;
|
||||
}
|
||||
|
||||
export enum GoogleOAuthErrorCode {
|
||||
INVALID_CLIENT = 'invalid_client',
|
||||
INVALID_GRANT = 'invalid_grant',
|
||||
INVALID_REQUEST = 'invalid_request',
|
||||
UNAUTHORIZED_CLIENT = 'unauthorized_client',
|
||||
UNSUPPORTED_GRANT_TYPE = 'unsupported_grant_type',
|
||||
}
|
||||
|
||||
export class GoogleOAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errorCode: GoogleOAuthErrorCode,
|
||||
public readonly errorDescription: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GoogleOAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GDriveAPIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errorCode: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GDriveAPIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GoogleOAuth {
|
||||
private static readonly tokenUrl: string =
|
||||
'https://oauth2.googleapis.com/token';
|
||||
private static readonly oauthUrl: string =
|
||||
'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
|
||||
private refreshToken: string;
|
||||
private accessToken: string | undefined;
|
||||
|
||||
constructor(refreshToken: string) {
|
||||
if (!Env.BUILTIN_GDRIVE_CLIENT_ID || !Env.BUILTIN_GDRIVE_CLIENT_SECRET) {
|
||||
throw new Error('Builtin GDrive client ID and secret are not set');
|
||||
}
|
||||
this.refreshToken = refreshToken;
|
||||
this.accessToken = accessTokenCache.get(this.refreshToken);
|
||||
}
|
||||
|
||||
private static get clientId(): string {
|
||||
if (!Env.BUILTIN_GDRIVE_CLIENT_ID) {
|
||||
throw new Error('Builtin GDrive client ID is not set');
|
||||
}
|
||||
return Env.BUILTIN_GDRIVE_CLIENT_ID;
|
||||
}
|
||||
|
||||
private static get clientSecret(): string {
|
||||
if (!Env.BUILTIN_GDRIVE_CLIENT_SECRET) {
|
||||
throw new Error('Builtin GDrive client secret is not set');
|
||||
}
|
||||
return Env.BUILTIN_GDRIVE_CLIENT_SECRET;
|
||||
}
|
||||
|
||||
private static get redirectUrl(): string {
|
||||
if (!Env.BASE_URL) {
|
||||
throw new Error('Base URL is not set');
|
||||
}
|
||||
return `${Env.BASE_URL}/oauth/callback/gdrive`;
|
||||
}
|
||||
|
||||
static getAuthorisationUrl() {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GoogleOAuth.clientId,
|
||||
redirect_uri: GoogleOAuth.redirectUrl,
|
||||
response_type: 'code',
|
||||
scope: 'https://www.googleapis.com/auth/drive',
|
||||
prompt: 'consent',
|
||||
access_type: 'offline',
|
||||
});
|
||||
return `${GoogleOAuth.oauthUrl}?${params.toString()}`;
|
||||
}
|
||||
|
||||
static async exchangeAuthorisationCode(
|
||||
code: string
|
||||
): Promise<RefreshTokenResponse> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GoogleOAuth.clientId,
|
||||
client_secret: GoogleOAuth.clientSecret,
|
||||
code,
|
||||
redirect_uri: GoogleOAuth.redirectUrl,
|
||||
grant_type: 'authorization_code',
|
||||
scope: '',
|
||||
});
|
||||
|
||||
const response = await fetch(GoogleOAuth.tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const parsedResponse = RefreshTokenResponseSchema.safeParse(data);
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new Error('Failed to parse authorisation code response');
|
||||
}
|
||||
|
||||
if ('error' in parsedResponse.data) {
|
||||
throw new GoogleOAuthError(
|
||||
`Failed to exchange authorisation code`,
|
||||
parsedResponse.data.error as GoogleOAuthErrorCode,
|
||||
parsedResponse.data.error_description
|
||||
);
|
||||
}
|
||||
|
||||
return parsedResponse.data;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken) {
|
||||
return this.accessToken;
|
||||
}
|
||||
await this.refreshAccessToken();
|
||||
if (!this.accessToken) {
|
||||
throw new Error('Failed to get or refresh access token');
|
||||
}
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<void> {
|
||||
const cachedToken = accessTokenCache.get(this.refreshToken);
|
||||
if (cachedToken) {
|
||||
this.accessToken = cachedToken;
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: GoogleOAuth.clientId,
|
||||
client_secret: GoogleOAuth.clientSecret,
|
||||
refresh_token: this.refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(GoogleOAuth.tokenUrl, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const parsedResponse = RefreshTokenResponseSchema.safeParse(data);
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new Error('Failed to parse refresh token response');
|
||||
}
|
||||
|
||||
if ('error' in parsedResponse.data) {
|
||||
throw new GoogleOAuthError(
|
||||
`Failed to refresh token`,
|
||||
parsedResponse.data.error as GoogleOAuthErrorCode,
|
||||
parsedResponse.data.error_description
|
||||
);
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = parsedResponse.data;
|
||||
accessTokenCache.set(this.refreshToken, access_token, expires_in);
|
||||
this.accessToken = access_token;
|
||||
} catch (error) {
|
||||
if (error instanceof GoogleOAuthError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to refresh token: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GDriveAPI {
|
||||
private static readonly baseUrl: string =
|
||||
'https://www.googleapis.com/drive/v3';
|
||||
private oauth: GoogleOAuth;
|
||||
|
||||
constructor(oauth: GoogleOAuth) {
|
||||
this.oauth = oauth;
|
||||
}
|
||||
|
||||
private async getHeaders() {
|
||||
const token = await this.oauth.getAccessToken();
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
public async listFiles(params: GDriveFileQueryParams) {
|
||||
const url = new URL(`${GDriveAPI.baseUrl}/files`);
|
||||
url.search = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined)
|
||||
) as Record<string, string>
|
||||
).toString();
|
||||
const headers = await this.getHeaders();
|
||||
const response = await makeRequest(url.toString(), {
|
||||
headers,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const parsedResponse = GDriveFileQueryResponseSchema.safeParse(data);
|
||||
logger.debug(`GDrive file query response: ${JSON.stringify(data)}`);
|
||||
|
||||
if (!parsedResponse.success || !parsedResponse.data) {
|
||||
throw new Error('Failed to parse GDrive file query response');
|
||||
}
|
||||
|
||||
if ('error' in parsedResponse.data) {
|
||||
let errorMsg = parsedResponse.data.error.message;
|
||||
if (parsedResponse.data.error.errors) {
|
||||
errorMsg += `, ${parsedResponse.data.error.errors.map((error) => `[${error.reason}] ${error.message} at ${error.location}`).join(', ')}`;
|
||||
}
|
||||
throw new GDriveAPIError(
|
||||
`GDrive API error: ${errorMsg}`,
|
||||
parsedResponse.data.error.code
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
files: parsedResponse.data.files,
|
||||
nextPageToken: parsedResponse.data.nextPageToken,
|
||||
incompleteSearch: parsedResponse.data.incompleteSearch,
|
||||
kind: parsedResponse.data.kind,
|
||||
};
|
||||
}
|
||||
|
||||
public async getFile(
|
||||
fileId: string,
|
||||
params: GDriveFileGetParams
|
||||
): Promise<GDriveFile> {
|
||||
const url = new URL(`${GDriveAPI.baseUrl}/files/${fileId}`);
|
||||
url.search = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined)
|
||||
) as Record<string, string>
|
||||
).toString();
|
||||
const headers = await this.getHeaders();
|
||||
const response = await makeRequest(url.toString(), {
|
||||
headers,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const parsedResponse = GDriveFileGetResponseSchema.safeParse(data);
|
||||
|
||||
if (!parsedResponse.success || !parsedResponse.data) {
|
||||
throw new Error('Failed to parse GDrive file get response');
|
||||
}
|
||||
|
||||
if ('error' in parsedResponse.data) {
|
||||
throw new Error('Failed to get GDrive file');
|
||||
}
|
||||
|
||||
return parsedResponse.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './addon';
|
||||
export * from './api';
|
||||
@@ -0,0 +1,133 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RefreshTokenResponseSuccessSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
token_type: z.string(),
|
||||
scope: z.string(),
|
||||
refresh_token: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RefreshTokenResponseErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string(),
|
||||
});
|
||||
|
||||
export const RefreshTokenResponseSchema = z.union([
|
||||
RefreshTokenResponseSuccessSchema,
|
||||
RefreshTokenResponseErrorSchema,
|
||||
]);
|
||||
|
||||
// export type RefreshTokenResponse = z.infer<typeof RefreshTokenResponseSchema>;
|
||||
export type RefreshTokenResponse = z.infer<
|
||||
typeof RefreshTokenResponseSuccessSchema
|
||||
>;
|
||||
|
||||
const GDriveFile = z.object({
|
||||
kind: z.string().optional(),
|
||||
driveId: z.string().optional(),
|
||||
teamDriveId: z.string().optional(),
|
||||
fileExtension: z.string().optional(),
|
||||
md5Checksum: z.string().optional(),
|
||||
contentHints: z
|
||||
.object({
|
||||
indexableText: z.string(),
|
||||
thumbnail: z.object({
|
||||
image: z.string(),
|
||||
mimeType: z.string(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
webViewLink: z.string().optional(),
|
||||
webContentLink: z.string().optional(),
|
||||
thumbnailLink: z.string().optional(),
|
||||
iconLink: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
size: z.string().optional(),
|
||||
createdTime: z.string(),
|
||||
modifiedTime: z.string(),
|
||||
videoMediaMetadata: z
|
||||
.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
durationMs: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type GDriveFile = z.infer<typeof GDriveFile>;
|
||||
|
||||
const GDriveFileQueryResponseSuccessSchema = z.object({
|
||||
files: z.array(GDriveFile),
|
||||
nextPageToken: z.string().optional(),
|
||||
incompleteSearch: z.boolean().optional(),
|
||||
kind: z.string().optional(),
|
||||
});
|
||||
|
||||
const GDriveApiErrorSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.number(),
|
||||
message: z.string(),
|
||||
errors: z.array(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
reason: z.string(),
|
||||
location: z.string().optional(),
|
||||
locationType: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
})
|
||||
),
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const GDriveFileQueryResponseSchema = z.union([
|
||||
GDriveFileQueryResponseSuccessSchema,
|
||||
GDriveApiErrorSchema,
|
||||
]);
|
||||
|
||||
export type GDriveFileQueryResponse = z.infer<
|
||||
typeof GDriveFileQueryResponseSchema
|
||||
>;
|
||||
|
||||
export const GDriveFileGetResponseSchema = z.union([
|
||||
GDriveFile,
|
||||
GDriveApiErrorSchema,
|
||||
]);
|
||||
|
||||
export const UserData = z.object({
|
||||
refreshToken: z.string().min(1),
|
||||
includeAudioFiles: z.boolean().optional(),
|
||||
// catalogSort: z.enum(['createdTime desc', 'modifiedTime desc', 'name', '']).optional(),
|
||||
catalogSort: z
|
||||
.array(
|
||||
z.enum([
|
||||
'createdTime_asc',
|
||||
'createdTime_desc',
|
||||
'modifiedTime_asc',
|
||||
'modifiedTime_desc',
|
||||
'modifiedByMeTime_asc',
|
||||
'modifiedByMeTime_desc',
|
||||
'viewedByMeTime_asc',
|
||||
'viewedByMeTime_desc',
|
||||
'sharedWithMeTime_asc',
|
||||
'sharedWithMeTime_desc',
|
||||
'name_asc',
|
||||
'name_desc',
|
||||
'name_natural_asc',
|
||||
'name_natural_desc',
|
||||
'recency_asc',
|
||||
'recency_desc',
|
||||
'starred_asc',
|
||||
'starred_desc',
|
||||
'folder_asc',
|
||||
'folder_desc',
|
||||
])
|
||||
)
|
||||
.optional(),
|
||||
metadataSource: z.enum(['imdb', 'tmdb']).optional(),
|
||||
tmdbReadAccessToken: z.string().optional(),
|
||||
});
|
||||
export type UserData = z.infer<typeof UserData>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './gdrive';
|
||||
export * from './torbox-search';
|
||||
@@ -0,0 +1,163 @@
|
||||
import { z } from 'zod';
|
||||
import { Manifest, Stream } from '../../db';
|
||||
import {
|
||||
createLogger,
|
||||
formatZodError,
|
||||
getTimeTakenSincePoint,
|
||||
} from '../../utils';
|
||||
import { TorBoxSearchAddonUserDataSchema } from './schemas';
|
||||
import { TorboxApi } from '@torbox/torbox-api';
|
||||
import TorboxSearchApi from './search-api';
|
||||
import { IdParser } from '../utils/id-parser';
|
||||
import { TorrentSourceHandler, UsenetSourceHandler } from './source-handlers';
|
||||
import { TorBoxSearchAddonError } from './errors';
|
||||
import { supportedIdTypes } from './search-api';
|
||||
|
||||
const logger = createLogger('torbox-search');
|
||||
|
||||
export class TorBoxSearchAddon {
|
||||
private readonly userData: z.infer<typeof TorBoxSearchAddonUserDataSchema>;
|
||||
private readonly searchApi: TorboxSearchApi;
|
||||
private readonly torboxApi: TorboxApi;
|
||||
private static readonly idParser: IdParser = new IdParser(supportedIdTypes);
|
||||
private readonly sourceHandlers: (
|
||||
| TorrentSourceHandler
|
||||
| UsenetSourceHandler
|
||||
)[];
|
||||
private readonly manifest: Manifest;
|
||||
|
||||
constructor(
|
||||
userData: z.infer<typeof TorBoxSearchAddonUserDataSchema>,
|
||||
private readonly clientIp?: string
|
||||
) {
|
||||
this.userData = this.validateUserData(userData);
|
||||
this.manifest = TorBoxSearchAddon.getManifest();
|
||||
|
||||
this.searchApi = new TorboxSearchApi(this.userData.torBoxApiKey);
|
||||
this.torboxApi = new TorboxApi({ token: this.userData.torBoxApiKey });
|
||||
|
||||
this.sourceHandlers = this.initializeSourceHandlers();
|
||||
}
|
||||
|
||||
private validateUserData(
|
||||
userData: unknown
|
||||
): z.infer<typeof TorBoxSearchAddonUserDataSchema> {
|
||||
const { success, data, error } =
|
||||
TorBoxSearchAddonUserDataSchema.safeParse(userData);
|
||||
if (!success) {
|
||||
throw new TorBoxSearchAddonError(
|
||||
`Invalid user data: ${formatZodError(error)}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
if (
|
||||
data.sources.includes('usenet') &&
|
||||
!data.services.some((service) => service.id === 'torbox')
|
||||
) {
|
||||
data.sources = data.sources.filter((source) => source !== 'usenet');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static getManifest(): Manifest {
|
||||
return {
|
||||
id: 'com.torbox-search.viren070',
|
||||
name: 'TorBox Search',
|
||||
description: 'Search for torrents and usenet on TorBox',
|
||||
version: '1.0.0',
|
||||
types: ['movie', 'series', 'anime'],
|
||||
resources: [
|
||||
{
|
||||
name: 'stream',
|
||||
types: ['movie', 'series', 'anime'],
|
||||
idPrefixes: TorBoxSearchAddon.idParser.supportedPrefixes,
|
||||
},
|
||||
],
|
||||
catalogs: [],
|
||||
};
|
||||
}
|
||||
|
||||
private initializeSourceHandlers(): (
|
||||
| TorrentSourceHandler
|
||||
| UsenetSourceHandler
|
||||
)[] {
|
||||
const handlers = [];
|
||||
if (this.userData.sources.includes('torrent')) {
|
||||
handlers.push(
|
||||
new TorrentSourceHandler(
|
||||
this.searchApi,
|
||||
this.userData.services,
|
||||
this.userData.searchUserEngines,
|
||||
this.clientIp
|
||||
)
|
||||
);
|
||||
}
|
||||
if (this.userData.sources.includes('usenet')) {
|
||||
handlers.push(
|
||||
new UsenetSourceHandler(this.searchApi, this.userData.searchUserEngines)
|
||||
);
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
|
||||
public getManifest(): Manifest {
|
||||
return this.manifest;
|
||||
}
|
||||
|
||||
public async getStreams(type: string, id: string): Promise<Stream[]> {
|
||||
const parsedId = TorBoxSearchAddon.idParser.parse(id);
|
||||
if (!parsedId) {
|
||||
throw new TorBoxSearchAddonError(`Unsupported ID: ${id}`, 400);
|
||||
}
|
||||
|
||||
logger.info(`Getting streams for ${id}`, {
|
||||
type,
|
||||
id,
|
||||
parsedId,
|
||||
sources: this.userData.sources,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
const streamPromises = this.sourceHandlers.map((handler) =>
|
||||
handler.getStreams(parsedId, this.userData).catch((error) => {
|
||||
return [];
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(streamPromises);
|
||||
const streams = results.flat();
|
||||
|
||||
// filter out duplicate error streams and merge them
|
||||
const errorStreams = streams.filter((stream) =>
|
||||
stream.name?.startsWith('[❌')
|
||||
);
|
||||
|
||||
const uniqueErrorStreams = errorStreams.filter(
|
||||
(stream, index, self) =>
|
||||
index === self.findIndex((t) => t.description === stream.description)
|
||||
);
|
||||
|
||||
const mergedErrorStreams = uniqueErrorStreams.reduce<Stream[]>(
|
||||
(acc, stream) => {
|
||||
const existing = acc.find((s) => s.description === stream.description);
|
||||
if (existing) {
|
||||
existing.name += `\n${stream.name?.replace('[❌', '')}`;
|
||||
} else {
|
||||
acc.push(stream);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const filteredStreams = streams.filter(
|
||||
(stream) => !stream.name?.startsWith('[❌')
|
||||
);
|
||||
const finalStreams = [...filteredStreams, ...mergedErrorStreams];
|
||||
|
||||
logger.info(
|
||||
`Created ${finalStreams.length} streams for ${id} in ${getTimeTakenSincePoint(start)}`
|
||||
);
|
||||
return finalStreams;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Cache,
|
||||
constants,
|
||||
createLogger,
|
||||
Env,
|
||||
getSimpleTextHash,
|
||||
getTimeTakenSincePoint,
|
||||
} from '../../utils';
|
||||
import { DebridInterface } from '../../debrid/interface';
|
||||
import { FileParser } from '../../parser';
|
||||
import { ParsedId } from '../utils/id-parser';
|
||||
import { Torrent } from './torrent';
|
||||
import { ServiceId } from '../../utils';
|
||||
import { z } from 'zod';
|
||||
import { TorBoxSearchAddonUserDataSchema } from './schemas';
|
||||
import { findMatchingFileInTorrent, isVideoFile } from '../../debrid/utils';
|
||||
import { StremThruError } from 'stremthru';
|
||||
|
||||
const logger = createLogger('torbox-search');
|
||||
|
||||
export interface DebridFile {
|
||||
hash: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
index?: number;
|
||||
service: {
|
||||
id: ServiceId;
|
||||
cached: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class DebridService {
|
||||
private readonly debridCache = Cache.getInstance<string, DebridFile[]>(
|
||||
'torbox-search-debrid'
|
||||
);
|
||||
|
||||
private readonly debridInterface: DebridInterface;
|
||||
private readonly serviceConfig: z.infer<
|
||||
typeof TorBoxSearchAddonUserDataSchema
|
||||
>['services'][0];
|
||||
|
||||
constructor(
|
||||
serviceConfig: z.infer<
|
||||
typeof TorBoxSearchAddonUserDataSchema
|
||||
>['services'][0],
|
||||
private readonly clientIp?: string
|
||||
) {
|
||||
this.serviceConfig = serviceConfig;
|
||||
this.debridInterface = new DebridInterface(
|
||||
{
|
||||
storeName: serviceConfig.id,
|
||||
storeCredential: serviceConfig.credential,
|
||||
},
|
||||
this.clientIp
|
||||
);
|
||||
}
|
||||
|
||||
public async getAvailableFiles(
|
||||
torrents: Torrent[],
|
||||
parsedId: ParsedId,
|
||||
requestedTitle?: string
|
||||
): Promise<DebridFile[] | { error: { title: string; description: string } }> {
|
||||
const { id, season, episode } = parsedId;
|
||||
const cacheKey = getSimpleTextHash(
|
||||
`${this.serviceConfig.id}:${torrents.map((t) => t.hash).join(',')}`
|
||||
);
|
||||
const cachedFiles = this.debridCache.get(cacheKey);
|
||||
if (cachedFiles) {
|
||||
logger.debug(
|
||||
`[${this.serviceConfig.id}] Using cached debrid results for ${torrents.length} torrents.`
|
||||
);
|
||||
return cachedFiles;
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const instantAvailability = await this.debridInterface.checkMagnets(
|
||||
torrents.map((t) => t.hash),
|
||||
id
|
||||
);
|
||||
|
||||
const chosenFiles: DebridFile[] = [];
|
||||
for (const torrent of torrents) {
|
||||
const item = instantAvailability.data.items.find(
|
||||
(avail) => avail.hash === torrent.hash
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file =
|
||||
item.files && item.files.length > 0
|
||||
? findMatchingFileInTorrent(
|
||||
item.files.map((file) => ({
|
||||
...file,
|
||||
parsed: FileParser.parse(file.name),
|
||||
isVideo: isVideoFile(file.name),
|
||||
})),
|
||||
torrent.fileIdx,
|
||||
undefined,
|
||||
requestedTitle,
|
||||
season,
|
||||
episode
|
||||
)
|
||||
: { name: torrent.title, size: torrent.size, index: -1 }; // Fallback for torrents with no file list
|
||||
|
||||
if (file) {
|
||||
chosenFiles.push({
|
||||
hash: torrent.hash,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
index: file.index !== -1 ? file.index : undefined,
|
||||
service: {
|
||||
id: this.serviceConfig.id,
|
||||
cached: item.status === 'cached',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
this.debridCache.set(
|
||||
cacheKey,
|
||||
chosenFiles,
|
||||
Env.BUILTIN_TORBOX_SEARCH_INSTANT_AVAILABILITY_CACHE_TTL
|
||||
);
|
||||
logger.info(
|
||||
`[${this.serviceConfig.id}] Successfully processed and checked ${chosenFiles.length} magnets in ${getTimeTakenSincePoint(start)} (${chosenFiles.filter((f) => f.service.cached).length} cached)`
|
||||
);
|
||||
return chosenFiles;
|
||||
} catch (error) {
|
||||
if (error instanceof StremThruError) {
|
||||
logger.error(
|
||||
`Got StremThru error during debrid check: ${error.code}: ${error.message}`
|
||||
);
|
||||
const serviceName =
|
||||
constants.SERVICE_DETAILS[this.serviceConfig.id].shortName;
|
||||
switch (error.code) {
|
||||
case 'FORBIDDEN':
|
||||
case 'UNAUTHORIZED':
|
||||
return {
|
||||
error: {
|
||||
title: serviceName,
|
||||
description: 'Invalid/expired credentials',
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
error: {
|
||||
title: serviceName,
|
||||
description: 'Internal Server Error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export class TorBoxSearchAddonError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number = 400,
|
||||
public readonly errorCode: string = 'BAD_REQUEST'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TorBoxSearchAddonError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './addon';
|
||||
export * from './errors';
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z } from 'zod';
|
||||
import { constants } from '../../utils';
|
||||
|
||||
const TorBoxApiErrorResponseSchema = z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
detail: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
data: z.null(),
|
||||
});
|
||||
|
||||
const TorBoxApiSuccessResponseSchema = <T>(dataSchema: z.ZodSchema<T>) =>
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
message: z.string().optional(),
|
||||
detail: z.string().optional(),
|
||||
data: z.union([z.null(), dataSchema]),
|
||||
});
|
||||
|
||||
export const TorBoxApiResponseSchema = <T>(dataSchema: z.ZodSchema<T>) =>
|
||||
z.union([
|
||||
TorBoxApiErrorResponseSchema,
|
||||
TorBoxApiSuccessResponseSchema(dataSchema),
|
||||
]);
|
||||
|
||||
const TorBoxSearchApiMetadataSchema = z.object({
|
||||
globalID: z.string(),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const TorBoxSearchApiTorrentSchema = z.object({
|
||||
hash: z.string(),
|
||||
raw_title: z.string(),
|
||||
title: z.string(),
|
||||
title_parsed_data: z.object({
|
||||
resolution: z.string().optional(),
|
||||
quality: z.string().optional(),
|
||||
year: z.number().optional(),
|
||||
codec: z.string().optional(),
|
||||
audio: z.string().optional(),
|
||||
bitDepth: z.number().optional(),
|
||||
hdr: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
encoder: z.string().optional(),
|
||||
site: z.string().optional(),
|
||||
}),
|
||||
magnet: z.union([z.string(), z.null()]),
|
||||
torrent: z.union([z.string(), z.null()]),
|
||||
last_known_seeders: z.number(),
|
||||
last_known_peers: z.number(),
|
||||
size: z.coerce.number(),
|
||||
tracker: z.string(),
|
||||
categories: z.array(z.string()),
|
||||
files: z.coerce.number(),
|
||||
type: z.union([z.literal('torrent'), z.literal('usenet')]),
|
||||
nzb: z.union([z.string(), z.null()]),
|
||||
age: z.string(),
|
||||
user_search: z.boolean(),
|
||||
cached: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const TorBoxSearchApiDataSchema = z.object({
|
||||
metadata: z.union([TorBoxSearchApiMetadataSchema, z.null()]).optional(),
|
||||
torrents: z
|
||||
.union([z.array(TorBoxSearchApiTorrentSchema), z.null()])
|
||||
.optional(),
|
||||
nzbs: z.union([z.array(TorBoxSearchApiTorrentSchema), z.null()]).optional(),
|
||||
});
|
||||
|
||||
export const TorBoxApiUsenetDownloadSchema = z.object({
|
||||
hash: z.string(),
|
||||
usenetdownload_id: z.number(),
|
||||
auth_id: z.string(),
|
||||
});
|
||||
|
||||
export const TorBoxApiUsenetDownloadLinkSchema = z.string();
|
||||
|
||||
export const TorBoxSearchAddonUserDataSchema = z.object({
|
||||
torBoxApiKey: z.string(),
|
||||
searchUserEngines: z.boolean(),
|
||||
sources: z
|
||||
.array(z.enum(['torrent', 'usenet']))
|
||||
.min(1, 'At least one source must be configured'),
|
||||
services: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.enum([
|
||||
constants.TORBOX_SERVICE,
|
||||
constants.REALDEBRID_SERVICE,
|
||||
constants.DEBRIDLINK_SERVICE,
|
||||
constants.PREMIUMIZE_SERVICE,
|
||||
constants.ALLDEBRID_SERVICE,
|
||||
constants.EASYDEBRID_SERVICE,
|
||||
constants.PUTIO_SERVICE,
|
||||
constants.PIKPAK_SERVICE,
|
||||
]),
|
||||
credential: z.string(),
|
||||
})
|
||||
)
|
||||
.min(1, 'At least one service must be configured'),
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { fetch, RequestInit } from 'undici';
|
||||
import { z } from 'zod';
|
||||
import { TorBoxApiResponseSchema, TorBoxSearchApiDataSchema } from './schemas';
|
||||
import {
|
||||
Cache,
|
||||
createLogger,
|
||||
Env,
|
||||
formatZodError,
|
||||
maskSensitiveInfo,
|
||||
} from '../../utils';
|
||||
|
||||
type TorboxSuccessResponse<T> = {
|
||||
success: true;
|
||||
message?: string;
|
||||
data: T | null;
|
||||
};
|
||||
|
||||
type TorboxErrorResponse = {
|
||||
success: false;
|
||||
error: string;
|
||||
detail?: string;
|
||||
message?: string;
|
||||
data: null;
|
||||
};
|
||||
|
||||
type TorboxResponse<T> = TorboxSuccessResponse<T> | TorboxErrorResponse;
|
||||
|
||||
export const supportedIdTypes: TorboxSearchApiIdType[] = [
|
||||
'anime-planet_id',
|
||||
'anidb_id',
|
||||
'anilist_id',
|
||||
'anisearch_id',
|
||||
'imdb_id',
|
||||
'kitsu_id',
|
||||
'livechart_id',
|
||||
'mal_id',
|
||||
'notify.moe_id',
|
||||
'thetvdb_id',
|
||||
'themoviedb_id',
|
||||
'tmdb',
|
||||
];
|
||||
export type TorboxSearchApiIdType =
|
||||
| 'anime-planet_id'
|
||||
| 'anidb_id'
|
||||
| 'anilist_id'
|
||||
| 'anisearch_id'
|
||||
| 'imdb_id'
|
||||
| 'kitsu_id'
|
||||
| 'livechart_id'
|
||||
| 'mal_id'
|
||||
| 'notify.moe_id'
|
||||
| 'thetvdb_id'
|
||||
| 'themoviedb_id'
|
||||
| 'tmdb';
|
||||
|
||||
const logger = createLogger('torbox-search');
|
||||
|
||||
function isErrorResponse<T>(
|
||||
response: TorboxResponse<T>
|
||||
): response is TorboxErrorResponse {
|
||||
return !response.success;
|
||||
}
|
||||
|
||||
export class TorboxApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
public readonly errorCode?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TorboxApiError';
|
||||
}
|
||||
}
|
||||
|
||||
const USER_AGENT = Env.DEFAULT_USER_AGENT;
|
||||
|
||||
class TorboxSearchApi {
|
||||
private readonly baseUrl = 'https://search-api.torbox.app';
|
||||
private static readonly ongoingRequests = new Map<string, Promise<any>>();
|
||||
private static readonly timeout =
|
||||
Env.BUILTIN_TORBOX_SEARCH_SEARCH_API_TIMEOUT;
|
||||
|
||||
constructor(private readonly apiKey: string) {}
|
||||
|
||||
private createRequestLock<T>(
|
||||
key: string,
|
||||
executor: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (TorboxSearchApi.ongoingRequests.has(key)) {
|
||||
logger.debug(
|
||||
`Found ongoing request for ${key.replace(this.apiKey, maskSensitiveInfo(this.apiKey))}. Waiting for it to complete.`
|
||||
);
|
||||
return TorboxSearchApi.ongoingRequests.get(key)!;
|
||||
}
|
||||
|
||||
const requestPromise = executor().finally(() => {
|
||||
TorboxSearchApi.ongoingRequests.delete(key);
|
||||
});
|
||||
|
||||
TorboxSearchApi.ongoingRequests.set(key, requestPromise);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
async request<T>(
|
||||
endpoint: string,
|
||||
schema: z.ZodSchema<T>,
|
||||
{
|
||||
body,
|
||||
method = 'GET',
|
||||
params,
|
||||
...options
|
||||
}: Omit<RequestInit, 'headers' | 'signal'> & {
|
||||
timeout?: number;
|
||||
params?: URLSearchParams;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const url = new URL(endpoint, this.baseUrl);
|
||||
if (params) {
|
||||
url.search = params.toString();
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT,
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
...options,
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(TorboxSearchApi.timeout),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const parsedResponse = TorBoxApiResponseSchema(schema).safeParse(data);
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new TorboxApiError(
|
||||
`Failed to parse API response: ${formatZodError(parsedResponse.error)}`,
|
||||
response.status,
|
||||
'PARSE_ERROR'
|
||||
);
|
||||
}
|
||||
|
||||
const result = parsedResponse.data as TorboxResponse<T>;
|
||||
|
||||
if (isErrorResponse(result)) {
|
||||
throw new TorboxApiError(
|
||||
result.detail || result.message || 'Unknown',
|
||||
response.status,
|
||||
result.error
|
||||
);
|
||||
}
|
||||
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
public async getTorrentsById(
|
||||
idType: TorboxSearchApiIdType,
|
||||
id: string,
|
||||
params: {
|
||||
check_cache?: 'true' | 'false';
|
||||
check_owned?: 'true' | 'false';
|
||||
search_user_engines?: 'true' | 'false';
|
||||
season?: string;
|
||||
metadata?: 'true' | 'false';
|
||||
episode?: string;
|
||||
} = {
|
||||
check_cache: 'true',
|
||||
check_owned: 'true',
|
||||
metadata: 'true',
|
||||
}
|
||||
): Promise<z.infer<typeof TorBoxSearchApiDataSchema>> {
|
||||
const endpoint = `/torrents/${idType}:${id}`;
|
||||
const lockKey =
|
||||
params.search_user_engines === 'true'
|
||||
? `${this.apiKey}:${endpoint}:${params.season}:${params.episode}`
|
||||
: `${endpoint}:${params.season}:${params.episode}`;
|
||||
|
||||
return this.createRequestLock(lockKey, () =>
|
||||
this.request<z.infer<typeof TorBoxSearchApiDataSchema>>(
|
||||
endpoint,
|
||||
TorBoxSearchApiDataSchema,
|
||||
{
|
||||
params: new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([_, value]) => value !== undefined)
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async getUsenetById(
|
||||
idType: TorboxSearchApiIdType,
|
||||
id: string,
|
||||
params: {
|
||||
check_cache?: 'true' | 'false';
|
||||
check_owned?: 'true' | 'false';
|
||||
search_user_engines?: 'true' | 'false';
|
||||
season?: string;
|
||||
episode?: string;
|
||||
metadata?: 'true' | 'false';
|
||||
} = {
|
||||
check_cache: 'true',
|
||||
check_owned: 'true',
|
||||
metadata: 'true',
|
||||
}
|
||||
) {
|
||||
const endpoint = `/usenet/${idType}:${id}`;
|
||||
const lockKey =
|
||||
params.search_user_engines === 'true'
|
||||
? `${this.apiKey}:${endpoint}:${params.season}:${params.episode}`
|
||||
: `${endpoint}:${params.season}:${params.episode}`;
|
||||
|
||||
return this.createRequestLock(lockKey, () =>
|
||||
this.request<z.infer<typeof TorBoxSearchApiDataSchema>>(
|
||||
endpoint,
|
||||
TorBoxSearchApiDataSchema,
|
||||
{
|
||||
params: new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).filter(([_, value]) => value !== undefined)
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TorboxSearchApi;
|
||||
@@ -0,0 +1,302 @@
|
||||
import { z } from 'zod';
|
||||
import { Stream } from '../../db';
|
||||
import {
|
||||
Cache,
|
||||
Env,
|
||||
SERVICE_DETAILS,
|
||||
createLogger,
|
||||
getTimeTakenSincePoint,
|
||||
} from '../../utils';
|
||||
import { DebridService, DebridFile } from './debrid-service';
|
||||
import { ParsedId } from '../utils/id-parser';
|
||||
import { TorBoxSearchAddonUserDataSchema } from './schemas';
|
||||
import TorboxSearchApi, {
|
||||
TorboxApiError,
|
||||
TorboxSearchApiIdType,
|
||||
} from './search-api';
|
||||
import { Torrent, convertDataToTorrents } from './torrent';
|
||||
import { StremThruError } from 'stremthru';
|
||||
|
||||
const logger = createLogger('torbox-search');
|
||||
|
||||
abstract class SourceHandler {
|
||||
protected searchCache = Cache.getInstance<string, Torrent[]>(
|
||||
'torbox-search-torrents'
|
||||
);
|
||||
protected metadataCache = Cache.getInstance<string, string>(
|
||||
'torbox-search-metadata'
|
||||
);
|
||||
|
||||
protected errorStreams: Stream[] = [];
|
||||
|
||||
constructor(protected searchApi: TorboxSearchApi) {}
|
||||
|
||||
abstract getStreams(
|
||||
parsedId: ParsedId,
|
||||
userData: z.infer<typeof TorBoxSearchAddonUserDataSchema>
|
||||
): Promise<Stream[]>;
|
||||
|
||||
protected createStream(
|
||||
torrent: Torrent,
|
||||
file: DebridFile,
|
||||
userData: z.infer<typeof TorBoxSearchAddonUserDataSchema>,
|
||||
season?: string,
|
||||
episode?: string
|
||||
): Stream & { behaviorHints: { type: 'torrent' | 'usenet' } } {
|
||||
const storeAuth = {
|
||||
storeName: file.service.id,
|
||||
storeCredential: userData.services.find(
|
||||
(service) => service.id === file.service.id
|
||||
)?.credential,
|
||||
};
|
||||
|
||||
const playbackInfo =
|
||||
torrent.type === 'torrent'
|
||||
? {
|
||||
type: 'torrent',
|
||||
hash: torrent.hash,
|
||||
index: file.index,
|
||||
season,
|
||||
episode,
|
||||
}
|
||||
: { type: 'usenet', nzb: torrent.nzb };
|
||||
|
||||
const svcMeta = SERVICE_DETAILS[file.service.id];
|
||||
const name = `[${svcMeta.shortName} ${file.service.cached ? '⚡' : '⏳'}] TorBox Search`;
|
||||
const description = `${torrent.title}\n${file.filename}\n${torrent.indexer ? `🔍 ${torrent.indexer}` : ''} ${torrent.seeders ? `👤 ${torrent.seeders}` : ''} ${torrent.age && torrent.age !== '0d' ? `🕒 ${torrent.age}` : ''}`;
|
||||
|
||||
return {
|
||||
url: `${Env.BASE_URL}/api/v1/debrid/resolve/${encodeURIComponent(Buffer.from(JSON.stringify(storeAuth)).toString('base64'))}/${encodeURIComponent(Buffer.from(JSON.stringify(playbackInfo)).toString('base64'))}/${encodeURIComponent(file.filename || torrent.title)}`,
|
||||
name,
|
||||
description,
|
||||
behaviorHints: {
|
||||
videoSize: file.size,
|
||||
filename: file.filename,
|
||||
type: torrent.type,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected createErrorStream(error: {
|
||||
title: string;
|
||||
description: string;
|
||||
}): Stream {
|
||||
return {
|
||||
name: `[❌] TorBox Search ${error.title}`,
|
||||
description: error.description,
|
||||
externalUrl: 'stremio:///',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TorrentSourceHandler extends SourceHandler {
|
||||
private readonly debridServices: DebridService[];
|
||||
private readonly searchUserEngines: boolean;
|
||||
|
||||
constructor(
|
||||
searchApi: TorboxSearchApi,
|
||||
services: z.infer<typeof TorBoxSearchAddonUserDataSchema>['services'],
|
||||
searchUserEngines: boolean,
|
||||
clientIp?: string
|
||||
) {
|
||||
super(searchApi);
|
||||
this.debridServices = services.map(
|
||||
(service) => new DebridService(service, clientIp)
|
||||
);
|
||||
this.searchUserEngines = searchUserEngines;
|
||||
}
|
||||
|
||||
async getStreams(
|
||||
parsedId: ParsedId,
|
||||
userData: z.infer<typeof TorBoxSearchAddonUserDataSchema>
|
||||
): Promise<Stream[]> {
|
||||
const { type, id, season, episode } = parsedId;
|
||||
let torrents: Torrent[] = [];
|
||||
try {
|
||||
torrents = await this.fetchTorrents(type, id, season, episode);
|
||||
} catch (error) {
|
||||
if (error instanceof TorboxApiError) {
|
||||
switch (error.errorCode) {
|
||||
case 'BAD_TOKEN':
|
||||
return [
|
||||
this.createErrorStream({
|
||||
title: ``,
|
||||
description: 'Invalid/expired credentials',
|
||||
}),
|
||||
];
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (torrents.length === 0) return [];
|
||||
|
||||
const requestedTitle = this.metadataCache.get(`metadata:${type}:${id}`);
|
||||
const filesByHash = await this.getAvailableFilesFromDebrid(
|
||||
torrents,
|
||||
parsedId,
|
||||
requestedTitle
|
||||
);
|
||||
|
||||
const streams: Stream[] = [];
|
||||
for (const torrent of torrents) {
|
||||
const availableFiles = filesByHash.get(torrent.hash);
|
||||
if (availableFiles) {
|
||||
for (const file of availableFiles) {
|
||||
streams.push(
|
||||
this.createStream(torrent, file, userData, season, episode)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streams.push(...this.errorStreams);
|
||||
|
||||
return streams;
|
||||
}
|
||||
|
||||
private async fetchTorrents(
|
||||
idType: TorboxSearchApiIdType,
|
||||
id: string,
|
||||
season?: string,
|
||||
episode?: string
|
||||
): Promise<Torrent[]> {
|
||||
const cacheKey = `torrents:${idType}:${id}:${season}:${episode}`;
|
||||
const cachedTorrents = this.searchCache.get(cacheKey);
|
||||
|
||||
if (cachedTorrents && !this.searchUserEngines) {
|
||||
logger.info(`Found ${cachedTorrents.length} (cached) torrents for ${id}`);
|
||||
return cachedTorrents;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const data = await this.searchApi.getTorrentsById(idType, id, {
|
||||
search_user_engines: this.searchUserEngines ? 'true' : 'false',
|
||||
season,
|
||||
episode,
|
||||
});
|
||||
|
||||
const torrents = convertDataToTorrents(data.torrents);
|
||||
logger.info(
|
||||
`Found ${torrents.length} torrents for ${id} in ${getTimeTakenSincePoint(start)}`
|
||||
);
|
||||
|
||||
if (data.metadata?.title) {
|
||||
this.metadataCache.set(
|
||||
`metadata:${idType}:${id}`,
|
||||
data.metadata.title,
|
||||
Env.BUILTIN_TORBOX_SEARCH_METADATA_CACHE_TTL
|
||||
);
|
||||
}
|
||||
|
||||
this.searchCache.set(
|
||||
cacheKey,
|
||||
torrents.filter((torrent) => !torrent.userSearch),
|
||||
Env.BUILTIN_TORBOX_SEARCH_SEARCH_API_CACHE_TTL
|
||||
);
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
private async getAvailableFilesFromDebrid(
|
||||
torrents: Torrent[],
|
||||
parsedId: ParsedId,
|
||||
requestedTitle?: string
|
||||
): Promise<Map<string, DebridFile[]>> {
|
||||
const allFiles = new Map<string, DebridFile[]>();
|
||||
const servicePromises = this.debridServices.map((service) =>
|
||||
service.getAvailableFiles(torrents, parsedId, requestedTitle)
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(servicePromises);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
if (result.value instanceof Array) {
|
||||
for (const file of result.value) {
|
||||
const existing = allFiles.get(file.hash) || [];
|
||||
allFiles.set(file.hash, [...existing, file]);
|
||||
}
|
||||
} else {
|
||||
this.errorStreams.push(
|
||||
this.createErrorStream({
|
||||
title: result.value.error.title,
|
||||
description: result.value.error.description,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
}
|
||||
|
||||
export class UsenetSourceHandler extends SourceHandler {
|
||||
private readonly searchUserEngines: boolean;
|
||||
private readonly clientIp?: string;
|
||||
|
||||
constructor(searchApi: TorboxSearchApi, searchUserEngines: boolean) {
|
||||
super(searchApi);
|
||||
this.searchUserEngines = searchUserEngines;
|
||||
}
|
||||
|
||||
async getStreams(
|
||||
parsedId: ParsedId,
|
||||
userData: z.infer<typeof TorBoxSearchAddonUserDataSchema>
|
||||
): Promise<Stream[]> {
|
||||
const { type, id, season, episode } = parsedId;
|
||||
const cacheKey = `usenet:${type}:${id}:${season}:${episode}`;
|
||||
let torrents = this.searchCache.get(cacheKey);
|
||||
|
||||
if (!torrents) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const data = await this.searchApi.getUsenetById(type, id, {
|
||||
season,
|
||||
episode,
|
||||
check_cache: 'true',
|
||||
search_user_engines: this.searchUserEngines ? 'true' : 'false',
|
||||
});
|
||||
torrents = convertDataToTorrents(data.nzbs);
|
||||
this.searchCache.set(
|
||||
cacheKey,
|
||||
torrents,
|
||||
Env.BUILTIN_TORBOX_SEARCH_SEARCH_API_CACHE_TTL
|
||||
);
|
||||
logger.info(
|
||||
`Found ${torrents.length} NZBs for ${id} in ${getTimeTakenSincePoint(start)}`
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TorboxApiError) {
|
||||
switch (error.errorCode) {
|
||||
case 'BAD_TOKEN':
|
||||
return [
|
||||
this.createErrorStream({
|
||||
title: ``,
|
||||
description: 'Invalid/expired credentials',
|
||||
}),
|
||||
];
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
logger.info(`Found ${torrents.length} (cached) NZBs for ${id}`);
|
||||
}
|
||||
|
||||
return torrents.map((torrent) => {
|
||||
const file: DebridFile = {
|
||||
hash: torrent.hash,
|
||||
filename: torrent.title,
|
||||
size: torrent.size,
|
||||
index: -1,
|
||||
service: { id: 'torbox', cached: torrent.cached ?? false },
|
||||
};
|
||||
return this.createStream(torrent, file, userData, season, episode);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
import { TorBoxSearchApiDataSchema } from './schemas';
|
||||
import { ServiceId } from '../../utils';
|
||||
import { DebridFile } from './debrid-service';
|
||||
|
||||
export interface Torrent {
|
||||
hash: string;
|
||||
magnet?: string;
|
||||
title: string;
|
||||
fileIdx?: number;
|
||||
size: number;
|
||||
indexer: string;
|
||||
age?: string;
|
||||
seeders?: number;
|
||||
type: 'torrent' | 'usenet';
|
||||
nzb?: string;
|
||||
userSearch?: boolean;
|
||||
cached?: boolean;
|
||||
availableFiles?: DebridFile[];
|
||||
}
|
||||
|
||||
export function convertDataToTorrents(
|
||||
data: z.infer<typeof TorBoxSearchApiDataSchema>['torrents']
|
||||
): Torrent[] {
|
||||
return (data || []).map((file) => ({
|
||||
hash: file.hash,
|
||||
magnet: file.magnet ?? undefined,
|
||||
title: file.raw_title,
|
||||
size: file.size,
|
||||
indexer: file.tracker,
|
||||
age: file.age,
|
||||
type: file.type,
|
||||
userSearch: file.user_search,
|
||||
seeders:
|
||||
file.last_known_seeders !== -1 ? file.last_known_seeders : undefined,
|
||||
nzb: file.nzb ?? undefined,
|
||||
cached: file.cached ?? undefined,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { TorboxSearchApiIdType } from '../torbox-search/search-api';
|
||||
|
||||
export interface ParsedId {
|
||||
type: TorboxSearchApiIdType;
|
||||
id: string;
|
||||
season?: string;
|
||||
episode?: string;
|
||||
}
|
||||
|
||||
interface IdParserDefinition {
|
||||
type: TorboxSearchApiIdType;
|
||||
prefixes: string[];
|
||||
// Regex should have named capture groups:
|
||||
// - id: the base ID (required)
|
||||
// - season: the season number (optional)
|
||||
// - episode: the episode number (optional)
|
||||
regex: RegExp;
|
||||
format: (id: string) => string;
|
||||
}
|
||||
|
||||
export class IdParser {
|
||||
private readonly ID_PARSERS: IdParserDefinition[] = [
|
||||
{
|
||||
type: 'imdb_id',
|
||||
prefixes: ['tt', 'imdb'],
|
||||
regex: /^(?:tt|imdb)[:-]?(?<id>\d+)(?::(?<season>\d+):(?<episode>\d+))?$/,
|
||||
format: (id) => `tt${id}`,
|
||||
},
|
||||
{
|
||||
type: 'mal_id',
|
||||
prefixes: ['mal'],
|
||||
regex: /^mal[:-]?(?<id>\d+)(?::(?<episode>\d+))?$/,
|
||||
format: (id) => id,
|
||||
},
|
||||
{
|
||||
type: 'thetvdb_id',
|
||||
prefixes: ['tvdb'],
|
||||
regex: /^tvdb[:-]?(?<id>\d+)(?::(?<season>\d+):(?<episode>\d+))?$/,
|
||||
format: (id) => id,
|
||||
},
|
||||
{
|
||||
type: 'tmdb',
|
||||
prefixes: ['tmdb'],
|
||||
regex: /^tmdb[:-]?(?<id>\d+)(?::(?<season>\d+):(?<episode>\d+))?$/,
|
||||
format: (id) => id,
|
||||
},
|
||||
{
|
||||
type: 'kitsu_id',
|
||||
prefixes: ['kitsu'],
|
||||
regex: /^kitsu[:-]?(?<id>\d+)(?::(?<episode>\d+))?$/,
|
||||
format: (id) => id,
|
||||
},
|
||||
{
|
||||
type: 'anilist_id',
|
||||
prefixes: ['anilist'],
|
||||
regex: /^anilist[:-]?(?<id>\d+)(?::(?<episode>\d+))?$/,
|
||||
format: (id) => id,
|
||||
},
|
||||
{
|
||||
type: 'anidb_id',
|
||||
prefixes: ['anidb', 'anidb_id', 'anidbid'],
|
||||
regex: /^(?:anidb|anidb_id|anidbid)[:-]?(?<id>\d+)(?::(?<episode>\d+))?$/,
|
||||
format: (id) => id,
|
||||
},
|
||||
];
|
||||
|
||||
constructor(private readonly supportedIdTypes: TorboxSearchApiIdType[]) {}
|
||||
|
||||
public readonly supportedPrefixes = this.ID_PARSERS.filter((p) =>
|
||||
this.supportedIdTypes.includes(p.type)
|
||||
).flatMap((p) => p.prefixes);
|
||||
|
||||
public parse(stremioId: string): ParsedId | null {
|
||||
for (const parser of this.ID_PARSERS) {
|
||||
const match = stremioId.match(parser.regex);
|
||||
if (match?.groups) {
|
||||
const { id, season, episode } = match.groups;
|
||||
const parsedId: ParsedId = {
|
||||
type: parser.type,
|
||||
id: parser.format(id),
|
||||
};
|
||||
|
||||
if (season) parsedId.season = season;
|
||||
if (episode) parsedId.episode = episode;
|
||||
|
||||
return parsedId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -185,10 +185,19 @@ const OptionDefinition = z.object({
|
||||
'url',
|
||||
'alert',
|
||||
'socials',
|
||||
'oauth',
|
||||
]),
|
||||
oauth: z
|
||||
.object({
|
||||
authorisationUrl: z.string().url(),
|
||||
oauthResultField: z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
required: z.boolean().optional(),
|
||||
default: z.any().optional(),
|
||||
// sensitive: z.boolean().optional(),
|
||||
forced: z.any().optional(),
|
||||
options: z
|
||||
.array(
|
||||
@@ -387,6 +396,7 @@ export const UserDataSchema = z.object({
|
||||
.object({
|
||||
mode: z.enum(['exact', 'contains']).optional(),
|
||||
matchYear: z.boolean().optional(),
|
||||
yearTolerance: z.number().min(0).max(100).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
requestTypes: z.array(z.string()).optional(),
|
||||
addons: z.array(z.string()).optional(),
|
||||
@@ -560,7 +570,7 @@ const MetaLinkSchema = z
|
||||
|
||||
const MetaVideoSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
id: z.string(),
|
||||
title: z.string().or(z.null()).optional(),
|
||||
name: z.string().or(z.null()).optional(),
|
||||
released: z.string().datetime().or(z.null()).optional(),
|
||||
@@ -806,6 +816,7 @@ const PresetMetadataSchema = z.object({
|
||||
DESCRIPTION: z.string(),
|
||||
URL: z.string(),
|
||||
TIMEOUT: z.number(),
|
||||
BUILTIN: z.boolean().optional(),
|
||||
USER_AGENT: z.string(),
|
||||
SUPPORTED_SERVICES: z.array(z.string()),
|
||||
OPTIONS: z.array(OptionDefinition),
|
||||
@@ -829,6 +840,7 @@ const PresetMinimalMetadataSchema = z.object({
|
||||
SUPPORTED_STREAM_TYPES: z.array(StreamTypes),
|
||||
SUPPORTED_SERVICES: z.array(z.string()),
|
||||
OPTIONS: z.array(OptionDefinition),
|
||||
BUILTIN: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const StatusResponseSchema = z.object({
|
||||
@@ -844,6 +856,12 @@ const StatusResponseSchema = z.object({
|
||||
customHtml: z.string().optional(),
|
||||
protected: z.boolean(),
|
||||
regexFilterAccess: z.enum(['none', 'trusted', 'all']),
|
||||
allowedRegexPatterns: z
|
||||
.object({
|
||||
patterns: z.array(z.string()),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
loggingSensitiveInfo: z.boolean(),
|
||||
tmdbApiAvailable: z.boolean(),
|
||||
forced: z.object({
|
||||
|
||||
@@ -36,7 +36,7 @@ export class UserRepository {
|
||||
let validatedConfig: UserData;
|
||||
if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) {
|
||||
return Promise.reject(
|
||||
new APIError(constants.ErrorCode.USER_INVALID_PASSWORD)
|
||||
new APIError(constants.ErrorCode.USER_INVALID_DETAILS)
|
||||
);
|
||||
}
|
||||
config.trusted = false;
|
||||
@@ -124,7 +124,9 @@ export class UserRepository {
|
||||
);
|
||||
|
||||
if (!result.length || !result[0].config) {
|
||||
return Promise.reject(new APIError(constants.ErrorCode.USER_NOT_FOUND));
|
||||
return Promise.reject(
|
||||
new APIError(constants.ErrorCode.USER_INVALID_DETAILS)
|
||||
);
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
@@ -138,7 +140,7 @@ export class UserRepository {
|
||||
);
|
||||
if (!isValid) {
|
||||
return Promise.reject(
|
||||
new APIError(constants.ErrorCode.USER_INVALID_PASSWORD)
|
||||
new APIError(constants.ErrorCode.USER_INVALID_DETAILS)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,12 +208,12 @@ export class UserRepository {
|
||||
);
|
||||
|
||||
if (!currentUser.rows.length) {
|
||||
throw new APIError(constants.ErrorCode.USER_NOT_FOUND);
|
||||
throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS);
|
||||
}
|
||||
|
||||
if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) {
|
||||
throw new APIError(
|
||||
constants.ErrorCode.USER_INVALID_PASSWORD,
|
||||
constants.ErrorCode.USER_INVALID_DETAILS,
|
||||
undefined,
|
||||
'Invalid password'
|
||||
);
|
||||
@@ -232,7 +234,7 @@ export class UserRepository {
|
||||
const storedHash = currentUser.rows[0].password_hash;
|
||||
const isValid = await this.verifyUserPassword(password, storedHash);
|
||||
if (!isValid) {
|
||||
throw new APIError(constants.ErrorCode.USER_INVALID_PASSWORD);
|
||||
throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS);
|
||||
}
|
||||
const { encryptedConfig } = await this.encryptConfig(
|
||||
validatedConfig,
|
||||
@@ -272,19 +274,28 @@ export class UserRepository {
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteUser(uuid: string): Promise<void> {
|
||||
static async deleteUser(uuid: string, password: string): Promise<void> {
|
||||
return txQueue.enqueue(async () => {
|
||||
let tx;
|
||||
let committed = false;
|
||||
try {
|
||||
tx = await db.begin();
|
||||
const result = await tx.execute('DELETE FROM users WHERE uuid = ?', [
|
||||
uuid,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new APIError(constants.ErrorCode.USER_NOT_FOUND);
|
||||
const result = await tx.execute(
|
||||
'SELECT password_hash FROM users WHERE uuid = ?',
|
||||
[uuid]
|
||||
);
|
||||
if (!result.rowCount) {
|
||||
throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS);
|
||||
}
|
||||
const isValid = await this.verifyUserPassword(
|
||||
password,
|
||||
result.rows[0].password_hash
|
||||
);
|
||||
if (!isValid) {
|
||||
throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS);
|
||||
}
|
||||
|
||||
await tx.execute('DELETE FROM users WHERE uuid = ?', [uuid]);
|
||||
|
||||
await tx.commit();
|
||||
committed = true;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './interface';
|
||||
@@ -0,0 +1,217 @@
|
||||
import { StremThru, StremThruError } from 'stremthru';
|
||||
import { constants, Env, ServiceId, createLogger } from '../utils';
|
||||
import { TorboxApi } from '@torbox/torbox-api';
|
||||
import { z } from 'zod';
|
||||
import { FileParser } from '../parser';
|
||||
import { findMatchingFileInTorrent, isVideoFile } from './utils';
|
||||
|
||||
const logger = createLogger('debrid');
|
||||
|
||||
export type DebridErrorCode = 'NO_MATCHING_FILE';
|
||||
export class DebridError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: DebridErrorCode
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const getProvider = (
|
||||
storeName: ServiceId,
|
||||
storeCredential: string,
|
||||
clientIp?: string
|
||||
): StremThru => {
|
||||
return new StremThru({
|
||||
baseUrl: Env.BUILTIN_STREMTHRU_URL,
|
||||
userAgent: Env.DEFAULT_USER_AGENT,
|
||||
auth: {
|
||||
store: storeName,
|
||||
token: storeCredential,
|
||||
},
|
||||
clientIp,
|
||||
timeout: 20000,
|
||||
});
|
||||
};
|
||||
|
||||
type StoreAuth = {
|
||||
storeName: ServiceId;
|
||||
storeCredential: string;
|
||||
};
|
||||
|
||||
export const StoreAuthSchema = z.object({
|
||||
storeName: z.enum(constants.SERVICES),
|
||||
storeCredential: z.string(),
|
||||
});
|
||||
|
||||
const TorrentPlaybackInfoSchema = z.object({
|
||||
type: z.literal('torrent'),
|
||||
hash: z.string(),
|
||||
magnet: z.string().optional(),
|
||||
index: z.number().optional(),
|
||||
season: z.string().optional(),
|
||||
episode: z.string().optional(),
|
||||
});
|
||||
|
||||
const UsenetPlaybackInfoSchema = z.object({
|
||||
type: z.literal('usenet'),
|
||||
nzb: z.string(),
|
||||
season: z.string().optional(),
|
||||
episode: z.string().optional(),
|
||||
});
|
||||
|
||||
export const PlaybackInfoSchema = z.discriminatedUnion('type', [
|
||||
TorrentPlaybackInfoSchema,
|
||||
UsenetPlaybackInfoSchema,
|
||||
]);
|
||||
|
||||
type PlaybackInfo = z.infer<typeof PlaybackInfoSchema>;
|
||||
|
||||
export class DebridInterface {
|
||||
private readonly stremthru: StremThru;
|
||||
private readonly torboxApi: TorboxApi | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly storeAuth: StoreAuth,
|
||||
private readonly clientIp?: string
|
||||
) {
|
||||
const { storeName, storeCredential } = storeAuth;
|
||||
this.stremthru = getProvider(storeName, storeCredential, clientIp);
|
||||
if (storeName === 'torbox') {
|
||||
this.torboxApi = new TorboxApi({
|
||||
token: storeCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async checkMagnets(magnets: string[], sid?: string) {
|
||||
return await this.stremthru.store.checkMagnet({
|
||||
magnet: magnets,
|
||||
sid,
|
||||
});
|
||||
}
|
||||
|
||||
public async resolve(
|
||||
playbackInfo: PlaybackInfo,
|
||||
filename: string
|
||||
): Promise<string | undefined> {
|
||||
if (playbackInfo.type === 'torrent') {
|
||||
logger.debug(`Resolving torrent ${playbackInfo.hash}`);
|
||||
return await this.resolveTorrent(playbackInfo, filename);
|
||||
} else {
|
||||
logger.debug(`Resolving usenet ${playbackInfo.nzb}`);
|
||||
return await this.resolveUsenet(playbackInfo, filename);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveTorrent(
|
||||
playbackInfo: PlaybackInfo & { type: 'torrent' },
|
||||
filename: string
|
||||
) {
|
||||
let { hash, index, season, episode } = playbackInfo;
|
||||
if (index === -1) {
|
||||
index = undefined;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Adding magnet to ${this.storeAuth.storeName} for ${playbackInfo.magnet || `magnet:?xt=urn:btih:${hash}`}`
|
||||
);
|
||||
const magnet = await this.stremthru.store.addMagnet({
|
||||
magnet: playbackInfo.magnet || `magnet:?xt=urn:btih:${hash}`,
|
||||
});
|
||||
|
||||
if (magnet.data.status !== 'downloaded') {
|
||||
// not cached, cannot be played
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// parse files first
|
||||
const files = magnet.data.files.map((file) => {
|
||||
return {
|
||||
parsed: FileParser.parse(file.name),
|
||||
isVideo: isVideoFile(file.name),
|
||||
index: file.index,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
link: file.link,
|
||||
path: file.path,
|
||||
};
|
||||
});
|
||||
|
||||
const requestedTitle = FileParser.parse(magnet.data.name).title;
|
||||
|
||||
const file = findMatchingFileInTorrent(
|
||||
files,
|
||||
index,
|
||||
filename,
|
||||
requestedTitle,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
|
||||
if (!file || !file.link) {
|
||||
throw new DebridError('No matching file found', 'NO_MATCHING_FILE');
|
||||
}
|
||||
|
||||
logger.debug(`Found matching file`, {
|
||||
season,
|
||||
episode,
|
||||
requestedTitle,
|
||||
chosenFile: file.name,
|
||||
availableFiles: `[${files.map((file) => file.name).join(', ')}]`,
|
||||
});
|
||||
|
||||
const link = await this.stremthru.store.generateLink({
|
||||
link: file.link,
|
||||
clientIp: this.clientIp,
|
||||
});
|
||||
|
||||
return link.data.link;
|
||||
}
|
||||
|
||||
private async resolveUsenet(
|
||||
playbackInfo: PlaybackInfo & { type: 'usenet' },
|
||||
filename: string
|
||||
): Promise<string | undefined> {
|
||||
const { nzb } = playbackInfo;
|
||||
|
||||
if (!this.torboxApi) {
|
||||
throw new Error('Torbox API not available');
|
||||
}
|
||||
logger.debug(`Creating usenet download for ${nzb}`);
|
||||
|
||||
const nzbFile = await this.torboxApi.usenet.createUsenetDownload('v1', {
|
||||
link: nzb,
|
||||
});
|
||||
|
||||
logger.debug(`Created usenet download for ${nzb}: ${nzbFile.data?.detail}`);
|
||||
if (
|
||||
nzbFile.data?.detail &&
|
||||
!nzbFile.data.detail.includes('Using cached download.')
|
||||
) {
|
||||
logger.debug(
|
||||
`Usenet download failed detected to not be cached, returning undefined`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (nzbFile.data?.error) {
|
||||
throw new Error(
|
||||
`Usenet download failed: ${nzbFile.data.error} ${nzbFile.data.detail}`
|
||||
);
|
||||
}
|
||||
|
||||
const link = await this.torboxApi.usenet.requestDownloadLink1('v1', {
|
||||
usenetId: nzbFile.data?.data?.usenetdownloadId,
|
||||
userIp: this.clientIp,
|
||||
redirect: 'false',
|
||||
token: this.storeAuth.storeCredential,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`Requested usenet download link for ${nzb}: ${link.data} ${link.metadata.status}`
|
||||
);
|
||||
|
||||
return link.data ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ParsedFile } from '../db/schemas';
|
||||
|
||||
interface FileWithParsedInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
index: number;
|
||||
parsed: ParsedFile;
|
||||
isVideo: boolean;
|
||||
path?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export function findMatchingFileInTorrent(
|
||||
files: FileWithParsedInfo[],
|
||||
chosenIndex?: number,
|
||||
requestedFilename?: string,
|
||||
requestedTitle?: string,
|
||||
season?: string,
|
||||
episode?: string
|
||||
): FileWithParsedInfo | null {
|
||||
for (const file of files) {
|
||||
if (!file.isVideo || file.name?.includes('sample')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (requestedFilename) {
|
||||
if (file.name === requestedFilename) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
if (season || episode) {
|
||||
if (
|
||||
(requestedTitle ? file.parsed.title === requestedTitle : true) &&
|
||||
((season && !episode && file.parsed.season === Number(season)) ||
|
||||
(episode && !season && file.parsed.episode === Number(episode)) ||
|
||||
(season &&
|
||||
episode &&
|
||||
file.parsed.season === Number(season) &&
|
||||
file.parsed.episode === Number(episode)))
|
||||
) {
|
||||
return file;
|
||||
}
|
||||
} else {
|
||||
if (requestedTitle) {
|
||||
if (file.parsed.title === requestedTitle) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chosenIndex) {
|
||||
const fileIdx = files.find((file) => file.index === chosenIndex);
|
||||
if (fileIdx) return fileIdx;
|
||||
}
|
||||
|
||||
if (files.length > 0 && files.some((file) => file.link)) {
|
||||
return files
|
||||
.filter((file) => file.link)
|
||||
.reduce((largest, current) =>
|
||||
current.size > largest.size ? current : largest
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
const videoExtensions = [
|
||||
'.3g2',
|
||||
'.3gp',
|
||||
'.amv',
|
||||
'.asf',
|
||||
'.avi',
|
||||
'.drc',
|
||||
'.f4a',
|
||||
'.f4b',
|
||||
'.f4p',
|
||||
'.f4v',
|
||||
'.flv',
|
||||
'.gif',
|
||||
'.gifv',
|
||||
'.m2v',
|
||||
'.m4p',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp2',
|
||||
'.mp4',
|
||||
'.mpg',
|
||||
'.mpeg',
|
||||
'.mpv',
|
||||
'.mng',
|
||||
'.mpe',
|
||||
'.mxf',
|
||||
'.nsv',
|
||||
'.ogg',
|
||||
'.ogv',
|
||||
'.qt',
|
||||
'.rm',
|
||||
'.rmvb',
|
||||
'.roq',
|
||||
'.svi',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
'.yuv',
|
||||
'.m3u8',
|
||||
'.m2ts',
|
||||
];
|
||||
return videoExtensions.some((ext) => filename.endsWith(ext));
|
||||
}
|
||||
@@ -4,4 +4,12 @@ export * from './main';
|
||||
export * from './parser';
|
||||
export * from './formatters';
|
||||
export * from './transformers';
|
||||
export * from './debrid';
|
||||
export {
|
||||
TorBoxSearchAddon,
|
||||
GDriveAddon,
|
||||
GoogleOAuth,
|
||||
GDriveAPI,
|
||||
TorBoxSearchAddonError,
|
||||
} from './builtins';
|
||||
export { PresetManager } from './presets';
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
maskSensitiveInfo,
|
||||
Cache,
|
||||
ExtrasParser,
|
||||
TMDBMetadata,
|
||||
Metadata,
|
||||
makeUrlLogSafe,
|
||||
} from './utils';
|
||||
import { Wrapper } from './wrapper';
|
||||
@@ -41,6 +39,7 @@ import {
|
||||
StreamUtils,
|
||||
} from './streams';
|
||||
import { getAddonName } from './utils/general';
|
||||
import { TMDBMetadata, TMDBMetadataResponse } from './metadata/tmdb';
|
||||
const logger = createLogger('core');
|
||||
|
||||
const shuffleCache = Cache.getInstance<string, MetaPreview[]>('shuffle');
|
||||
@@ -1069,7 +1068,7 @@ export class AIOStreams {
|
||||
}
|
||||
|
||||
private validateAddon(addon: Addon) {
|
||||
const manifestUrl = new URL(addon.manifestUrl);
|
||||
const manifestUrl = new URL(addon.manifestUrl, Env.BASE_URL);
|
||||
const baseUrl = Env.BASE_URL ? new URL(Env.BASE_URL) : undefined;
|
||||
if (this.userData.uuid && addon.manifestUrl.includes(this.userData.uuid)) {
|
||||
logger.warn(
|
||||
@@ -1082,6 +1081,7 @@ export class AIOStreams {
|
||||
((baseUrl && manifestUrl.host === baseUrl.host) ||
|
||||
(manifestUrl.host.startsWith('localhost') &&
|
||||
manifestUrl.port === Env.PORT.toString())) &&
|
||||
!manifestUrl.pathname.startsWith('/builtins') &&
|
||||
Env.DISABLE_SELF_SCRAPING === true
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -1155,11 +1155,13 @@ export class AIOStreams {
|
||||
});
|
||||
}
|
||||
|
||||
private async getMetadata(id: string): Promise<Metadata | undefined> {
|
||||
private async getMetadata(
|
||||
id: string
|
||||
): Promise<TMDBMetadataResponse | undefined> {
|
||||
try {
|
||||
const metadata = await new TMDBMetadata(
|
||||
this.userData.tmdbAccessToken
|
||||
).getMetadata(id, 'series');
|
||||
const metadata = await new TMDBMetadata({
|
||||
accessToken: this.userData.tmdbAccessToken,
|
||||
}).getMetadata(id, 'series');
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
logger.warn(`Error getting metadata for ${id}`, {
|
||||
@@ -1172,7 +1174,7 @@ export class AIOStreams {
|
||||
private _getNextEpisode(
|
||||
currentSeason: number,
|
||||
currentEpisode: number,
|
||||
metadata?: Metadata
|
||||
metadata?: TMDBMetadataResponse
|
||||
): {
|
||||
season: number;
|
||||
episode: number;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from 'zod';
|
||||
import { Cache, makeRequest, Env, TYPES } from '../utils';
|
||||
import { Wrapper } from '../wrapper';
|
||||
import { Metadata } from './utils';
|
||||
|
||||
const IMDBSuggestionSchema = z.object({
|
||||
d: z.array(
|
||||
z.object({
|
||||
i: z.object({
|
||||
height: z.number(),
|
||||
imageUrl: z.string(),
|
||||
width: z.number(),
|
||||
}),
|
||||
id: z.string(),
|
||||
l: z.string(), // title
|
||||
q: z.string(), // 'feature' |
|
||||
qid: z.string(), // e.g. 'movie',
|
||||
rank: z.number(),
|
||||
s: z.string(), // comma separated cast
|
||||
y: z.number(), // year
|
||||
yr: z.string().optional(),
|
||||
})
|
||||
),
|
||||
q: z.string(),
|
||||
v: z.number(),
|
||||
});
|
||||
|
||||
export class IMDBMetadata {
|
||||
private readonly titleCache: Cache<string, Metadata>;
|
||||
private readonly titleCacheTTL = 7 * 24 * 60 * 60;
|
||||
private readonly IMDB_SUGGESTION_API =
|
||||
'https://v3.sg.media-imdb.com/suggestion/a/';
|
||||
private readonly CINEMETA_URL = 'https://v3-cinemeta.strem.io/manifest.json';
|
||||
public constructor() {
|
||||
this.titleCache = Cache.getInstance('imdb-title');
|
||||
}
|
||||
|
||||
public async getTitleAndYear(id: string, type: string): Promise<Metadata> {
|
||||
if (!id.startsWith('tt')) {
|
||||
throw new Error('IMDB ID must start with "tt"');
|
||||
}
|
||||
try {
|
||||
const imdbSuggestionData = await this.getImdbSuggestionData(id, type);
|
||||
return imdbSuggestionData;
|
||||
} catch (error) {
|
||||
const cinemetaData = await this.getCinemetaData(id, type);
|
||||
if (!cinemetaData.name || !cinemetaData.year) {
|
||||
throw new Error('Cinemeta data is missing title or year');
|
||||
}
|
||||
return {
|
||||
title: cinemetaData.name,
|
||||
year: Number(cinemetaData.releaseInfo?.toString().split('-')[0]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async getImdbSuggestionData(id: string, type: string) {
|
||||
const key = `${id}:${type}`;
|
||||
const cached = this.titleCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const url = `${this.IMDB_SUGGESTION_API}${id}.json`;
|
||||
const response = await makeRequest(url, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const data = IMDBSuggestionSchema.parse(await response.json());
|
||||
const item = data.d.find((item) => item.id === id);
|
||||
if (!item) {
|
||||
throw new Error(`IMDB item not found for id: ${id}`);
|
||||
}
|
||||
const title = item.l;
|
||||
const year = item.y;
|
||||
this.titleCache.set(key, { title, year }, this.titleCacheTTL);
|
||||
return { title, year };
|
||||
}
|
||||
|
||||
private async getCinemetaData(id: string, type: string) {
|
||||
const cinemeta = new Wrapper({
|
||||
instanceId: 'cinemeta',
|
||||
preset: {
|
||||
id: 'custom',
|
||||
type: 'custom',
|
||||
options: {
|
||||
id: id,
|
||||
},
|
||||
},
|
||||
manifestUrl: this.CINEMETA_URL,
|
||||
name: 'Cinemeta',
|
||||
timeout: 1000,
|
||||
enabled: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const meta = await cinemeta.getMeta(type, id);
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { AnimeKitsuPreset } from '../presets/animeKitsu';
|
||||
import { Cache } from '../utils';
|
||||
import { Wrapper } from '../wrapper';
|
||||
import { Metadata } from './utils';
|
||||
|
||||
export class KitsuMetadata {
|
||||
private readonly titleCache: Cache<string, Metadata>;
|
||||
private readonly titleCacheTTL = 7 * 24 * 60 * 60;
|
||||
// private readonly kitsuAddon = new Wrapper({
|
||||
// instanceId: 'kitsu',
|
||||
// manifestUrl: 'https://kitsu.strem.io/manifest.json',
|
||||
// enabled: true,
|
||||
// preset: {
|
||||
// type: 'anime-kitsu',
|
||||
// id: 'kitsu',
|
||||
// options: {},
|
||||
// },
|
||||
// name: 'Kitsu',
|
||||
// timeout: 10000,
|
||||
// });
|
||||
|
||||
public constructor() {
|
||||
this.titleCache = Cache.getInstance('kitsu-title');
|
||||
}
|
||||
|
||||
public async getMetadata(id: string, type: string): Promise<Metadata> {
|
||||
const [kitsuAddon] = await AnimeKitsuPreset.generateAddons(
|
||||
{
|
||||
sortCriteria: { global: [] },
|
||||
formatter: { id: 'gdrive' },
|
||||
presets: [],
|
||||
},
|
||||
{}
|
||||
);
|
||||
if (!id.startsWith('kitsu')) {
|
||||
throw new Error('Kitsu ID must start with "kitsu"');
|
||||
}
|
||||
const meta = await new Wrapper(kitsuAddon).getMeta(type, id);
|
||||
if (!meta.name || !meta.releaseInfo) {
|
||||
throw new Error('Kitsu metadata is missing title or year');
|
||||
}
|
||||
return {
|
||||
title: meta.name,
|
||||
titles: [meta.name, ...(meta.aliases ? (meta.aliases as string[]) : [])],
|
||||
year: Number(meta.releaseInfo?.toString().split('-')[0]),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import { Env } from './env';
|
||||
import { Cache } from './cache';
|
||||
import { TYPES } from './constants';
|
||||
import { makeRequest } from './http';
|
||||
|
||||
import { Env, Cache, TYPES, makeRequest } from '../utils';
|
||||
export type ExternalIdType = 'imdb' | 'tmdb' | 'tvdb';
|
||||
|
||||
interface ExternalId {
|
||||
@@ -21,9 +17,9 @@ const ID_CACHE_TTL = 24 * 60 * 60; // 24 hours
|
||||
const TITLE_CACHE_TTL = 7 * 24 * 60 * 60; // 7 days
|
||||
const ACCESS_TOKEN_CACHE_TTL = 2 * 24 * 60 * 60; // 2 day
|
||||
|
||||
export interface Metadata {
|
||||
export interface TMDBMetadataResponse {
|
||||
titles: string[];
|
||||
year?: string;
|
||||
year: string;
|
||||
seasons?: {
|
||||
season_number: number;
|
||||
episode_count: number;
|
||||
@@ -35,16 +31,20 @@ export class TMDBMetadata {
|
||||
private readonly TVDB_ID_REGEX = /^(?:tvdb)[-:](\d+)(?::\d+:\d+)?$/;
|
||||
private readonly IMDB_ID_REGEX = /^(?:tt)(\d+)(?::\d+:\d+)?$/;
|
||||
private readonly idCache: Cache<string, string>;
|
||||
private readonly metadataCache: Cache<string, Metadata>;
|
||||
private readonly accessToken: string;
|
||||
private readonly metadataCache: Cache<string, TMDBMetadataResponse>;
|
||||
private readonly accessToken: string | undefined;
|
||||
private readonly apiKey: string | undefined;
|
||||
private readonly validationCache: Cache<string, boolean>;
|
||||
public constructor(accessToken?: string) {
|
||||
if (!accessToken && !Env.TMDB_ACCESS_TOKEN) {
|
||||
throw new Error('TMDB Access Token is not set');
|
||||
public constructor(auth?: { accessToken?: string; apiKey?: string }) {
|
||||
if (!auth?.accessToken && !Env.TMDB_ACCESS_TOKEN && !auth?.apiKey) {
|
||||
throw new Error('TMDB Access Token or API Key is not set');
|
||||
}
|
||||
this.accessToken = (accessToken || Env.TMDB_ACCESS_TOKEN)!;
|
||||
this.accessToken = auth?.accessToken || Env.TMDB_ACCESS_TOKEN;
|
||||
this.apiKey = auth?.apiKey;
|
||||
this.idCache = Cache.getInstance<string, string>('tmdb_id_conversion');
|
||||
this.metadataCache = Cache.getInstance<string, Metadata>('tmdb_metadata');
|
||||
this.metadataCache = Cache.getInstance<string, TMDBMetadataResponse>(
|
||||
'tmdb_metadata'
|
||||
);
|
||||
this.validationCache = Cache.getInstance<string, boolean>(
|
||||
'tmdb_validation'
|
||||
);
|
||||
@@ -53,6 +53,7 @@ export class TMDBMetadata {
|
||||
private getHeaders(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ export class TMDBMetadata {
|
||||
|
||||
const url = new URL(API_BASE_URL + FIND_BY_ID_PATH + `/${id.value}`);
|
||||
url.searchParams.set('external_source', `${id.type}_id`);
|
||||
|
||||
this.addSearchParams(url);
|
||||
const response = await makeRequest(url.toString(), {
|
||||
timeout: 10000,
|
||||
headers: this.getHeaders(),
|
||||
@@ -121,17 +122,11 @@ export class TMDBMetadata {
|
||||
public async getMetadata(
|
||||
id: string,
|
||||
type: (typeof TYPES)[number]
|
||||
): Promise<Metadata> {
|
||||
): Promise<TMDBMetadataResponse> {
|
||||
if (!['movie', 'series', 'anime'].includes(type)) {
|
||||
return { titles: [], year: undefined };
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
|
||||
let metadata: Metadata = {
|
||||
titles: [],
|
||||
year: undefined,
|
||||
seasons: undefined,
|
||||
};
|
||||
|
||||
const externalId = this.parseExternalId(id);
|
||||
if (!externalId) {
|
||||
throw new Error(
|
||||
@@ -145,7 +140,7 @@ export class TMDBMetadata {
|
||||
const cacheKey = `${tmdbId}:${type}`;
|
||||
const cachedMetadata = this.metadataCache.get(cacheKey);
|
||||
if (cachedMetadata) {
|
||||
metadata = cachedMetadata;
|
||||
return cachedMetadata;
|
||||
}
|
||||
|
||||
// Fetch primary title from details endpoint
|
||||
@@ -154,7 +149,7 @@ export class TMDBMetadata {
|
||||
(type === 'movie' ? MOVIE_DETAILS_PATH : TV_DETAILS_PATH) +
|
||||
`/${tmdbId}`
|
||||
);
|
||||
|
||||
this.addSearchParams(detailsUrl);
|
||||
const detailsResponse = await makeRequest(detailsUrl.toString(), {
|
||||
timeout: 10000,
|
||||
headers: this.getHeaders(),
|
||||
@@ -185,7 +180,7 @@ export class TMDBMetadata {
|
||||
`/${tmdbId}` +
|
||||
ALTERNATIVE_TITLES_PATH
|
||||
);
|
||||
|
||||
this.addSearchParams(altTitlesUrl);
|
||||
const altTitlesResponse = await makeRequest(altTitlesUrl.toString(), {
|
||||
timeout: 10000,
|
||||
headers: this.getHeaders(),
|
||||
@@ -206,21 +201,33 @@ export class TMDBMetadata {
|
||||
// Combine primary title with alternative titles, ensuring no duplicates
|
||||
const allTitles = [primaryTitle, ...alternativeTitles];
|
||||
const uniqueTitles = [...new Set(allTitles)];
|
||||
metadata.titles = uniqueTitles;
|
||||
metadata.year = year;
|
||||
metadata.seasons = seasons;
|
||||
const metadata: TMDBMetadataResponse = {
|
||||
titles: uniqueTitles,
|
||||
year,
|
||||
seasons,
|
||||
};
|
||||
// Cache the result
|
||||
this.metadataCache.set(cacheKey, metadata, TITLE_CACHE_TTL);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private addSearchParams(url: URL) {
|
||||
if (this.apiKey) {
|
||||
url.searchParams.set('api_key', this.apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async validateAccessToken() {
|
||||
const cacheKey = this.accessToken;
|
||||
const cacheKey = this.accessToken || this.apiKey;
|
||||
if (!cacheKey) {
|
||||
throw new Error('TMDB Access Token or API Key is not set');
|
||||
}
|
||||
const cachedResult = this.validationCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
const url = new URL(API_BASE_URL + '/authentication');
|
||||
this.addSearchParams(url);
|
||||
const validationResponse = await makeRequest(url.toString(), {
|
||||
timeout: 10000,
|
||||
headers: this.getHeaders(),
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Metadata {
|
||||
title: string;
|
||||
titles?: string[];
|
||||
year: number;
|
||||
seasons?: {
|
||||
season_number: number;
|
||||
episode_count: number;
|
||||
}[];
|
||||
}
|
||||
@@ -60,8 +60,7 @@ export const PARSE_REGEX: PARSE_REGEX = {
|
||||
'144p': createRegex('(bd|hd|m)?(144(p|i)?)'),
|
||||
},
|
||||
qualities: {
|
||||
'BluRay REMUX':
|
||||
/((?<=remux.*)[ .\-_](blu[ .\-_]?ray))|((blu[ .\-_]?ray)[ .\-_](?=.*remux))|((?<![^\s\[(_\-.,])(bd|br|b|uhd)[ .\\-_]?remux(?=\s\)\]_.\-,]|$))/i,
|
||||
'BluRay REMUX': /(remux.*bluray|bluray.*remux|(bd|br|b|uhd)[.\-_]?remux)/i,
|
||||
BluRay: createRegex(
|
||||
'blu[ .\\-_]?ray|((bd|br|b)[ .\\-_]?(rip|r)?)(?![ .\\-_]?remux)'
|
||||
),
|
||||
|
||||
@@ -11,6 +11,10 @@ class StreamParser {
|
||||
pattern: /invalid\s+\w+\s+(account|apikey|token)/i,
|
||||
message: 'Invalid account or apikey or token',
|
||||
},
|
||||
{
|
||||
pattern: /public\s+rate[-\s]?limit\s+exceeded/i,
|
||||
message: 'Public rate limit exceeded',
|
||||
},
|
||||
];
|
||||
}
|
||||
protected get filenameRegex(): RegExp | undefined {
|
||||
@@ -53,6 +57,9 @@ class StreamParser {
|
||||
constructor(protected readonly addon: Addon) {}
|
||||
|
||||
parse(stream: Stream): ParsedStream | { skip: true } {
|
||||
if (this.shouldSkip(stream)) {
|
||||
return { skip: true };
|
||||
}
|
||||
stream.description = stream.description || stream.title;
|
||||
|
||||
let parsedStream: ParsedStream = {
|
||||
@@ -138,6 +145,10 @@ class StreamParser {
|
||||
return url;
|
||||
}
|
||||
|
||||
protected shouldSkip(stream: Stream): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected raiseErrorIfNecessary(
|
||||
stream: Stream,
|
||||
currentParsedStream: ParsedStream
|
||||
|
||||
@@ -37,6 +37,14 @@ class ArgentinaTvStreamParser extends StreamParser {
|
||||
): string | undefined {
|
||||
return `${stream.name} - ${stream.description}`;
|
||||
}
|
||||
|
||||
protected getStreamType(
|
||||
stream: Stream,
|
||||
service: ParsedStream['service'],
|
||||
currentParsedStream: ParsedStream
|
||||
): ParsedStream['type'] {
|
||||
return constants.LIVE_STREAM_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
export class ArgentinaTVPreset extends Preset {
|
||||
|
||||
@@ -39,6 +39,14 @@ class DebridioTvStreamParser extends StreamParser {
|
||||
): string | undefined {
|
||||
return `${stream.name} - ${stream.description}`;
|
||||
}
|
||||
|
||||
protected getStreamType(
|
||||
stream: Stream,
|
||||
service: ParsedStream['service'],
|
||||
currentParsedStream: ParsedStream
|
||||
): ParsedStream['type'] {
|
||||
return constants.LIVE_STREAM_TYPE;
|
||||
}
|
||||
}
|
||||
export class DebridioTvPreset extends Preset {
|
||||
static override getParser(): typeof StreamParser {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { baseOptions, Preset } from './preset';
|
||||
import { constants, Env } from '../utils';
|
||||
import {
|
||||
PresetMetadata,
|
||||
Option,
|
||||
Addon,
|
||||
UserData,
|
||||
ParsedStream,
|
||||
Stream,
|
||||
} from '../db';
|
||||
import { StreamParser } from '../parser';
|
||||
import { GDriveAPI } from '../builtins/gdrive';
|
||||
import { GoogleOAuth } from '../builtins/gdrive/api';
|
||||
|
||||
export class GDriveParser extends StreamParser {
|
||||
protected override raiseErrorIfNecessary(
|
||||
stream: Stream,
|
||||
currentParsedStream: ParsedStream
|
||||
): void {
|
||||
if (stream.name?.startsWith('[❌]')) {
|
||||
throw new Error(stream.description ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
protected getDuration(
|
||||
stream: Stream,
|
||||
currentParsedStream: ParsedStream
|
||||
): number | undefined {
|
||||
return (stream as any).duration as number | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class GDrivePreset extends Preset {
|
||||
static override getParser(): typeof StreamParser {
|
||||
return GDriveParser;
|
||||
}
|
||||
|
||||
static override get METADATA(): PresetMetadata {
|
||||
const supportedResources = [
|
||||
constants.STREAM_RESOURCE,
|
||||
constants.CATALOG_RESOURCE,
|
||||
constants.META_RESOURCE,
|
||||
];
|
||||
|
||||
const options: Option[] = [
|
||||
{
|
||||
id: 'refreshToken',
|
||||
name: 'Authorise',
|
||||
description: 'Authorise AIOStreams to access your Google Drive',
|
||||
type: 'oauth',
|
||||
required: true,
|
||||
oauth: {
|
||||
authorisationUrl: GoogleOAuth.getAuthorisationUrl(),
|
||||
oauthResultField: {
|
||||
name: 'Refresh Token',
|
||||
description: 'The refresh token for the Google Drive API',
|
||||
},
|
||||
},
|
||||
},
|
||||
...baseOptions(
|
||||
'Stremio GDrive',
|
||||
supportedResources,
|
||||
Env.BUILTIN_GDRIVE_TIMEOUT
|
||||
).filter((option) => option.id !== 'url'),
|
||||
{
|
||||
id: 'metadataSource',
|
||||
name: 'Metadata Source',
|
||||
description: 'The source of metadata to use for the addon.',
|
||||
type: 'select',
|
||||
default: 'imdb',
|
||||
options: [
|
||||
{
|
||||
label: 'IMDB',
|
||||
value: 'imdb',
|
||||
},
|
||||
{
|
||||
label: 'TMDB',
|
||||
value: 'tmdb',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'catalogSort',
|
||||
name: 'Catalog Sort',
|
||||
description:
|
||||
'The sort order of the catalog. Supports multiple values. Select the sort criteria in the order you want them to be applied. Default is "Created Time (Descending)". For more info, see the [API Reference](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list)',
|
||||
type: 'multi-select',
|
||||
default: ['createdTime_desc'],
|
||||
options: [
|
||||
{ label: 'Created Time (Ascending)', value: 'createdTime_asc' },
|
||||
{ label: 'Created Time (Descending)', value: 'createdTime_desc' },
|
||||
{ label: 'Modified Time (Ascending)', value: 'modifiedTime_asc' },
|
||||
{ label: 'Modified Time (Descending)', value: 'modifiedTime_desc' },
|
||||
{
|
||||
label: 'Modified By Me Time (Ascending)',
|
||||
value: 'modifiedByMeTime_asc',
|
||||
},
|
||||
{
|
||||
label: 'Modified By Me Time (Descending)',
|
||||
value: 'modifiedByMeTime_desc',
|
||||
},
|
||||
{ label: 'Last Viewed (Ascending)', value: 'viewedByMeTime_asc' },
|
||||
{ label: 'Last Viewed (Descending)', value: 'viewedByMeTime_desc' },
|
||||
{
|
||||
label: 'Shared With Me (Ascending)',
|
||||
value: 'sharedWithMeTime_asc',
|
||||
},
|
||||
{
|
||||
label: 'Shared With Me (Descending)',
|
||||
value: 'sharedWithMeTime_desc',
|
||||
},
|
||||
{ label: 'Name (A-Z)', value: 'name_asc' },
|
||||
{ label: 'Name (Z-A)', value: 'name_desc' },
|
||||
{ label: 'Name Natural (A-Z)', value: 'name_natural_asc' },
|
||||
{ label: 'Name Natural (Z-A)', value: 'name_natural_desc' },
|
||||
{ label: 'Recent First', value: 'recency_desc' },
|
||||
{ label: 'Oldest First', value: 'recency_asc' },
|
||||
{ label: 'Starred (First)', value: 'starred_desc' },
|
||||
{ label: 'Starred (Last)', value: 'starred_asc' },
|
||||
{ label: 'Folders (First)', value: 'folder_desc' },
|
||||
{ label: 'Folders (Last)', value: 'folder_asc' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'includeAudioFiles',
|
||||
name: 'Include Audio Files',
|
||||
description: 'Whether to include audio files in the search',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
ID: 'stremio-gdrive',
|
||||
NAME: 'Stremio GDrive',
|
||||
DESCRIPTION: 'Access content from your Google Drive in Stremio!',
|
||||
LOGO: `https://upload.wikimedia.org/wikipedia/commons/thumb/1/12/Google_Drive_icon_%282020%29.svg/1200px-Google_Drive_icon_%282020%29.svg.png`,
|
||||
URL: `${Env.INTERNAL_URL}/builtins/gdrive`,
|
||||
TIMEOUT: Env.BUILTIN_GDRIVE_TIMEOUT || Env.DEFAULT_TIMEOUT,
|
||||
USER_AGENT: Env.BUILTIN_GDRIVE_USER_AGENT || Env.DEFAULT_USER_AGENT,
|
||||
SUPPORTED_RESOURCES: supportedResources,
|
||||
SUPPORTED_STREAM_TYPES: [constants.HTTP_STREAM_TYPE],
|
||||
SUPPORTED_SERVICES: [],
|
||||
OPTIONS: options,
|
||||
BUILTIN: true,
|
||||
};
|
||||
}
|
||||
|
||||
static async generateAddons(
|
||||
userData: UserData,
|
||||
options: Record<string, any>
|
||||
): Promise<Addon[]> {
|
||||
return [this.generateAddon(userData, options)];
|
||||
}
|
||||
|
||||
private static generateAddon(
|
||||
userData: UserData,
|
||||
options: Record<string, any>
|
||||
): Addon {
|
||||
return {
|
||||
name: options.name || this.METADATA.NAME,
|
||||
manifestUrl: this.generateManifestUrl(userData, options),
|
||||
enabled: true,
|
||||
resources: options.resources || this.METADATA.SUPPORTED_RESOURCES,
|
||||
timeout: options.timeout || this.METADATA.TIMEOUT,
|
||||
preset: {
|
||||
id: '',
|
||||
type: this.METADATA.ID,
|
||||
options: options,
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': this.METADATA.USER_AGENT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static generateManifestUrl(
|
||||
userData: UserData,
|
||||
options: Record<string, any>
|
||||
) {
|
||||
if (!options.refreshToken) {
|
||||
throw new Error(
|
||||
`${this.METADATA.NAME} requires a refresh token to be set. Please authorise the addon.`
|
||||
);
|
||||
}
|
||||
if (options.metadataSource === 'tmdb') {
|
||||
if (!userData.tmdbAccessToken && !Env.TMDB_ACCESS_TOKEN) {
|
||||
throw new Error(
|
||||
`${this.METADATA.NAME} requires a TMDB Access Token when using TMDB as the metadata source`
|
||||
);
|
||||
}
|
||||
}
|
||||
const config = this.base64EncodeJSON({
|
||||
refreshToken: options.refreshToken,
|
||||
metadataSource: options.metadataSource || 'imdb',
|
||||
includeAudioFiles: options.includeAudioFiles ?? false,
|
||||
tmdbReadAccessToken:
|
||||
options.metadataSource === 'tmdb'
|
||||
? userData.tmdbAccessToken
|
||||
: undefined,
|
||||
});
|
||||
return `${this.METADATA.URL}/${config}/manifest.json`;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ class MediaFusionStreamParser extends StreamParser {
|
||||
if (stream.description?.includes('Content Warning')) {
|
||||
throw new Error(stream.description);
|
||||
}
|
||||
super.raiseErrorIfNecessary(stream, currentParsedStream);
|
||||
}
|
||||
|
||||
protected override shouldSkip(stream: Stream): boolean {
|
||||
return (
|
||||
stream.description?.includes('🚫 Streams Found\n⚙️ Filtered') ?? false
|
||||
);
|
||||
}
|
||||
|
||||
protected override get indexerEmojis(): string[] {
|
||||
|
||||
@@ -46,8 +46,13 @@ import { AIOSubtitlePreset } from './aiosubtitle';
|
||||
import { SubHeroPreset } from './subhero';
|
||||
import { StreamAsiaPreset } from './streamasia';
|
||||
import { MoreLikeThisPreset } from './moreLikeThis';
|
||||
import { GDriveAPI } from '../builtins/gdrive';
|
||||
import { GDrivePreset } from './gdrive';
|
||||
import { GoogleOAuth } from '../builtins/gdrive/api';
|
||||
import { TorBoxSearchPreset } from './torboxSearch';
|
||||
import { Env } from '../utils/env';
|
||||
|
||||
const PRESET_LIST: string[] = [
|
||||
let PRESET_LIST: string[] = [
|
||||
'custom',
|
||||
'torrentio',
|
||||
'comet',
|
||||
@@ -62,6 +67,7 @@ const PRESET_LIST: string[] = [
|
||||
'fkstream',
|
||||
'debridio',
|
||||
'torbox',
|
||||
'torbox-search',
|
||||
'easynews',
|
||||
'easynewsPlus',
|
||||
'easynewsPlusPlus',
|
||||
@@ -69,6 +75,9 @@ const PRESET_LIST: string[] = [
|
||||
'nuvio-streams',
|
||||
'webstreamr',
|
||||
'streamasia',
|
||||
Env.BUILTIN_GDRIVE_CLIENT_ID && Env.BUILTIN_GDRIVE_CLIENT_SECRET
|
||||
? 'stremio-gdrive'
|
||||
: '',
|
||||
'usa-tv',
|
||||
'argentina-tv',
|
||||
'debridio-tv',
|
||||
@@ -95,12 +104,12 @@ const PRESET_LIST: string[] = [
|
||||
'aiosubtitle',
|
||||
'more-like-this',
|
||||
'aiostreams',
|
||||
];
|
||||
].filter(Boolean);
|
||||
|
||||
export class PresetManager {
|
||||
static getPresetList(): PresetMinimalMetadata[] {
|
||||
return PRESET_LIST.map((presetId) => this.fromId(presetId).METADATA).map(
|
||||
(metadata) => ({
|
||||
(metadata: PresetMetadata) => ({
|
||||
ID: metadata.ID,
|
||||
NAME: metadata.NAME,
|
||||
LOGO: metadata.LOGO,
|
||||
@@ -110,6 +119,7 @@ export class PresetManager {
|
||||
SUPPORTED_STREAM_TYPES: metadata.SUPPORTED_STREAM_TYPES,
|
||||
SUPPORTED_SERVICES: metadata.SUPPORTED_SERVICES,
|
||||
OPTIONS: metadata.OPTIONS,
|
||||
BUILTIN: metadata.BUILTIN,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -210,6 +220,10 @@ export class PresetManager {
|
||||
return StreamAsiaPreset;
|
||||
case 'more-like-this':
|
||||
return MoreLikeThisPreset;
|
||||
case 'stremio-gdrive':
|
||||
return GDrivePreset;
|
||||
case 'torbox-search':
|
||||
return TorBoxSearchPreset;
|
||||
default:
|
||||
throw new Error(`Preset ${id} not found`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Addon, Option, UserData, Resource, Stream } from '../db';
|
||||
import { Addon, Option, UserData, Resource, Stream, ParsedStream } from '../db';
|
||||
import { baseOptions, Preset } from './preset';
|
||||
import { Env } from '../utils';
|
||||
import { constants, ServiceId } from '../utils';
|
||||
@@ -32,6 +32,32 @@ class StremthruStoreStreamParser extends StreamParser {
|
||||
}
|
||||
return url;
|
||||
}
|
||||
// ensure release groups aren't misidentified as indexers
|
||||
protected override getIndexer(
|
||||
stream: Stream,
|
||||
currentParsedStream: ParsedStream
|
||||
): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected override getFolderSize(
|
||||
stream: Stream,
|
||||
currentParsedStream: ParsedStream
|
||||
): number | undefined {
|
||||
let folderSize = this.calculateBytesFromSizeString(
|
||||
stream.description ?? '',
|
||||
/📦\s*(\d+(\.\d+)?)\s?(KB|MB|GB|TB)/i
|
||||
);
|
||||
if (folderSize && currentParsedStream.size) {
|
||||
if (
|
||||
Math.abs(folderSize - currentParsedStream.size) <=
|
||||
currentParsedStream.size * 0.05
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return folderSize;
|
||||
}
|
||||
}
|
||||
|
||||
export class StremthruStorePreset extends Preset {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Addon, Option, UserData, Resource, Stream, ParsedStream } from '../db';
|
||||
import { Preset, baseOptions } from './preset';
|
||||
import { Env, SERVICE_DETAILS } from '../utils';
|
||||
import { constants, ServiceId } from '../utils';
|
||||
import { StreamParser } from '../parser';
|
||||
|
||||
export class TorboxSearchParser extends StreamParser {
|
||||
override getFolder(stream: Stream): string | undefined {
|
||||
if (!stream.description) {
|
||||
return undefined;
|
||||
}
|
||||
const folderName = stream.description.split('\n')[0];
|
||||
return folderName.trim() || undefined;
|
||||
}
|
||||
|
||||
protected getError(
|
||||
stream: Stream,
|
||||
currentParsedStream: ParsedStream
|
||||
): ParsedStream['error'] | undefined {
|
||||
if (stream.name?.startsWith('[❌]')) {
|
||||
return {
|
||||
title: stream.name.replace('[❌]', ''),
|
||||
description: stream.description || 'Unknown error',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
protected parseServiceData(
|
||||
string: string
|
||||
): ParsedStream['service'] | undefined {
|
||||
return super.parseServiceData(string.replace('TorBox', ''));
|
||||
}
|
||||
|
||||
protected get ageRegex(): RegExp | undefined {
|
||||
return this.getRegexForTextAfterEmojis(['🕒']);
|
||||
}
|
||||
|
||||
protected getStreamType(
|
||||
stream: Stream,
|
||||
service: ParsedStream['service'],
|
||||
currentParsedStream: ParsedStream
|
||||
): ParsedStream['type'] {
|
||||
return (stream as any).behaviorHints?.type === 'usenet'
|
||||
? 'usenet'
|
||||
: super.getStreamType(stream, service, currentParsedStream);
|
||||
}
|
||||
}
|
||||
|
||||
export class TorBoxSearchPreset extends Preset {
|
||||
static override getParser(): typeof StreamParser {
|
||||
return TorboxSearchParser;
|
||||
}
|
||||
|
||||
static override get METADATA() {
|
||||
const supportedServices: ServiceId[] = [
|
||||
constants.REALDEBRID_SERVICE,
|
||||
constants.PREMIUMIZE_SERVICE,
|
||||
constants.ALLDEBRID_SERVICE,
|
||||
constants.TORBOX_SERVICE,
|
||||
constants.EASYDEBRID_SERVICE,
|
||||
constants.DEBRIDLINK_SERVICE,
|
||||
constants.OFFCLOUD_SERVICE,
|
||||
];
|
||||
const supportedResources = [constants.STREAM_RESOURCE];
|
||||
|
||||
const options: Option[] = [
|
||||
{
|
||||
id: 'alert',
|
||||
type: 'alert',
|
||||
name: 'Info',
|
||||
description: 'This addon requires a TorBox API Key to be provided',
|
||||
},
|
||||
...baseOptions(
|
||||
'TorBox Search',
|
||||
supportedResources,
|
||||
Env.BUILTIN_TORBOX_SEARCH_TIMEOUT
|
||||
).filter((option) => option.id !== 'url' && option.id !== 'resources'),
|
||||
{
|
||||
id: 'sources',
|
||||
name: 'Sources',
|
||||
description:
|
||||
'Optionally override the sources that are used. If not specified, then the default sources will be used.',
|
||||
type: 'multi-select',
|
||||
required: false,
|
||||
default: ['torrent'],
|
||||
options: [
|
||||
{
|
||||
value: 'torrent',
|
||||
label: 'Torrent',
|
||||
},
|
||||
{
|
||||
value: 'usenet',
|
||||
label: 'Usenet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Services',
|
||||
description:
|
||||
'Optionally override the services that are used. If not specified, then the services that are enabled and supported will be used.',
|
||||
type: 'multi-select',
|
||||
required: false,
|
||||
options: supportedServices.map((service) => ({
|
||||
value: service,
|
||||
label: constants.SERVICE_DETAILS[service].name,
|
||||
})),
|
||||
default: undefined,
|
||||
emptyIsUndefined: true,
|
||||
},
|
||||
{
|
||||
id: 'userSearchEngines',
|
||||
name: 'Use User Search Engines',
|
||||
description:
|
||||
'Whether to use the user search engines to search for torrents.',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: 'useMultipleInstances',
|
||||
name: 'Use Multiple Instances',
|
||||
description:
|
||||
'Use a different TorBox Search addon for each service when using multiple services, rather than using one instance for all services.',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
ID: 'torbox-search',
|
||||
NAME: 'TorBox Search',
|
||||
LOGO: `https://torbox.app/android-chrome-512x512.png`,
|
||||
URL: `${Env.INTERNAL_URL}/builtins/torbox-search`,
|
||||
TIMEOUT: Env.BUILTIN_TORBOX_SEARCH_TIMEOUT || Env.DEFAULT_TIMEOUT,
|
||||
USER_AGENT:
|
||||
Env.BUILTIN_TORBOX_SEARCH_USER_AGENT || Env.DEFAULT_USER_AGENT,
|
||||
SUPPORTED_SERVICES: supportedServices,
|
||||
REQUIRES_SERVICE: true,
|
||||
DESCRIPTION:
|
||||
'Unofficial debrid/usenet addon for the TorBox Search API, with support for multiple services.',
|
||||
OPTIONS: options,
|
||||
SUPPORTED_STREAM_TYPES: [
|
||||
constants.DEBRID_STREAM_TYPE,
|
||||
constants.USENET_STREAM_TYPE,
|
||||
],
|
||||
SUPPORTED_RESOURCES: [constants.STREAM_RESOURCE],
|
||||
BUILTIN: true,
|
||||
};
|
||||
}
|
||||
|
||||
static async generateAddons(
|
||||
userData: UserData,
|
||||
options: Record<string, any>
|
||||
): Promise<Addon[]> {
|
||||
const usableServices = this.getUsableServices(userData, options.services);
|
||||
|
||||
// if no services are usable, return a single addon with no services
|
||||
if (!usableServices || usableServices.length === 0) {
|
||||
// return [this.generateAddon(userData, options, [])];
|
||||
throw new Error(`${this.METADATA.NAME} requires at least one service`);
|
||||
}
|
||||
|
||||
if (options.useMultipleInstances) {
|
||||
return usableServices.map((service) =>
|
||||
this.generateAddon(userData, options, [service.id])
|
||||
);
|
||||
}
|
||||
|
||||
// return a single addon with all usable services
|
||||
return [
|
||||
this.generateAddon(
|
||||
userData,
|
||||
options,
|
||||
usableServices.map((service) => service.id)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private static generateAddon(
|
||||
userData: UserData,
|
||||
options: Record<string, any>,
|
||||
services: ServiceId[]
|
||||
): Addon {
|
||||
return {
|
||||
name: options.name || this.METADATA.NAME,
|
||||
displayIdentifier: services
|
||||
.map((id) => constants.SERVICE_DETAILS[id].shortName)
|
||||
.join(' | '),
|
||||
identifier:
|
||||
services.length > 1
|
||||
? 'multi'
|
||||
: constants.SERVICE_DETAILS[services[0]].shortName,
|
||||
manifestUrl: this.generateManifestUrl(userData, services, options),
|
||||
enabled: true,
|
||||
resources: this.METADATA.SUPPORTED_RESOURCES,
|
||||
timeout: options.timeout || this.METADATA.TIMEOUT,
|
||||
preset: {
|
||||
id: '',
|
||||
type: this.METADATA.ID,
|
||||
options: options,
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': this.METADATA.USER_AGENT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static generateManifestUrl(
|
||||
userData: UserData,
|
||||
services: ServiceId[],
|
||||
options: Record<string, any>
|
||||
) {
|
||||
let sources = options.sources;
|
||||
|
||||
const torboxApiKey = userData.services?.find(
|
||||
(service) => service.id === constants.TORBOX_SERVICE
|
||||
)?.credentials.apiKey;
|
||||
if (!torboxApiKey) {
|
||||
throw new Error(`${this.METADATA.NAME} requires a TorBox API key`);
|
||||
}
|
||||
|
||||
const config = {
|
||||
sources: sources,
|
||||
torBoxApiKey: torboxApiKey,
|
||||
searchUserEngines: options.userSearchEngines,
|
||||
services: services.map((service) => ({
|
||||
id: service,
|
||||
credential: this.getServiceCredential(service, userData, {
|
||||
[constants.OFFCLOUD_SERVICE]: (credentials: any) =>
|
||||
`${credentials.email}:${credentials.password}`,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
const configString = this.base64EncodeJSON(config);
|
||||
return `${this.METADATA.URL}/${configString}/manifest.json`;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,14 @@ class USATvStreamParser extends StreamParser {
|
||||
): string | undefined {
|
||||
return `${stream.name} - ${stream.description}`;
|
||||
}
|
||||
|
||||
protected getStreamType(
|
||||
stream: Stream,
|
||||
service: ParsedStream['service'],
|
||||
currentParsedStream: ParsedStream
|
||||
): ParsedStream['type'] {
|
||||
return constants.LIVE_STREAM_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
export class USATVPreset extends Preset {
|
||||
|
||||
@@ -147,7 +147,12 @@ export abstract class BaseProxy {
|
||||
return urls;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to generate proxy URLs: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Failed to generate proxy URLs: ${error instanceof Error ? error : String(error)}`,
|
||||
error instanceof Error
|
||||
? Object.fromEntries(
|
||||
Object.entries(error).filter(([key]) => key !== 'stack')
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ import { ParsedStream, UserData } from '../db/schemas';
|
||||
import {
|
||||
createLogger,
|
||||
FeatureControl,
|
||||
Metadata,
|
||||
getTimeTakenSincePoint,
|
||||
TMDBMetadata,
|
||||
constants,
|
||||
} from '../utils';
|
||||
import { TYPES } from '../utils/constants';
|
||||
@@ -14,6 +12,7 @@ import { safeRegexTest } from '../utils/regex';
|
||||
import { StreamType } from '../utils/constants';
|
||||
import { StreamSelector } from '../parser/streamExpression';
|
||||
import StreamUtils from './utils';
|
||||
import { TMDBMetadata, TMDBMetadataResponse } from '../metadata/tmdb';
|
||||
|
||||
const logger = createLogger('filterer');
|
||||
|
||||
@@ -88,12 +87,12 @@ class StreamFilterer {
|
||||
const start = Date.now();
|
||||
const isRegexAllowed = FeatureControl.isRegexAllowed(this.userData);
|
||||
|
||||
let requestedMetadata: Metadata | undefined;
|
||||
let requestedMetadata: TMDBMetadataResponse | undefined;
|
||||
if (this.userData.titleMatching?.enabled && TYPES.includes(type as any)) {
|
||||
try {
|
||||
requestedMetadata = await new TMDBMetadata(
|
||||
this.userData.tmdbAccessToken
|
||||
).getMetadata(id, type as any);
|
||||
requestedMetadata = await new TMDBMetadata({
|
||||
accessToken: this.userData.tmdbAccessToken,
|
||||
}).getMetadata(id, type as any);
|
||||
logger.info(`Fetched metadata for ${id}`, requestedMetadata);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching titles for ${id}: ${error}`);
|
||||
@@ -112,6 +111,7 @@ class StreamFilterer {
|
||||
// const titleMatchingOptions = this.userData.titleMatching;
|
||||
const titleMatchingOptions = {
|
||||
mode: 'exact',
|
||||
yearTolerance: 1,
|
||||
...(this.userData.titleMatching ?? {}),
|
||||
};
|
||||
if (!titleMatchingOptions || !titleMatchingOptions.enabled) {
|
||||
@@ -148,7 +148,10 @@ class StreamFilterer {
|
||||
}
|
||||
const yearMatch =
|
||||
titleMatchingOptions.matchYear && streamYear
|
||||
? requestedMetadata?.year === streamYear
|
||||
? requestedMetadata.year === streamYear ||
|
||||
(titleMatchingOptions.yearTolerance &&
|
||||
Math.abs(Number(requestedMetadata.year) - Number(streamYear)) <=
|
||||
titleMatchingOptions.yearTolerance)
|
||||
: true;
|
||||
|
||||
if (titleMatchingOptions.mode === 'exact') {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { AIOStreams } from '../main';
|
||||
import { Preset, PresetManager } from '../presets';
|
||||
import { createProxy } from '../proxy';
|
||||
import { constants, TMDBMetadata } from '.';
|
||||
import { constants } from '.';
|
||||
import { TMDBMetadata } from '../metadata/tmdb';
|
||||
import { isEncrypted, decryptString, encryptString } from './crypto';
|
||||
import { Env } from './env';
|
||||
import { createLogger, maskSensitiveInfo } from './logger';
|
||||
@@ -347,17 +348,11 @@ export async function validateConfig(
|
||||
);
|
||||
}
|
||||
|
||||
if (config.proxy) {
|
||||
const decryptedProxy = ensureDecrypted(config).proxy;
|
||||
if (decryptedProxy) {
|
||||
config.proxy = await validateProxy(
|
||||
config.proxy,
|
||||
decryptedProxy,
|
||||
skipErrorsFromAddonsOrProxies,
|
||||
decryptValues
|
||||
);
|
||||
}
|
||||
}
|
||||
config.proxy = await validateProxy(
|
||||
config,
|
||||
skipErrorsFromAddonsOrProxies,
|
||||
decryptValues
|
||||
);
|
||||
|
||||
if (config.rpdbApiKey) {
|
||||
try {
|
||||
@@ -373,7 +368,9 @@ export async function validateConfig(
|
||||
|
||||
if (config.titleMatching?.enabled === true) {
|
||||
try {
|
||||
const tmdb = new TMDBMetadata(config.tmdbAccessToken);
|
||||
const tmdb = new TMDBMetadata({
|
||||
accessToken: config.tmdbAccessToken,
|
||||
});
|
||||
await tmdb.validateAccessToken();
|
||||
} catch (error) {
|
||||
if (!skipErrorsFromAddonsOrProxies) {
|
||||
@@ -408,18 +405,6 @@ async function validateRegexes(config: UserData) {
|
||||
const preferredRegexes = config.preferredRegexPatterns;
|
||||
const regexAllowed = FeatureControl.isRegexAllowed(config);
|
||||
|
||||
if (
|
||||
!regexAllowed &&
|
||||
(excludedRegexes?.length ||
|
||||
includedRegexes?.length ||
|
||||
requiredRegexes?.length ||
|
||||
preferredRegexes?.length)
|
||||
) {
|
||||
throw new Error(
|
||||
'You do not have permission to use regex filters, please remove them from your config'
|
||||
);
|
||||
}
|
||||
|
||||
const regexes = [
|
||||
...(excludedRegexes ?? []),
|
||||
...(includedRegexes ?? []),
|
||||
@@ -427,6 +412,22 @@ async function validateRegexes(config: UserData) {
|
||||
...(preferredRegexes ?? []).map((regex) => regex.pattern),
|
||||
];
|
||||
|
||||
if (!regexAllowed && regexes.length > 0) {
|
||||
const allowedRegexes = regexes.filter((regex) =>
|
||||
FeatureControl.allowedRegexPatterns.patterns.includes(regex)
|
||||
);
|
||||
if (allowedRegexes.length === 0) {
|
||||
throw new Error(
|
||||
'You do not have permission to use regex filters, please remove them from your config'
|
||||
);
|
||||
}
|
||||
if (allowedRegexes.length !== regexes.length) {
|
||||
throw new Error(
|
||||
`You are only permitted to use specific regex patterns, you have ${regexes.length - allowedRegexes.length} / ${regexes.length} regexes that are not allowed. Please remove them from your config.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
regexes.map(async (regex) => {
|
||||
try {
|
||||
@@ -663,12 +664,12 @@ function validateOption(
|
||||
}
|
||||
|
||||
async function validateProxy(
|
||||
proxy: StreamProxyConfig,
|
||||
decryptedProxy: StreamProxyConfig,
|
||||
config: UserData,
|
||||
skipProxyErrors: boolean = false,
|
||||
decryptCredentials: boolean = false
|
||||
): Promise<StreamProxyConfig> {
|
||||
// apply forced values if they exist
|
||||
const proxy = config.proxy ?? {};
|
||||
proxy.enabled = Env.FORCE_PROXY_ENABLED ?? proxy.enabled;
|
||||
proxy.id = Env.FORCE_PROXY_ID ?? proxy.id;
|
||||
proxy.url = Env.FORCE_PROXY_URL
|
||||
@@ -738,8 +739,9 @@ async function validateProxy(
|
||||
}
|
||||
proxy.publicUrl = data;
|
||||
}
|
||||
|
||||
// use decrypted proxy config for validation.
|
||||
const ProxyService = createProxy(decryptedProxy);
|
||||
const ProxyService = createProxy(ensureDecrypted(config).proxy ?? {});
|
||||
|
||||
try {
|
||||
proxy.publicIp || (await ProxyService.getPublicIp());
|
||||
|
||||
@@ -2,9 +2,8 @@ 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_DETAILS = 'USER_INVALID_DETAILS',
|
||||
USER_INVALID_CONFIG = 'USER_INVALID_CONFIG',
|
||||
USER_ERROR = 'USER_ERROR',
|
||||
USER_NEW_PASSWORD_TOO_SHORT = 'USER_NEW_PASSWORD_TOO_SHORT',
|
||||
@@ -31,17 +30,13 @@ export const ErrorMap: Record<ErrorCode, ErrorDetails> = {
|
||||
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_DETAILS]: {
|
||||
statusCode: 400,
|
||||
message: 'Invalid UUID or password',
|
||||
},
|
||||
[ErrorCode.USER_INVALID_CONFIG]: {
|
||||
statusCode: 400,
|
||||
@@ -109,6 +104,8 @@ const HEADERS_FOR_IP_FORWARDING = [
|
||||
'Forwarded-For',
|
||||
];
|
||||
|
||||
export const INTERNAL_SECRET_HEADER = 'X-AIOStreams-Internal-Secret';
|
||||
|
||||
const API_VERSION = 1;
|
||||
|
||||
export const GDRIVE_FORMATTER = 'gdrive';
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
} from 'envalid';
|
||||
import { ResourceManager } from './resources';
|
||||
import * as constants from './constants';
|
||||
import { randomBytes } from 'crypto';
|
||||
try {
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||||
} catch (error) {
|
||||
console.error('Error loading .env file', error);
|
||||
}
|
||||
|
||||
let metadata: any = undefined;
|
||||
try {
|
||||
metadata = ResourceManager.getResource('metadata.json') || {};
|
||||
@@ -199,9 +199,17 @@ export const Env = cleanEnv(process.env, {
|
||||
choices: ['none', 'trusted', 'all'],
|
||||
}),
|
||||
BASE_URL: url({
|
||||
desc: 'Base URL of the addon e.g. https://aiostreams.com',
|
||||
default: undefined,
|
||||
devDefault: 'http://localhost:3000',
|
||||
desc: 'Base URL of the addon, including protocol, hostname, and optionally port',
|
||||
example: 'https://aiostreams.example.com',
|
||||
devDefault: `http://localhost:${process.env.PORT || 3000}`,
|
||||
}),
|
||||
INTERNAL_URL: url({
|
||||
default: `http://localhost:${process.env.PORT || 3000}`,
|
||||
desc: 'Internal URL of the addon, used for internal communication between built-in addons and the server',
|
||||
}),
|
||||
INTERNAL_SECRET: readonly({
|
||||
default: randomBytes(32).toString('hex'),
|
||||
desc: 'Internal secret for the addon, used for internal communication between built-in addons and the server',
|
||||
}),
|
||||
ADDON_NAME: str({
|
||||
default: 'AIOStreams',
|
||||
@@ -404,6 +412,15 @@ export const Env = cleanEnv(process.env, {
|
||||
desc: 'Max number of groups',
|
||||
}),
|
||||
|
||||
ALLOWED_REGEX_PATTERNS: json({
|
||||
default: [],
|
||||
desc: 'Allowed regex patterns',
|
||||
}),
|
||||
ALLOWED_REGEX_PATTERNS_DESCRIPTION: str({
|
||||
default: undefined,
|
||||
desc: 'Description of the allowed regex patterns',
|
||||
}),
|
||||
|
||||
MAX_TIMEOUT: num({
|
||||
default: 50000,
|
||||
desc: 'Max timeout for the addon',
|
||||
@@ -1322,6 +1339,57 @@ export const Env = cleanEnv(process.env, {
|
||||
desc: 'Default More Like This user agent',
|
||||
}),
|
||||
|
||||
BUILTIN_STREMTHRU_URL: url({
|
||||
default: 'https://stremthru.13377001.xyz',
|
||||
desc: 'Builtin StremThru URL',
|
||||
}),
|
||||
|
||||
BUILTIN_GDRIVE_CLIENT_ID: str({
|
||||
default: undefined,
|
||||
desc: 'Builtin GDrive client ID',
|
||||
}),
|
||||
BUILTIN_GDRIVE_CLIENT_SECRET: str({
|
||||
default: undefined,
|
||||
desc: 'Builtin GDrive client secret',
|
||||
}),
|
||||
BUILTIN_GDRIVE_TIMEOUT: num({
|
||||
default: undefined,
|
||||
desc: 'Builtin GDrive timeout',
|
||||
}),
|
||||
BUILTIN_GDRIVE_USER_AGENT: userAgent({
|
||||
default: undefined,
|
||||
desc: 'Builtin GDrive user agent',
|
||||
}),
|
||||
BUILTIN_GDRIVE_PAGE_SIZE_LIMIT: num({
|
||||
default: 1000,
|
||||
desc: 'Builtin GDrive page size limit',
|
||||
}),
|
||||
|
||||
BUILTIN_TORBOX_SEARCH_TIMEOUT: num({
|
||||
default: undefined,
|
||||
desc: 'Builtin TorBox Search timeout',
|
||||
}),
|
||||
BUILTIN_TORBOX_SEARCH_USER_AGENT: userAgent({
|
||||
default: undefined,
|
||||
desc: 'Builtin TorBox Search user agent',
|
||||
}),
|
||||
BUILTIN_TORBOX_SEARCH_SEARCH_API_TIMEOUT: num({
|
||||
default: 30000, // 30 seconds
|
||||
desc: 'Builtin TorBox Search search API timeout',
|
||||
}),
|
||||
BUILTIN_TORBOX_SEARCH_SEARCH_API_CACHE_TTL: num({
|
||||
default: 1 * 60 * 60, // 1 hour
|
||||
desc: 'Builtin TorBox Search search API cache TTL',
|
||||
}),
|
||||
BUILTIN_TORBOX_SEARCH_METADATA_CACHE_TTL: num({
|
||||
default: 7 * 24 * 60 * 60, // 7 days
|
||||
desc: 'Builtin TorBox Search metadata cache TTL',
|
||||
}),
|
||||
BUILTIN_TORBOX_SEARCH_INSTANT_AVAILABILITY_CACHE_TTL: num({
|
||||
default: 15 * 60, // 15 minutes
|
||||
desc: 'Builtin TorBox Search instant availability cache TTL',
|
||||
}),
|
||||
|
||||
// Rate limiting settings
|
||||
DISABLE_RATE_LIMITS: bool({
|
||||
default: false,
|
||||
@@ -1404,4 +1472,12 @@ export const Env = cleanEnv(process.env, {
|
||||
default: 15, // allow 100 requests per IP per minute
|
||||
desc: 'Maximum number of requests allowed per IP within the time window',
|
||||
}),
|
||||
GDRIVE_STREAM_RATE_LIMIT_WINDOW: num({
|
||||
default: 5, // 1 minute
|
||||
desc: 'Time window for Google Drive stream rate limiting in seconds',
|
||||
}),
|
||||
GDRIVE_STREAM_RATE_LIMIT_MAX_REQUESTS: num({
|
||||
default: 10, // allow 100 requests per IP per minute
|
||||
desc: 'Maximum number of requests allowed per IP within the time window',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,16 @@ import { Env } from './env';
|
||||
const DEFAULT_REASON = 'Disabled by owner of the instance';
|
||||
|
||||
export class FeatureControl {
|
||||
private static readonly _allowedRegexPatterns: {
|
||||
patterns: string[];
|
||||
description?: string;
|
||||
} = (() => {
|
||||
return {
|
||||
patterns: Env.ALLOWED_REGEX_PATTERNS,
|
||||
description: Env.ALLOWED_REGEX_PATTERNS_DESCRIPTION,
|
||||
};
|
||||
})();
|
||||
|
||||
private static readonly _disabledHosts: Map<string, string> = (() => {
|
||||
const map = new Map<string, string>();
|
||||
if (Env.DISABLED_HOSTS) {
|
||||
@@ -52,6 +62,10 @@ export class FeatureControl {
|
||||
return this._disabledServices;
|
||||
}
|
||||
|
||||
public static get allowedRegexPatterns() {
|
||||
return this._allowedRegexPatterns;
|
||||
}
|
||||
|
||||
public static isRegexAllowed(userData: UserData) {
|
||||
switch (this.regexFilterAccess) {
|
||||
case 'trusted':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Cache } from './cache';
|
||||
import { HEADERS_FOR_IP_FORWARDING } from './constants';
|
||||
import { HEADERS_FOR_IP_FORWARDING, INTERNAL_SECRET_HEADER } from './constants';
|
||||
import { Env } from './env';
|
||||
import { createLogger, maskSensitiveInfo } from './logger';
|
||||
import {
|
||||
@@ -60,6 +60,10 @@ export function makeRequest(url: string, options: RequestOptions) {
|
||||
headers.delete('User-Agent');
|
||||
}
|
||||
|
||||
if (url.startsWith(Env.INTERNAL_URL)) {
|
||||
headers.set(INTERNAL_SECRET_HEADER, Env.INTERNAL_SECRET);
|
||||
}
|
||||
|
||||
let domainUserAgent = domainHasUserAgent(url);
|
||||
if (domainUserAgent) {
|
||||
headers.set('User-Agent', domainUserAgent);
|
||||
|
||||
@@ -6,7 +6,6 @@ export * from './resources';
|
||||
export * from './feature';
|
||||
export * from './crypto';
|
||||
export * from './http';
|
||||
export * from './metadata';
|
||||
export * as constants from './constants';
|
||||
export * from './config';
|
||||
export * from './languages';
|
||||
|
||||
@@ -36,6 +36,9 @@ const moduleMap: { [key: string]: string } = {
|
||||
sorter: '📊 SORTER',
|
||||
proxifier: '🔀 PROXIFIER',
|
||||
fetcher: '🔎 SCRAPER',
|
||||
gdrive: '☁️ GDRIVE',
|
||||
'torbox-search': '🔍 TORBOX SEARCH',
|
||||
debrid: '🔗 DEBRID',
|
||||
};
|
||||
|
||||
// Define colors for each log level using full names
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Env } from './env';
|
||||
|
||||
const logger = createLogger('startup');
|
||||
|
||||
// import { logSensitiveInfo } from './logger';
|
||||
import { maskSensitiveInfo } from './logger';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
@@ -477,6 +480,74 @@ const logStartupInfo = () => {
|
||||
);
|
||||
});
|
||||
|
||||
logSection('BUILT-IN ADDONS', '🔧', () => {
|
||||
const torboxSearchEnabled = Env.BASE_URL;
|
||||
logKeyValue(
|
||||
'Torbox Search:',
|
||||
torboxSearchEnabled ? '✅ Enabled' : '❌ Disabled (Set BASE_URL)'
|
||||
);
|
||||
if (torboxSearchEnabled) {
|
||||
if (Env.BUILTIN_TORBOX_SEARCH_INSTANT_AVAILABILITY_CACHE_TTL)
|
||||
logKeyValue(
|
||||
' Instant Availability Cache TTL:',
|
||||
formatDuration(
|
||||
Env.BUILTIN_TORBOX_SEARCH_INSTANT_AVAILABILITY_CACHE_TTL
|
||||
)
|
||||
);
|
||||
logKeyValue(
|
||||
' Metadata Cache TTL:',
|
||||
formatDuration(Env.BUILTIN_TORBOX_SEARCH_METADATA_CACHE_TTL)
|
||||
);
|
||||
logKeyValue(
|
||||
' Search API Cache TTL:',
|
||||
formatDuration(Env.BUILTIN_TORBOX_SEARCH_SEARCH_API_CACHE_TTL)
|
||||
);
|
||||
|
||||
if (Env.BUILTIN_TORBOX_SEARCH_TIMEOUT) {
|
||||
logKeyValue(
|
||||
' Timeout:',
|
||||
formatMilliseconds(Env.BUILTIN_TORBOX_SEARCH_TIMEOUT)
|
||||
);
|
||||
}
|
||||
if (Env.BUILTIN_TORBOX_SEARCH_USER_AGENT) {
|
||||
logKeyValue(' User Agent:', Env.BUILTIN_TORBOX_SEARCH_USER_AGENT);
|
||||
}
|
||||
}
|
||||
const gdriveEnabled =
|
||||
Env.BUILTIN_GDRIVE_CLIENT_ID &&
|
||||
Env.BUILTIN_GDRIVE_CLIENT_SECRET &&
|
||||
Env.BASE_URL;
|
||||
logKeyValue(
|
||||
'Gdrive:',
|
||||
gdriveEnabled
|
||||
? '✅ Enabled'
|
||||
: '❌ Disabled (Set GDRIVE_CLIENT_ID, GDRIVE_CLIENT_SECRET, BASE_URL)'
|
||||
);
|
||||
if (gdriveEnabled) {
|
||||
logKeyValue(' Client ID:', Env.BUILTIN_GDRIVE_CLIENT_ID!, ' ');
|
||||
logKeyValue(
|
||||
' Client Secret:',
|
||||
maskSensitiveInfo(Env.BUILTIN_GDRIVE_CLIENT_SECRET!),
|
||||
' '
|
||||
);
|
||||
logKeyValue(
|
||||
' Page Size Limit:',
|
||||
Env.BUILTIN_GDRIVE_PAGE_SIZE_LIMIT.toString(),
|
||||
' '
|
||||
);
|
||||
if (Env.BUILTIN_GDRIVE_TIMEOUT) {
|
||||
logKeyValue(
|
||||
' Timeout:',
|
||||
formatMilliseconds(Env.BUILTIN_GDRIVE_TIMEOUT),
|
||||
' '
|
||||
);
|
||||
}
|
||||
if (Env.BUILTIN_GDRIVE_USER_AGENT) {
|
||||
logKeyValue(' User Agent:', Env.BUILTIN_GDRIVE_USER_AGENT, ' ');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Addon Sources
|
||||
logSection('ADDONS', '🎬', () => {
|
||||
// Comet
|
||||
@@ -1207,6 +1278,36 @@ const logStartupInfo = () => {
|
||||
logKeyValue(' User Agent:', Env.DEFAULT_SUBHERO_USER_AGENT, ' ');
|
||||
}
|
||||
|
||||
// StreamAsia
|
||||
logKeyValue('StreamAsia:', Env.STREAMASIA_URL);
|
||||
if (Env.DEFAULT_STREAMASIA_TIMEOUT) {
|
||||
logKeyValue(
|
||||
' Timeout:',
|
||||
formatMilliseconds(Env.DEFAULT_STREAMASIA_TIMEOUT),
|
||||
' '
|
||||
);
|
||||
}
|
||||
if (Env.DEFAULT_STREAMASIA_USER_AGENT) {
|
||||
logKeyValue(' User Agent:', Env.DEFAULT_STREAMASIA_USER_AGENT, ' ');
|
||||
}
|
||||
|
||||
// More Like This
|
||||
logKeyValue('More Like This:', Env.MORE_LIKE_THIS_URL);
|
||||
if (Env.DEFAULT_MORE_LIKE_THIS_TIMEOUT) {
|
||||
logKeyValue(
|
||||
' Timeout:',
|
||||
formatMilliseconds(Env.DEFAULT_MORE_LIKE_THIS_TIMEOUT),
|
||||
' '
|
||||
);
|
||||
}
|
||||
if (Env.DEFAULT_MORE_LIKE_THIS_USER_AGENT) {
|
||||
logKeyValue(
|
||||
' User Agent:',
|
||||
Env.DEFAULT_MORE_LIKE_THIS_USER_AGENT,
|
||||
' '
|
||||
);
|
||||
}
|
||||
|
||||
// Additional Features
|
||||
const features: string[] = [];
|
||||
if (Env.TMDB_ACCESS_TOKEN) features.push('TMDB Integration');
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingOverlay } from '@/components/ui/loading-spinner';
|
||||
import { TextGenerateEffect } from '@/components/shared/text-generate-effect';
|
||||
import { LuffyError } from '@/components/shared/luffy-error';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { toast } from 'sonner';
|
||||
import { UserConfigAPI } from '@/services/api';
|
||||
import { BiCopy } from 'react-icons/bi';
|
||||
|
||||
function OAuthCallbackContent() {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Get the code from URL search params
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const authCode = searchParams.get('code');
|
||||
const errorParam = searchParams.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
setError(errorParam);
|
||||
} else if (authCode) {
|
||||
const response = await UserConfigAPI.exchangeGDriveAuthCode(authCode);
|
||||
if (response.success) {
|
||||
setCode(response.data?.refreshToken || null);
|
||||
} else {
|
||||
setError(
|
||||
response.error?.message || 'Failed to exchange GDrive auth code'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setError('No authorization code found in URL');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (code) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
toast.success('Copied!', {
|
||||
description:
|
||||
'The authorization code has been copied to your clipboard.',
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error('Failed to copy', {
|
||||
description: 'Please try copying the code manually.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<LoadingOverlay showSpinner>
|
||||
<TextGenerateEffect
|
||||
words="Processing OAuth callback..."
|
||||
className="text-2xl"
|
||||
/>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<LoadingOverlay showSpinner={false}>
|
||||
<LuffyError title="OAuth Error" showRefreshButton>
|
||||
<p>{error}</p>
|
||||
</LuffyError>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<Card className="w-full max-w-lg p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Google Drive Authorisation</h1>
|
||||
<div className="space-y-4">
|
||||
<div className="text-[--muted] space-y-2">
|
||||
<p>Authorisation successful! Please follow these steps:</p>
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
<li>Copy the refresh token below</li>
|
||||
<li>Return to the previous tab</li>
|
||||
<li>Paste the refresh token into the Refresh Token field</li>
|
||||
</ol>
|
||||
</div>
|
||||
{code && (
|
||||
<div className="relative">
|
||||
<div className="p-4 pr-12 bg-[--subtle] rounded-md relative">
|
||||
<p className="text-sm font-mono break-all">{code}</p>
|
||||
<IconButton
|
||||
icon={<BiCopy />}
|
||||
intent="primary-subtle"
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy authorization code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthCallback() {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" forcedTheme="dark">
|
||||
<OAuthCallbackContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -806,7 +806,13 @@ function AddonCard({
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col min-h-72 h-auto bg-[--background] border border-[--border] rounded-lg shadow-sm p-4 relative">
|
||||
<div className="flex flex-col min-h-72 h-auto bg-[--background] border border-[--border] rounded-lg shadow-sm p-4 relative overflow-hidden">
|
||||
{/* Built-in ribbon */}
|
||||
{preset.BUILTIN && (
|
||||
<div className="absolute -left-[30px] top-[20px] bg-[rgb(var(--color-brand-500))] text-white text-xs font-semibold py-1 w-[120px] text-center transform -rotate-45 shadow-md">
|
||||
Built-in
|
||||
</div>
|
||||
)}
|
||||
{/* Top: Logo + Name/Description */}
|
||||
<div className="flex gap-4 items-start">
|
||||
{preset.ID === 'custom' ? (
|
||||
|
||||
@@ -79,6 +79,7 @@ import { toast } from 'sonner';
|
||||
import { Slider } from '../ui/slider/slider';
|
||||
import { TbFilterCode } from 'react-icons/tb';
|
||||
import { PasswordInput } from '../ui/password-input';
|
||||
import MarkdownLite from '../shared/markdown-lite';
|
||||
|
||||
type Resolution = (typeof RESOLUTIONS)[number];
|
||||
type Quality = (typeof QUALITIES)[number];
|
||||
@@ -289,7 +290,8 @@ function Content() {
|
||||
<TbFilterCode className="text-lg mr-3" />
|
||||
Stream Expression
|
||||
</TabsTrigger>
|
||||
{status?.settings.regexFilterAccess !== 'none' && (
|
||||
{(status?.settings.regexFilterAccess !== 'none' ||
|
||||
status?.settings.allowedRegexPatterns) && (
|
||||
<TabsTrigger value="regex">
|
||||
<BsRegex className="text-lg mr-3" />
|
||||
Regex
|
||||
@@ -1155,6 +1157,24 @@ function Content() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Year Tolerance"
|
||||
disabled={!userData.titleMatching?.matchYear}
|
||||
value={userData.titleMatching?.yearTolerance ?? 1}
|
||||
onValueChange={(value) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
titleMatching: {
|
||||
...prev.titleMatching,
|
||||
yearTolerance: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
min={0}
|
||||
max={100}
|
||||
help="The number of years to tolerate when matching years. For example, if the year tolerance is 5, then a stream with a year of 2020 will match a request for 2025."
|
||||
/>
|
||||
|
||||
<Select
|
||||
disabled={!userData.titleMatching?.enabled}
|
||||
label="Matching Mode"
|
||||
@@ -1606,13 +1626,13 @@ function Content() {
|
||||
{status?.settings.regexFilterAccess === 'trusted' && (
|
||||
<Alert
|
||||
intent="info"
|
||||
title="Admin Only"
|
||||
title="Trusted Users Only"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
Regex filters are only available to trusted users due
|
||||
to the potential for abuse. Ask the owner of the
|
||||
instance to add your UUID to the{' '}
|
||||
to the potential for abuse. If you are the owner of
|
||||
the instance, you can add your UUID to the{' '}
|
||||
<code className="font-mono">TRUSTED_UUIDS</code>{' '}
|
||||
environment variable.
|
||||
</p>
|
||||
@@ -1620,6 +1640,26 @@ function Content() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status?.settings.allowedRegexPatterns && (
|
||||
<Alert
|
||||
intent="info"
|
||||
title="Allowed Regex Patterns"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
This instance has allowed a specific set of regexes to
|
||||
be used by all users.
|
||||
</p>
|
||||
<p>
|
||||
<MarkdownLite>
|
||||
{status?.settings.allowedRegexPatterns
|
||||
.description || ''}
|
||||
</MarkdownLite>
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<TextInputs
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
AccordionTrigger,
|
||||
} from '../ui/accordion';
|
||||
import { PasswordInput } from '../ui/password-input';
|
||||
import { useMenu } from '@/context/menu';
|
||||
import {
|
||||
ConfirmationDialog,
|
||||
useConfirmationDialog,
|
||||
} from '../shared/confirmation-dialog';
|
||||
|
||||
export function SaveInstallMenu() {
|
||||
return (
|
||||
@@ -56,7 +61,20 @@ function Content() {
|
||||
const passwordModal = useDisclosure(false);
|
||||
const [filterCredentialsInExport, setFilterCredentialsInExport] =
|
||||
React.useState(false);
|
||||
|
||||
const deleteModal = useDisclosure(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState('');
|
||||
const { setSelectedMenu, firstMenu } = useMenu();
|
||||
const confirmDelete = useConfirmationDialog({
|
||||
title: 'Confirm Deletion',
|
||||
description:
|
||||
'Are you sure you want to delete your configuration? This will permanently remove all your data. This action cannot be undone.',
|
||||
actionText: 'Delete',
|
||||
actionIntent: 'alert',
|
||||
onConfirm: () => {
|
||||
setLoading(true);
|
||||
handleDelete();
|
||||
},
|
||||
});
|
||||
React.useEffect(() => {
|
||||
const requirements: string[] = [];
|
||||
|
||||
@@ -98,7 +116,7 @@ function Content() {
|
||||
: await UserConfigAPI.createConfig(userData, newPassword);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error?.code === 'USER_INVALID_PASSWORD') {
|
||||
if (result.error?.code === 'USER_INVALID_DETAILS') {
|
||||
toast.error('Your addon password is incorrect');
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
@@ -247,6 +265,43 @@ function Content() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
if (!uuid) {
|
||||
toast.error('No UUID found');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await UserConfigAPI.deleteUser(uuid, deletePassword);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error?.code === 'USER_INVALID_DETAILS') {
|
||||
toast.error('Invalid password');
|
||||
} else {
|
||||
toast.error(
|
||||
result.error?.message || 'Failed to delete configuration'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only clear data after successful deletion
|
||||
toast.success('Configuration deleted successfully');
|
||||
setUuid(null);
|
||||
setEncryptedPassword(null);
|
||||
setPassword(null);
|
||||
setUserData(null);
|
||||
setSelectedMenu(firstMenu);
|
||||
deleteModal.close();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Failed to delete configuration'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center w-full">
|
||||
@@ -505,6 +560,71 @@ function Content() {
|
||||
</Accordion>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{uuid && (
|
||||
<SettingsCard
|
||||
title="Delete Configuration"
|
||||
description="Delete your configuration and all associated data"
|
||||
>
|
||||
<Button intent="alert" rounded onClick={deleteModal.open}>
|
||||
Delete
|
||||
</Button>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={deleteModal.isOpen}
|
||||
onOpenChange={deleteModal.toggle}
|
||||
title="Delete Configuration"
|
||||
description={
|
||||
<Alert
|
||||
intent="warning"
|
||||
description="Please enter your password to confirm deletion of your user and all associated data. This action cannot be undone."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!deletePassword) {
|
||||
toast.error('Please enter your password');
|
||||
return;
|
||||
}
|
||||
confirmDelete.open();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
value={deletePassword}
|
||||
required
|
||||
placeholder="Enter your password to confirm deletion"
|
||||
onValueChange={(value) => setDeletePassword(value)}
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<div className="grid grid-cols-2 gap-3 w-full">
|
||||
<Button
|
||||
type="button"
|
||||
intent="gray-outline"
|
||||
onClick={deleteModal.close}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="alert"
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<ConfirmationDialog {...confirmDelete} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,14 @@ import { Switch } from '../ui/switch';
|
||||
import { Select } from '../ui/select';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { Option } from '@aiostreams/core';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import MarkdownLite from './markdown-lite';
|
||||
import { Alert } from '../ui/alert';
|
||||
import { SocialIcon } from './social-icon';
|
||||
import { PasswordInput } from '../ui/password-input';
|
||||
import { Button } from '../ui/button';
|
||||
import { IconButton } from '../ui/button';
|
||||
import { ArrowLeftIcon, KeyIcon } from 'lucide-react';
|
||||
// this component, accepts an option and returns a component that renders the option.
|
||||
// string - TextInput
|
||||
// number - NumberInput
|
||||
@@ -43,6 +46,7 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
|
||||
default: defaultValue,
|
||||
intent,
|
||||
socials,
|
||||
oauth,
|
||||
emptyIsUndefined = false,
|
||||
} = option;
|
||||
|
||||
@@ -218,6 +222,71 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'oauth': {
|
||||
const [showInput, setShowInput] = useState(!!value);
|
||||
|
||||
if (!showInput) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 bg-[--subtle] p-4 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium mb-1">{name}</h4>
|
||||
<p className="text-sm text-[--muted]">
|
||||
<MarkdownLite>{description}</MarkdownLite>
|
||||
</p>
|
||||
</div>
|
||||
<IconButton
|
||||
icon={<KeyIcon />}
|
||||
intent="primary-outline"
|
||||
onClick={() => {
|
||||
window.open(oauth?.authorisationUrl || '', '_blank');
|
||||
setShowInput(true);
|
||||
}}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-[--subtle] p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium">
|
||||
Enter {oauth?.oauthResultField?.name || 'Authorization Code'}
|
||||
</h4>
|
||||
<IconButton
|
||||
icon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
intent="primary-subtle"
|
||||
size="sm"
|
||||
onClick={() => setShowInput(false)}
|
||||
aria-label="Go back to authorization"
|
||||
/>
|
||||
</div>
|
||||
<PasswordInput
|
||||
value={value}
|
||||
onValueChange={(value: string) =>
|
||||
onChange(emptyIsUndefined ? value || undefined : value)
|
||||
}
|
||||
required={required}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
{oauth?.oauthResultField?.description && (
|
||||
<div className="text-sm text-[--muted] mt-2">
|
||||
<MarkdownLite>
|
||||
{oauth.oauthResultField.description}
|
||||
</MarkdownLite>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -166,6 +166,52 @@ export class UserConfigAPI {
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteUser(
|
||||
uuid: string,
|
||||
password: string
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await fetch(`${this.BASE_URL}/user`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uuid,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: data.error?.code || 'UNKNOWN_ERROR',
|
||||
message: data.error?.message || 'Failed to delete configuration',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to delete configuration',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async formatStream(
|
||||
stream: ParsedStream,
|
||||
formatter: FormatterType,
|
||||
@@ -253,4 +299,45 @@ export class UserConfigAPI {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async exchangeGDriveAuthCode(code: string): Promise<
|
||||
ApiResponse<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>
|
||||
> {
|
||||
if (!code) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Code is required',
|
||||
},
|
||||
};
|
||||
}
|
||||
const response = await fetch(`${this.BASE_URL}/oauth/exchange/gdrive`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: data.error?.code || 'UNKNOWN_ERROR',
|
||||
message: data.error?.message || 'Failed to exchange GDrive auth code',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
formatApi,
|
||||
catalogApi,
|
||||
rpdbApi,
|
||||
gdriveApi,
|
||||
debridApi,
|
||||
} from './routes/api';
|
||||
import {
|
||||
configure,
|
||||
@@ -18,7 +20,7 @@ import {
|
||||
addonCatalog,
|
||||
alias,
|
||||
} from './routes/stremio';
|
||||
|
||||
import { gdrive, torboxSearch } from './routes/builtins';
|
||||
import {
|
||||
ipMiddleware,
|
||||
loggerMiddleware,
|
||||
@@ -26,16 +28,29 @@ import {
|
||||
errorMiddleware,
|
||||
corsMiddleware,
|
||||
staticRateLimiter,
|
||||
internalMiddleware,
|
||||
} from './middlewares';
|
||||
|
||||
import { constants, createLogger, Env } from '@aiostreams/core';
|
||||
import { StremioTransformer } from '@aiostreams/core';
|
||||
import { createResponse } from './utils/responses';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
const app = express();
|
||||
const logger = createLogger('server');
|
||||
|
||||
export const frontendRoot = path.join(__dirname, '../../frontend/out');
|
||||
export const staticRoot = path.join(__dirname, './static');
|
||||
export const STATIC_DOWNLOAD_FAILED = 'download_failed.mp4';
|
||||
export const STATIC_DOWNLOADING = 'downloading.mp4';
|
||||
export const STATIC_UNAVAILABLE_FOR_LEGAL_REASONS =
|
||||
'unavailable_for_legal_reasons.mp4';
|
||||
export const STATIC_CONTENT_PROXY_LIMIT_REACHED =
|
||||
'content_proxy_limit_reached.mp4';
|
||||
export const STATIC_INTERNAL_SERVER_ERROR = '500.mp4';
|
||||
export const STATIC_FORBIDDEN = '403.mp4';
|
||||
export const STATIC_UNAUTHORIZED = '401.mp4';
|
||||
export const STATIC_NO_MATCHING_FILE = 'no_matching_file.mp4';
|
||||
|
||||
app.use(ipMiddleware);
|
||||
app.use(loggerMiddleware);
|
||||
@@ -56,6 +71,8 @@ apiRouter.use('/status', statusApi);
|
||||
apiRouter.use('/format', formatApi);
|
||||
apiRouter.use('/catalogs', catalogApi);
|
||||
apiRouter.use('/rpdb', rpdbApi);
|
||||
apiRouter.use('/oauth/exchange/gdrive', gdriveApi);
|
||||
apiRouter.use('/debrid', debridApi);
|
||||
app.use(`/api/v${constants.API_VERSION}`, apiRouter);
|
||||
|
||||
// Stremio Routes
|
||||
@@ -89,6 +106,12 @@ stremioAuthRouter.use('/addon_catalog', addonCatalog);
|
||||
app.use('/stremio', stremioRouter); // For public routes
|
||||
app.use('/stremio/:uuid/:encryptedPassword', stremioAuthRouter); // For authenticated routes
|
||||
|
||||
const builtinsRouter = express.Router();
|
||||
builtinsRouter.use(internalMiddleware);
|
||||
builtinsRouter.use('/gdrive', gdrive);
|
||||
builtinsRouter.use('/torbox-search', torboxSearch);
|
||||
app.use('/builtins', builtinsRouter);
|
||||
|
||||
app.get(
|
||||
['/_next/*', '/assets/*', '/favicon.ico', '/logo.png'],
|
||||
staticRateLimiter,
|
||||
@@ -102,6 +125,22 @@ app.get(
|
||||
}
|
||||
);
|
||||
|
||||
app.get('/static/*', (req, res, next) => {
|
||||
const filePath = path.resolve(
|
||||
staticRoot,
|
||||
req.path.replace(/^\/static\//, '')
|
||||
);
|
||||
logger.debug(`Static file requested: ${filePath}`);
|
||||
if (filePath.startsWith(staticRoot) && fs.existsSync(filePath)) {
|
||||
res.sendFile(filePath);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/oauth/callback/gdrive', (req, res) => {
|
||||
res.sendFile(path.join(frontendRoot, 'oauth/callback/gdrive.html'));
|
||||
});
|
||||
app.get('/', (req, res) => {
|
||||
res.redirect('/stremio/configure');
|
||||
});
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './userData';
|
||||
export * from './ip';
|
||||
export * from './cors';
|
||||
export * from './ratelimit';
|
||||
export * from './internal';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { createResponse } from '../utils/responses';
|
||||
import { constants, Env } from '@aiostreams/core';
|
||||
|
||||
export const internalMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const internalSecret = req.get(constants.INTERNAL_SECRET_HEADER);
|
||||
if (internalSecret !== Env.INTERNAL_SECRET) {
|
||||
res.status(403).json(
|
||||
createResponse({
|
||||
success: false,
|
||||
detail: 'Forbidden',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const loggerMiddleware = (
|
||||
ip: req.userIp ? maskSensitiveInfo(req.userIp) : undefined,
|
||||
contentType: res.get('content-type'),
|
||||
contentLength: res.get('content-length'),
|
||||
formatted: `${req.method} ${makeUrlLogSafe(req.originalUrl)}${req.userIp ? ` - ${maskSensitiveInfo(req.userIp)}` : ''} - Response: ${res.statusCode} - ${duration}ms`,
|
||||
formatted: `${req.method} ${makeUrlLogSafe(req.originalUrl)}${req.userIp ? ` - ${maskSensitiveInfo(req.userIp)}` : ''} - Response: ${res.statusCode} - ${duration}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const userDataMiddleware = async (
|
||||
|
||||
// Both uuid and encryptedPassword should be present since we mounted the router on this path
|
||||
if (!uuid || !encryptedPassword) {
|
||||
next(new APIError(constants.ErrorCode.USER_NOT_FOUND));
|
||||
next(new APIError(constants.ErrorCode.USER_INVALID_DETAILS));
|
||||
return;
|
||||
}
|
||||
// First check - validate path has two components followed by valid resource
|
||||
@@ -42,7 +42,7 @@ export const userDataMiddleware = async (
|
||||
const uuidRegex =
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||
if (!uuidRegex.test(uuid)) {
|
||||
next(new APIError(constants.ErrorCode.USER_NOT_FOUND));
|
||||
next(new APIError(constants.ErrorCode.USER_INVALID_DETAILS));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const userDataMiddleware = async (
|
||||
);
|
||||
return;
|
||||
}
|
||||
next(new APIError(constants.ErrorCode.USER_NOT_FOUND));
|
||||
next(new APIError(constants.ErrorCode.USER_INVALID_DETAILS));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export const userDataMiddleware = async (
|
||||
);
|
||||
return;
|
||||
}
|
||||
next(new APIError(constants.ErrorCode.USER_INVALID_PASSWORD));
|
||||
next(new APIError(constants.ErrorCode.USER_INVALID_DETAILS));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import {
|
||||
APIError,
|
||||
constants,
|
||||
createLogger,
|
||||
formatZodError,
|
||||
} from '@aiostreams/core';
|
||||
import {
|
||||
DebridInterface,
|
||||
DebridError,
|
||||
PlaybackInfoSchema,
|
||||
StoreAuthSchema,
|
||||
} from '@aiostreams/core';
|
||||
import { ZodError } from 'zod';
|
||||
import { StremThruError } from 'stremthru';
|
||||
import {
|
||||
STATIC_DOWNLOAD_FAILED,
|
||||
STATIC_DOWNLOADING,
|
||||
STATIC_UNAVAILABLE_FOR_LEGAL_REASONS,
|
||||
STATIC_CONTENT_PROXY_LIMIT_REACHED,
|
||||
STATIC_INTERNAL_SERVER_ERROR,
|
||||
STATIC_FORBIDDEN,
|
||||
STATIC_UNAUTHORIZED,
|
||||
STATIC_NO_MATCHING_FILE,
|
||||
} from '../../app';
|
||||
const router = Router();
|
||||
const logger = createLogger('server');
|
||||
|
||||
router.get(
|
||||
'/resolve/:encodedStoreAuth/:encodedPlaybackInfo/:filename',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { encodedStoreAuth, encodedPlaybackInfo, filename } = req.params;
|
||||
if (!encodedStoreAuth || !encodedPlaybackInfo || !filename) {
|
||||
throw new APIError(
|
||||
constants.ErrorCode.BAD_REQUEST,
|
||||
undefined,
|
||||
'Store auth, playback info and filename are required'
|
||||
);
|
||||
}
|
||||
const playbackInfo = PlaybackInfoSchema.parse(
|
||||
JSON.parse(Buffer.from(encodedPlaybackInfo, 'base64').toString('utf-8'))
|
||||
);
|
||||
|
||||
const storeAuth = StoreAuthSchema.parse(
|
||||
JSON.parse(Buffer.from(encodedStoreAuth, 'base64').toString('utf-8'))
|
||||
);
|
||||
|
||||
const debridInterface = new DebridInterface(storeAuth, req.userIp);
|
||||
|
||||
let streamUrl: string | undefined;
|
||||
try {
|
||||
streamUrl = await debridInterface.resolve(playbackInfo, filename);
|
||||
} catch (error: any) {
|
||||
let staticFile: string = STATIC_INTERNAL_SERVER_ERROR;
|
||||
if (error instanceof StremThruError) {
|
||||
logger.error(
|
||||
`Got StremThru error during debrid resolve: ${error.code}: ${error.message}`
|
||||
);
|
||||
|
||||
switch (error.code) {
|
||||
case 'UNAVAILABLE_FOR_LEGAL_REASONS':
|
||||
staticFile = STATIC_UNAVAILABLE_FOR_LEGAL_REASONS;
|
||||
break;
|
||||
case 'STORE_LIMIT_EXCEEDED':
|
||||
staticFile = STATIC_CONTENT_PROXY_LIMIT_REACHED;
|
||||
break;
|
||||
case 'FORBIDDEN':
|
||||
staticFile = STATIC_FORBIDDEN;
|
||||
break;
|
||||
case 'UNAUTHORIZED':
|
||||
staticFile = STATIC_UNAUTHORIZED;
|
||||
break;
|
||||
case 'UNPROCESSABLE_ENTITY':
|
||||
case 'UNSUPPORTED_MEDIA_TYPE':
|
||||
case 'STORE_MAGNET_INVALID':
|
||||
staticFile = STATIC_DOWNLOAD_FAILED;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (error instanceof DebridError) {
|
||||
logger.error(
|
||||
`Got Debrid error during debrid resolve: ${error.code}: ${error.message}`
|
||||
);
|
||||
switch (error.code) {
|
||||
case 'NO_MATCHING_FILE':
|
||||
staticFile = STATIC_NO_MATCHING_FILE;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`Got unknown error during debrid resolve: ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
res.status(302).redirect(`/static/${staticFile}`);
|
||||
}
|
||||
|
||||
if (!streamUrl) {
|
||||
res.status(302).redirect(`/static/${STATIC_DOWNLOADING}`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(302).redirect(streamUrl);
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Got unexpected error during debrid resolve: ${error.message}`
|
||||
);
|
||||
if (error instanceof APIError) {
|
||||
next(error);
|
||||
} else if (error instanceof ZodError) {
|
||||
next(
|
||||
new APIError(
|
||||
constants.ErrorCode.BAD_REQUEST,
|
||||
undefined,
|
||||
formatZodError(error)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
next(
|
||||
new APIError(
|
||||
constants.ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
undefined,
|
||||
error.message
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { createResponse } from '../../utils/responses';
|
||||
import { APIError, constants, createLogger, GDriveAPI } from '@aiostreams/core';
|
||||
import { GoogleOAuth } from '@aiostreams/core';
|
||||
const router = Router();
|
||||
const logger = createLogger('server');
|
||||
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
if (!code) {
|
||||
throw new APIError(
|
||||
constants.ErrorCode.BAD_REQUEST,
|
||||
undefined,
|
||||
'Code is required'
|
||||
);
|
||||
}
|
||||
const { access_token, refresh_token } =
|
||||
await GoogleOAuth.exchangeAuthorisationCode(code);
|
||||
res.status(200).json(
|
||||
createResponse({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error(`GDrive authorisation failed: ${error.message}`);
|
||||
next(
|
||||
new APIError(
|
||||
constants.ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
undefined,
|
||||
error.message
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,3 +4,5 @@ export { default as statusApi } from './status';
|
||||
export { default as formatApi } from './format';
|
||||
export { default as catalogApi } from './catalog';
|
||||
export { default as rpdbApi } from './rpdb';
|
||||
export { default as gdriveApi } from './gdrive';
|
||||
export { default as debridApi } from './debrid';
|
||||
|
||||
@@ -15,7 +15,7 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
const userCount = await UserRepository.getUserCount();
|
||||
let forcedPublicProxyUrl = Env.FORCE_PROXY_PUBLIC_URL;
|
||||
if (Env.FORCE_PUBLIC_PROXY_HOST) {
|
||||
forcedPublicProxyUrl = `${Env.FORCE_PUBLIC_PROXY_PROTOCOL}://${Env.FORCE_PUBLIC_PROXY_HOST}:${Env.FORCE_PUBLIC_PROXY_PORT}`;
|
||||
forcedPublicProxyUrl = `${Env.FORCE_PUBLIC_PROXY_PROTOCOL}://${Env.FORCE_PUBLIC_PROXY_HOST}:${Env.FORCE_PUBLIC_PROXY_PORT ?? ''}`;
|
||||
}
|
||||
const info: StatusResponse = {
|
||||
version: Env.VERSION,
|
||||
@@ -31,6 +31,13 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
protected: !!Env.ADDON_PASSWORD,
|
||||
tmdbApiAvailable: !!Env.TMDB_ACCESS_TOKEN,
|
||||
regexFilterAccess: Env.REGEX_FILTER_ACCESS,
|
||||
allowedRegexPatterns:
|
||||
Env.ALLOWED_REGEX_PATTERNS.length > 0
|
||||
? {
|
||||
patterns: Env.ALLOWED_REGEX_PATTERNS,
|
||||
description: Env.ALLOWED_REGEX_PATTERNS_DESCRIPTION,
|
||||
}
|
||||
: undefined,
|
||||
loggingSensitiveInfo: Env.LOG_SENSITIVE_INFO,
|
||||
forced: {
|
||||
proxy: {
|
||||
|
||||
@@ -42,7 +42,7 @@ router.head('/', async (req, res, next) => {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
next(new APIError(constants.ErrorCode.USER_NOT_FOUND));
|
||||
next(new APIError(constants.ErrorCode.USER_INVALID_DETAILS));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
@@ -173,4 +173,27 @@ router.post('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/', async (req, res, next) => {
|
||||
const { uuid, password } = req.body;
|
||||
if (!uuid || !password) {
|
||||
next(new APIError(constants.ErrorCode.MISSING_REQUIRED_FIELDS));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await UserRepository.deleteUser(uuid, password);
|
||||
res.status(200).json(
|
||||
createResponse({
|
||||
success: true,
|
||||
detail: 'User deleted successfully',
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (error instanceof APIError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(new APIError(constants.ErrorCode.USER_ERROR));
|
||||
}
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { AIOStreams, AIOStreamResponse, GDriveAddon } from '@aiostreams/core';
|
||||
import { stremioStreamRateLimiter } from '../../middlewares/ratelimit';
|
||||
import { createLogger } from '@aiostreams/core';
|
||||
const router = Router();
|
||||
|
||||
const logger = createLogger('server');
|
||||
|
||||
router.use(stremioStreamRateLimiter);
|
||||
|
||||
router.get(
|
||||
'/:encodedConfig?/manifest.json',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { encodedConfig } = req.params;
|
||||
const config = encodedConfig
|
||||
? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8'))
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const manifest = config
|
||||
? new GDriveAddon(config).getManifest()
|
||||
: GDriveAddon.getManifest();
|
||||
res.json(manifest);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:encodedConfig/meta/:type/:id.json',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { encodedConfig, type, id } = req.params;
|
||||
const config = JSON.parse(
|
||||
Buffer.from(encodedConfig, 'base64').toString('utf-8')
|
||||
);
|
||||
|
||||
try {
|
||||
const addon = new GDriveAddon(config);
|
||||
const meta = await addon.getMeta(type, id);
|
||||
res.json({
|
||||
meta: meta,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:encodedConfig/catalog/:type/:id/:extras?.json',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { encodedConfig, type, id, extras } = req.params;
|
||||
const config = JSON.parse(
|
||||
Buffer.from(encodedConfig, 'base64').toString('utf-8')
|
||||
);
|
||||
|
||||
try {
|
||||
const addon = new GDriveAddon(config);
|
||||
const catalog = await addon.getCatalog(type, id, extras);
|
||||
res.json({
|
||||
metas: catalog,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:encodedConfig/stream/:type/:id.json',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { encodedConfig, type, id } = req.params;
|
||||
const config = JSON.parse(
|
||||
Buffer.from(encodedConfig, 'base64').toString('utf-8')
|
||||
);
|
||||
|
||||
try {
|
||||
const addon = new GDriveAddon(config);
|
||||
const streams = await addon.getStreams(type, id);
|
||||
res.json({
|
||||
streams: streams,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as gdrive } from './gdrive';
|
||||
export { default as torboxSearch } from './torbox-search';
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { stremioStreamRateLimiter } from '../../middlewares/ratelimit';
|
||||
import {
|
||||
createLogger,
|
||||
TorBoxSearchAddon,
|
||||
TorBoxSearchAddonError,
|
||||
} from '@aiostreams/core';
|
||||
import { createResponse } from '../../utils/responses';
|
||||
const router = Router();
|
||||
|
||||
const logger = createLogger('builtins:torbox-search');
|
||||
|
||||
router.get(
|
||||
'/:encodedConfig?/manifest.json',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { encodedConfig } = req.params;
|
||||
|
||||
const config = encodedConfig
|
||||
? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8'))
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const manifest = config
|
||||
? new TorBoxSearchAddon(config, req.userIp).getManifest()
|
||||
: TorBoxSearchAddon.getManifest();
|
||||
res.json(manifest);
|
||||
} catch (error) {
|
||||
if (error instanceof TorBoxSearchAddonError) {
|
||||
res.status(error.statusCode).json(
|
||||
createResponse({
|
||||
success: false,
|
||||
error: {
|
||||
code: error.errorCode,
|
||||
message: error.message,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:encodedConfig/stream/:type/:id.json',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { encodedConfig, type, id } = req.params;
|
||||
const config = JSON.parse(
|
||||
Buffer.from(encodedConfig, 'base64').toString('utf-8')
|
||||
);
|
||||
|
||||
try {
|
||||
const addon = new TorBoxSearchAddon(config, req.userIp);
|
||||
const streams = await addon.getStreams(type as any, id);
|
||||
res.json({
|
||||
streams: streams,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TorBoxSearchAddonError) {
|
||||
res.status(error.statusCode).json(
|
||||
createResponse({
|
||||
success: false,
|
||||
error: {
|
||||
code: error.errorCode,
|
||||
message: error.message,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`Unexpected error: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -18,7 +18,7 @@ router.get('/:alias/*', (req: Request<AliasParams>, res) => {
|
||||
|
||||
const configuration = Env.ALIASED_CONFIGURATIONS[alias];
|
||||
if (!configuration || !configuration.uuid || !configuration.password) {
|
||||
throw new APIError(constants.ErrorCode.USER_NOT_FOUND);
|
||||
throw new APIError(constants.ErrorCode.USER_INVALID_DETAILS);
|
||||
}
|
||||
|
||||
const redirectPath = `/stremio/${configuration.uuid}/${configuration.password}${wildcardPath ? `/${wildcardPath}` : ''}`;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user