diff --git a/.env.sample b/.env.sample index a7c504ff..65c8e68c 100644 --- a/.env.sample +++ b/.env.sample @@ -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)|(?=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", diff --git a/packages/core/package.json b/packages/core/package.json index b09008dd..93f430ce 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/builtins/gdrive/addon.ts b/packages/core/src/builtins/gdrive/addon.ts new file mode 100644 index 00000000..1858d9cf --- /dev/null +++ b/packages/core/src/builtins/gdrive/addon.ts @@ -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 { + 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 { + 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 { + 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}`, + }, + }, + }, + }; + } +} diff --git a/packages/core/src/builtins/gdrive/api.ts b/packages/core/src/builtins/gdrive/api.ts new file mode 100644 index 00000000..b71e6bad --- /dev/null +++ b/packages/core/src/builtins/gdrive/api.ts @@ -0,0 +1,297 @@ +import { Cache, createLogger, Env, makeRequest } from '../../utils'; +import { + GDriveFile, + GDriveFileGetResponseSchema, + GDriveFileQueryResponseSchema, + RefreshTokenResponse, + RefreshTokenResponseSchema, +} from './schemas'; + +const accessTokenCache = Cache.getInstance( + '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 { + 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 { + 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 { + 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 + ).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 { + const url = new URL(`${GDriveAPI.baseUrl}/files/${fileId}`); + url.search = new URLSearchParams( + Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined) + ) as Record + ).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; + } +} diff --git a/packages/core/src/builtins/gdrive/index.ts b/packages/core/src/builtins/gdrive/index.ts new file mode 100644 index 00000000..83bbf6eb --- /dev/null +++ b/packages/core/src/builtins/gdrive/index.ts @@ -0,0 +1,2 @@ +export * from './addon'; +export * from './api'; diff --git a/packages/core/src/builtins/gdrive/schemas.ts b/packages/core/src/builtins/gdrive/schemas.ts new file mode 100644 index 00000000..cc11d887 --- /dev/null +++ b/packages/core/src/builtins/gdrive/schemas.ts @@ -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; +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; + +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; diff --git a/packages/core/src/builtins/index.ts b/packages/core/src/builtins/index.ts new file mode 100644 index 00000000..75da6de3 --- /dev/null +++ b/packages/core/src/builtins/index.ts @@ -0,0 +1,2 @@ +export * from './gdrive'; +export * from './torbox-search'; diff --git a/packages/core/src/builtins/torbox-search/addon.ts b/packages/core/src/builtins/torbox-search/addon.ts new file mode 100644 index 00000000..5392cbfb --- /dev/null +++ b/packages/core/src/builtins/torbox-search/addon.ts @@ -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; + 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, + 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 { + 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 { + 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( + (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; + } +} diff --git a/packages/core/src/builtins/torbox-search/debrid-service.ts b/packages/core/src/builtins/torbox-search/debrid-service.ts new file mode 100644 index 00000000..285238bb --- /dev/null +++ b/packages/core/src/builtins/torbox-search/debrid-service.ts @@ -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( + '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 { + 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 []; + } + } +} diff --git a/packages/core/src/builtins/torbox-search/errors.ts b/packages/core/src/builtins/torbox-search/errors.ts new file mode 100644 index 00000000..522e38d1 --- /dev/null +++ b/packages/core/src/builtins/torbox-search/errors.ts @@ -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'; + } +} diff --git a/packages/core/src/builtins/torbox-search/index.ts b/packages/core/src/builtins/torbox-search/index.ts new file mode 100644 index 00000000..b3b55d1c --- /dev/null +++ b/packages/core/src/builtins/torbox-search/index.ts @@ -0,0 +1,2 @@ +export * from './addon'; +export * from './errors'; diff --git a/packages/core/src/builtins/torbox-search/schemas.ts b/packages/core/src/builtins/torbox-search/schemas.ts new file mode 100644 index 00000000..81168661 --- /dev/null +++ b/packages/core/src/builtins/torbox-search/schemas.ts @@ -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 = (dataSchema: z.ZodSchema) => + z.object({ + success: z.literal(true), + message: z.string().optional(), + detail: z.string().optional(), + data: z.union([z.null(), dataSchema]), + }); + +export const TorBoxApiResponseSchema = (dataSchema: z.ZodSchema) => + 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'), +}); diff --git a/packages/core/src/builtins/torbox-search/search-api.ts b/packages/core/src/builtins/torbox-search/search-api.ts new file mode 100644 index 00000000..bbb707cf --- /dev/null +++ b/packages/core/src/builtins/torbox-search/search-api.ts @@ -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 = { + success: true; + message?: string; + data: T | null; +}; + +type TorboxErrorResponse = { + success: false; + error: string; + detail?: string; + message?: string; + data: null; +}; + +type TorboxResponse = TorboxSuccessResponse | 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( + response: TorboxResponse +): 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>(); + private static readonly timeout = + Env.BUILTIN_TORBOX_SEARCH_SEARCH_API_TIMEOUT; + + constructor(private readonly apiKey: string) {} + + private createRequestLock( + key: string, + executor: () => Promise + ): Promise { + 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( + endpoint: string, + schema: z.ZodSchema, + { + body, + method = 'GET', + params, + ...options + }: Omit & { + timeout?: number; + params?: URLSearchParams; + } = {} + ): Promise { + 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; + + 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> { + 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>( + 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>( + endpoint, + TorBoxSearchApiDataSchema, + { + params: new URLSearchParams( + Object.fromEntries( + Object.entries(params).filter(([_, value]) => value !== undefined) + ) + ), + } + ) + ); + } +} + +export default TorboxSearchApi; diff --git a/packages/core/src/builtins/torbox-search/source-handlers.ts b/packages/core/src/builtins/torbox-search/source-handlers.ts new file mode 100644 index 00000000..f5d34822 --- /dev/null +++ b/packages/core/src/builtins/torbox-search/source-handlers.ts @@ -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( + 'torbox-search-torrents' + ); + protected metadataCache = Cache.getInstance( + 'torbox-search-metadata' + ); + + protected errorStreams: Stream[] = []; + + constructor(protected searchApi: TorboxSearchApi) {} + + abstract getStreams( + parsedId: ParsedId, + userData: z.infer + ): Promise; + + protected createStream( + torrent: Torrent, + file: DebridFile, + userData: z.infer, + 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['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 + ): Promise { + 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 { + 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> { + const allFiles = new Map(); + 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 + ): Promise { + 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); + }); + } +} diff --git a/packages/core/src/builtins/torbox-search/torrent.ts b/packages/core/src/builtins/torbox-search/torrent.ts new file mode 100644 index 00000000..b3f690e3 --- /dev/null +++ b/packages/core/src/builtins/torbox-search/torrent.ts @@ -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['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, + })); +} diff --git a/packages/core/src/builtins/utils/id-parser.ts b/packages/core/src/builtins/utils/id-parser.ts new file mode 100644 index 00000000..a00bccdb --- /dev/null +++ b/packages/core/src/builtins/utils/id-parser.ts @@ -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)[:-]?(?\d+)(?::(?\d+):(?\d+))?$/, + format: (id) => `tt${id}`, + }, + { + type: 'mal_id', + prefixes: ['mal'], + regex: /^mal[:-]?(?\d+)(?::(?\d+))?$/, + format: (id) => id, + }, + { + type: 'thetvdb_id', + prefixes: ['tvdb'], + regex: /^tvdb[:-]?(?\d+)(?::(?\d+):(?\d+))?$/, + format: (id) => id, + }, + { + type: 'tmdb', + prefixes: ['tmdb'], + regex: /^tmdb[:-]?(?\d+)(?::(?\d+):(?\d+))?$/, + format: (id) => id, + }, + { + type: 'kitsu_id', + prefixes: ['kitsu'], + regex: /^kitsu[:-]?(?\d+)(?::(?\d+))?$/, + format: (id) => id, + }, + { + type: 'anilist_id', + prefixes: ['anilist'], + regex: /^anilist[:-]?(?\d+)(?::(?\d+))?$/, + format: (id) => id, + }, + { + type: 'anidb_id', + prefixes: ['anidb', 'anidb_id', 'anidbid'], + regex: /^(?:anidb|anidb_id|anidbid)[:-]?(?\d+)(?::(?\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; + } +} diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 59dd8395..209752a6 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -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({ diff --git a/packages/core/src/db/users.ts b/packages/core/src/db/users.ts index 93140986..2d187c85 100644 --- a/packages/core/src/db/users.ts +++ b/packages/core/src/db/users.ts @@ -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 { + static async deleteUser(uuid: string, password: string): Promise { 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; diff --git a/packages/core/src/debrid/index.ts b/packages/core/src/debrid/index.ts new file mode 100644 index 00000000..fc141f79 --- /dev/null +++ b/packages/core/src/debrid/index.ts @@ -0,0 +1 @@ +export * from './interface'; diff --git a/packages/core/src/debrid/interface.ts b/packages/core/src/debrid/interface.ts new file mode 100644 index 00000000..0f3d7492 --- /dev/null +++ b/packages/core/src/debrid/interface.ts @@ -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; + +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 { + 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 { + 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; + } +} diff --git a/packages/core/src/debrid/utils.ts b/packages/core/src/debrid/utils.ts new file mode 100644 index 00000000..7e738034 --- /dev/null +++ b/packages/core/src/debrid/utils.ts @@ -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)); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0aaec49c..2c37085f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 0e72e361..5872878f 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -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('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 { + private async getMetadata( + id: string + ): Promise { 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; diff --git a/packages/core/src/metadata/imdb.ts b/packages/core/src/metadata/imdb.ts new file mode 100644 index 00000000..06484817 --- /dev/null +++ b/packages/core/src/metadata/imdb.ts @@ -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; + 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 { + 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; + } +} diff --git a/packages/core/src/metadata/kitsu.ts b/packages/core/src/metadata/kitsu.ts new file mode 100644 index 00000000..2b671f06 --- /dev/null +++ b/packages/core/src/metadata/kitsu.ts @@ -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; + 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 { + 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]), + }; + } +} diff --git a/packages/core/src/utils/metadata.ts b/packages/core/src/metadata/tmdb.ts similarity index 82% rename from packages/core/src/utils/metadata.ts rename to packages/core/src/metadata/tmdb.ts index 820242e8..e60bcdb0 100644 --- a/packages/core/src/utils/metadata.ts +++ b/packages/core/src/metadata/tmdb.ts @@ -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; - private readonly metadataCache: Cache; - private readonly accessToken: string; + private readonly metadataCache: Cache; + private readonly accessToken: string | undefined; + private readonly apiKey: string | undefined; private readonly validationCache: Cache; - 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('tmdb_id_conversion'); - this.metadataCache = Cache.getInstance('tmdb_metadata'); + this.metadataCache = Cache.getInstance( + 'tmdb_metadata' + ); this.validationCache = Cache.getInstance( 'tmdb_validation' ); @@ -53,6 +53,7 @@ export class TMDBMetadata { private getHeaders(): Record { 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 { + ): Promise { 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(), diff --git a/packages/core/src/metadata/utils.ts b/packages/core/src/metadata/utils.ts new file mode 100644 index 00000000..80e9ef35 --- /dev/null +++ b/packages/core/src/metadata/utils.ts @@ -0,0 +1,9 @@ +export interface Metadata { + title: string; + titles?: string[]; + year: number; + seasons?: { + season_number: number; + episode_count: number; + }[]; +} diff --git a/packages/core/src/parser/regex.ts b/packages/core/src/parser/regex.ts index 951ecdb9..ca463ca5 100644 --- a/packages/core/src/parser/regex.ts +++ b/packages/core/src/parser/regex.ts @@ -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))|((? 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 + ): Promise { + return [this.generateAddon(userData, options)]; + } + + private static generateAddon( + userData: UserData, + options: Record + ): 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 + ) { + 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`; + } +} diff --git a/packages/core/src/presets/mediafusion.ts b/packages/core/src/presets/mediafusion.ts index 1e3cc85e..9b6e0390 100644 --- a/packages/core/src/presets/mediafusion.ts +++ b/packages/core/src/presets/mediafusion.ts @@ -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[] { diff --git a/packages/core/src/presets/presetManager.ts b/packages/core/src/presets/presetManager.ts index 86121fdd..691aba7e 100644 --- a/packages/core/src/presets/presetManager.ts +++ b/packages/core/src/presets/presetManager.ts @@ -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`); } diff --git a/packages/core/src/presets/stremthruStore.ts b/packages/core/src/presets/stremthruStore.ts index ab65b381..bd18d15f 100644 --- a/packages/core/src/presets/stremthruStore.ts +++ b/packages/core/src/presets/stremthruStore.ts @@ -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 { diff --git a/packages/core/src/presets/torboxSearch.ts b/packages/core/src/presets/torboxSearch.ts new file mode 100644 index 00000000..7a2a8283 --- /dev/null +++ b/packages/core/src/presets/torboxSearch.ts @@ -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 + ): Promise { + 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, + 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 + ) { + 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`; + } +} diff --git a/packages/core/src/presets/usaTv.ts b/packages/core/src/presets/usaTv.ts index 18acfb91..0c6be1d1 100644 --- a/packages/core/src/presets/usaTv.ts +++ b/packages/core/src/presets/usaTv.ts @@ -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 { diff --git a/packages/core/src/proxy/base.ts b/packages/core/src/proxy/base.ts index c12fb03c..76b1c697 100644 --- a/packages/core/src/proxy/base.ts +++ b/packages/core/src/proxy/base.ts @@ -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; } diff --git a/packages/core/src/streams/filterer.ts b/packages/core/src/streams/filterer.ts index d0941032..e2d59597 100644 --- a/packages/core/src/streams/filterer.ts +++ b/packages/core/src/streams/filterer.ts @@ -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') { diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index f324be3b..11fbb178 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -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 { // 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()); diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index afc542f3..b8bccb98 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -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 = { 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'; diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 78903a0a..614e7430 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -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', + }), }); diff --git a/packages/core/src/utils/feature.ts b/packages/core/src/utils/feature.ts index 0a936eab..38da20f5 100644 --- a/packages/core/src/utils/feature.ts +++ b/packages/core/src/utils/feature.ts @@ -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 = (() => { const map = new Map(); 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': diff --git a/packages/core/src/utils/http.ts b/packages/core/src/utils/http.ts index 4776f839..c6747d4d 100644 --- a/packages/core/src/utils/http.ts +++ b/packages/core/src/utils/http.ts @@ -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); diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index c4bf4135..1c174ec5 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -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'; diff --git a/packages/core/src/utils/logger.ts b/packages/core/src/utils/logger.ts index 9957155f..5483b1f1 100644 --- a/packages/core/src/utils/logger.ts +++ b/packages/core/src/utils/logger.ts @@ -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 diff --git a/packages/core/src/utils/startup.ts b/packages/core/src/utils/startup.ts index 2e020711..c1ed9036 100644 --- a/packages/core/src/utils/startup.ts +++ b/packages/core/src/utils/startup.ts @@ -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'); diff --git a/packages/frontend/src/app/oauth/callback/gdrive/page.tsx b/packages/frontend/src/app/oauth/callback/gdrive/page.tsx new file mode 100644 index 00000000..ffc31cb7 --- /dev/null +++ b/packages/frontend/src/app/oauth/callback/gdrive/page.tsx @@ -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(null); + const [error, setError] = useState(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 ( + + + + ); + } + + if (error) { + return ( + + +

{error}

+
+
+ ); + } + + return ( +
+ +

Google Drive Authorisation

+
+
+

Authorisation successful! Please follow these steps:

+
    +
  1. Copy the refresh token below
  2. +
  3. Return to the previous tab
  4. +
  5. Paste the refresh token into the Refresh Token field
  6. +
+
+ {code && ( +
+
+

{code}

+ } + intent="primary-subtle" + className="absolute top-1/2 -translate-y-1/2 right-2" + onClick={handleCopy} + aria-label="Copy authorization code" + /> +
+
+ )} +
+
+ +
+ ); +} + +export default function OAuthCallback() { + return ( + + + + ); +} diff --git a/packages/frontend/src/components/menu/addons.tsx b/packages/frontend/src/components/menu/addons.tsx index 55bcfe6c..87a40c57 100644 --- a/packages/frontend/src/components/menu/addons.tsx +++ b/packages/frontend/src/components/menu/addons.tsx @@ -806,7 +806,13 @@ function AddonCard({ onAdd: () => void; }) { return ( -
+
+ {/* Built-in ribbon */} + {preset.BUILTIN && ( +
+ Built-in +
+ )} {/* Top: Logo + Name/Description */}
{preset.ID === 'custom' ? ( diff --git a/packages/frontend/src/components/menu/filters.tsx b/packages/frontend/src/components/menu/filters.tsx index a05e54b6..897f9d75 100644 --- a/packages/frontend/src/components/menu/filters.tsx +++ b/packages/frontend/src/components/menu/filters.tsx @@ -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() { Stream Expression - {status?.settings.regexFilterAccess !== 'none' && ( + {(status?.settings.regexFilterAccess !== 'none' || + status?.settings.allowedRegexPatterns) && ( Regex @@ -1155,6 +1157,24 @@ function Content() { }} /> + { + 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." + /> +