diff --git a/.gitignore b/.gitignore index 933bdda2..4150dcb2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ out/ .next/ next-env.d.ts .wrangler/ -.env \ No newline at end of file +.env +metadata.json +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b146be96..d13430eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,9 @@ COPY LICENSE ./ # Copy the relevant package.json and package-lock.json files. COPY package*.json ./ -COPY packages/formatters/package*.json ./packages/formatters/ -COPY packages/parser/package*.json ./packages/parser/ -COPY packages/types/package*.json ./packages/types/ -COPY packages/wrappers/package*.json ./packages/wrappers/ -COPY packages/addon/package*.json ./packages/addon/ +COPY packages/server/package*.json ./packages/server/ +COPY packages/core/package*.json ./packages/core/ COPY packages/frontend/package*.json ./packages/frontend/ -COPY packages/utils/package*.json ./packages/utils/ # Install dependencies. RUN npm install @@ -21,13 +17,12 @@ RUN npm install # Copy source files. COPY tsconfig.*json ./ -COPY packages/addon ./packages/addon -COPY packages/formatters ./packages/formatters -COPY packages/parser ./packages/parser -COPY packages/types ./packages/types -COPY packages/wrappers ./packages/wrappers +COPY packages/server ./packages/server +COPY packages/core ./packages/core COPY packages/frontend ./packages/frontend -COPY packages/utils ./packages/utils +COPY scripts ./scripts +COPY resources ./resources + # Build the project. RUN npm run build @@ -43,25 +38,18 @@ WORKDIR /app # The package.json files must be copied as well for NPM workspace symlinks between local packages to work. COPY --from=builder /build/package*.json /build/LICENSE ./ -COPY --from=builder /build/packages/addon/package.*json ./packages/addon/ +COPY --from=builder /build/packages/core/package.*json ./packages/core/ COPY --from=builder /build/packages/frontend/package.*json ./packages/frontend/ -COPY --from=builder /build/packages/formatters/package.*json ./packages/formatters/ -COPY --from=builder /build/packages/parser/package.*json ./packages/parser/ -COPY --from=builder /build/packages/types/package.*json ./packages/types/ -COPY --from=builder /build/packages/wrappers/package.*json ./packages/wrappers/ -COPY --from=builder /build/packages/utils/package.*json ./packages/utils/ +COPY --from=builder /build/packages/server/package.*json ./packages/server/ - -COPY --from=builder /build/packages/addon/dist ./packages/addon/dist +COPY --from=builder /build/packages/core/out ./packages/core/out COPY --from=builder /build/packages/frontend/out ./packages/frontend/out -COPY --from=builder /build/packages/formatters/dist ./packages/formatters/dist -COPY --from=builder /build/packages/parser/dist ./packages/parser/dist -COPY --from=builder /build/packages/types/dist ./packages/types/dist -COPY --from=builder /build/packages/wrappers/dist ./packages/wrappers/dist -COPY --from=builder /build/packages/utils/dist ./packages/utils/dist +COPY --from=builder /build/packages/server/dist ./packages/server/dist + +COPY --from=builder /build/resources ./resources COPY --from=builder /build/node_modules ./node_modules EXPOSE 3000 -ENTRYPOINT ["npm", "run", "start:addon"] \ No newline at end of file +ENTRYPOINT ["npm", "run", "start"] \ No newline at end of file diff --git a/package.json b/package.json index bc24b516..875c9db4 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,20 @@ { "name": "aiostreams", - "version": "1.21.1", - "description": "Stremio addon to combine streams into one addon", - "main": "dist/server.js", + "version": "2.0.0", + "description": "AIOStreams consolidates multiple Stremio addons and debrid services into a single, easily configurable addon. It allows highly customisable filtering, sorting, and formatting of results and supports proxying all your streams through MediaFlow Proxy or StremThru for improved compatibility and IP restriction bypassing.", + "main": "dist/index.js", "scripts": { "test": "npm run test --workspaces", "release": "commit-and-tag-version", "format": "prettier --write .", - "build": "npm -w packages/types run build && npm -w packages/utils run build && npm -w packages/parser run build && npm -w packages/formatters run build && npm -w packages/wrappers run build && npm -w packages/addon run build && npm -w packages/frontend run build", + "prebuild": "node scripts/generateMetadata.js", + "build": "npm -w packages/core run build && npm -w packages/server run build && npm -w packages/frontend run build", "build:watch": "tsc --build --watch", - "start": "npm -w packages/addon start", - "start:addon": "npm -w packages/addon start", - "start:addon:dev": "npm -w packages/addon run start:dev", - "start:frontend:dev": "npm -w packages/frontend run dev", - "start:cloudflare-worker:dev": "npm -w packages/cloudflare-worker run dev", - "deploy:beamup": "beamup", - "deploy:cloudflare-worker": "npm -w packages/cloudflare-worker run deploy" + "start": "node packages/server/dist/server", + "start:addon": "npm run start", + "start:dev": "cross-env NODE_ENV=development tsx watch packages/server/src/server.ts", + "start:addon:dev": "npm run start:dev", + "start:frontend:dev": "npm -w packages/frontend run dev" }, "author": "Viren070", "license": "MIT", @@ -23,20 +22,14 @@ "packages/*" ], "devDependencies": { - "@types/node": "^20.14.10", - "beamup-cli": "^1.3.0", - "commit-and-tag-version": "^12.5.0", "cross-env": "^7.0.3", "prettier": "^3.3.2", "tsx": "^4.16.2", "typescript": "^5.5.3", - "vitest": "^2.1.5" + "vitest": "^2.1.5", + "ts-node": "^10.9.2" }, "engines": { "node": ">=20.0.0" - }, - "dependencies": { - "super-regex": "^1.0.0", - "undici": "^7.2.3" } -} +} \ No newline at end of file diff --git a/packages/addon/package.json b/packages/addon/package.json deleted file mode 100644 index d1675917..00000000 --- a/packages/addon/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@aiostreams/addon", - "version": "1.21.1", - "main": "dist/index.js", - "scripts": { - "test": "vitest run --passWithNoTests", - "test:watch": "vitest watch", - "build": "tsc", - "prepublish": "npm run build", - "start": "node dist/server.js", - "start:dev": "cross-env NODE_ENV=dev tsx watch src/server.ts" - }, - "description": "Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.", - "dependencies": { - "@aiostreams/formatters": "^1.0.0", - "@aiostreams/types": "^1.0.0", - "@aiostreams/utils": "^1.0.0", - "@aiostreams/wrappers": "^1.0.0", - "dotenv": "^16.4.7", - "express": "^4.21.2" - }, - "devDependencies": { - "@types/express": "^5.0.0" - } -} diff --git a/packages/addon/src/addon.ts b/packages/addon/src/addon.ts deleted file mode 100644 index caf6e363..00000000 --- a/packages/addon/src/addon.ts +++ /dev/null @@ -1,1416 +0,0 @@ -import { - BaseWrapper, - getCometStreams, - getDebridioStreams, - getDMMCastStreams, - getEasynewsPlusPlusStreams, - getEasynewsPlusStreams, - getEasynewsStreams, - getJackettioStreams, - getMediafusionStreams, - getOrionStreams, - getPeerflixStreams, - getStremioJackettStreams, - getStremThruStoreStreams, - getTorboxStreams, - getTorrentioStreams, -} from '@aiostreams/wrappers'; -import { - Stream, - ParsedStream, - StreamRequest, - Config, - ErrorStream, -} from '@aiostreams/types'; -import { - gdriveFormat, - torrentioFormat, - torboxFormat, - imposterFormat, - customFormat, -} from '@aiostreams/formatters'; -import { - addonDetails, - getMediaFlowConfig, - getMediaFlowPublicIp, - getTimeTakenSincePoint, - Settings, - createLogger, - generateMediaFlowStreams, - getStremThruConfig, - getStremThruPublicIp, - generateStremThruStreams, - safeRegexTest, - compileRegex, - formRegexFromKeywords, -} from '@aiostreams/utils'; -import { errorStream } from './responses'; -import { isMatch } from 'super-regex'; - -const logger = createLogger('addon'); - -export class AIOStreams { - private config: Config; - - constructor(config: any) { - this.config = config; - } - - private async retryGetIp( - getter: () => Promise, - label: string, - maxRetries: number = 3 - ): Promise { - for (let attempt = 1; attempt <= maxRetries; attempt++) { - const result = await getter(); - if (result) { - return result; - } - logger.warn( - `Failed to get ${label}, retrying... (${attempt}/${maxRetries})` - ); - } - throw new Error(`Failed to get ${label} after ${maxRetries} attempts`); - } - - private async getRequestingIp() { - let userIp = this.config.requestingIp; - const PRIVATE_IP_REGEX = - /^(::1|::ffff:(10|127|192|172)\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|10\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|192\.168\.(\d{1,3})\.(\d{1,3})|172\.(1[6-9]|2[0-9]|3[0-1])\.(\d{1,3})\.(\d{1,3}))$/; - - if (userIp && PRIVATE_IP_REGEX.test(userIp)) { - userIp = undefined; - } - const mediaflowConfig = getMediaFlowConfig(this.config); - const stremThruConfig = getStremThruConfig(this.config); - if (mediaflowConfig.mediaFlowEnabled) { - userIp = await this.retryGetIp( - () => getMediaFlowPublicIp(mediaflowConfig), - 'MediaFlow public IP' - ); - } else if (stremThruConfig.stremThruEnabled) { - userIp = await this.retryGetIp( - () => getStremThruPublicIp(stremThruConfig), - 'StremThru public IP' - ); - } - return userIp; - } - - public async getStreams(streamRequest: StreamRequest): Promise { - const streams: Stream[] = []; - const startTime = new Date().getTime(); - - try { - this.config.requestingIp = await this.getRequestingIp(); - } catch (error) { - logger.error(error); - return [errorStream(`Failed to get Proxy IP`)]; - } - - const { parsedStreams, errorStreams } = - await this.getParsedStreams(streamRequest); - - const skipReasons = { - excludeLanguages: 0, - excludeResolutions: 0, - excludeQualities: 0, - excludeEncodes: 0, - excludeAudioTags: 0, - excludeVisualTags: 0, - excludeStreamTypes: 0, - excludeUncached: 0, - sizeFilters: 0, - duplicateStreams: 0, - streamLimiters: 0, - excludeRegex: 0, - requiredRegex: 0, - }; - - logger.info( - `Got ${parsedStreams.length} parsed streams and ${errorStreams.length} error streams in ${getTimeTakenSincePoint(startTime)}` - ); - - const excludeRegexPattern = this.config.apiKey - ? this.config.regexFilters?.excludePattern || - Settings.DEFAULT_REGEX_EXCLUDE_PATTERN - : undefined; - const excludeRegex = excludeRegexPattern - ? compileRegex(excludeRegexPattern, 'i') - : undefined; - - const excludeKeywordsRegex = this.config.excludeFilters - ? formRegexFromKeywords(this.config.excludeFilters) - : undefined; - - const requiredRegexPattern = this.config.apiKey - ? this.config.regexFilters?.includePattern || - Settings.DEFAULT_REGEX_INCLUDE_PATTERN - : undefined; - const requiredRegex = requiredRegexPattern - ? compileRegex(requiredRegexPattern, 'i') - : undefined; - - const requiredKeywordsRegex = this.config.strictIncludeFilters - ? formRegexFromKeywords(this.config.strictIncludeFilters) - : undefined; - - const sortRegexPatterns = this.config.apiKey - ? this.config.regexSortPatterns || Settings.DEFAULT_REGEX_SORT_PATTERNS - : undefined; - - const sortRegexes: { name?: string; regex: RegExp }[] | undefined = - sortRegexPatterns - ? sortRegexPatterns - .split(/\s+/) - .filter(Boolean) - .map((pattern) => { - const delimiter = '<::>'; - const delimiterIndex = pattern.indexOf(delimiter); - if (delimiterIndex !== -1) { - const name = pattern - .slice(0, delimiterIndex) - .replace(/_/g, ' '); - const regexPattern = pattern.slice( - delimiterIndex + delimiter.length - ); - - const regex = compileRegex(regexPattern, 'i'); - return { name, regex }; - } - return { regex: compileRegex(pattern, 'i') }; - }) - : undefined; - - excludeRegex || - excludeKeywordsRegex || - requiredRegex || - requiredKeywordsRegex || - sortRegexes - ? logger.debug( - `The following regex patterns are being used:\n` + - `Exclude Regex: ${excludeRegex}\n` + - `Exclude Keywords: ${excludeKeywordsRegex}\n` + - `Required Regex: ${requiredRegex}\n` + - `Required Keywords: ${requiredKeywordsRegex}\n` + - `Sort Regexes: ${sortRegexes?.map((regex) => `${regex.name || 'Unnamed'}: ${regex.regex}`).join(' --> ')}\n` - ) - : []; - - const filterStartTime = new Date().getTime(); - - let filteredResults = parsedStreams.filter((parsedStream) => { - const streamTypeFilter = this.config.streamTypes?.find( - (streamType) => streamType[parsedStream.type] === false - ); - if (this.config.streamTypes && streamTypeFilter) { - skipReasons.excludeStreamTypes++; - return false; - } - - const resolutionFilter = this.config.resolutions?.find( - (resolution) => resolution[parsedStream.resolution] === false - ); - if (resolutionFilter) { - skipReasons.excludeResolutions++; - return false; - } - - const qualityFilter = this.config.qualities?.find( - (quality) => quality[parsedStream.quality] === false - ); - if (this.config.qualities && qualityFilter) { - skipReasons.excludeQualities++; - return false; - } - - // Check for HDR and DV tags in the parsed stream - const hasHDR = parsedStream.visualTags.some((tag) => - tag.startsWith('HDR') - ); - const hasDV = parsedStream.visualTags.includes('DV'); - const hasHDRAndDV = hasHDR && hasDV; - const HDRAndDVEnabled = this.config.visualTags.some( - (visualTag) => visualTag['HDR+DV'] === true - ); - - const isTagDisabled = (tag: string) => - this.config.visualTags.some((visualTag) => visualTag[tag] === false); - - if (hasHDRAndDV) { - if (!HDRAndDVEnabled) { - skipReasons.excludeVisualTags++; - return false; - } - } else if (hasHDR) { - const specificHdrTags = parsedStream.visualTags.filter((tag) => - tag.startsWith('HDR') - ); - const disabledTags = specificHdrTags.filter( - (tag) => isTagDisabled(tag) === true - ); - if (disabledTags.length > 0) { - skipReasons.excludeVisualTags++; - return; - } - } else if (hasDV && isTagDisabled('DV')) { - skipReasons.excludeVisualTags++; - return false; - } - - // Check other visual tags for explicit disabling - for (const tag of parsedStream.visualTags) { - if (tag.startsWith('HDR') || tag === 'DV') continue; - if (isTagDisabled(tag)) { - skipReasons.excludeVisualTags++; - return false; - } - } - - // apply excludedLanguages filter - const excludedLanguages = this.config.excludedLanguages; - if (excludedLanguages && parsedStream.languages.length > 0) { - if ( - parsedStream.languages.every((lang) => - excludedLanguages.includes(lang) - ) - ) { - skipReasons.excludeLanguages++; - return false; - } - } else if ( - excludedLanguages && - excludedLanguages.includes('Unknown') && - parsedStream.languages.length === 0 - ) { - skipReasons.excludeLanguages++; - return false; - } - - const audioTagFilter = parsedStream.audioTags.find((tag) => - this.config.audioTags.some((audioTag) => audioTag[tag] === false) - ); - if (audioTagFilter) { - skipReasons.excludeAudioTags++; - return false; - } - - if ( - parsedStream.encode && - this.config.encodes.some( - (encode) => encode[parsedStream.encode] === false - ) - ) { - skipReasons.excludeEncodes++; - return false; - } - - if ( - this.config.onlyShowCachedStreams && - parsedStream.provider && - !parsedStream.provider.cached - ) { - skipReasons.excludeUncached++; - return false; - } - - if ( - this.config.minSize && - parsedStream.size && - parsedStream.size < this.config.minSize - ) { - skipReasons.sizeFilters++; - return false; - } - - if ( - this.config.maxSize && - parsedStream.size && - parsedStream.size > this.config.maxSize - ) { - skipReasons.sizeFilters++; - return false; - } - - if ( - streamRequest.type === 'movie' && - this.config.maxMovieSize && - parsedStream.size && - parsedStream.size > this.config.maxMovieSize - ) { - skipReasons.sizeFilters++; - return false; - } - - if ( - streamRequest.type === 'movie' && - this.config.minMovieSize && - parsedStream.size && - parsedStream.size < this.config.minMovieSize - ) { - skipReasons.sizeFilters++; - return false; - } - - if ( - streamRequest.type === 'series' && - this.config.maxEpisodeSize && - parsedStream.size && - parsedStream.size > this.config.maxEpisodeSize - ) { - skipReasons.sizeFilters++; - return false; - } - - if ( - streamRequest.type === 'series' && - this.config.minEpisodeSize && - parsedStream.size && - parsedStream.size < this.config.minEpisodeSize - ) { - skipReasons.sizeFilters++; - return false; - } - - // generate array of excludeTests. for each regex, only add to array if the filename or indexers are defined - let excludeTests: (boolean | null)[] = []; - let requiredTests: (boolean | null)[] = []; - - const addToTests = (field: string | undefined) => { - if (field) { - excludeTests.push( - excludeRegex ? safeRegexTest(excludeRegex, field) : null, - excludeKeywordsRegex - ? safeRegexTest(excludeKeywordsRegex, field) - : null - ); - requiredTests.push( - requiredRegex ? safeRegexTest(requiredRegex, field) : null, - requiredKeywordsRegex - ? safeRegexTest(requiredKeywordsRegex, field) - : null - ); - } - }; - - addToTests(parsedStream.filename); - addToTests(parsedStream.folderName); - addToTests(parsedStream.indexers); - - // filter out any null values as these are when the regex is not defined - excludeTests = excludeTests.filter((test) => test !== null); - requiredTests = requiredTests.filter((test) => test !== null); - - if (excludeTests.length > 0 && excludeTests.some((test) => test)) { - skipReasons.excludeRegex++; - return false; - } - - if (requiredTests.length > 0 && !requiredTests.some((test) => test)) { - skipReasons.requiredRegex++; - return false; - } - - return true; - }); - - logger.info( - `Initial filter to ${filteredResults.length} streams in ${getTimeTakenSincePoint(filterStartTime)}` - ); - - if (this.config.cleanResults) { - const cleanedStreams: ParsedStream[] = []; - const initialStreams = filteredResults; - const normaliseFilename = (filename?: string): string | undefined => - filename - ? filename - ?.replace( - /\.(mkv|mp4|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|3g2|m2ts|ts|vob|ogv|ogm|divx|xvid|rm|rmvb|asf|mxf|mka|mks|mk3d|webm|f4v|f4p|f4a|f4b)$/i, - '' - ) - .replace(/[^\p{L}\p{N}+]/gu, '') - .replace(/\s+/g, '') - .toLowerCase() - : undefined; - - const groupStreamsByKey = ( - streams: ParsedStream[], - keyExtractor: (stream: ParsedStream) => string | undefined - ): Record => { - return streams.reduce( - (acc, stream) => { - const key = keyExtractor(stream); - if (!key) { - if (!cleanedStreams.includes(stream)) { - cleanedStreams.push(stream); - } - return acc; - } - acc[key] = acc[key] || []; - acc[key].push(stream); - return acc; - }, - {} as Record - ); - }; - - const cleanResultsStartTime = new Date().getTime(); - // Deduplication by normalised filename - const cleanResultsByFilenameStartTime = new Date().getTime(); - logger.info(`Received ${initialStreams.length} streams to clean`); - const streamsGroupedByFilename = groupStreamsByKey( - initialStreams, - (stream) => normaliseFilename(stream.filename) - ); - - logger.info( - `Found ${Object.keys(streamsGroupedByFilename).length} unique filenames with ${ - initialStreams.length - - Object.values(streamsGroupedByFilename).reduce( - (sum, group) => sum + group.length, - 0 - ) - } streams not grouped` - ); - - // Process grouped streams by filename - const cleanedStreamsByFilename = await this.processGroupedStreams( - streamsGroupedByFilename - ); - - logger.info( - `Deduplicated streams by filename to ${cleanedStreamsByFilename.length} streams in ${getTimeTakenSincePoint(cleanResultsByFilenameStartTime)}` - ); - - // Deduplication by hash - const cleanResultsByHashStartTime = new Date().getTime(); - - const streamsGroupedByHash = groupStreamsByKey( - cleanedStreamsByFilename, - (stream) => stream._infoHash - ); - logger.info( - `Found ${Object.keys(streamsGroupedByHash).length} unique hashes with ${cleanedStreamsByFilename.length - Object.values(streamsGroupedByHash).reduce((sum, group) => sum + group.length, 0)} streams not grouped` - ); - - // Process grouped streams by hash - const cleanedStreamsByHash = - await this.processGroupedStreams(streamsGroupedByHash); - - logger.info( - `Deduplicated streams by hash to ${cleanedStreamsByHash.length} streams in ${getTimeTakenSincePoint(cleanResultsByHashStartTime)}` - ); - - cleanedStreams.push(...cleanedStreamsByHash); - logger.info( - `Deduplicated streams to ${cleanedStreams.length} streams in ${getTimeTakenSincePoint(cleanResultsStartTime)}` - ); - skipReasons.duplicateStreams = - filteredResults.length - cleanedStreams.length; - filteredResults = cleanedStreams; - } - // pre compute highest indexes for regexSortPatterns - const startPrecomputeTime = new Date().getTime(); - filteredResults.forEach((stream: ParsedStream) => { - if (sortRegexes) { - for (let i = 0; i < sortRegexes.length; i++) { - if (!stream.filename && !stream.folderName) continue; - const regex = sortRegexes[i]; - if ( - (stream.filename && isMatch(regex.regex, stream.filename)) || - (stream.folderName && isMatch(regex.regex, stream.folderName)) - ) { - stream.regexMatched = { - name: regex.name, - pattern: regex.regex.source, - index: i, - }; - break; - } - } - } - }); - logger.info( - `Precomputed sortRegex indexes for ${filteredResults.length} streams in ${getTimeTakenSincePoint( - startPrecomputeTime - )}` - ); - // Apply sorting - const sortStartTime = new Date().getTime(); - // initially sort by filename to ensure consistent results - filteredResults.sort((a, b) => - a.filename && b.filename ? a.filename.localeCompare(b.filename) : 0 - ); - - // then apply our this.config sorting - filteredResults.sort((a, b) => { - for (const sortByField of this.config.sortBy) { - const field = Object.keys(sortByField).find( - (key) => typeof sortByField[key] === 'boolean' - ); - if (!field) continue; - const value = sortByField[field]; - - if (value) { - const fieldComparison = this.compareByField(a, b, field); - if (fieldComparison !== 0) return fieldComparison; - } - } - - return 0; - }); - - logger.info(`Sorted results in ${getTimeTakenSincePoint(sortStartTime)}`); - - // apply config.maxResultsPerResolution - if (this.config.maxResultsPerResolution) { - const startTime = new Date().getTime(); - const resolutionCounts = new Map(); - - const limitedResults = filteredResults.filter((result) => { - const resolution = result.resolution || 'Unknown'; - const currentCount = resolutionCounts.get(resolution) || 0; - - if (currentCount < this.config.maxResultsPerResolution!) { - resolutionCounts.set(resolution, currentCount + 1); - return true; - } - - return false; - }); - skipReasons.streamLimiters = - filteredResults.length - limitedResults.length; - filteredResults = limitedResults; - - logger.info( - `Limited results to ${limitedResults.length} streams after applying maxResultsPerResolution in ${new Date().getTime() - startTime}ms` - ); - } - - const totalSkipped = Object.values(skipReasons).reduce( - (acc, val) => acc + val, - 0 - ); - const reportLines = [ - '╔═══════════════════════╤════════════╗', - '║ Skip Reason │ Count ║', - '╟───────────────────────┼────────────╢', - ...Object.entries(skipReasons) - .filter(([reason, count]) => count > 0) - .map( - ([reason, count]) => - `║ ${reason.padEnd(21)} │ ${String(count).padStart(10)} ║` - ), - '╟───────────────────────┼────────────╢', - `║ Total Skipped │ ${String(totalSkipped).padStart(10)} ║`, - '╚═══════════════════════╧════════════╝', - ]; - - if (totalSkipped > 0) logger.info('\n' + reportLines.join('\n')); - - // Create stream objects - const streamsStartTime = new Date().getTime(); - const streamObjects = await this.createStreamObjects(filteredResults); - streams.push(...streamObjects.filter((s) => s !== null)); - - // Add error streams to the end - streams.push( - ...errorStreams.map((e) => errorStream(e.error, e.addon.name)) - ); - - logger.info( - `Created ${streams.length} stream objects in ${getTimeTakenSincePoint(streamsStartTime)}` - ); - logger.info( - `Total time taken to get streams: ${getTimeTakenSincePoint(startTime)}` - ); - return streams; - } - - private shouldProxyStream( - stream: ParsedStream, - mediaFlowConfig: ReturnType, - stremThruConfig: ReturnType - ): boolean { - if (!stream.url) return false; - - const streamProvider = stream.provider ? stream.provider.id : 'none'; - - // // now check if mediaFlowConfig.proxiedAddons or mediaFlowConfig.proxiedServices is not null - // logger.info(this.config.mediaFlowConfig?.proxiedAddons); - // logger.info(stream.addon.id); - if ( - mediaFlowConfig.mediaFlowEnabled && - (!mediaFlowConfig.proxiedAddons?.length || - mediaFlowConfig.proxiedAddons.includes(stream.addon.id)) && - (!mediaFlowConfig.proxiedServices?.length || - mediaFlowConfig.proxiedServices.includes(streamProvider)) - ) { - return true; - } - - if ( - stremThruConfig.stremThruEnabled && - (!stremThruConfig.proxiedAddons?.length || - stremThruConfig.proxiedAddons.includes(stream.addon.id)) && - (!stremThruConfig.proxiedServices?.length || - stremThruConfig.proxiedServices.includes(streamProvider)) - ) { - return true; - } - - return false; - } - - private getFormattedText(parsedStream: ParsedStream): { - name: string; - description: string; - } { - switch (this.config.formatter) { - case 'gdrive': { - return gdriveFormat(parsedStream, false); - } - case 'minimalistic-gdrive': { - return gdriveFormat(parsedStream, true); - } - case 'imposter': { - return imposterFormat(parsedStream); - } - case 'torrentio': { - return torrentioFormat(parsedStream); - } - case 'torbox': { - return torboxFormat(parsedStream); - } - default: { - if ( - this.config.formatter.startsWith('custom:') && - this.config.formatter.length > 7 - ) { - const jsonString = this.config.formatter.slice(7); - const formatter = JSON.parse(jsonString); - if (formatter.name && formatter.description) { - try { - return customFormat(parsedStream, formatter); - } catch (error: any) { - logger.error( - `Error in custom formatter: ${error.message || error}, falling back to default formatter` - ); - return gdriveFormat(parsedStream, false); - } - } - } - - return gdriveFormat(parsedStream, false); - } - } - } - - private async createStreamObjects( - parsedStreams: ParsedStream[] - ): Promise { - const mediaFlowConfig = getMediaFlowConfig(this.config); - const stremThruConfig = getStremThruConfig(this.config); - - // Identify streams that require proxying - const streamsToProxy = parsedStreams - .map((stream, index) => ({ stream, index })) - .filter( - ({ stream }) => - stream.url && - this.shouldProxyStream(stream, mediaFlowConfig, stremThruConfig) - ); - - const proxiedUrls = streamsToProxy.length - ? mediaFlowConfig.mediaFlowEnabled - ? await generateMediaFlowStreams( - mediaFlowConfig, - streamsToProxy.map(({ stream }) => ({ - url: stream.url!, - filename: stream.filename, - headers: stream.stream?.behaviorHints?.proxyHeaders, - })) - ) - : stremThruConfig.stremThruEnabled - ? await generateStremThruStreams( - stremThruConfig, - streamsToProxy.map(({ stream }) => ({ - url: stream.url!, - filename: stream.filename, - headers: stream.stream?.behaviorHints?.proxyHeaders, - })) - ) - : null - : null; - - const removeIndexes = new Set(); - - // Apply proxied URLs and mark as proxied - streamsToProxy.forEach(({ stream, index }, i) => { - const proxiedUrl = proxiedUrls?.[i]; - if (proxiedUrl) { - stream.url = proxiedUrl; - stream.proxied = true; - } else { - removeIndexes.add(index); - } - }); - - // Remove streams that failed to proxy - if (removeIndexes.size > 0) { - logger.error( - `Failed to proxy ${removeIndexes.size} streams, removing them from the final list` - ); - parsedStreams = parsedStreams.filter( - (_, index) => !removeIndexes.has(index) - ); - } - - // Build final Stream objects - const proxyBingeGroupPrefix = mediaFlowConfig.mediaFlowEnabled - ? 'mfp.' - : stremThruConfig.stremThruEnabled - ? 'st.' - : ''; - const streamObjects: Stream[] = await Promise.all( - parsedStreams.map((parsedStream) => { - const { name, description } = this.getFormattedText(parsedStream); - - const combinedTags = [ - parsedStream.resolution, - parsedStream.quality, - parsedStream.encode, - ...parsedStream.visualTags, - ...parsedStream.audioTags, - ...parsedStream.languages, - ]; - - return { - url: parsedStream.url, - externalUrl: parsedStream.externalUrl, - infoHash: parsedStream.torrent?.infoHash, - fileIdx: parsedStream.torrent?.fileIdx, - name, - description, - subtitles: parsedStream.stream?.subtitles, - sources: parsedStream.torrent?.sources, - behaviorHints: { - videoSize: parsedStream.size - ? Math.floor(parsedStream.size) - : undefined, - filename: parsedStream.filename, - bingeGroup: `${parsedStream.proxied ? proxyBingeGroupPrefix : ''}${Settings.ADDON_ID}|${parsedStream.addon.name}|${combinedTags.join('|')}`, - proxyHeaders: parsedStream.stream?.behaviorHints?.proxyHeaders, - notWebReady: parsedStream.stream?.behaviorHints?.notWebReady, - }, - }; - }) - ); - - return streamObjects; - } - - private compareLanguages(a: ParsedStream, b: ParsedStream) { - if (this.config.prioritiseLanguage) { - const aHasPrioritisedLanguage = a.languages.includes( - this.config.prioritiseLanguage - ); - const bHasPrioritisedLanguage = b.languages.includes( - this.config.prioritiseLanguage - ); - - if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1; - if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1; - } - return 0; - } - - private compareByField(a: ParsedStream, b: ParsedStream, field: string) { - if (field === 'resolution') { - return ( - this.config.resolutions.findIndex( - (resolution) => resolution[a.resolution] - ) - - this.config.resolutions.findIndex( - (resolution) => resolution[b.resolution] - ) - ); - } else if (field === 'regexSort') { - const regexSortPatterns = - this.config.regexSortPatterns || Settings.DEFAULT_REGEX_SORT_PATTERNS; - if (!regexSortPatterns) return 0; - try { - // Get direction once - const direction = this.config.sortBy.find( - (sort) => Object.keys(sort)[0] === 'regexSort' - )?.direction; - - // Early exit if no filename to test - if (!a.filename && !b.filename) return 0; - if (!a.filename) return direction === 'asc' ? -1 : 1; - if (!b.filename) return direction === 'asc' ? 1 : -1; - - const aHighestIndex = a.regexMatched?.index; - const bHighestIndex = b.regexMatched?.index; - - // If both have a regex match, sort by the highest index - if (aHighestIndex !== undefined && bHighestIndex !== undefined) { - return direction === 'asc' - ? bHighestIndex - aHighestIndex - : aHighestIndex - bHighestIndex; - } - // If one has a regex match and the other doesn't, sort by the one that does - if (aHighestIndex !== undefined) return direction === 'asc' ? 1 : -1; - if (bHighestIndex !== undefined) return direction === 'asc' ? -1 : 1; - - // If both have no regex match, they are equal - return 0; - } catch (e) { - return 0; - } - } else if (field === 'cached') { - let aCanbeCached = a.provider; - let bCanbeCached = b.provider; - let aCached = a.provider?.cached; - let bCached = b.provider?.cached; - - // prioritise non debrid/usenet p2p over uncached - if (aCanbeCached && !bCanbeCached && !aCached) return 1; - if (!aCanbeCached && bCanbeCached && !bCached) return -1; - if (aCanbeCached && bCanbeCached) { - if (aCached === bCached) return 0; - // prioritise a false value over undefined - if (aCached === false && bCached === undefined) return -1; - if (aCached === undefined && bCached === false) return 1; - return this.config.sortBy.find( - (sort) => Object.keys(sort)[0] === 'cached' - )?.direction === 'asc' - ? aCached - ? 1 - : -1 // uncached > cached - : aCached - ? -1 - : 1; // cached > uncached - } - } else if (field === 'personal') { - // depending on direction, sort by personal or not personal - const direction = this.config.sortBy.find( - (sort) => Object.keys(sort)[0] === 'personal' - )?.direction; - if (direction === 'asc') { - // prefer not personal over personal - return a.personal === b.personal ? 0 : a.personal ? 1 : -1; - } - if (direction === 'desc') { - // prefer personal over not personal - return a.personal === b.personal ? 0 : a.personal ? -1 : 1; - } - } else if (field === 'service') { - // sort files with providers by name - let aProvider = a.provider?.id; - let bProvider = b.provider?.id; - - if (aProvider && bProvider) { - const aIndex = this.config.services.findIndex( - (service) => service.id === aProvider - ); - const bIndex = this.config.services.findIndex( - (service) => service.id === bProvider - ); - return aIndex - bIndex; - } - } else if (field === 'size') { - return this.config.sortBy.find((sort) => Object.keys(sort)[0] === 'size') - ?.direction === 'asc' - ? (a.size || 0) - (b.size || 0) - : (b.size || 0) - (a.size || 0); - } else if (field === 'seeders') { - if ( - a.torrent?.seeders !== undefined && - b.torrent?.seeders !== undefined - ) { - return this.config.sortBy.find( - (sort) => Object.keys(sort)[0] === 'seeders' - )?.direction === 'asc' - ? a.torrent.seeders - b.torrent.seeders - : b.torrent.seeders - a.torrent.seeders; - } else if ( - a.torrent?.seeders !== undefined && - b.torrent?.seeders === undefined - ) { - return -1; - } else if ( - a.torrent?.seeders === undefined && - b.torrent?.seeders !== undefined - ) { - return 1; - } - } else if (field === 'streamType') { - return ( - (this.config.streamTypes?.findIndex( - (streamType) => streamType[a.type] - ) ?? -1) - - (this.config.streamTypes?.findIndex( - (streamType) => streamType[b.type] - ) ?? -1) - ); - } else if (field === 'quality') { - return ( - this.config.qualities.findIndex((quality) => quality[a.quality]) - - this.config.qualities.findIndex((quality) => quality[b.quality]) - ); - } else if (field === 'visualTag') { - // Find the highest priority visual tag in each file - const getIndexOfTag = (tag: string) => - this.config.visualTags.findIndex((t) => t[tag]); - - const getHighestPriorityTagIndex = (tags: string[]) => { - // Check if the file contains both any HDR tag and DV - const hasHDR = tags.some((tag) => tag.startsWith('HDR')); - const hasDV = tags.includes('DV'); - - if (hasHDR && hasDV) { - // Sort according to the position of the HDR+DV tag - const hdrDvIndex = this.config.visualTags.findIndex( - (t) => t['HDR+DV'] - ); - if (hdrDvIndex !== -1) { - return hdrDvIndex; - } - } - - // If the file contains multiple HDR tags, look at the HDR tag that has the highest priority - const hdrTagIndices = tags - .filter((tag) => tag.startsWith('HDR')) - .map((tag) => getIndexOfTag(tag)); - if (hdrTagIndices.length > 0) { - return Math.min(...hdrTagIndices); - } - - // Always consider the highest priority visual tag when a file has multiple visual tags - return tags.reduce( - (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), - this.config.visualTags.length - ); - }; - - const aVisualTagIndex = getHighestPriorityTagIndex(a.visualTags); - const bVisualTagIndex = getHighestPriorityTagIndex(b.visualTags); - - // Sort by the visual tag index - return aVisualTagIndex - bVisualTagIndex; - } else if (field === 'audioTag') { - // Find the highest priority audio tag in each file - const getIndexOfTag = (tag: string) => - this.config.audioTags.findIndex((t) => t[tag]); - const aAudioTagIndex = a.audioTags.reduce( - (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), - this.config.audioTags.length - ); - - const bAudioTagIndex = b.audioTags.reduce( - (minIndex, tag) => Math.min(minIndex, getIndexOfTag(tag)), - this.config.audioTags.length - ); - // Sort by the audio tag index - return aAudioTagIndex - bAudioTagIndex; - } else if (field === 'encode') { - return ( - this.config.encodes.findIndex((encode) => encode[a.encode]) - - this.config.encodes.findIndex((encode) => encode[b.encode]) - ); - } else if (field === 'addon') { - const aAddon = a.addon.id; - const bAddon = b.addon.id; - - const addonIds = this.config.addons.map((addon) => { - return `${addon.id}-${JSON.stringify(addon.options)}`; - }); - return addonIds.indexOf(aAddon) - addonIds.indexOf(bAddon); - } else if (field === 'language') { - if (this.config.prioritiseLanguage) { - return this.compareLanguages(a, b); - } - if (!this.config.prioritisedLanguages) { - return 0; - } - // else, we look at the array of prioritisedLanguages. - // any file with a language in the prioritisedLanguages array should be prioritised - // if both files contain a prioritisedLanguage, we compare the index of the highest priority language - - const aHasPrioritisedLanguage = - a.languages.some((lang) => - this.config.prioritisedLanguages?.includes(lang) - ) || - (a.languages.length === 0 && - this.config.prioritisedLanguages?.includes('Unknown')); - const bHasPrioritisedLanguage = - b.languages.some((lang) => - this.config.prioritisedLanguages?.includes(lang) - ) || - (b.languages.length === 0 && - this.config.prioritisedLanguages?.includes('Unknown')); - - if (aHasPrioritisedLanguage && !bHasPrioritisedLanguage) return -1; - if (!aHasPrioritisedLanguage && bHasPrioritisedLanguage) return 1; - - if (aHasPrioritisedLanguage && bHasPrioritisedLanguage) { - const getHighestPriorityLanguageIndex = (languages: string[]) => { - if (languages.length === 0) { - const unknownIndex = - this.config.prioritisedLanguages!.indexOf('Unknown'); - return unknownIndex !== -1 - ? unknownIndex - : this.config.prioritisedLanguages!.length; - } - return languages.reduce((minIndex, lang) => { - const index = - this.config.prioritisedLanguages?.indexOf(lang) ?? - this.config.prioritisedLanguages!.length; - return index !== -1 ? Math.min(minIndex, index) : minIndex; - }, this.config.prioritisedLanguages!.length); - }; - - const aHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex( - a.languages - ); - const bHighestPriorityLanguageIndex = getHighestPriorityLanguageIndex( - b.languages - ); - - return aHighestPriorityLanguageIndex - bHighestPriorityLanguageIndex; - } - } - return 0; - } - - private async getParsedStreams( - streamRequest: StreamRequest - ): Promise<{ parsedStreams: ParsedStream[]; errorStreams: ErrorStream[] }> { - const parsedStreams: ParsedStream[] = []; - const errorStreams: ErrorStream[] = []; - const formatError = (error: string) => - typeof error === 'string' - ? error - .replace(/- |: /g, '\n') - .split('\n') - .map((line: string) => line.trim()) - .join('\n') - .trim() - : error; - - const addonPromises = this.config.addons.map(async (addon) => { - const addonName = - addon.options.name || - addon.options.overrideName || - addonDetails.find((addonDetail) => addonDetail.id === addon.id)?.name || - addon.id; - const addonId = `${addon.id}-${JSON.stringify(addon.options)}`; - try { - const startTime = new Date().getTime(); - const { addonStreams, addonErrors } = await this.getStreamsFromAddon( - addon, - addonId, - streamRequest - ); - parsedStreams.push(...addonStreams); - errorStreams.push( - ...[...new Set(addonErrors)].map((error) => ({ - error: formatError(error), - addon: { id: addonId, name: addonName }, - })) - ); - logger.info( - `Got ${addonStreams.length} streams ${addonErrors.length > 0 ? `and ${addonErrors.length} errors ` : ''}from addon ${addonName} in ${getTimeTakenSincePoint(startTime)}` - ); - } catch (error: any) { - logger.error(`Failed to get streams from ${addonName}: ${error}`); - errorStreams.push({ - error: formatError(error.message ?? error ?? 'Unknown error'), - addon: { - id: addonId, - name: addonName, - }, - }); - } - }); - - await Promise.all(addonPromises); - return { parsedStreams, errorStreams }; - } - - private async getStreamsFromAddon( - addon: Config['addons'][0], - addonId: string, - streamRequest: StreamRequest - ): Promise<{ addonStreams: ParsedStream[]; addonErrors: string[] }> { - switch (addon.id) { - case 'torbox': { - return await getTorboxStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'torrentio': { - return await getTorrentioStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'comet': { - return await getCometStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'mediafusion': { - return await getMediafusionStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'stremio-jackett': { - return await getStremioJackettStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'jackettio': { - return await getJackettioStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'orion-stremio-addon': { - return await getOrionStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'easynews': { - return await getEasynewsStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'easynews-plus': { - return await getEasynewsPlusStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'easynews-plus-plus': { - return await getEasynewsPlusPlusStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'debridio': { - return await getDebridioStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'peerflix': { - return await getPeerflixStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'stremthru-store': { - return await getStremThruStoreStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'dmm-cast': { - return await getDMMCastStreams( - this.config, - addon.options, - streamRequest, - addonId - ); - } - case 'gdrive': { - if (!addon.options.addonUrl) { - throw new Error('The addon URL was undefined for GDrive'); - } - const wrapper = new BaseWrapper( - addon.options.overrideName || 'GDrive', - addon.options.addonUrl, - addonId, - this.config, - addon.options.indexerTimeout - ? parseInt(addon.options.indexerTimeout) - : Settings.DEFAULT_GDRIVE_TIMEOUT - ); - return await wrapper.getParsedStreams(streamRequest); - } - default: { - if (!addon.options.url) { - throw new Error( - `The addon URL was undefined for ${addon.options.name}` - ); - } - const wrapper = new BaseWrapper( - addon.options.name || 'Custom', - addon.options.url.trim(), - addonId, - this.config, - addon.options.indexerTimeout - ? parseInt(addon.options.indexerTimeout) - : undefined - ); - return wrapper.getParsedStreams(streamRequest); - } - } - } - private async processGroupedStreams( - groupedStreams: Record - ) { - const uniqueStreams: ParsedStream[] = []; - Object.values(groupedStreams).forEach((groupedStreams) => { - if (groupedStreams.length === 1) { - uniqueStreams.push(groupedStreams[0]); - return; - } - - /*logger.info( - `==================\nDetermining unique streams for ${groupedStreams[0].filename} from ${groupedStreams.length} total duplicates` - ); - logger.info( - groupedStreams.map( - (stream) => - `Addon ID: ${stream.addon.id}, Provider ID: ${stream.provider?.id}, Provider Cached: ${stream.provider?.cached}, type: ${stream.torrent ? 'torrent' : 'usenet'}` - ) - ); - logger.info('==================');*/ - // Separate streams into categories - const cachedStreams = groupedStreams.filter( - (stream) => stream.provider?.cached || (!stream.provider && stream.url) - ); - const uncachedStreams = groupedStreams.filter( - (stream) => stream.provider && !stream.provider.cached - ); - const noProviderStreams = groupedStreams.filter( - (stream) => !stream.provider && stream.torrent?.infoHash - ); - - // Select uncached streams by addon priority (one per provider) - const selectedUncachedStreams = Object.values( - uncachedStreams.reduce( - (acc, stream) => { - acc[stream.provider!.id] = acc[stream.provider!.id] || []; - acc[stream.provider!.id].push(stream); - return acc; - }, - {} as Record - ) - ).map((providerGroup) => { - return providerGroup.sort((a, b) => { - const aIndex = this.config.addons.findIndex( - (addon) => - `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id - ); - const bIndex = this.config.addons.findIndex( - (addon) => - `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id - ); - return aIndex - bIndex; - })[0]; - }); - //selectedUncachedStreams.forEach(stream => logger.info(`Selected uncached stream for provider ${stream.provider!.id}: Addon ID: ${stream.addon.id}`)); - - // Select cached streams by provider and addon priority - const selectedCachedStream = cachedStreams.sort((a, b) => { - const aProviderIndex = this.config.services.findIndex( - (service) => service.id === a.provider?.id - ); - const bProviderIndex = this.config.services.findIndex( - (service) => service.id === b.provider?.id - ); - - if (aProviderIndex !== bProviderIndex) { - return aProviderIndex - bProviderIndex; - } - - const aAddonIndex = this.config.addons.findIndex( - (addon) => - `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id - ); - const bAddonIndex = this.config.addons.findIndex( - (addon) => - `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id - ); - - if (aAddonIndex !== bAddonIndex) { - return aAddonIndex - bAddonIndex; - } - - // now look at the type of stream. prefer usenet over torrents - if (a.torrent?.seeders && !b.torrent?.seeders) return 1; - if (!a.torrent?.seeders && b.torrent?.seeders) return -1; - return 0; - })[0]; - // Select one non-provider stream (highest addon priority) - const selectedNoProviderStream = noProviderStreams.sort((a, b) => { - const aIndex = this.config.addons.findIndex( - (addon) => - `${addon.id}-${JSON.stringify(addon.options)}` === a.addon.id - ); - const bIndex = this.config.addons.findIndex( - (addon) => - `${addon.id}-${JSON.stringify(addon.options)}` === b.addon.id - ); - - if (aIndex !== bIndex) { - return aIndex - bIndex; - } - - // now look at the type of stream. prefer usenet over torrents - if (a.torrent?.seeders && !b.torrent?.seeders) return 1; - if (!a.torrent?.seeders && b.torrent?.seeders) return -1; - return 0; - })[0]; - - // Combine selected streams for this group - if (selectedNoProviderStream) { - //logger.info(`Selected no provider stream: Addon ID: ${selectedNoProviderStream.addon.id}`); - uniqueStreams.push(selectedNoProviderStream); - } - if (selectedCachedStream) { - //logger.info(`Selected cached stream for provider ${selectedCachedStream.provider!.id} from Addon ID: ${selectedCachedStream.addon.id}`); - uniqueStreams.push(selectedCachedStream); - } - uniqueStreams.push(...selectedUncachedStreams); - }); - - return uniqueStreams; - } -} diff --git a/packages/addon/src/config.ts b/packages/addon/src/config.ts deleted file mode 100644 index 619a1f21..00000000 --- a/packages/addon/src/config.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { AddonDetail, Config } from '@aiostreams/types'; -import { - addonDetails, - isValueEncrypted, - parseAndDecryptString, - serviceDetails, - Settings, - unminifyConfig, -} from '@aiostreams/utils'; - -export const allowedFormatters = [ - 'gdrive', - 'minimalistic-gdrive', - 'torrentio', - 'torbox', - 'imposter', - 'custom', -]; - -export const allowedLanguages = [ - 'Multi', - 'English', - 'Japanese', - 'Chinese', - 'Russian', - 'Arabic', - 'Portuguese', - 'Spanish', - 'French', - 'German', - 'Italian', - 'Korean', - 'Hindi', - 'Bengali', - 'Punjabi', - 'Marathi', - 'Gujarati', - 'Tamil', - 'Telugu', - 'Kannada', - 'Malayalam', - 'Thai', - 'Vietnamese', - 'Indonesian', - 'Turkish', - 'Hebrew', - 'Persian', - 'Ukrainian', - 'Greek', - 'Lithuanian', - 'Latvian', - 'Estonian', - 'Polish', - 'Czech', - 'Slovak', - 'Hungarian', - 'Romanian', - 'Bulgarian', - 'Serbian', - 'Croatian', - 'Slovenian', - 'Dutch', - 'Danish', - 'Finnish', - 'Swedish', - 'Norwegian', - 'Malay', - 'Latino', - 'Unknown', - 'Dual Audio', - 'Dubbed', -]; - -export function validateConfig( - config: Config, - environment: 'client' | 'server' = 'server' -): { - valid: boolean; - errorCode: string | null; - errorMessage: string | null; -} { - config = unminifyConfig(config); - const createResponse = ( - valid: boolean, - errorCode: string | null, - errorMessage: string | null - ) => { - return { valid, errorCode, errorMessage }; - }; - - if (config.addons.length < 1) { - return createResponse( - false, - 'noAddons', - 'At least one addon must be selected' - ); - } - - if (config.addons.length > Settings.MAX_ADDONS) { - return createResponse( - false, - 'tooManyAddons', - `You can only select a maximum of ${Settings.MAX_ADDONS} addons` - ); - } - // check for apiKey if Settings.API_KEY is set - if (environment === 'server' && Settings.API_KEY) { - const { apiKey } = config; - if (!apiKey) { - return createResponse( - false, - 'missingApiKey', - 'The AIOStreams API key is required' - ); - } - let decryptedApiKey = apiKey; - if (isValueEncrypted(apiKey)) { - const decryptionResult = parseAndDecryptString(apiKey); - if (decryptionResult === null) { - return createResponse( - false, - 'decryptionFailed', - 'Failed to decrypt the AIOStreams API key' - ); - } else if (decryptionResult === '') { - return createResponse( - false, - 'emptyDecryption', - 'Decrypted API key is empty' - ); - } - decryptedApiKey = decryptionResult; - } - if (decryptedApiKey !== Settings.API_KEY) { - return createResponse( - false, - 'invalidApiKey', - 'Invalid AIOStreams API key. Please use the one defined in your environment variables' - ); - } - } - const duplicateAddons = config.addons.filter( - (addon, index) => - config.addons.findIndex( - (a) => - a.id === addon.id && - JSON.stringify(a.options) === JSON.stringify(addon.options) - ) !== index - ); - - if (duplicateAddons.length > 0) { - return createResponse( - false, - 'duplicateAddons', - 'Duplicate addons found. Please remove any duplicates' - ); - } - - for (const addon of config.addons) { - if (Settings.DISABLE_TORRENTIO && addon.id === 'torrentio') { - return createResponse( - false, - 'torrentioDisabled', - Settings.DISABLE_TORRENTIO_MESSAGE - ); - } - - const details = addonDetails.find( - (detail: AddonDetail) => detail.id === addon.id - ); - if (!details) { - return createResponse( - false, - 'invalidAddon', - `Invalid addon: ${addon.id}` - ); - } - if (details.requiresService) { - const supportedServices = details.supportedServices; - const isAtLeastOneServiceEnabled = config.services.some( - (service) => supportedServices.includes(service.id) && service.enabled - ); - const isOverrideUrlSet = addon.options?.overrideUrl; - if (!isAtLeastOneServiceEnabled && !isOverrideUrlSet) { - return createResponse( - false, - 'missingService', - `${addon.options?.name || details.name} requires at least one of the following services to be enabled: ${supportedServices - .map( - (service) => - serviceDetails.find((detail) => detail.id === service)?.name || - service - ) - .join(', ')}` - ); - } - } - if (details.options) { - for (const option of details.options) { - if (option.required && !addon.options[option.id]) { - return createResponse( - false, - 'missingRequiredOption', - `Option ${option.label} is required for addon ${addon.id}` - ); - } - - if ( - option.id.toLowerCase().includes('url') && - addon.options[option.id] && - ((isValueEncrypted(addon.options[option.id]) && - environment === 'server') || - !isValueEncrypted(addon.options[option.id])) - ) { - const url = parseAndDecryptString(addon.options[option.id] ?? ''); - if (url === null) { - return createResponse( - false, - 'decryptionFailed', - `Failed to decrypt URL for ${option.label}` - ); - } else if (url === '') { - return createResponse( - false, - 'emptyDecryption', - `Decrypted URL for ${option.label} is empty` - ); - } - if ( - Settings.DISABLE_TORRENTIO && - url.match(/torrentio\.strem\.fun/) !== null - ) { - // if torrentio is disabled, don't allow the user to set URLs with torrentio.strem.fun - return createResponse( - false, - 'torrentioDisabled', - Settings.DISABLE_TORRENTIO_MESSAGE - ); - } else if ( - Settings.DISABLE_TORRENTIO && - url.match(/stremthru\.elfhosted\.com/) !== null - ) { - // if torrentio is disabled, we need to inspect the stremthru URL to see if it's using torrentio - try { - const parsedUrl = new URL(url); - // get the component before manifest.json - const pathComponents = parsedUrl.pathname.split('/'); - if (pathComponents.includes('manifest.json')) { - const index = pathComponents.indexOf('manifest.json'); - const componentBeforeManifest = pathComponents[index - 1]; - // base64 decode the component before manifest.json - const decodedComponent = atob(componentBeforeManifest); - const stremthruData = JSON.parse(decodedComponent); - if (stremthruData?.manifest_url?.match(/torrentio.strem.fun/)) { - return createResponse( - false, - 'torrentioDisabled', - Settings.DISABLE_TORRENTIO_MESSAGE - ); - } - } - } catch (_) { - // ignore - } - } else { - try { - new URL(url); - } catch (_) { - return createResponse( - false, - 'invalidUrl', - ` Invalid URL for ${option.label}` - ); - } - } - } - - if (option.type === 'number' && addon.options[option.id]) { - const input = addon.options[option.id]; - if (input !== undefined && !parseInt(input)) { - return createResponse( - false, - 'invalidNumber', - `${option.label} must be a number` - ); - } else if (input !== undefined) { - const value = parseInt(input); - const { min, max } = option.constraints || {}; - if ( - (min !== undefined && value < min) || - (max !== undefined && value > max) - ) { - return createResponse( - false, - 'invalidNumber', - `${option.label} must be between ${min} and ${max}` - ); - } - } - } - } - } - } - - if (!allowedFormatters.includes(config.formatter)) { - if (config.formatter.startsWith('custom') && config.formatter.length > 7) { - const jsonString = config.formatter.slice(7); - const data = JSON.parse(jsonString); - if (!data.name || !data.description) { - return createResponse( - false, - 'invalidCustomFormatter', - 'Invalid custom formatter: name and description are required' - ); - } - } else { - return createResponse( - false, - 'invalidFormatter', - `Invalid formatter: ${config.formatter}` - ); - } - } - for (const service of config.services) { - if (service.enabled) { - const serviceDetail = serviceDetails.find( - (detail) => detail.id === service.id - ); - if (!serviceDetail) { - return createResponse( - false, - 'invalidService', - `Invalid service: ${service.id}` - ); - } - for (const credential of serviceDetail.credentials) { - if (!service.credentials[credential.id]) { - return createResponse( - false, - 'missingCredential', - `${credential.label} is required for ${service.name}` - ); - } - } - } - } - - // need at least one visual tag, resolution, quality - - if ( - !config.visualTags.some((tag) => Object.values(tag)[0]) || - !config.resolutions.some((resolution) => Object.values(resolution)[0]) || - !config.qualities.some((quality) => Object.values(quality)[0]) - ) { - return createResponse( - false, - 'noFilters', - 'At least one visual tag, resolution, and quality must be selected' - ); - } - - for (const [min, max] of [ - [config.minMovieSize, config.maxMovieSize], - [config.minEpisodeSize, config.maxEpisodeSize], - [config.minSize, config.maxSize], - ]) { - if (min && max) { - if (min >= max) { - return createResponse( - false, - 'invalidSizeRange', - "Your minimum size limit can't be greater than or equal to your maximum size limit" - ); - } - } - } - - if (config.maxResultsPerResolution && config.maxResultsPerResolution < 1) { - return createResponse( - false, - 'invalidMaxResultsPerResolution', - 'Max results per resolution must be greater than 0' - ); - } - - if ( - config.mediaFlowConfig?.mediaFlowEnabled && - config.stremThruConfig?.stremThruEnabled - ) { - return createResponse( - false, - 'multipleProxyServices', - 'Multiple proxy services are not allowed' - ); - } - if (config.mediaFlowConfig?.mediaFlowEnabled) { - if (!config.mediaFlowConfig.proxyUrl) { - return createResponse( - false, - 'missingProxyUrl', - 'Proxy URL is required if MediaFlow is enabled' - ); - } - if (!config.mediaFlowConfig.apiPassword) { - return createResponse( - false, - 'missingApiPassword', - 'API Password is required if MediaFlow is enabled' - ); - } - } - - if (config.stremThruConfig?.stremThruEnabled) { - if (!config.stremThruConfig.url) { - return createResponse( - false, - 'missingUrl', - 'URL is required if Stremthru is enabled' - ); - } - if (!config.stremThruConfig.credential) { - return createResponse( - false, - 'missingCredential', - 'Credential is required if StremThru is enabled' - ); - } - } - - if ( - (config.excludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS || - (config.strictIncludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS - ) { - return createResponse( - false, - 'tooManyFilters', - `You can only have a maximum of ${Settings.MAX_KEYWORD_FILTERS} filters` - ); - } - - const filters = [ - ...(config.excludeFilters || []), - ...(config.strictIncludeFilters || []), - ]; - filters.forEach((filter) => { - if (filter.length > 20) { - return createResponse( - false, - 'invalidFilter', - 'One of your filters is too long' - ); - } - if (!filter) { - return createResponse( - false, - 'invalidFilter', - 'Filters must not be empty' - ); - } - }); - - if (config.regexFilters) { - if (!config.apiKey) { - return createResponse( - false, - 'missingApiKey', - 'Regex filtering requires an API key to be set' - ); - } - - if (config.regexFilters.excludePattern) { - try { - new RegExp(config.regexFilters.excludePattern); - } catch (e) { - return createResponse( - false, - 'invalidExcludeRegex', - 'Invalid exclude regex pattern' - ); - } - } - - if (config.regexFilters.includePattern) { - try { - new RegExp(config.regexFilters.includePattern); - } catch (e) { - return createResponse( - false, - 'invalidIncludeRegex', - 'Invalid include regex pattern' - ); - } - } - } - - if (config.regexSortPatterns) { - if (!config.apiKey) { - return createResponse( - false, - 'missingApiKey', - 'Regex sorting requires an API key to be set' - ); - } - - // Split the pattern by spaces and validate each one - const patterns = config.regexSortPatterns.split(/\s+/).filter(Boolean); - // Enforce an upper bound on the number of patterns - if (patterns.length > Settings.MAX_REGEX_SORT_PATTERNS) { - return createResponse( - false, - 'tooManyRegexSortPatterns', - `You can specify at most ${Settings.MAX_REGEX_SORT_PATTERNS} regex sort patterns` - ); - } - - for (const pattern of patterns) { - const delimiter = '<::>'; - const delimiterIndex = pattern.indexOf(delimiter); - let name: string = 'Unamed'; - let regexPattern = pattern; - if (delimiterIndex !== -1) { - name = pattern.slice(0, delimiterIndex).replace(/_/g, ' '); - regexPattern = pattern.slice(delimiterIndex + delimiter.length); - } - try { - new RegExp(regexPattern); - } catch (e) { - return createResponse( - false, - 'invalidRegexSortPattern', - `Invalid regex sort pattern: ${name ? `"${name}" ` : ''}${regexPattern}` - ); - } - } - } - return createResponse(true, null, null); -} diff --git a/packages/addon/src/index.ts b/packages/addon/src/index.ts deleted file mode 100644 index 21d564cb..00000000 --- a/packages/addon/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './addon'; -export * from './config'; -export * from './manifest'; -export * from './responses'; diff --git a/packages/addon/src/manifest.ts b/packages/addon/src/manifest.ts deleted file mode 100644 index 891bce51..00000000 --- a/packages/addon/src/manifest.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Config } from '@aiostreams/types'; -import { version, description } from '../package.json'; -import { getTextHash, Settings } from '@aiostreams/utils'; - -const manifest = (config?: Config, configPresent?: boolean) => { - let addonId = Settings.ADDON_ID; - if (config && Settings.DETERMINISTIC_ADDON_ID) { - addonId = - addonId += `.${getTextHash(JSON.stringify(config)).substring(0, 12)}`; - } - return { - name: config?.overrideName || Settings.ADDON_NAME, - id: addonId, - version: version, - description: description, - catalogs: [], - resources: ['stream'], - background: - 'https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/background.png', - logo: 'https://raw.githubusercontent.com/Viren070/AIOStreams/refs/heads/main/packages/frontend/public/assets/logo.png', - types: ['movie', 'series'], - behaviorHints: { - configurable: true, - configurationRequired: config || configPresent ? false : true, - }, - stremioAddonsConfig: - Settings.STREMIO_ADDONS_CONFIG_ISSUER && - Settings.STREMIO_ADDONS_CONFIG_SIGNATURE - ? { - issuer: Settings.STREMIO_ADDONS_CONFIG_ISSUER, - signature: Settings.STREMIO_ADDONS_CONFIG_SIGNATURE, - } - : undefined, - }; -}; - -export default manifest; diff --git a/packages/addon/src/responses.ts b/packages/addon/src/responses.ts deleted file mode 100644 index dc604ae0..00000000 --- a/packages/addon/src/responses.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Settings } from '@aiostreams/utils'; - -export const errorResponse = ( - errorMessage: string, - origin?: string, - path?: string, - externalUrl?: string -) => { - return { - streams: [errorStream(errorMessage, 'Error', origin, path, externalUrl)], - }; -}; - -export const errorStream = ( - errorMessage: string, - errorTitle?: string, - origin?: string, - path?: string, - externalUrl?: string -) => { - return { - externalUrl: - (origin && path ? origin + path : undefined) || - externalUrl || - 'https://github.com/Viren070/AIOStreams', - name: `[❌] ${Settings.ADDON_NAME}\n${errorTitle || 'Error'}`, - description: errorMessage, - }; -}; diff --git a/packages/addon/src/server.ts b/packages/addon/src/server.ts deleted file mode 100644 index e67cfa85..00000000 --- a/packages/addon/src/server.ts +++ /dev/null @@ -1,645 +0,0 @@ -import express, { Request, Response } from 'express'; - -import path from 'path'; -import { AIOStreams } from './addon'; -import { Config, StreamRequest } from '@aiostreams/types'; -import { validateConfig } from './config'; -import manifest from './manifest'; -import { errorResponse } from './responses'; -import { - Settings, - addonDetails, - parseAndDecryptString, - Cache, - unminifyConfig, - minifyConfig, - crushJson, - compressData, - encryptData, - decompressData, - decryptData, - uncrushJson, - loadSecretKey, - createLogger, - getTimeTakenSincePoint, - isValueEncrypted, - maskSensitiveInfo, -} from '@aiostreams/utils'; - -const logger = createLogger('server'); - -const app = express(); -//logger.info(`Starting server and loading settings...`); -logger.info('Starting server and loading settings...', { func: 'init' }); -Object.entries(Settings).forEach(([key, value]) => { - switch (key) { - case 'SECRET_KEY': - if (value) { - logger.info(`${key} = ${value.replace(/./g, '*').slice(0, 64)}`); - } - break; - - case 'BRANDING': - case 'CUSTOM_CONFIGS': - // Skip CUSTOM_CONFIGS processing here, handled later - break; - - default: - logger.info(`${key} = ${value}`); - } -}); - -// attempt to load the secret key -try { - if (Settings.SECRET_KEY) loadSecretKey(true); -} catch (error: any) { - // determine command to run based on system OS - const command = - process.platform === 'win32' - ? '[System.Guid]::NewGuid().ToString("N").Substring(0, 32) + [System.Guid]::NewGuid().ToString("N").Substring(0, 32)' - : 'openssl rand -hex 32'; - logger.error( - `The secret key is invalid. You will not be able to generate configurations. You can generate a new secret key by running the following command\n${command}` - ); -} - -// Built-in middleware for parsing JSON -app.use(express.json()); -// Built-in middleware for parsing URL-encoded data -app.use(express.urlencoded({ extended: true })); - -// unhandled errors -app.use((err: any, req: Request, res: Response, next: any) => { - logger.error(`${err.message}`); - res.status(500).send('Internal server error'); -}); - -app.use((req, res, next) => { - res.append('Access-Control-Allow-Origin', '*'); - res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); - const start = Date.now(); - res.on('finish', () => { - logger.info( - `${req.method} ${req.path - .replace(/\/ey[JI][\w\=]+/g, '/*******') - .replace( - /\/(E2?|B)?-[\w-\%]+/g, - '/*******' - )} - ${getIp(req) ? maskSensitiveInfo(getIp(req)!) : 'Unknown IP'} - ${res.statusCode} - ${getTimeTakenSincePoint(start)}` - ); - }); - next(); -}); - -app.get('/', (req, res) => { - res.redirect('/configure'); -}); - -app.get( - ['/_next/*', '/assets/*', '/icon.ico', '/configure.txt'], - (req, res) => { - res.sendFile(path.join(__dirname, '../../frontend/out', req.path)); - } -); - -if (!Settings.DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE) { - app.get('/custom-config-generator', (req, res) => { - res.sendFile( - path.join(__dirname, '../../frontend/out/custom-config-generator.html') - ); - }); -} - -app.get('/configure', (req, res) => { - res.sendFile(path.join(__dirname, '../../frontend/out/configure.html')); -}); - -app.get('/:config/configure', (req, res) => { - const config = req.params.config; - if (config.startsWith('eyJ') || config.startsWith('eyI')) { - return res.sendFile( - path.join(__dirname, '../../frontend/out/configure.html') - ); - } - try { - let configJson = extractJsonConfig(config); - let configString = config; - if (Settings.CUSTOM_CONFIGS) { - const customConfig = extractCustomConfig(config); - if (customConfig) { - configJson = customConfig; - configString = decodeURIComponent(Settings.CUSTOM_CONFIGS[config]); - } - } - if (isValueEncrypted(configString)) { - logger.info(`Encrypted config detected, encrypting credentials`); - configJson = encryptInfoInConfig(configJson); - } - const base64Config = Buffer.from(JSON.stringify(configJson)).toString( - 'base64' - ); - res.redirect(`/${encodeURIComponent(base64Config)}/configure`); - } catch (error: any) { - logger.error(`Failed to extract config: ${error.message}`); - res.status(400).send('Invalid config'); - } -}); - -app.get('/manifest.json', (req, res) => { - res.status(200).json(manifest()); -}); - -app.get('/:config/manifest.json', (req, res) => { - const config = decodeURIComponent(req.params.config); - let configJson: Config; - try { - configJson = extractJsonConfig(config); - logger.info(`Extracted config for manifest request`); - configJson = decryptEncryptedInfoFromConfig(configJson); - if (Settings.LOG_SENSITIVE_INFO) { - logger.info(`Final config: ${JSON.stringify(configJson)}`); - } - logger.info(`Successfully removed or decrypted sensitive info`); - const { valid, errorMessage } = validateConfig(configJson); - if (!valid) { - logger.error( - `Received invalid config for manifest request: ${errorMessage}` - ); - res.status(400).json({ error: 'Invalid config', message: errorMessage }); - return; - } - } catch (error: any) { - logger.error(`Failed to extract config: ${error.message}`); - res.status(400).json({ error: 'Invalid config' }); - return; - } - res.status(200).json(manifest(configJson)); -}); - -// Route for /stream -app.get('/stream/:type/:id', (req: Request, res: Response) => { - res - .status(200) - .json( - errorResponse( - 'You must configure this addon to use it', - rootUrl(req), - '/configure' - ) - ); -}); - -app.get('/:config/stream/:type/:id.json', (req, res: Response): void => { - const { config, type, id } = req.params; - let configJson: Config; - try { - configJson = extractJsonConfig(config); - logger.info(`Extracted config for stream request`); - configJson = decryptEncryptedInfoFromConfig(configJson); - if (Settings.LOG_SENSITIVE_INFO) { - logger.info(`Final config: ${JSON.stringify(configJson)}`); - } - logger.info(`Successfully removed or decrypted sensitive info`); - } catch (error: any) { - logger.error(`Failed to extract config: ${error.message}`); - res.json( - errorResponse( - `${error.message}, please check the logs or click this stream to create an issue on GitHub`, - rootUrl(req), - undefined, - 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' - ) - ); - return; - } - - logger.info(`Requesting streams for ${type} ${id}`); - - if (type !== 'movie' && type !== 'series') { - logger.error(`Invalid type for stream request`); - res.json( - errorResponse( - 'Invalid type for stream request, must be movie or series', - rootUrl(req), - '/' - ) - ); - return; - } - let streamRequest: StreamRequest = { id, type }; - - try { - const { valid, errorCode, errorMessage } = validateConfig(configJson); - if (!valid) { - logger.error(`Received invalid config: ${errorCode} - ${errorMessage}`); - res.json( - errorResponse(errorMessage ?? 'Unknown', rootUrl(req), '/configure') - ); - return; - } - configJson.requestingIp = getIp(req); - const aioStreams = new AIOStreams(configJson); - aioStreams - .getStreams(streamRequest) - .then((streams) => { - res.json({ streams: streams }); - }) - .catch((error: any) => { - logger.error(`Internal addon error: ${error.message}`); - res.json( - errorResponse( - 'An unexpected error occurred, please check the logs or create an issue on GitHub', - rootUrl(req), - undefined, - 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' - ) - ); - }); - } catch (error: any) { - logger.error(`Internal addon error: ${error.message}`); - res.json( - errorResponse( - 'An unexpected error occurred, please check the logs or create an issue on GitHub', - rootUrl(req), - undefined, - 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' - ) - ); - } -}); - -app.post('/encrypt-user-data', (req, res) => { - const { data } = req.body; - let finalString: string = ''; - if (!data) { - logger.error('/encrypt-user-data: No data provided'); - res.json({ success: false, message: 'No data provided' }); - return; - } - // First, validate the config - try { - const config = JSON.parse(data); - const { valid, errorCode, errorMessage } = validateConfig(config); - if (!valid) { - logger.error( - `generateConfig: Invalid config: ${errorCode} - ${errorMessage}` - ); - res.json({ success: false, message: errorMessage, error: errorMessage }); - return; - } - } catch (error: any) { - logger.error(`/encrypt-user-data: Invalid JSON: ${error.message}`); - res.json({ success: false, message: 'Malformed configuration' }); - return; - } - - try { - const minified = minifyConfig(JSON.parse(data)); - const crushed = crushJson(JSON.stringify(minified)); - const compressed = compressData(crushed); - if (!Settings.SECRET_KEY) { - // use base64 encoding if no secret key is set - finalString = `B-${encodeURIComponent(compressed.toString('base64'))}`; - } else { - const { iv, data } = encryptData(compressed); - finalString = `E2-${encodeURIComponent(iv)}-${encodeURIComponent(data)}`; - } - - logger.info( - `|INF| server > /encrypt-user-data: Encrypted user data, compression report:` - ); - logger.info(`+--------------------------------------------+`); - logger.info(`| Original: ${data.length} bytes`); - logger.info(`| URL Encoded: ${encodeURIComponent(data).length} bytes`); - logger.info(`| Minified: ${JSON.stringify(minified).length} bytes`); - logger.info(`| Crushed: ${crushed.length} bytes`); - logger.info(`| Compressed: ${compressed.length} bytes`); - logger.info(`| Final String: ${finalString.length} bytes`); - logger.info( - `| Ratio: ${((finalString.length / data.length) * 100).toFixed(2)}%` - ); - logger.info( - `| Reduction: ${data.length - finalString.length} bytes (${(((data.length - finalString.length) / data.length) * 100).toFixed(2)}%)` - ); - logger.info(`+--------------------------------------------+`); - - res.json({ success: true, data: finalString }); - } catch (error: any) { - logger.error(`/encrypt-user-data: ${error.message}`); - logger.error(error); - res.json({ success: false, message: error.message }); - } -}); - -app.get('/get-addon-config', (req, res) => { - res.status(200).json({ - success: true, - maxMovieSize: Settings.MAX_MOVIE_SIZE, - maxEpisodeSize: Settings.MAX_EPISODE_SIZE, - torrentioDisabled: Settings.DISABLE_TORRENTIO, - apiKeyRequired: !!Settings.API_KEY, - }); -}); - -app.get('/health', (req, res) => { - res.status(200).json({ status: 'ok' }); -}); - -// define 404 -app.use((req, res) => { - res.status(404).sendFile(path.join(__dirname, '../../frontend/out/404.html')); -}); - -app.listen(Settings.PORT, () => { - logger.info(`Listening on port ${Settings.PORT}`); -}); - -function getIp(req: Request): string | undefined { - return ( - req.get('X-Client-IP') || - req.get('X-Forwarded-For')?.split(',')[0].trim() || - req.get('X-Real-IP') || - req.get('CF-Connecting-IP') || - req.get('True-Client-IP') || - req.get('X-Forwarded')?.split(',')[0].trim() || - req.get('Forwarded-For')?.split(',')[0].trim() || - req.ip - ); -} -function extractJsonConfig(config: string): Config { - if ( - config.startsWith('eyJ') || - config.startsWith('eyI') || - config.startsWith('B-') || - isValueEncrypted(config) - ) { - return extractEncryptedOrEncodedConfig(config, 'Config'); - } - if (Settings.CUSTOM_CONFIGS) { - const customConfig = extractCustomConfig(config); - if (customConfig) return customConfig; - } - throw new Error('Config was in an unexpected format'); -} - -function extractCustomConfig(config: string): Config | undefined { - const customConfig = Settings.CUSTOM_CONFIGS[config]; - if (!customConfig) return undefined; - logger.info( - `Found custom config for alias ${config}, attempting to extract config` - ); - return extractEncryptedOrEncodedConfig( - decodeURIComponent(customConfig), - `CustomConfig ${config}` - ); -} - -function extractEncryptedOrEncodedConfig( - config: string, - label: string -): Config { - let decodedConfig: Config; - try { - if (config.startsWith('E-')) { - // compressed and encrypted (hex) - logger.info(`Extracting encrypted (v1) config`); - const parts = config.split('-'); - if (parts.length !== 3) { - throw new Error('Invalid encrypted config format'); - } - const iv = Buffer.from(decodeURIComponent(parts[1]), 'hex'); - const data = Buffer.from(decodeURIComponent(parts[2]), 'hex'); - decodedConfig = JSON.parse(decompressData(decryptData(data, iv))); - } else if (config.startsWith('E2-')) { - // minified, crushed, compressed and encrypted (base64) - logger.info(`Extracting encrypted (v2) config`); - const parts = config.split('-'); - if (parts.length !== 3) { - throw new Error('Invalid encrypted config format'); - } - const iv = Buffer.from(decodeURIComponent(parts[1]), 'base64'); - const data = Buffer.from(decodeURIComponent(parts[2]), 'base64'); - const compressedCrushedJson = decryptData(data, iv); - const crushedJson = decompressData(compressedCrushedJson); - const minifiedConfig = uncrushJson(crushedJson); - decodedConfig = unminifyConfig(JSON.parse(minifiedConfig)); - } else if (config.startsWith('B-')) { - // minifed, crushed, compressed, base64 encoded - logger.info(`Extracting base64 encoded and compressed config`); - decodedConfig = unminifyConfig( - JSON.parse( - uncrushJson(decompressData(Buffer.from(config.slice(2), 'base64'))) - ) - ); - } else { - // plain base64 encoded - logger.info(`Extracting plain base64 encoded config`); - decodedConfig = JSON.parse( - Buffer.from(config, 'base64').toString('utf-8') - ); - } - return decodedConfig; - } catch (error: any) { - logger.error(`Failed to parse ${label}: ${error.message}`, { - func: 'extractJsonConfig', - }); - logger.error(error, { func: 'extractJsonConfig' }); - throw new Error(`Failed to parse ${label}`); - } -} - -function decryptEncryptedInfoFromConfig(config: Config): Config { - if (config.services) { - config.services.forEach( - (service) => - service.credentials && - processObjectValues( - service.credentials, - `service ${service.id}`, - true, - (key, value) => isValueEncrypted(value) - ) - ); - } - - if (config.mediaFlowConfig) { - decryptMediaFlowConfig(config.mediaFlowConfig); - } - if (config.stremThruConfig) { - decryptStremThruConfig(config.stremThruConfig); - } - - if (config.apiKey) { - config.apiKey = decryptValue(config.apiKey, 'aioStreams apiKey'); - } - - if (config.addons) { - config.addons.forEach((addon) => { - if (addon.options) { - processObjectValues( - addon.options, - `addon ${addon.id}`, - true, - (key, value) => - isValueEncrypted(value) && - // Decrypt only if the option is secret - ( - addonDetails.find((addonDetail) => addonDetail.id === addon.id) - ?.options ?? [] - ).some((option) => option.id === key && option.secret) - ); - } - }); - } - return config; -} - -function decryptMediaFlowConfig(mediaFlowConfig: { - apiPassword: string; - proxyUrl: string; - publicIp: string; -}): void { - const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig; - mediaFlowConfig.apiPassword = decryptValue( - apiPassword, - 'MediaFlow apiPassword' - ); - mediaFlowConfig.proxyUrl = decryptValue(proxyUrl, 'MediaFlow proxyUrl'); - mediaFlowConfig.publicIp = decryptValue(publicIp, 'MediaFlow publicIp'); -} - -function decryptStremThruConfig( - stremThruConfig: Config['stremThruConfig'] -): void { - if (!stremThruConfig) return; - const { url, credential, publicIp } = stremThruConfig; - stremThruConfig.url = decryptValue(url, 'StremThru url'); - stremThruConfig.credential = decryptValue(credential, 'StremThru credential'); - stremThruConfig.publicIp = decryptValue(publicIp, 'StremThru publicIp'); -} - -function encryptInfoInConfig(config: Config): Config { - if (config.services) { - config.services.forEach( - (service) => - service.credentials && - processObjectValues( - service.credentials, - `service ${service.id}`, - false, - () => true - ) - ); - } - - if (config.mediaFlowConfig) { - encryptMediaFlowConfig(config.mediaFlowConfig); - } - - if (config.stremThruConfig) { - encryptStremThruConfig(config.stremThruConfig); - } - - if (config.apiKey) { - // we can either remove the api key for better security or encrypt it for usability - // removing it means the user has to enter it every time upon reconfiguration. - config.apiKey = encryptValue(config.apiKey, 'aioStreams apiKey'); - } - - if (config.addons) { - config.addons.forEach((addon) => { - if (addon.options) { - processObjectValues( - addon.options, - `addon ${addon.id}`, - false, - (key) => { - const addonDetail = addonDetails.find( - (addonDetail) => addonDetail.id === addon.id - ); - if (!addonDetail) return false; - const optionDetail = addonDetail.options?.find( - (option) => option.id === key - ); - // Encrypt only if the option is secret - return optionDetail?.secret ?? false; - } - ); - } - }); - } - return config; -} - -function encryptMediaFlowConfig(mediaFlowConfig: { - apiPassword: string; - proxyUrl: string; - publicIp: string; -}): void { - const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig; - mediaFlowConfig.apiPassword = encryptValue( - apiPassword, - 'MediaFlow apiPassword' - ); - mediaFlowConfig.proxyUrl = encryptValue(proxyUrl, 'MediaFlow proxyUrl'); - mediaFlowConfig.publicIp = encryptValue(publicIp, 'MediaFlow publicIp'); -} - -function encryptStremThruConfig( - stremThruConfig: Config['stremThruConfig'] -): void { - if (!stremThruConfig) return; - const { url, credential, publicIp } = stremThruConfig; - stremThruConfig.url = encryptValue(url, 'StremThru url'); - stremThruConfig.credential = encryptValue(credential, 'StremThru credential'); - stremThruConfig.publicIp = encryptValue(publicIp, 'StremThru publicIp'); -} - -function processObjectValues( - obj: Record, - labelPrefix: string, - decrypt: boolean, - condition: (key: string, value: any) => boolean -): void { - Object.keys(obj).forEach((key) => { - const value = obj[key]; - if (condition(key, value)) { - logger.debug(`Processing ${labelPrefix} ${key}`); - obj[key] = decrypt - ? decryptValue(value, `${labelPrefix} ${key}`) - : encryptValue(value, `${labelPrefix} ${key}`); - } - }); -} - -function encryptValue(value: any, label: string): any { - if (value && !isValueEncrypted(value)) { - try { - const { iv, data } = encryptData(compressData(value)); - return `E2-${iv}-${data}`; - } catch (error: any) { - logger.error(`Failed to encrypt ${label}`, { func: 'encryptValue' }); - logger.error(error, { func: 'encryptValue' }); - return ''; - } - } - return value; -} - -function decryptValue(value: any, label: string): any { - try { - if (!isValueEncrypted(value)) return value; - const decrypted = parseAndDecryptString(value); - if (decrypted === null) throw new Error('Decryption failed'); - return decrypted; - } catch (error: any) { - logger.error(`Failed to decrypt ${label}: ${error.message}`, { - func: 'decryptValue', - }); - logger.error(error, { func: 'decryptValue' }); - throw new Error('Failed to decrypt config'); - } -} - -const rootUrl = (req: Request) => - `${req.protocol}://${req.hostname}${req.hostname === 'localhost' ? `:${Settings.PORT}` : ''}`; diff --git a/packages/addon/tsconfig.json b/packages/addon/tsconfig.json deleted file mode 100644 index ea109a6b..00000000 --- a/packages/addon/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist", - "resolveJsonModule": true - }, - "references": [ - { - "path": "../wrappers" - }, - { - "path": "../formatters" - }, - { - "path": "../types" - }, - { - "path": "../utils" - } - ] -} diff --git a/packages/cloudflare-worker/package.json b/packages/cloudflare-worker/package.json deleted file mode 100644 index bdf209ce..00000000 --- a/packages/cloudflare-worker/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@aiostreams/cloudflare-worker", - "version": "1.21.1", - "scripts": { - "deploy": "wrangler deploy", - "dev": "wrangler dev", - "start": "wrangler dev", - "test": "vitest", - "cf-typegen": "wrangler types" - }, - "dependencies": { - "@aiostreams/addon": "^1.0.0", - "@aiostreams/types": "^1.0.0" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20241224.0", - "typescript": "^5.5.2", - "wrangler": "^3.99.0" - } -} diff --git a/packages/cloudflare-worker/src/index.ts b/packages/cloudflare-worker/src/index.ts deleted file mode 100644 index 254a9f68..00000000 --- a/packages/cloudflare-worker/src/index.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { AIOStreams, errorResponse, validateConfig } from '@aiostreams/addon'; -import manifest from '@aiostreams/addon/src/manifest'; -import { Config, StreamRequest } from '@aiostreams/types'; -import { Cache, unminifyConfig } from '@aiostreams/utils'; - -const HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET,HEAD,POST,OPTIONS', -}; - -function createJsonResponse(data: any): Response { - return new Response(JSON.stringify(data, null, 4), { - headers: HEADERS, - }); -} - -function createResponse(message: string, status: number): Response { - return new Response(message, { - status, - headers: HEADERS, - }); -} - -export default { - async fetch(request, env, ctx): Promise { - try { - const url = new URL(decodeURIComponent(request.url)); - const components = url.pathname.split('/').splice(1); - - // handle static asset requests - if (components.includes('_next') || components.includes('assets')) { - return env.ASSETS.fetch(request); - } - - if (url.pathname === '/icon.ico') { - return env.ASSETS.fetch(request); - } - - // redirect to /configure if root path is requested - if (url.pathname === '/') { - return Response.redirect(url.origin + '/configure', 301); - } - - // handle /encrypt-user-data POST requests - if (components.includes('encrypt-user-data')) { - const data = (await request.json()) as { data: string }; - if (!data) { - return createResponse('Invalid Request', 400); - } - const dataToEncode = data.data; - try { - console.log( - `Received /encrypt-user-data request with Data: ${dataToEncode}` - ); - const encodedData = Buffer.from(dataToEncode).toString('base64'); - return createJsonResponse({ data: encodedData, success: true }); - } catch (error: any) { - console.error(error); - return createJsonResponse({ error: error.message, success: false }); - } - } - // handle /configure and /:config/configure requests - if (components.includes('configure')) { - if (components.length === 1) { - return env.ASSETS.fetch(request); - } else { - // display configure page with config still in url - return env.ASSETS.fetch( - new Request(url.origin + '/configure', request) - ); - } - } - - // handle /manifest.json and /:config/manifest.json requests - if (components.includes('manifest.json')) { - if (components.length === 1) { - return createJsonResponse(manifest()); - } else { - return createJsonResponse(manifest(undefined, true)); - } - } - - if (components.includes('stream')) { - // when /stream is requested without config - let config = decodeURIComponent(components[0]); - console.log(`components: ${components}`); - if (components.length < 4) { - return createJsonResponse( - errorResponse( - 'You must configure this addon first', - url.origin, - '/configure' - ) - ); - } - console.log(`Received /stream request with Config: ${config}`); - const decodedPath = decodeURIComponent(url.pathname); - - const streamMatch = /stream\/(movie|series)\/([^/]+)\.json/.exec( - decodedPath - ); - if (!streamMatch) { - let path = decodedPath.replace(`/${config}`, ''); - console.error(`Invalid request: ${path}`); - return createResponse('Invalid request', 400); - } - - const [type, id] = streamMatch.slice(1); - console.log(`Received /stream request with Type: ${type}, ID: ${id}`); - - let decodedConfig: Config; - - if (config.startsWith('E-') || config.startsWith('E2-')) { - return createResponse('Encrypted Config Not Supported', 400); - } - try { - decodedConfig = unminifyConfig( - JSON.parse(Buffer.from(config, 'base64').toString('utf-8')) - ); - } catch (error: any) { - console.error(error); - return createJsonResponse( - errorResponse( - 'Unable to parse config, please reconfigure or create an issue on GitHub', - url.origin, - '/configure' - ) - ); - } - const { valid, errorMessage, errorCode } = - validateConfig(decodedConfig); - if (!valid) { - console.error(`Invalid config: ${errorMessage}`); - return createJsonResponse( - errorResponse(errorMessage ?? 'Unknown', url.origin, '/configure') - ); - } - - if (type !== 'movie' && type !== 'series') { - return createResponse('Invalid Request', 400); - } - - let streamRequest: StreamRequest = { id, type }; - - decodedConfig.requestingIp = - request.headers.get('X-Forwarded-For') || - request.headers.get('X-Real-IP') || - request.headers.get('CF-Connecting-IP') || - request.headers.get('X-Client-IP') || - undefined; - - const aioStreams = new AIOStreams(decodedConfig); - const streams = await aioStreams.getStreams(streamRequest); - return createJsonResponse({ streams }); - } - - const notFound = await env.ASSETS.fetch( - new Request(url.origin + '/404', request) - ); - return new Response(notFound.body, { ...notFound, status: 404 }); - } catch (e) { - console.error(e); - return new Response('Internal Server Error', { - status: 500, - headers: { - 'Content-Type': 'text/plain', - }, - }); - } - }, -} satisfies ExportedHandler; diff --git a/packages/cloudflare-worker/tsconfig.json b/packages/cloudflare-worker/tsconfig.json deleted file mode 100644 index e006935d..00000000 --- a/packages/cloudflare-worker/tsconfig.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "compilerOptions": { - "target": "es2021", - "lib": ["es2021"], - /* Specify what JSX code is generated. */ - "jsx": "react-jsx", - - /* Specify what module code is generated. */ - "module": "es2022", - /* Specify how TypeScript looks up a file from a given module specifier. */ - "moduleResolution": "Bundler", - /* Specify type package names to be included without being referenced in a source file. */ - "types": ["@cloudflare/workers-types"], - /* Enable importing .json files */ - "resolveJsonModule": true, - - /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ - "allowJs": true, - /* Enable error reporting in type-checked JavaScript files. */ - "checkJs": false, - - /* Disable emitting files from a compilation. */ - "noEmit": true, - - /* Ensure that each file can be safely transpiled without relying on other imports. */ - "isolatedModules": true, - /* Allow 'import x from y' when a module doesn't have a default export. */ - "allowSyntheticDefaultImports": true, - /* Ensure that casing is correct in imports. */ - "forceConsistentCasingInFileNames": true, - - /* Enable all strict type-checking options. */ - "strict": true, - - /* Skip type checking all .d.ts files. */ - "skipLibCheck": true - }, - "references": [ - { - "path": "../addon" - }, - { - "path": "../types" - } - ], - "exclude": ["test"], - "include": ["worker-configuration.d.ts", "src/**/*.ts"] -} diff --git a/packages/cloudflare-worker/worker-configuration.d.ts b/packages/cloudflare-worker/worker-configuration.d.ts deleted file mode 100644 index 43fbb88a..00000000 --- a/packages/cloudflare-worker/worker-configuration.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Generated by Wrangler by running `wrangler types` - -interface Env { - ASSETS: Fetcher; -} diff --git a/packages/cloudflare-worker/wrangler.toml b/packages/cloudflare-worker/wrangler.toml deleted file mode 100644 index 16cca838..00000000 --- a/packages/cloudflare-worker/wrangler.toml +++ /dev/null @@ -1,115 +0,0 @@ -#:schema node_modules/wrangler/config-schema.json -name = "aiostreams" -main = "src/index.ts" -compatibility_date = "2024-12-24" -compatibility_flags = ["nodejs_compat"] -assets = { directory = "../frontend/out", binding = "ASSETS", experimental_serve_directly = false} - -# Workers Logs -# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ -# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs -[observability] -enabled = true - -# Automatically place your workloads in an optimal location to minimize latency. -# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure -# rather than the end user may result in better performance. -# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement -# [placement] -# mode = "smart" - -# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) -# Docs: -# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables -# Note: Use secrets to store sensitive data. -# - https://developers.cloudflare.com/workers/configuration/secrets/ -# [vars] -# MY_VARIABLE = "production_value" - -# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai -# [ai] -# binding = "AI" - -# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets -# [[analytics_engine_datasets]] -# binding = "MY_DATASET" - -# Bind a headless browser instance running on Cloudflare's global network. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering -# [browser] -# binding = "MY_BROWSER" - -# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases -# [[d1_databases]] -# binding = "MY_DB" -# database_name = "my-database" -# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - -# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms -# [[dispatch_namespaces]] -# binding = "MY_DISPATCHER" -# namespace = "my-namespace" - -# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. -# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects -# [[durable_objects.bindings]] -# name = "MY_DURABLE_OBJECT" -# class_name = "MyDurableObject" - -# Durable Object migrations. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations -# [[migrations]] -# tag = "v1" -# new_classes = ["MyDurableObject"] - -# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive -# [[hyperdrive]] -# binding = "MY_HYPERDRIVE" -# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces -# [[kv_namespaces]] -# binding = "MY_KV_NAMESPACE" -# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - -# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates -# [[mtls_certificates]] -# binding = "MY_CERTIFICATE" -# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - -# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues -# [[queues.producers]] -# binding = "MY_QUEUE" -# queue = "my-queue" - -# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues -# [[queues.consumers]] -# queue = "my-queue" - -# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets -# [[r2_buckets]] -# binding = "MY_BUCKET" -# bucket_name = "my-bucket" - -# Bind another Worker service. Use this binding to call another Worker without network overhead. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings -# [[services]] -# binding = "MY_SERVICE" -# service = "my-service" - -# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. -# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes -# [[vectorize]] -# binding = "MY_INDEX" -# index_name = "my-index" diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..49b0c2b1 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@aiostreams/core", + "version": "2.0.0", + "main": "dist/index.js", + "scripts": { + "test": "vitest run --passWithNoTests", + "test:watch": "vitest watch", + "build": "tsc" + }, + "description": "Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.", + "dependencies": { + "dotenv": "^16.4.7", + "envalid": "^8.0.0", + "moment-timezone": "^0.5.48", + "parse-torrent-title": "github:TheBeastLT/parse-torrent-title", + "pg": "^8.16.0", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7", + "super-regex": "^1.0.0", + "undici": "^7.2.3", + "winston": "^3.17.0", + "zod": "^3.24.4" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "@types/pg": "^8.15.2" + } +} \ No newline at end of file diff --git a/packages/core/src/db/db.ts b/packages/core/src/db/db.ts new file mode 100644 index 00000000..65844c52 --- /dev/null +++ b/packages/core/src/db/db.ts @@ -0,0 +1,202 @@ +import { TABLES } from './schemas'; +import { createLogger } from '../utils'; +import { parseConnectionURI, adaptQuery, ConnectionURI } from './utils'; + +const logger = createLogger('database'); + +import { Pool, Client, QueryResult } from 'pg'; +import sqlite3 from 'sqlite3'; +import { open, Database } from 'sqlite'; +import { URL } from 'url'; +import path from 'path'; + +type QueryResultRow = Record; + +interface UnifiedQueryResult { + rows: T[]; + rowCount: number; + command?: string; +} + +type DBDialect = 'postgres' | 'sqlite'; + +type DSNModifier = (url: URL, query: URLSearchParams) => void; + +type Transaction = { + commit: () => Promise; + rollback: () => Promise; + execute: (query: string, params?: any[]) => Promise>; +}; + +export class DB { + private static instance: DB; + private db!: Pool | Database; + private static initialised: boolean = false; + private static dialect: DBDialect; + private uri!: ConnectionURI; + private dsnModifiers: DSNModifier[] = []; + + private constructor() {} + + static getInstance(): DB { + if (!this.instance) { + this.instance = new DB(); + } + return this.instance; + } + isInitialised(): boolean { + return DB.initialised; + } + + getDialect(): DBDialect { + return DB.dialect; + } + + async initialise( + uri: string, + dsnModifiers: DSNModifier[] = [] + ): Promise { + if (DB.initialised) { + return; + } + try { + this.uri = parseConnectionURI(uri); + this.dsnModifiers = dsnModifiers; + await this.open(); + await this.ping(); + + // create tables + for (const [name, schema] of Object.entries(TABLES)) { + const createTableQuery = `CREATE TABLE IF NOT EXISTS ${name} (${schema})`; + await this.execute(createTableQuery); + } + + if (this.uri.dialect === 'sqlite') { + await this.execute('PRAGMA busy_timeout = 5000'); + await this.execute('PRAGMA foreign_keys = ON'); + await this.execute('PRAGMA synchronous = OFF'); + await this.execute('PRAGMA journal_mode = WAL'); + await this.execute('PRAGMA locking_mode = IMMEDIATE'); + } + + DB.initialised = true; + DB.dialect = this.uri.dialect; + } catch (error) { + logger.error('Failed to initialize database:', error); + throw error; + } + } + + async open(): Promise { + if (this.uri.dialect === 'postgres') { + const pool = new Pool({ connectionString: this.uri.url.toString() }); + this.db = pool; + this.uri.dialect = 'postgres'; + } else if (this.uri.dialect === 'sqlite') { + // make parent directory if it does not exist + const parentDir = path.dirname(this.uri.filename); + if (!parentDir) { + throw new Error('Invalid SQLite path'); + } + const fs = require('fs'); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + logger.debug(`Opening SQLite database: ${this.uri.filename}`); + + this.db = await open({ + filename: this.uri.filename, + driver: sqlite3.Database, + }); + this.uri.dialect = 'sqlite'; + } + } + + async close(): Promise { + if (this.uri.dialect === 'postgres') { + await (this.db as Pool).end(); + } else if (this.uri.dialect === 'sqlite') { + await (this.db as Database).close(); + } + } + + async ping(): Promise { + if (this.uri.dialect === 'postgres') { + await (this.db as Pool).query('SELECT 1'); + } else if (this.uri.dialect === 'sqlite') { + await (this.db as Database).get('SELECT 1'); + } + } + + async execute(query: string, params?: any[]): Promise { + if (this.uri.dialect === 'postgres') { + return (this.db as Pool).query( + adaptQuery(query, this.uri.dialect), + params + ); + } else if (this.uri.dialect === 'sqlite') { + return (this.db as Database).run(query, params); + } + throw new Error('Unsupported dialect'); + } + + async query(query: string, params?: any[]): Promise { + const adaptedQuery = adaptQuery(query, this.uri.dialect); + if (this.uri.dialect === 'postgres') { + const result = await (this.db as Pool).query(adaptedQuery, params); + return result.rows; + } else if (this.uri.dialect === 'sqlite') { + return (this.db as Database).all(adaptedQuery, params); + } + return []; + } + + async begin(): Promise { + if (this.uri.dialect === 'postgres') { + const client = await (this.db as Pool).connect(); + await client.query('BEGIN'); + return { + commit: async () => { + await client.query('COMMIT'); + }, + rollback: async () => { + await client.query('ROLLBACK'); + }, + execute: async ( + query: string, + params?: any[] + ): Promise => { + const result = await client.query(query, params); + return { + rows: result.rows, + rowCount: result.rowCount || 0, + command: result.command, + }; + }, + }; + } else if (this.uri.dialect === 'sqlite') { + const db = this.db as Database; + await db.run('BEGIN'); + return { + commit: async () => { + await db.run('COMMIT'); + }, + rollback: async () => { + await db.run('ROLLBACK'); + }, + execute: async ( + query: string, + params?: any[] + ): Promise => { + const result = await db.all(query, params); + return { + rows: result, + rowCount: result.length || 0, + command: 'SELECT', + }; + }, + }; + } + throw new Error('Unsupported transaction dialect'); + } +} diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts new file mode 100644 index 00000000..efc8bdba --- /dev/null +++ b/packages/core/src/db/index.ts @@ -0,0 +1,4 @@ +export * from './db'; +export * from './users'; +export * from './schemas'; +export * from './queue'; diff --git a/packages/core/src/db/queue.ts b/packages/core/src/db/queue.ts new file mode 100644 index 00000000..f7b9cbfa --- /dev/null +++ b/packages/core/src/db/queue.ts @@ -0,0 +1,58 @@ +import { createLogger } from '../utils'; +import { DB } from './db'; +const logger = createLogger('db'); +const db = DB.getInstance(); + +// Queue for SQLite transactions + +export class TransactionQueue { + private queue: Array<() => Promise> = []; + private processing = false; + private static instance: TransactionQueue; + + private constructor() {} + + static getInstance(): TransactionQueue { + if (!this.instance) { + this.instance = new TransactionQueue(); + } + return this.instance; + } + + async enqueue(operation: () => Promise): Promise { + // If using PostgreSQL, execute directly without queuing + if (db['uri']?.dialect === 'postgres') { + return operation(); + } + + return new Promise((resolve, reject) => { + this.queue.push(async () => { + try { + const result = await operation(); + resolve(result); + } catch (error) { + reject(error); + } + }); + this.processQueue(); + }); + } + + private async processQueue() { + if (this.processing || this.queue.length === 0) return; + this.processing = true; + + while (this.queue.length > 0) { + const operation = this.queue.shift(); + if (operation) { + try { + await operation(); + } catch (error) { + logger.error('Error processing queued operation:', error); + } + } + } + + this.processing = false; + } +} diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts new file mode 100644 index 00000000..a337e749 --- /dev/null +++ b/packages/core/src/db/schemas.ts @@ -0,0 +1,590 @@ +import { z } from 'zod'; +import { constants } from '../utils'; + +const ServiceIds = z.enum(constants.SERVICES); + +const Resolutions = z.enum(constants.RESOLUTIONS); + +const Qualities = z.enum(constants.QUALITIES); + +const VisualTags = z.enum(constants.VISUAL_TAGS); + +const AudioTags = z.enum(constants.AUDIO_TAGS); + +const Encodes = z.enum(constants.ENCODES); + +const SortCriteria = z.enum(constants.SORT_CRITERIA); + +const SortDirections = z.enum(constants.SORT_DIRECTIONS); +const StreamTypes = z.enum(constants.STREAM_TYPES); +const Languages = z.enum(constants.LANGUAGES); + +const Formatter = z.object({ + id: z.enum(constants.FORMATTERS), + definition: z + .object({ + name: z.string().min(1), + description: z.string().min(1), + }) + .optional(), +}); + +const StreamProxyConfig = z.object({ + enabled: z.boolean().optional(), + id: z.enum(constants.PROXY_SERVICES), + url: z.string().url(), + credentials: z.string().min(1), + publicIp: z.string().min(1).optional(), + proxiedAddons: z.array(z.string().min(1)), + proxiedServices: z.array(z.string().min(1)), +}); + +export type StreamProxyConfig = z.infer; + +const ResultLimitOptions = z.object({ + global: z.number().min(1).optional(), + service: z.number().min(1).optional(), + addon: z.number().min(1).optional(), + resolution: z.number().min(1).optional(), + quality: z.number().min(1).optional(), + language: z.number().min(1).optional(), + visualTag: z.number().min(1).optional(), + audioTag: z.number().min(1).optional(), + streamType: z.number().min(1).optional(), + encode: z.number().min(1).optional(), + regexPattern: z.number().min(1).optional(), +}); + +const SizeFilter = z.object({ + movies: z + .object({ + min: z.number().min(1).optional(), + max: z.number().min(1).optional(), + }) + .optional(), + series: z + .object({ + min: z.number().min(1).optional(), + max: z.number().min(1).optional(), + }) + .optional(), +}); + +const SizeFilterOptions = z.object({ + global: SizeFilter.optional(), + service: SizeFilter.optional(), + addon: SizeFilter.optional(), + resolution: SizeFilter.optional(), +}); + +const Service = z.object({ + id: ServiceIds, + enabled: z.boolean().optional(), + credentials: z.object({ + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), + email: z.string().min(1).optional(), + token: z.string().min(1).optional(), + clientId: z.string().min(1).optional(), + apiKey: z.string().min(1).optional(), + }), +}); + +const ServiceList = z.array(Service); + +const ResourceSchema = z.enum(constants.RESOURCES); + +export type Resource = z.infer; + +const ResourceList = z.array(ResourceSchema); + +const AddonSchema = z.object({ + manifestUrl: z.string().url(), + enabled: z.boolean(), + resources: ResourceList.optional(), + name: z.string().min(1), + timeout: z.number().min(1), + library: z.boolean().optional(), + fromPresetId: z.string().min(1).optional(), + headers: z.record(z.string().min(1), z.string().min(1)).optional(), + ip: z.string().ip().optional(), +}); + +// preset objects are transformed into addons by a preset transformer. +const PresetSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + enabled: z.boolean().optional(), + baseUrl: z.string().url().optional(), + timeout: z.number().min(1).optional(), + resources: ResourceList.optional(), + options: z.record(z.string().min(1), z.any()).optional(), +}); + +const AddonList = z.array(AddonSchema); +const PresetList = z.array(PresetSchema); + +export type Addon = z.infer; +export type Preset = z.infer; + +const DeduplicatorKey = z.enum(constants.DEDUPLICATOR_KEYS); + +// deduplicator options. +// can choose what keys to use for identifying duplicates. +// can choose how duplicates are removed specifically. +// we can either +// - keep only 1 result from the highest priority service from the highest priority addon (single_result) +// - keep 1 result for each enabled service from the higest priority addon (per_service) +// - keep 1 result from the highest priority service from each enabled addon (per_addon) +const DeduplicatorMode = z.enum(['single_result', 'per_service', 'per_addon']); + +const DeduplicatorTypeOptions = z.object({ + enabled: z.boolean().optional(), + mode: DeduplicatorMode.optional(), +}); + +const DeduplicatorOptions = z.object({ + enabled: z.boolean().optional(), + keys: z.array(DeduplicatorKey).optional(), + cached: DeduplicatorTypeOptions.optional(), + uncached: DeduplicatorTypeOptions.optional(), + p2p: DeduplicatorTypeOptions.optional(), + http: DeduplicatorTypeOptions.optional(), +}); + +const OptionDefinition = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + type: z.enum(['string', 'number', 'boolean', 'select', 'multi-select']), + required: z.boolean().optional(), + default: z.any().optional(), + sensitive: z.boolean().optional(), + options: z + .array( + z.object({ + value: z.any(), + label: z.string().min(1), + }) + ) + .optional(), + constraints: z + .object({ + min: z.number().min(1).optional(), + max: z.number().min(1).optional(), + enum: z.array(z.any()).optional(), + }) + .optional(), +}); + +export type Option = z.infer; + +const NameableRegex = z.object({ + name: z.string().min(1), + pattern: z.string().regex(/^.*$/), +}); + +export const UserDataSchema = z.object({ + uuid: z.string().uuid().optional(), + admin: z.boolean().optional(), + apiKey: z.string().min(1).optional(), + ip: z.string().ip().optional(), + addonName: z.string().min(1).optional(), + addonLogo: z.string().url().optional(), + addonBackground: z.string().url().optional(), + addonDescription: z.string().min(1).optional(), + excludedResolutions: z.array(Resolutions).optional(), + requiredResolutions: z.array(Resolutions).optional(), + preferredResolutions: z.array(Resolutions).optional(), + excludedQualities: z.array(Qualities).optional(), + requiredQualities: z.array(Qualities).optional(), + preferredQualities: z.array(Qualities).optional(), + excludedLanguages: z.array(Languages).optional(), + requiredLanguages: z.array(Languages).optional(), + preferredLanguages: z.array(Languages).optional(), + excludedVisualTags: z.array(VisualTags).optional(), + requiredVisualTags: z.array(VisualTags).optional(), + preferredVisualTags: z.array(VisualTags).optional(), + excludedAudioTags: z.array(AudioTags).optional(), + requiredAudioTags: z.array(AudioTags).optional(), + preferredAudioTags: z.array(AudioTags).optional(), + excludedStreamTypes: z.array(StreamTypes).optional(), + requiredStreamTypes: z.array(StreamTypes).optional(), + preferredStreamTypes: z.array(StreamTypes).optional(), + excludedEncodes: z.array(Encodes).optional(), + requiredEncodes: z.array(Encodes).optional(), + preferredEncodes: z.array(Encodes).optional(), + requiredRegexPatterns: z.array(z.string().min(1)).optional(), + excludedRegexPatterns: z.array(z.string().min(1)).optional(), + preferredRegexPatterns: z.array(NameableRegex).optional(), + + sortCriteria: z + .object({ + // global must be defined. + global: z.array(z.tuple([SortCriteria, SortDirections.optional()])), + // results must be from either a movie or series search, so we can safely apply different sort criteria. + movies: z + .array(z.tuple([SortCriteria, SortDirections.optional()])) + .optional(), + series: z + .array(z.tuple([SortCriteria, SortDirections.optional()])) + .optional(), + // cached and uncached results are a sort criteria themselves, so this can only be applied when cache is high enough in the global + // sort criteria, and we would have to split the results into two (cached and uncached) lists, and then apply both sort criteria below + // and then merge the results. + cached: z + .array(z.tuple([SortCriteria, SortDirections.optional()])) + .optional(), + uncached: z + .array(z.tuple([SortCriteria, SortDirections.optional()])) + .optional(), + }) + .optional(), + formatter: Formatter, + proxy: StreamProxyConfig.optional(), + resultLimiterOptions: ResultLimitOptions.optional(), + sizeFilters: SizeFilterOptions.optional(), + hideErrors: z.boolean().optional(), + strictTitleMatching: z.boolean().optional(), + deduplicator: DeduplicatorOptions.optional(), + precacheNextEpisode: z.boolean().optional(), + hideZeroSeederResults: z.boolean().optional(), + services: ServiceList.optional(), + addons: AddonList, + presets: PresetList.optional(), +}); + +export type UserData = z.infer; + +export const TABLES = { + USERS: ` + uuid TEXT PRIMARY KEY, + config TEXT NOT NULL, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + accessed_at DATETIME DEFAULT CURRENT_TIMESTAMP + `, +}; + +const strictManifestResourceSchema = z.object({ + name: z.enum(constants.RESOURCES), + types: z.array(z.string()), + idPrefixes: z.array(z.string().min(1)).optional(), +}); + +export type StrictManifestResource = z.infer< + typeof strictManifestResourceSchema +>; + +const ManifestResourceSchema = z.union([ + z.string(), + strictManifestResourceSchema, +]); + +const ManifestExtraSchema = z.object({ + name: z.string().min(1), + isRequired: z.boolean().optional(), + options: z.array(z.string()).optional(), + optionsLimit: z.number().min(1).optional(), +}); +const ManifestCatalogSchema = z.object({ + type: z.string(), + id: z.string().min(1), + name: z.string().min(1), + extra: z.array(ManifestExtraSchema).optional(), +}); + +const AddonCatalogDefinitionSchema = z.object({ + type: z.string(), + id: z.string().min(1), + name: z.string().min(1), +}); + +export const ManifestSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + version: z.string().min(1), + types: z.array(z.string()), + idPrefixes: z.array(z.string().min(1)).optional(), + resources: z.array(ManifestResourceSchema), + catalogs: z.array(ManifestCatalogSchema), + addonCatalogs: z.array(AddonCatalogDefinitionSchema).optional(), + background: z.string().min(1).optional(), + logo: z.string().min(1).optional(), + contactEmail: z.string().min(1).optional(), + behaviorHints: z.object({ + adult: z.boolean().optional(), + p2p: z.boolean().optional(), + configurable: z.boolean().optional(), + configurationRequired: z.boolean().optional(), + }), + // not part of the manifest scheme, but needed for stremio-addons.net + stremioAddonsConfig: z + .object({ + issuer: z.string().min(1), + signature: z.string().min(1), + }) + .optional(), +}); + +export type Manifest = z.infer; + +const SubtitleSchema = z.object({ + id: z.string().min(1), + url: z.string().url(), + lang: z.string().min(1), +}); + +export const SubtitleResponseSchema = z.object({ + subtitles: z.array(SubtitleSchema), +}); +export type SubtitleResponse = z.infer; +export type Subtitle = z.infer; + +const StreamSchema = z.object({ + url: z.string().url().optional(), + ytId: z.string().min(1).optional(), + infoHash: z.string().min(1).optional(), + fileIdx: z.number().optional(), + externalUrl: z.string().min(1).optional(), + + name: z.string().min(1).optional(), + title: z.string().min(1).optional(), + description: z.string().min(1).optional(), + subtitles: z.array(SubtitleSchema).optional(), + sources: z.array(z.string().min(1)).optional(), + behaviorHints: z + .object({ + countryWhitelist: z.array(z.string().length(3)).optional(), + notWebReady: z.boolean().optional(), + bingeGroup: z.string().min(1).optional(), + proxyHeaders: z + .object({ + request: z.record(z.string().min(1), z.string().min(1)).optional(), + response: z.record(z.string().min(1), z.string().min(1)).optional(), + }) + .optional(), + videoHash: z.string().min(1).optional(), + videoSize: z.number().optional(), + filename: z.string().min(1).optional(), + }) + .optional(), +}); + +export const StreamResponseSchema = z.object({ + streams: z.array(StreamSchema), +}); + +export type StreamResponse = z.infer; + +export type Stream = z.infer; + +const TrailerSchema = z.object({ + source: z.string().min(1), + type: z.enum(['Trailer']), +}); + +const MetaLinkSchema = z.object({ + name: z.string().min(1), + category: z.string().min(1), + url: z.string().url().or(z.string().startsWith('stremio:///')), +}); + +const MetaVideoSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + released: z.string().datetime(), + thumbnail: z.string().url().optional(), + streams: z.array(StreamSchema).optional(), + available: z.boolean().optional(), + episode: z.number().optional(), + season: z.number().optional(), + trailers: z.array(TrailerSchema).optional(), + overview: z.string().min(1).optional(), +}); + +const MetaPreviewSchema = z.object({ + id: z.string().min(1), + type: z.string().min(1), + name: z.string().min(1), + poster: z.string(), + posterShape: z.enum(['square', 'poster', 'landscape']).optional(), + // discover sidebar + //@deprecated use links instead + genres: z.array(z.string()).optional(), + imdbRating: z.string().or(z.null()).optional(), + releaseInfo: z.string().or(z.null()).optional(), + //@deprecated + director: z.array(z.string()).or(z.null()).optional(), + //@deprecated + cast: z.array(z.string()).or(z.null()).optional(), + // background: z.string().min(1).optional(), + // logo: z.string().min(1).optional(), + description: z.string().optional(), + trailers: z.array(TrailerSchema).optional(), + links: z.array(MetaLinkSchema).optional(), + // released: z.string().datetime().optional(), +}); + +const MetaSchema = MetaPreviewSchema.extend({ + poster: z.string().min(1).optional(), + background: z.string().min(1).optional(), + logo: z.string().min(1).optional(), + videos: z.array(MetaVideoSchema).optional(), + runtime: z.string().min(1).optional(), + language: z.string().min(1).optional(), + country: z.string().length(3).optional(), + awards: z.string().min(1).optional(), + website: z.string().url().optional(), + behaviorHints: z + .object({ + defaultVideoId: z.string().min(1).optional(), + }) + .optional(), +}); + +export const MetaResponseSchema = z.object({ + meta: MetaSchema, +}); +export const CatalogResponseSchema = z.object({ + metas: z.array(MetaPreviewSchema), +}); +export type MetaResponse = z.infer; +export type CatalogResponse = z.infer; +export type Meta = z.infer; +export type MetaPreview = z.infer; + +const AddonCatalogSchema = z.object({ + transportName: z.literal('http'), + transportUrl: z.string().url(), + manifest: ManifestSchema, +}); +export const AddonCatalogResponseSchema = z.object({ + addons: z.array(AddonCatalogSchema), +}); +export type AddonCatalogResponse = z.infer; +export type AddonCatalog = z.infer; + +const ParsedFileSchema = z.object({ + releaseGroup: z.string().min(1).optional(), + resolution: z.string().min(1).optional(), + quality: z.string().min(1).optional(), + encode: z.string().min(1).optional(), + visualTags: z.array(z.string()).optional(), + audioTags: z.array(z.string()).optional(), + languages: z.array(z.string()).optional(), + title: z.string().min(1).optional(), + year: z.string().min(1).optional(), + season: z.number().optional(), + seasons: z.array(z.number()).optional(), + episode: z.number().optional(), +}); + +export type ParsedFile = z.infer; + +export const ParsedStreamSchema = z.object({ + proxied: z.boolean().optional(), + addon: AddonSchema, + parsedFile: ParsedFileSchema, + message: z.string().min(1).max(1000).optional(), + regexMatched: z + .object({ + name: z.string().min(1).optional(), + pattern: z.string().min(1).optional(), + index: z.number().optional(), + }) + .optional(), + size: z.number().optional(), + type: StreamTypes, + indexer: z.string().min(1).optional(), + age: z.string().optional(), + torrent: z + .object({ + infoHash: z.string().min(1).optional(), + fileIdx: z.number().optional(), + seeders: z.number().optional(), + sources: z.array(z.string().min(1)).optional(), // array of tracker urls and DHT nodes + }) + .optional(), + countryWhitelist: z.array(z.string().length(3)).optional(), + notWebReady: z.boolean().optional(), + bingeGroup: z.string().min(1).optional(), + requestHeaders: z.record(z.string().min(1), z.string().min(1)).optional(), + responseHeaders: z.record(z.string().min(1), z.string().min(1)).optional(), + videoHash: z.string().min(1).optional(), + subtitles: z.array(SubtitleSchema).optional(), + filename: z.string().min(1).optional(), + folderName: z.string().min(1).optional(), + service: z + .object({ + id: z.enum(constants.SERVICES), + cached: z.boolean(), + }) + .optional(), + duration: z.number().optional(), + inLibrary: z.boolean().optional(), + url: z.string().url().optional(), + ytId: z.string().min(1).optional(), + externalUrl: z.string().min(1).optional(), +}); + +export type ParsedStream = z.infer; + +const PresetMetadataSchema = z.object({ + ID: z.string(), + NAME: z.string(), + LOGO: z.string(), + DESCRIPTION: z.string(), + URL: z.string(), + TIMEOUT: z.number(), + USER_AGENT: z.string(), + SUPPORTED_SERVICES: z.array(z.string()), + OPTIONS: z.array(OptionDefinition), + TAGS: z.array(z.string()), + RESOURCES: z.array(ResourceSchema), + REQUIRES_SERVICE: z.boolean(), +}); + +const StatusResponseSchema = z.object({ + version: z.string(), + commit: z.string(), + buildTime: z.string(), + commitTime: z.string(), + users: z.number(), + settings: z.object({ + disabledAddons: z.array(z.string()), + disabledServices: z.array(z.string()), + forced: z.object({ + proxy: z.object({ + enabled: z.boolean().or(z.null()), + id: z.string().or(z.null()), + url: z.string().or(z.null()), + publicIp: z.string().or(z.null()), + credentials: z.string().or(z.null()), + proxiedAddons: z.array(z.string()).or(z.null()), + proxiedServices: z.array(z.string()).or(z.null()), + }), + }), + defaults: z.object({ + proxy: z.object({ + enabled: z.boolean().or(z.null()), + id: z.string().or(z.null()), + url: z.string().or(z.null()), + publicIp: z.string().or(z.null()), + credentials: z.string().or(z.null()), + proxiedAddons: z.array(z.string()).or(z.null()), + proxiedServices: z.array(z.string()).or(z.null()), + }), + preferredRegex: z.array(z.string()), + requiredRegex: z.array(z.string()), + excludedRegex: z.array(z.string()), + }), + presets: z.array(PresetMetadataSchema), + }), +}); + +export type StatusResponse = z.infer; +export type PresetMetadata = z.infer; diff --git a/packages/core/src/db/users.ts b/packages/core/src/db/users.ts new file mode 100644 index 00000000..f2a08a4f --- /dev/null +++ b/packages/core/src/db/users.ts @@ -0,0 +1,321 @@ +// import { UserDataSchema, UserData, DB } from '../db'; +import { UserDataSchema, UserData } from './schemas'; +import { TransactionQueue } from './queue'; +import { DB } from './db'; +import { + decryptString, + deriveKey, + encryptString, + generateUUID, + getTextHash, + maskSensitiveInfo, + createLogger, + constants, + Env, +} from '../utils'; + +const APIError = constants.APIError; +const logger = createLogger('users'); +const db = DB.getInstance(); +const txQueue = TransactionQueue.getInstance(); + +export class UserRepository { + static async createUser( + config: UserData, + password: string + ): Promise<{ uuid: string; encryptedPassword: string }> { + return txQueue.enqueue(async () => { + if (password.length < 8) { + return Promise.reject( + new APIError(constants.ErrorCode.USER_NEW_PASSWORD_TOO_SHORT) + ); + } + + // require at least one uppercase, one lowercase, one number, and one special character + // [@$!%*?&\-\._#~^()+=<>,;:'"`{}[\]|\\] + if ( + !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&\-\._#~^()+=<>,;:'"`{}[\]|\\])[A-Za-z\d@$!%*?&\-\._#~^()+=<>,;:'"`{}[\]|\\]{8,}$/.test( + password + ) + ) { + return Promise.reject( + new APIError(constants.ErrorCode.USER_NEW_PASSWORD_TOO_SIMPLE) + ); + } + + const uuid = await this.generateUUID(); + config.uuid = uuid; + + const { encryptedConfig, salt } = await this.encryptConfig( + config, + password + ); + const { hash: hashedPassword } = getTextHash(password, salt); + + const { success, data } = encryptString(password); + if (success === false) { + return Promise.reject(constants.ErrorCode.USER_ERROR); + } + + const encryptedPassword = data; + const tx = await db.begin(); + try { + await tx.execute( + 'INSERT INTO users (uuid, password_hash, password_salt, config) VALUES (?, ?, ?, ?)', + [uuid, hashedPassword, salt, encryptedConfig] + ); + await tx.commit(); + logger.info(`Created a new user with UUID: ${uuid}`); + return { uuid, encryptedPassword }; + } catch (error) { + await tx.rollback(); + logger.error( + `Failed to create user: ${error instanceof Error ? error.message : String(error)}` + ); + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + }); + } + + static async checkUserExists(uuid: string): Promise { + try { + const result = await db.query('SELECT uuid FROM users WHERE uuid = ?', [ + uuid, + ]); + return result.length > 0; + } catch (error) { + logger.error(`Error checking user existence: ${error}`); + return Promise.reject(constants.ErrorCode.USER_ERROR); + } + } + + static async getUser( + uuid: string, + encryptedPassword?: string, + password?: string + ): Promise { + try { + if (!encryptedPassword && !password) { + return Promise.reject( + new APIError(constants.ErrorCode.MISSING_REQUIRED_FIELDS) + ); + } + const result = await db.query( + 'SELECT config, password_salt FROM users WHERE uuid = ?', + [uuid] + ); + + if (!result.length || !result[0].config) { + return Promise.reject(new APIError(constants.ErrorCode.USER_NOT_FOUND)); + } + + await db.execute( + 'UPDATE users SET accessed_at = CURRENT_TIMESTAMP WHERE uuid = ?', + [uuid] + ); + + if (!password) { + const { success, data } = decryptString(encryptedPassword!); + if (!success) { + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + password = data; + } + + const isValid = await this.verifyUserPassword(uuid, password); + if (!isValid) { + return Promise.reject( + new APIError(constants.ErrorCode.USER_INVALID_PASSWORD) + ); + } + + const decryptedConfig = await this.decryptConfig( + result[0].config, + password, + result[0].password_salt + ); + + decryptedConfig.admin = + Env.ADMIN_UUIDS?.split(',').some((u) => new RegExp(u).test(uuid)) ?? + false; + logger.info(`Retrieved configuration for user ${uuid}`); + return decryptedConfig; + } catch (error) { + logger.error( + `Error retrieving user ${uuid}: ${error instanceof Error ? error.message : String(error)}` + ); + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + } + + static async updateUser( + uuid: string, + password: string, + config: UserData + ): Promise { + return txQueue.enqueue(async () => { + const tx = await db.begin(); + try { + const currentUser = await tx.execute( + 'SELECT password_salt FROM users WHERE uuid = ?', + [uuid] + ); + + if (!currentUser.rows.length) { + await tx.rollback(); + return Promise.reject( + new APIError(constants.ErrorCode.USER_NOT_FOUND) + ); + } + + const isValid = await this.verifyUserPassword(uuid, password); + if (!isValid) { + await tx.rollback(); + return Promise.reject( + new APIError(constants.ErrorCode.USER_INVALID_PASSWORD) + ); + } + + const { encryptedConfig } = await this.encryptConfig( + config, + password, + currentUser.rows[0].password_salt + ); + + await tx.execute( + 'UPDATE users SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE uuid = ?', + [encryptedConfig, uuid] + ); + + await tx.commit(); + logger.info(`Updated user ${uuid} with an updated configuration`); + } catch (error) { + await tx.rollback(); + logger.error( + `Failed to update user ${uuid}: ${error instanceof Error ? error.message : String(error)}` + ); + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + }); + } + + static async getUserCount(): Promise { + try { + const result = await db.query('SELECT * FROM users'); + return result.length; + } catch (error) { + logger.error(`Error getting user count: ${error}`); + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + } + + static async deleteUser(uuid: string): Promise { + return txQueue.enqueue(async () => { + const tx = await db.begin(); + try { + const result = await tx.execute('DELETE FROM users WHERE uuid = ?', [ + uuid, + ]); + + if (result.rowCount === 0) { + await tx.rollback(); + throw new APIError(constants.ErrorCode.USER_NOT_FOUND); + } + + await tx.commit(); + logger.info(`Deleted user ${uuid}`); + } catch (error) { + await tx.rollback(); + logger.error( + `Failed to delete user ${uuid}: ${error instanceof Error ? error.message : String(error)}` + ); + throw new APIError(constants.ErrorCode.USER_ERROR); + } + }); + } + + static async pruneUsers(maxDays: number = 30): Promise { + try { + const query = + db.getDialect() === 'postgres' + ? `DELETE FROM users WHERE accessed_at < NOW() - INTERVAL ${maxDays} DAY` + : `DELETE FROM users WHERE accessed_at < datetime('now', '-' || ${maxDays} || ' days')`; + await db.execute(query); + logger.info(`Pruned users older than ${maxDays} days`); + } catch (error) { + logger.error('Failed to prune users:', error); + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + } + + private static async verifyUserPassword( + uuid: string, + password: string + ): Promise { + const result = await db.query( + 'SELECT password_hash, password_salt FROM users WHERE uuid = ?', + [uuid] + ); + + if (!result.length) { + return false; + } + + const { password_hash: hashedPassword, password_salt: salt } = result[0]; + const { hash } = getTextHash(password, salt); + return hash === hashedPassword; + } + + private static async encryptConfig( + config: UserData, + password: string, + salt?: string + ): Promise<{ + encryptedConfig: string; + salt: string; + }> { + const { key, salt: saltUsed } = deriveKey(password, salt); + const configString = JSON.stringify(config); + const { success, data, error } = encryptString(configString, key); + + if (!success) { + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + + return { encryptedConfig: data, salt: saltUsed }; + } + + private static async decryptConfig( + encryptedConfig: string, + password: string, + salt: string + ): Promise { + const { key } = deriveKey(password, salt); + const { + success, + data: decryptedString, + error, + } = decryptString(encryptedConfig, key); + + if (!success || !decryptedString) { + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + + return UserDataSchema.parse(JSON.parse(decryptedString)); + } + + private static async generateUUID(count: number = 1): Promise { + if (count > 10) { + return Promise.reject(new APIError(constants.ErrorCode.USER_ERROR)); + } + + const uuid = generateUUID(); + const existingUser = await this.checkUserExists(uuid); + + if (existingUser) { + return this.generateUUID(count + 1); + } + + return uuid; + } +} diff --git a/packages/core/src/db/utils.ts b/packages/core/src/db/utils.ts new file mode 100644 index 00000000..434d46c9 --- /dev/null +++ b/packages/core/src/db/utils.ts @@ -0,0 +1,75 @@ +import { URL } from 'url'; +import { createLogger } from '../utils/logger'; +import path from 'path'; + +type BaseConnectionURI = { + url: URL; + driverName: string; +}; + +type PostgresConnectionURI = BaseConnectionURI & { + dialect: 'postgres'; +}; + +type SQLiteConnectionURI = BaseConnectionURI & { + filename: string; + dialect: 'sqlite'; +}; + +export type ConnectionURI = PostgresConnectionURI | SQLiteConnectionURI; + +type DBDialect = 'postgres' | 'sqlite'; + +type DSNModifier = (url: URL, query: URLSearchParams) => void; +const logger = createLogger('database'); + +function parseConnectionURI(uri: string): ConnectionURI { + const url = new URL(uri); + let driverName: string; + let dialect: DBDialect; + + switch (url.protocol) { + case 'sqlite:': + driverName = 'sqlite3'; + dialect = 'sqlite'; + let filename = url.pathname; + if (url.hostname && url.hostname !== '.') { + throw new Error("Invalid path, must start with '/' or './'"); + } + if (!url.pathname) { + throw new Error('Invalid path, must be absolute'); + } + if (url.hostname === '.') { + // resolve relative path using process.cwd() + filename = path.join(process.cwd(), url.pathname.replace(/^\//, '')); + } + return { + url, + driverName, + filename: filename, + dialect, + }; + case 'postgres:': + driverName = 'pg'; + dialect = 'postgres'; + return { + url, + + driverName, + dialect, + }; + default: + throw new Error('Unsupported scheme: ' + url.protocol); + } +} + +function adaptQuery(query: string, dialect: DBDialect): string { + if (dialect === 'sqlite') { + return query; + } + + let position = 1; + return query.replace(/\?/g, () => `$${position++}`); +} + +export { parseConnectionURI, adaptQuery }; diff --git a/packages/core/src/formatters/Intl.d.ts b/packages/core/src/formatters/Intl.d.ts new file mode 100644 index 00000000..817f2a33 --- /dev/null +++ b/packages/core/src/formatters/Intl.d.ts @@ -0,0 +1,12 @@ +// vs code... +declare namespace Intl { + type Key = + | 'calendar' + | 'collation' + | 'currency' + | 'numberingSystem' + | 'timeZone' + | 'unit'; + + function supportedValuesOf(input: Key): string[]; +} diff --git a/packages/core/src/formatters/base.ts b/packages/core/src/formatters/base.ts new file mode 100644 index 00000000..fc17c204 --- /dev/null +++ b/packages/core/src/formatters/base.ts @@ -0,0 +1,586 @@ +import { ParsedStream } from '../db'; +// import { constants, Env, createLogger } from '../utils'; +import * as constants from '../utils/constants'; +import { createLogger } from '../utils/logger'; +import { formatBytes, formatDuration, languageToEmoji } from './utils'; +import { Env } from '../utils/env'; + +const logger = createLogger('formatter'); + +/** + * + * The custom formatter code in this file was adapted from https://github.com/diced/zipline/blob/trunk/src/lib/parser/index.ts + * + * The original code is licensed under the MIT License. + * + * MIT License + * + * Copyright (c) 2023 dicedtomato + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export interface FormatterConfig { + name: string; + description: string; +} + +export interface ParseValue { + config?: { + addonName: string | null; + }; + stream?: { + filename: string | null; + folderName: string | null; + size: number | null; + inLibrary: boolean | null; + quality: string | null; + resolution: string | null; + languages: string[] | null; + languageEmojis: string[] | null; + visualTags: string[] | null; + audioTags: string[] | null; + releaseGroup: string | null; + regexMatched: string | null; + encode: string | null; + indexer: string | null; + year: string | null; + title: string | null; + season: number | null; + seasons: number[] | null; + episode: number | null; + seeders: number | null; + age: string | null; + duration: number | null; + infoHash: string | null; + type: string | null; + message: string | null; + proxied: boolean | null; + }; + service?: { + id: string | null; + shortName: string | null; + name: string | null; + cached: boolean | null; + }; + addon?: { + name: string; + manifestUrl: string; + }; + debug?: { + json: string | null; + jsonf: string | null; + }; +} + +export abstract class BaseFormatter { + protected config: FormatterConfig; + + constructor(config: FormatterConfig) { + this.config = config; + } + + public format(stream: ParsedStream): { name: string; description: string } { + const parseValue = this.convertStreamToParseValue(stream); + return { + name: this.parseString(this.config.name, parseValue) || '', + description: this.parseString(this.config.description, parseValue) || '', + }; + } + + protected convertStreamToParseValue(stream: ParsedStream): ParseValue { + return { + config: { + addonName: Env.ADDON_NAME, + }, + stream: { + filename: stream.filename || null, + folderName: stream.folderName || null, + size: stream.size || null, + inLibrary: stream.inLibrary !== undefined ? stream.inLibrary : null, + quality: stream.parsedFile.quality || null, + resolution: stream.parsedFile.resolution || null, + languages: stream.parsedFile.languages || null, + languageEmojis: stream.parsedFile.languages + ? stream.parsedFile.languages + .map((lang) => languageToEmoji(lang) || lang) + .filter((value, index, self) => self.indexOf(value) === index) + : null, + visualTags: stream.parsedFile.visualTags || null, + audioTags: stream.parsedFile.audioTags || null, + releaseGroup: stream.parsedFile.releaseGroup || null, + regexMatched: stream.regexMatched?.name || null, + encode: stream.parsedFile.encode || null, + indexer: stream.indexer || null, + seeders: stream.torrent?.seeders || null, + year: stream.parsedFile.year || null, + type: stream.type || null, + title: stream.parsedFile.title || null, + season: stream.parsedFile.season || null, + seasons: stream.parsedFile.seasons || null, + episode: stream.parsedFile.episode || null, + duration: stream.duration || null, + infoHash: stream.torrent?.infoHash || null, + age: stream.age || null, + message: stream.message || null, + proxied: stream.proxied !== undefined ? stream.proxied : null, + }, + addon: { + name: stream.addon.name, + manifestUrl: stream.addon.manifestUrl, + }, + service: { + id: stream.service?.id || null, + shortName: stream.service?.id + ? Object.values(constants.SERVICE_DETAILS).find( + (service) => service.id === stream.service?.id + )?.shortName || null + : null, + name: stream.service?.id + ? Object.values(constants.SERVICE_DETAILS).find( + (service) => service.id === stream.service?.id + )?.name || null + : null, + cached: + stream.service?.cached !== undefined ? stream.service?.cached : null, + }, + }; + } + + protected parseString(str: string, value: ParseValue): string | null { + if (!str) return null; + + const replacer = (key: string, value: unknown) => { + return value; + }; + + const data = { + stream: value.stream, + service: value.service, + addon: value.addon, + config: value.config, + }; + + value.debug = { + json: JSON.stringify(data, replacer), + jsonf: JSON.stringify(data, replacer, 2), + }; + + const re = + /\{(?stream|service|addon|config|debug)\.(?\w+)(::(?(\w+(\([^)]*\))?|<|<=|=|>=|>|\^|\$|~|\/)+))?((::(?\S+?))|(?\[(?".*?")\|\|(?".*?")\]))?\}/gi; + let matches: RegExpExecArray | null; + + while ((matches = re.exec(str))) { + if (!matches.groups) continue; + + const index = matches.index as number; + + const getV = value[matches.groups.type as keyof ParseValue]; + + if (!getV) { + str = this.replaceCharsFromString( + str, + '{unknown_type}', + index, + re.lastIndex + ); + re.lastIndex = index; + continue; + } + + const v = + getV[ + matches.groups.prop as + | keyof ParseValue['stream'] + | keyof ParseValue['service'] + | keyof ParseValue['addon'] + ]; + + if (v === undefined) { + str = this.replaceCharsFromString( + str, + '{unknown_value}', + index, + re.lastIndex + ); + re.lastIndex = index; + continue; + } + + if (matches.groups.mod) { + str = this.replaceCharsFromString( + str, + this.modifier( + matches.groups.mod, + v, + matches.groups.mod_tzlocale ?? undefined, + matches.groups.mod_check_true ?? undefined, + matches.groups.mod_check_false ?? undefined, + value + ), + index, + re.lastIndex + ); + re.lastIndex = index; + continue; + } + + str = this.replaceCharsFromString(str, v, index, re.lastIndex); + re.lastIndex = index; + } + + return str + .replace(/\\n/g, '\n') + .split('\n') + .filter( + (line) => line.trim() !== '' && !line.includes('{tools.removeLine}') + ) + .join('\n') + .replace(/\{tools.newLine\}/g, '\n'); + } + + protected modifier( + mod: string, + value: unknown, + tzlocale?: string, + check_true?: string, + check_false?: string, + _value?: ParseValue + ): string { + mod = mod.toLowerCase(); + check_true = check_true?.slice(1, -1); + check_false = check_false?.slice(1, -1); + + if (Array.isArray(value)) { + switch (true) { + case mod === 'join': + return value.join(', '); + case mod.startsWith('join(') && mod.endsWith(')'): + // Extract the separator from join(separator) + // e.g. join(' - ') + const separator = mod + .substring(5, mod.length - 1) + .replace(/^['"]|['"]$/g, ''); + return value.join(separator); + case mod == 'length': + return value.length.toString(); + case mod == 'first': + return value.length > 0 ? String(value[0]) : ''; + case mod == 'last': + return value.length > 0 ? String(value[value.length - 1]) : ''; + case mod == 'random': + return value.length > 0 + ? String(value[Math.floor(Math.random() * value.length)]) + : ''; + case mod == 'sort': + return [...value].sort().join(', '); + case mod == 'reverse': + return [...value].reverse().join(', '); + case mod == 'exists': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_array_modifier(${mod})}`; + + if (_value) { + return value.length > 0 + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.length > 0 ? check_true : check_false; + } + default: + return `{unknown_array_modifier(${mod})}`; + } + } else if (typeof value === 'string') { + switch (true) { + case mod == 'upper': + return value.toUpperCase(); + case mod == 'lower': + return value.toLowerCase(); + case mod == 'title': + return value.charAt(0).toUpperCase() + value.slice(1); + case mod == 'length': + return value.length.toString(); + case mod == 'reverse': + return value.split('').reverse().join(''); + case mod == 'base64': + return btoa(value); + case mod == 'string': + return value; + case mod == 'exists': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value != 'null' && value + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value != 'null' && value ? check_true : check_false; + } + case mod.startsWith('='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('=', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase() == check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase() == check ? check_true : check_false; + } + case mod.startsWith('$'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('$', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().startsWith(check) + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().startsWith(check) + ? check_true + : check_false; + } + case mod.startsWith('^'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('^', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().endsWith(check) + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().endsWith(check) ? check_true : check_false; + } + case mod.startsWith('~'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_str_modifier(${mod})}`; + + const check = mod.replace('~', ''); + + if (!check) return `{unknown_str_modifier(${mod})}`; + + if (_value) { + return value.toLowerCase().includes(check) + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value.toLowerCase().includes(check) ? check_true : check_false; + } + default: + return `{unknown_str_modifier(${mod})}`; + } + } else if (typeof value === 'number') { + switch (true) { + case mod == 'comma': + return value.toLocaleString(); + case mod == 'hex': + return value.toString(16); + case mod == 'octal': + return value.toString(8); + case mod == 'binary': + return value.toString(2); + case mod == 'bytes10' || mod == 'bytes': + return formatBytes(value, 1000); + case mod == 'bytes2': + return formatBytes(value, 1024); + case mod == 'string': + return value.toString(); + case mod == 'time': + return formatDuration(value); + case mod.startsWith('>='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('>=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value >= check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value >= check ? check_true : check_false; + } + case mod.startsWith('>'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('>', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value > check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value > check ? check_true : check_false; + } + case mod.startsWith('='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value == check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value == check ? check_true : check_false; + } + case mod.startsWith('<='): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('<=', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value <= check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value <= check ? check_true : check_false; + } + case mod.startsWith('<'): { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_int_modifier(${mod})}`; + + const check = Number(mod.replace('<', '')); + + if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; + + if (_value) { + return value < check + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value < check ? check_true : check_false; + } + default: + return `{unknown_int_modifier(${mod})}`; + } + } else if (typeof value === 'boolean') { + switch (true) { + case mod == 'istrue': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_bool_modifier(${mod})}`; + + if (_value) { + return value + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return value ? check_true : check_false; + } + case mod == 'isfalse': { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_bool_modifier(${mod})}`; + + if (_value) { + return !value + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + + return !value ? check_true : check_false; + } + default: + return `{unknown_bool_modifier(${mod})}`; + } + } + + if ( + typeof check_false == 'string' && + (['>', '>=', '=', '<=', '<', '~', '$', '^'].some((modif) => + mod.startsWith(modif) + ) || + ['istrue', 'exists', 'isfalse'].includes(mod)) + ) { + if (_value) return this.parseString(check_false, _value) || check_false; + return check_false; + } + + return `{unknown_modifier(${mod})}`; + } + + private handleComparison(mod: string, value: unknown): boolean { + const operator = mod.match(/^[<>=]+/)?.[0]; + const compareValue = mod.slice(operator?.length || 0); + + if (!operator) return false; + + const numValue = Number(value); + const numCompare = Number(compareValue); + + switch (operator) { + case '<': + return numValue < numCompare; + case '<=': + return numValue <= numCompare; + case '>': + return numValue > numCompare; + case '>=': + return numValue >= numCompare; + case '=': + return numValue === numCompare; + default: + return false; + } + } + + protected replaceCharsFromString( + str: string, + replace: string, + start: number, + end: number + ): string { + return str.slice(0, start) + replace + str.slice(end); + } +} diff --git a/packages/core/src/formatters/custom.ts b/packages/core/src/formatters/custom.ts new file mode 100644 index 00000000..6a25a957 --- /dev/null +++ b/packages/core/src/formatters/custom.ts @@ -0,0 +1,28 @@ +import { BaseFormatter, FormatterConfig } from './base'; + +export class CustomFormatter extends BaseFormatter { + constructor(nameTemplate: string, descriptionTemplate: string) { + super({ + name: nameTemplate, + description: descriptionTemplate, + }); + } + + public static fromConfig(config: FormatterConfig): CustomFormatter { + return new CustomFormatter(config.name, config.description); + } + + public updateTemplate( + nameTemplate: string, + descriptionTemplate: string + ): void { + this.config = { + name: nameTemplate, + description: descriptionTemplate, + }; + } + + public getTemplate(): FormatterConfig { + return this.config; + } +} diff --git a/packages/core/src/formatters/index.ts b/packages/core/src/formatters/index.ts new file mode 100644 index 00000000..d005c0ef --- /dev/null +++ b/packages/core/src/formatters/index.ts @@ -0,0 +1,40 @@ +export * from './base'; +export * from './predefined'; +export * from './custom'; +export * from './utils'; + +import { BaseFormatter, FormatterConfig } from './base'; +import { + TorrentioFormatter, + TorboxFormatter, + GDriveFormatter, + LightGDriveFormatter, + MinimalisticGdriveFormatter, +} from './predefined'; +import { CustomFormatter } from './custom'; +import { FormatterType } from '../utils/constants'; + +export function createFormatter( + type: FormatterType, + config?: FormatterConfig +): BaseFormatter { + switch (type) { + case 'torrentio': + return new TorrentioFormatter(); + case 'torbox': + return new TorboxFormatter(); + case 'gdrive': + return new GDriveFormatter(); + case 'lightgdrive': + return new LightGDriveFormatter(); + case 'minimalisticgdrive': + return new MinimalisticGdriveFormatter(); + case 'custom': + if (!config) { + throw new Error('Config is required for custom formatter'); + } + return CustomFormatter.fromConfig(config); + default: + throw new Error(`Unknown formatter type: ${type}`); + } +} diff --git a/packages/core/src/formatters/predefined.ts b/packages/core/src/formatters/predefined.ts new file mode 100644 index 00000000..e025944b --- /dev/null +++ b/packages/core/src/formatters/predefined.ts @@ -0,0 +1,46 @@ +import { BaseFormatter, FormatterConfig } from './base'; + +export class TorrentioFormatter extends BaseFormatter { + constructor() { + super({ + name: '{stream.title} {stream.quality}', + description: '{stream.size::bytes} {stream.seeders} seeders', + }); + } +} + +export class TorboxFormatter extends BaseFormatter { + constructor() { + super({ + name: '{stream.title} {stream.quality}', + description: '{stream.size::bytes} {stream.seeders} seeders', + }); + } +} + +export class GDriveFormatter extends BaseFormatter { + constructor() { + super({ + name: '{stream.title} {stream.quality}', + description: '{stream.size::bytes} {stream.seeders} seeders', + }); + } +} + +export class LightGDriveFormatter extends BaseFormatter { + constructor() { + super({ + name: '{stream.title} {stream.quality}', + description: '{stream.size::bytes} {stream.seeders} seeders', + }); + } +} + +export class MinimalisticGdriveFormatter extends BaseFormatter { + constructor() { + super({ + name: '{stream.title} {stream.quality}', + description: '{stream.size::bytes} {stream.seeders} seeders', + }); + } +} diff --git a/packages/formatters/src/utils.ts b/packages/core/src/formatters/utils.ts similarity index 94% rename from packages/formatters/src/utils.ts rename to packages/core/src/formatters/utils.ts index 5d498f3b..0085c09b 100644 --- a/packages/formatters/src/utils.ts +++ b/packages/core/src/formatters/utils.ts @@ -1,7 +1,9 @@ -export function formatSize(bytes: number): string { +export function formatBytes(bytes: number, k: 1024 | 1000): string { if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']; + const sizes = + k === 1024 + ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + : ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..3d942aa3 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,6 @@ +export * from './utils'; +export * from './db'; +export * from './main'; +export * from './parser'; +export * from './formatters'; +export { PresetManager } from './presets'; diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts new file mode 100644 index 00000000..9ca1c1fd --- /dev/null +++ b/packages/core/src/main.ts @@ -0,0 +1,638 @@ +import { + Addon, + Manifest, + Resource, + StrictManifestResource, + UserData, +} from './db'; +import { + constants, + createErrorStream, + createLogger, + getTimeTakenSincePoint, +} from './utils'; +import { Wrapper } from './wrapper'; +import { PresetManager } from './presets'; +import { AddonCatalog, ParsedStream, Stream, Subtitle } from './db/schemas'; +import { createProxy } from './proxy'; +import { createFormatter } from './formatters'; +const logger = createLogger('core'); + +export class AIOStreams { + private readonly userData: UserData; + private manifests: Record; + private supportedResources: Record; + private finalResources: StrictManifestResource[] = []; + private finalCatalogs: Manifest['catalogs'] = []; + private finalAddonCatalogs: Manifest['addonCatalogs'] = []; + private isInitialised: boolean = false; + + constructor(userData: UserData) { + this.userData = userData; + this.manifests = {}; + this.supportedResources = {}; + } + + public async initialise() { + if (this.isInitialised) return; + await this.applyPresets(); + await this.fetchManifests(); + await this.fetchResources(); + this.isInitialised = true; + } + + private checkInitialised() { + if (!this.isInitialised) { + throw new Error( + 'AIOStreams is not initialised. Call initialise() first.' + ); + } + } + + public async getStreams( + id: string, + type: string + ): Promise<{ + streams: ParsedStream[]; + errors: { addon: Addon; error: string }[]; + }> { + logger.info(`getStreams: ${id}`); + + // step 1 + // get the public IP of the requesting user, using the proxy server if configured + // if a proxy server is configured, and we fail to get the IP, return an error. + // however, note that some addons may not be configured to use a proxy, + // so we should attach an IP to each addon depending on if it would be using the proxy or not. + + this.assignPublicIps(); + + // step 2 + // get all parsed stream objects and errors from all addons that have the stream resource. + // and that support the type and match the id prefix + + const { streams, errors } = await this.getStreamsFromAddons(type, id); + + // step 3 + // apply all filters to the streams. + + const filteredStreams = this.applyFilters(streams); + + // step 4 + // deduplicate streams based on the depuplicatoroptions + + const deduplicatedStreams = this.deduplicateStreams(filteredStreams); + + // step 5 + // sort the streams based on the sort criteria. + + const sortedStreams = this.sortStreams(deduplicatedStreams); + + // step 6 + // limit the number of streams based on the limit criteria. + + const limitedStreams = this.limitStreams(sortedStreams); + + // step 7 + // proxify streaming links if a proxy is provided + + const proxifiedStreams = await this.proxifyStreams(limitedStreams); + + // step 8 + // if this.userData.precacheNextEpisode is true, start a new thread to request the next episode, check if + // all provider streams are uncached, and only if so, then send a request to the first uncached stream in the list. + + // step 9 + // return the final list of streams, followed by the error streams. + return { + streams: proxifiedStreams, + errors: errors, + }; + } + + public async transformStreams({ + streams, + errors, + }: { + streams: ParsedStream[]; + errors: { addon: Addon; error: string }[]; + }): Promise { + const transformedStreams: Stream[] = []; + // need to generate a name, description, and other stremio-specific fields + // use the configured formatter to generate the name and description. + let formatter; + if (this.userData.formatter.id === constants.CUSTOM_FORMATTER) { + const template = this.userData.formatter.definition; + if (!template) { + throw new Error('No template defined for custom formatter'); + } + formatter = createFormatter(this.userData.formatter.id, template); + } else { + formatter = createFormatter(this.userData.formatter.id); + } + + await Promise.all( + streams.map(async (stream: ParsedStream): Promise => { + const { name, description } = formatter.format(stream); + const bingeGroup = `${stream.proxied ? 'proxied.' : ''}${stream.parsedFile.resolution}|${stream.parsedFile.quality}|${stream.parsedFile.encode}`; + return { + name, + description, + url: ['http', 'usenet', 'debrid', 'live'].includes(stream.type) + ? stream.url + : undefined, + infoHash: + stream.type === 'p2p' ? stream.torrent?.infoHash : undefined, + ytId: stream.type === 'youtube' ? stream.ytId : undefined, + externalUrl: + stream.type === 'external' ? stream.externalUrl : undefined, + sources: stream.type === 'p2p' ? stream.torrent?.sources : undefined, + subtitles: stream.subtitles, + behaviorHints: { + countryWhitelist: stream.countryWhitelist, + notWebReady: stream.notWebReady, + bingeGroup: bingeGroup, + proxyHeaders: + stream.requestHeaders || stream.responseHeaders + ? { + request: stream.requestHeaders, + response: stream.responseHeaders, + } + : undefined, + videoHash: stream.videoHash, + videoSize: stream.size, + filename: stream.filename, + }, + }; + }) + ); + + // add errors to the end (if this.userData.hideErrors is false ) + if (!this.userData.hideErrors) { + transformedStreams.push( + ...errors.map((error) => + createErrorStream({ + description: error.error, + name: `[❌] ${error.addon.name}`, + }) + ) + ); + } + + return transformedStreams; + } + + public async getCatalog(type: string, id: string, extras?: string) { + // step 1 + // get the addon index from the id + logger.info(`Handling catalog request`, { type, id, extras }); + const start = Date.now(); + const addonIndex = id.split('.', 2)[0]; + const addon = this.getAddon(Number(addonIndex)); + if (!addon) { + logger.error(`Addon ${addonIndex} not found`); + throw new Error(`Addon ${addonIndex} not found`); + } + + // step 2 + // get the actual catalog id from the id + const actualCatalogId = id.split('.', 2)[1]; + // step 3 + // get the catalog from the addon + const catalog = await new Wrapper(addon).getCatalog( + type, + actualCatalogId, + extras + ); + + logger.info( + `Received catalog ${actualCatalogId} of type ${type} from ${addon.name} in ${getTimeTakenSincePoint(start)}` + ); + + // step 4 + return catalog; + } + + public async getMeta(type: string, id: string) { + logger.info(`getMeta: ${id}`); + // step 1 + // determine what addon has a meta resource with an id prefix that matches this id + + for (const [index, resources] of Object.entries(this.supportedResources)) { + const resource = resources.find((r) => + r.name === 'meta' && r.types.includes(type) && r.idPrefixes + ? r.idPrefixes.some((prefix) => id.startsWith(prefix)) + : true + ); + if (resource) { + const addon = this.getAddon(Number(index)); + logger.info(`Found addon that supports the requested meta resource`, { + addonName: addon.name, + addonIndex: index, + }); + return new Wrapper(addon).getMeta(type, id); + } + } + } + + // subtitle resource + public async getSubtitles(type: string, id: string, extras?: string) { + logger.info(`getSubtitles: ${id}`); + + // Find all addons that support subtitles for this type and id prefix + const supportedAddons = []; + for (const [addonIndex, addonResources] of Object.entries( + this.supportedResources + )) { + const resource = addonResources.find((r) => + r.name === 'subtitles' && r.types.includes(type) && r.idPrefixes + ? r.idPrefixes.some((prefix) => id.startsWith(prefix)) + : true + ); + if (resource) { + const addon = this.getAddon(Number(addonIndex)); + if (addon) { + supportedAddons.push(addon); + } + } + } + + // Request subtitles from all supported addons in parallel + let errors: { addon: Addon; error: string }[] = []; + let allSubtitles: Subtitle[] = []; + + await Promise.all( + supportedAddons.map(async (addon) => { + try { + const subtitles = await new Wrapper(addon).getSubtitles( + type, + id, + extras + ); + if (subtitles) { + allSubtitles.push(...subtitles); + } + } catch (error) { + errors.push({ + addon: addon, + error: error instanceof Error ? error.message : String(error), + }); + } + }) + ); + + return { + subtitles: allSubtitles, + errors: errors, + }; + } + + // addon_catalog resource + public async getAddonCatalog(type: string, id: string) { + logger.info(`getAddonCatalog: ${id}`); + // step 1 + // get the addon index from the id + const addonIndex = id.split('.', 2)[0]; + const addon = this.getAddon(Number(addonIndex)); + if (!addon) { + throw new Error(`Addon ${addonIndex} not found`); + } + + // step 2 + // get the actual addon catalog id from the id + const actualAddonCatalogId = id.split('.', 2)[1]; + + // step 3 + // get the addon catalog from the addon + const addonCatalogs: AddonCatalog[] = await new Wrapper( + addon + ).getAddonCatalog(type, actualAddonCatalogId); + + // step 4 + return addonCatalogs; + } + // convers presets to addons + private async applyPresets() { + if (!this.userData.presets) { + return; + } + + for (const preset of this.userData.presets) { + const addons = await PresetManager.fromId(preset.id).generateAddons( + this.userData, + preset.options, + preset.baseUrl, + preset.name, + preset.timeout, + preset.resources + ); + this.userData.addons.push(...addons); + } + } + + private async fetchManifests() { + this.manifests = Object.fromEntries( + await Promise.all( + this.userData.addons.map(async (addon, index) => [ + index, + await new Wrapper(addon).getManifest(), + ]) + ) + ); + } + + private async fetchResources() { + for (const [index, manifest] of Object.entries(this.manifests)) { + if (!manifest) continue; + + // Convert string resources to StrictManifestResource objects + const addonResources = manifest.resources.map((resource) => { + if (typeof resource === 'string') { + return { + name: resource as Resource, + types: manifest.types, + idPrefixes: manifest.idPrefixes, + }; + } + return resource; + }); + + const addon = this.userData.addons[Number(index)]; + + // Filter and merge resources + for (const resource of addonResources) { + if (addon.resources && !addon.resources.includes(resource.name)) + continue; + + const existing = this.finalResources.find( + (r) => r.name === resource.name + ); + if (existing) { + existing.types = [...new Set([...existing.types, ...resource.types])]; + if (resource.idPrefixes) { + existing.idPrefixes = existing.idPrefixes || []; + existing.idPrefixes = [ + ...new Set([...existing.idPrefixes, ...resource.idPrefixes]), + ]; + } + } else { + this.finalResources.push({ + ...resource, + idPrefixes: resource.idPrefixes + ? [...resource.idPrefixes] + : undefined, + }); + } + } + + // Add catalogs with prefixed IDs (ensure to check that if addon.resources is defined and does not have catalog + // then we do not add the catalogs) + + if ( + !addon.resources || + (addon.resources && addon.resources.includes('catalog')) + ) { + this.finalCatalogs.push( + ...manifest.catalogs.map((catalog) => ({ + ...catalog, + id: `${index}.${catalog.id}`, + })) + ); + } + + // add all addon catalogs, prefixing id with index + if (manifest.addonCatalogs) { + this.finalAddonCatalogs!.push( + ...(manifest.addonCatalogs || []).map((catalog) => ({ + ...catalog, + id: `${index}.${catalog.id}`, + })) + ); + } + + this.supportedResources[Number(index)] = addonResources; + } + } + + public getResources(): StrictManifestResource[] { + this.checkInitialised(); + return this.finalResources; + } + + public getCatalogs(): Manifest['catalogs'] { + this.checkInitialised(); + return this.finalCatalogs; + } + + public getAddonCatalogs(): Manifest['addonCatalogs'] { + this.checkInitialised(); + return this.finalAddonCatalogs; + } + + private getAddon(index: number): Addon { + return this.userData.addons[index]; + } + + private shouldProxyAddon(addon: Addon): boolean { + let proxyConfig = this.userData.proxy; + if (!proxyConfig) { + return false; + } + + if (!proxyConfig.proxiedAddons) { + return true; + } + + if (proxyConfig.proxiedAddons.includes(addon.manifestUrl)) { + return true; + } + + if ( + addon.fromPresetId && + proxyConfig.proxiedAddons.includes(addon.fromPresetId) + ) { + return true; + } + + return false; + } + + private shouldProxyStream(stream: ParsedStream): boolean { + const streamService = stream.service ? stream.service.id : 'none'; + const proxy = this.userData.proxy; + if (!stream.url || !proxy?.enabled) { + return false; + } + + const proxyAddon = + !proxy.proxiedAddons?.length || + proxy.proxiedAddons.includes(stream.addon.manifestUrl) || + (stream.addon.fromPresetId && + proxy.proxiedAddons.includes(stream.addon.fromPresetId)); + const proxyService = + !proxy.proxiedServices?.length || + proxy.proxiedServices.includes(streamService); + + if (proxy.enabled && proxyAddon && proxyService) { + return true; + } + + return false; + } + + private async getProxyIp() { + let userIp = this.userData.ip; + const PRIVATE_IP_REGEX = + /^(::1|::ffff:(10|127|192|172)\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|10\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})|192\.168\.(\d{1,3})\.(\d{1,3})|172\.(1[6-9]|2[0-9]|3[0-1])\.(\d{1,3})\.(\d{1,3}))$/; + + if (userIp && PRIVATE_IP_REGEX.test(userIp)) { + userIp = undefined; + } + if (!this.userData.proxy) { + return userIp; + } + + const proxy = createProxy(this.userData.proxy); + if (proxy.getConfig().enabled) { + userIp = await this.retryGetIp( + () => proxy.getPublicIp(), + 'Proxy public IP' + ); + } + return userIp; + } + + private async retryGetIp( + getter: () => Promise, + label: string, + maxRetries: number = 3 + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const result = await getter(); + if (result) { + return result; + } + logger.warn( + `Failed to get ${label}, retrying... (${attempt}/${maxRetries})` + ); + } + throw new Error(`Failed to get ${label} after ${maxRetries} attempts`); + } + // stream utility functions + private async assignPublicIps() { + let userIp = this.userData.ip; + let proxyIp = undefined; + if (this.userData.proxy) { + proxyIp = await this.getProxyIp(); + } + for (const addon of this.userData.addons) { + const proxy = this.shouldProxyAddon(addon); + if (proxy) { + addon.ip = proxyIp; + } else { + addon.ip = userIp; + } + } + } + + private async getStreamsFromAddons(type: string, id: string) { + // get a list of all addons that support the stream resource with the given type and id. + const supportedAddons = []; + for (const [index, addonResources] of Object.entries( + this.supportedResources + )) { + const resource = addonResources.find( + (r) => + r.name === 'stream' && r.types.includes(type) && r.idPrefixes + ? r.idPrefixes?.some((prefix) => id.startsWith(prefix)) + : true // if no id prefixes are defined, assume it supports all IDs + ); + if (resource) { + const addon = this.getAddon(Number(index)); + if (addon) { + supportedAddons.push(addon); + } + } + } + + // fetch all streams in parallel, maintaining a list of errors too, + let errors: { addon: Addon; error: string }[] = []; + let parsedStreams: ParsedStream[] = []; + await Promise.all( + supportedAddons.map(async (addon) => { + try { + const streams = await new Wrapper(addon).getStreams(type, id); + parsedStreams.push(...streams); + return streams; + } catch (error) { + errors.push({ + addon: addon, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + }) + ); + return { streams: parsedStreams, errors }; + } + + private applyFilters(streams: ParsedStream[]): ParsedStream[] { + return streams; + } + + private deduplicateStreams(streams: ParsedStream[]): ParsedStream[] { + return streams; + } + + private sortStreams(streams: ParsedStream[]): ParsedStream[] { + return streams; + } + + private limitStreams(streams: ParsedStream[]): ParsedStream[] { + return streams; + } + + private async proxifyStreams( + streams: ParsedStream[] + ): Promise { + if (!this.userData.proxy?.enabled) { + return streams; + } + + const streamsToProxy = streams + .map((stream, index) => ({ stream, index })) + .filter(({ stream }) => stream.url && this.shouldProxyStream(stream)); + + const proxy = createProxy(this.userData.proxy); + + const proxiedUrls = streamsToProxy.length + ? await proxy.generateUrls( + streamsToProxy.map(({ stream }) => ({ + url: stream.url!, + filename: stream.filename, + headers: { + request: stream.requestHeaders, + response: stream.responseHeaders, + }, + })) + ) + : []; + + const removeIndexes = new Set(); + + streamsToProxy.forEach(({ stream, index }, i) => { + const proxiedUrl = proxiedUrls?.[i]; + if (proxiedUrl) { + stream.url = proxiedUrl; + stream.proxied = true; + } else { + removeIndexes.add(index); + } + }); + + if (removeIndexes.size > 0) { + streams = streams.filter((_, index) => !removeIndexes.has(index)); + } + + return streams; + } +} diff --git a/packages/core/src/parser/file.ts b/packages/core/src/parser/file.ts new file mode 100644 index 00000000..34b6784b --- /dev/null +++ b/packages/core/src/parser/file.ts @@ -0,0 +1,57 @@ +import { PARSE_REGEX } from './regex'; +import * as PTT from 'parse-torrent-title'; +import { ParsedFile } from '../db'; + +function matchPattern( + filename: string, + patterns: Record +): string | undefined { + return Object.entries(patterns).find(([_, pattern]) => + pattern.test(filename) + )?.[0]; +} + +function matchMultiplePatterns( + filename: string, + patterns: Record +): string[] { + return Object.entries(patterns) + .filter(([_, pattern]) => pattern.test(filename)) + .map(([tag]) => tag); +} + +class FileParser { + static parse(filename: string): ParsedFile { + const resolution = matchPattern(filename, PARSE_REGEX.resolutions); + const quality = matchPattern(filename, PARSE_REGEX.qualities); + const encode = matchPattern(filename, PARSE_REGEX.encodes); + const visualTags = matchMultiplePatterns(filename, PARSE_REGEX.visualTags); + const audioTags = matchMultiplePatterns(filename, PARSE_REGEX.audioTags); + const languages = matchMultiplePatterns(filename, PARSE_REGEX.languages); + + const parsed = PTT.parse(filename); + const releaseGroup = parsed.group; + const title = parsed.title; + const year = parsed.year ? parsed.year.toString() : undefined; + const season = parsed.season; + const seasons = parsed.seasons; + const episode = parsed.episode; + + return { + resolution, + quality, + languages, + encode, + audioTags, + visualTags, + releaseGroup, + title, + year, + season, + seasons, + episode, + }; + } +} + +export default FileParser; diff --git a/packages/core/src/parser/index.ts b/packages/core/src/parser/index.ts new file mode 100644 index 00000000..0a07f956 --- /dev/null +++ b/packages/core/src/parser/index.ts @@ -0,0 +1,2 @@ +export { default as FileParser } from './file'; +export { default as StreamParser } from './streams'; diff --git a/packages/parser/src/regex.ts b/packages/core/src/parser/regex.ts similarity index 100% rename from packages/parser/src/regex.ts rename to packages/core/src/parser/regex.ts diff --git a/packages/core/src/parser/streams.ts b/packages/core/src/parser/streams.ts new file mode 100644 index 00000000..a671771f --- /dev/null +++ b/packages/core/src/parser/streams.ts @@ -0,0 +1,245 @@ +import { Stream, ParsedStream, Addon } from '../db'; +import { constants } from '../utils'; +import FileParser from './file'; + +class StreamParser { + constructor(private readonly addon: Addon) {} + + parse(stream: Stream): ParsedStream { + let parsedStream: ParsedStream = { + addon: this.addon, + type: this.getStreamType(stream, undefined), + parsedFile: {}, + }; + + this.raiseErrorIfNecessary(stream); + + parsedStream.filename = this.getFilename(stream); + parsedStream.folderName = this.getFolder(stream); + parsedStream.size = this.getSize(stream); + parsedStream.indexer = this.getIndexer(stream); + parsedStream.service = this.getService(stream); + parsedStream.duration = this.getDuration(stream); + parsedStream.type = this.getStreamType(stream, parsedStream.service); + + if (parsedStream.filename) { + parsedStream.parsedFile = FileParser.parse(parsedStream.filename); + } + + parsedStream.torrent = { + infoHash: stream.infoHash, + seeders: this.getSeeders(stream), + sources: stream.sources, + fileIdx: stream.fileIdx, + }; + + return parsedStream; + } + + protected raiseErrorIfNecessary(stream: Stream) { + const errorRegex = /invalid\s+\w+\s+(account|apikey|token)/i; + if (errorRegex.test(stream.description || stream.title || '')) { + throw new Error('Invalid account or apikey or token'); + } + } + + protected getFilename(stream: Stream): string | undefined { + let filename = stream.behaviorHints?.filename; + + if (filename) { + return this.normaliseFilename(filename); + } + + const description = stream.description || stream.title; + if (!description) { + return undefined; + } + + // attempt to find a filename by finding the most suitable line that has more info + const potentialFilenames = description + .split('\n') + .filter((line) => line.trim() !== '') + .splice(0, 5); + + for (const line of potentialFilenames) { + const parsed = FileParser.parse(line); + if (parsed.year || (parsed.season && parsed.episode) || parsed.episode) { + filename = line; + break; + } + } + + if (!filename) { + filename = description.split('\n')[0]; + } + + return this.normaliseFilename(filename); + } + + protected getFolder(stream: Stream): string | undefined { + return undefined; + } + + protected getSize(stream: Stream): number | undefined { + const description = stream.description || stream.title; + let size = + stream.behaviorHints?.videoSize || + (stream as any).size || + (stream as any).sizeBytes || + (stream as any).sizebytes || + (description && this.calculateBytesFromSizeString(description)) || + (stream.name && this.calculateBytesFromSizeString(stream.name)); + + if (typeof size === 'string') { + size = parseInt(size); + } + + return size; + } + + protected getSeeders(stream: Stream): number | undefined { + const regex = /[👥👤]\s*(\d+)/u; + const match = stream.title?.match(regex); + if (match) { + return parseInt(match[1]); + } + + return undefined; + } + + protected getIndexer(stream: Stream): string | undefined { + const regex = + /(?:🌐|⚙️|🔗|🔎|☁️)\s?(.*?)(?=[\p{Emoji_Presentation}]|$|\n)/u; + const match = stream.title?.match(regex); + if (match) { + return match[1]; + } + + return undefined; + } + + protected getService(stream: Stream): ParsedStream['service'] | undefined { + return this.parseServiceData(stream.name || ''); + } + + protected getDuration(stream: Stream): number | undefined { + // Regular expression to match different formats of time durations + const regex = + /(? { + // for each service, generate a regexp which creates a regex with all known names separated by | + const regex = new RegExp( + `(^|(? string.includes(symbol))) { + cached = false; + } + // check if any of the cachedSymbols are in the string + else if (cachedSymbols.some((symbol) => string.includes(symbol))) { + cached = true; + } + + streamService = { + id: service.id, + cached: cached, + }; + } + }); + return streamService; + } +} + +export default StreamParser; diff --git a/packages/core/src/presets/index.ts b/packages/core/src/presets/index.ts new file mode 100644 index 00000000..e41c246d --- /dev/null +++ b/packages/core/src/presets/index.ts @@ -0,0 +1,2 @@ +export * from './preset'; +export * from './presetManager'; diff --git a/packages/core/src/presets/opensubtitles.ts b/packages/core/src/presets/opensubtitles.ts new file mode 100644 index 00000000..4422e7d7 --- /dev/null +++ b/packages/core/src/presets/opensubtitles.ts @@ -0,0 +1,20 @@ +import { constants, Env } from '../utils'; +import { Preset } from './preset'; + +// export class OpenSubtitlesPreset extends Preset { +// static override get METADATA() { +// return { +// ID: 'opensubtitles', +// NAME: 'OpenSubtitles', +// LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png', +// DESCRIPTION: 'OpenSubtitles preset', +// OPTIONS: [], +// TAGS: [], +// RESOURCES: [constants.SUBTITLES_RESOURCE], +// URL: 'https://www.opensubtitles.org/', +// TIMEOUT: 10000, +// USER_AGENT: Env.DEFAULT_USER_AGENT, +// SUPPORTED_SERVICES: [], +// }; +// } +// } diff --git a/packages/core/src/presets/preset.ts b/packages/core/src/presets/preset.ts new file mode 100644 index 00000000..3131533f --- /dev/null +++ b/packages/core/src/presets/preset.ts @@ -0,0 +1,93 @@ +import { + Option, + Resource, + Stream, + ParsedStream, + UserData, + PresetMetadata, +} from '../db'; +import { StreamParser } from '../parser'; +/** + * + * What modifications are needed for each preset: + * + * comet: apply FORCE_COMET_HOSTNAME, FORCE_COMET_PORT, FORCE_COMET_PROTOCOl to stream urls if they are defined + * dmm cast: need to split title by newline, replace trailing dashes, excluding lines with box emoji, and + * then joining the array back together. + * easynews,easynews+,easynews++: need to set type as usenet + * jackettio: apply FORCE_JACKETTIO_HOSTNAME, FORCE_JACKETTIO_PORT, FORCE_JACKETTIO_PROTOCOL to stream urls if they are defined + * mediafusion: need to add hint for folder name, 📁 emoji, and split on arrow, take last index. + * stremio-jacektt: need to inspect stream urls to extract service info. + * stremthruStore: need to mark each stream as 'inLibrary' and unset any parsed 'indexer' + * torbox: need to use different regex for probably everything. + * torrentio: extract folder name from first line + */ + +export abstract class Preset { + static get METADATA(): PresetMetadata { + throw new Error('METADATA must be implemented by derived classes'); + } + + static getParser(): typeof StreamParser { + return StreamParser; + } + + /** + * Creates a preset from a preset id. + * @param presetId - The id of the preset to create. + * @returns The preset. + */ + + static generateAddons( + userData: UserData, + options?: Record, + baseUrl?: string, + name?: string, + timeout?: number, + resources?: Resource[] + ) { + throw new Error('generateAddons must be implemented by derived classes'); + } + + // Utility functions for generating config strings + /** + * Encodes a JSON object into a base64 encoded string. + * @param json - The JSON object to encode. + * @returns The base64 encoded string. + */ + protected static base64EncodeJSON( + json: any, + urlEncode: boolean = false, // url encode the string + makeUrlSafe: boolean = false // replace + with -, / with _ and = with nothing + ) { + let encoded = Buffer.from(JSON.stringify(json)).toString('base64'); + if (makeUrlSafe) { + encoded = encoded + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } else if (urlEncode) { + encoded = encodeURIComponent(encoded); + } + return encoded; + } + + protected static urlEncodeJSON(json: any) { + return encodeURIComponent(JSON.stringify(json)); + } + + /** + * Transforms key-value pairs into a url encoded string + * @param options - The key-value pair object to encode. + * @returns The encoded string. + */ + protected static urlEncodeKeyValuePairs( + options: Record | string[][] + ) { + return encodeURIComponent( + (Array.isArray(options) ? options : Object.entries(options)) + .map(([key, value]) => `${key}=${value}`) + .join('|') + ); + } +} diff --git a/packages/core/src/presets/presetManager.ts b/packages/core/src/presets/presetManager.ts new file mode 100644 index 00000000..3d183525 --- /dev/null +++ b/packages/core/src/presets/presetManager.ts @@ -0,0 +1,23 @@ +import { PresetMetadata } from '../db'; +import { Preset } from './preset'; +import { StremthruStorePreset } from './stremthruStore'; +import { TorrentioPreset } from './torrentio'; + +const PRESET_LIST: string[] = ['torrentio', 'stremthruStore']; + +export class PresetManager { + static getPresetList(): PresetMetadata[] { + return PRESET_LIST.map((presetId) => this.fromId(presetId).METADATA); + } + + static fromId(id: string) { + switch (id) { + case 'torrentio': + return TorrentioPreset; + case 'stremthruStore': + return StremthruStorePreset; + default: + throw new Error(`Preset ${id} not found`); + } + } +} diff --git a/packages/core/src/presets/stremthruStore.ts b/packages/core/src/presets/stremthruStore.ts new file mode 100644 index 00000000..8871258a --- /dev/null +++ b/packages/core/src/presets/stremthruStore.ts @@ -0,0 +1,226 @@ +import { Addon, Option, UserData, Resource } from '../db'; +import { Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; + +export class StremthruStorePreset extends Preset { + static override get METADATA() { + const supportedServices = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.PUTIO_SERVICE, + constants.OFFCLOUD_SERVICE, + ]; + + const options: Option[] = [ + { + id: 'services', + name: 'Services', + description: 'The services to use', + type: 'multi-select', + required: true, + options: supportedServices.map((service) => ({ + value: service, + label: service, + })), + }, + { + id: 'hideCatalog', + name: 'Hide Catalog', + description: 'Hide the catalog', + type: 'boolean', + }, + { + id: 'hideStream', + name: 'Hide Stream', + description: 'Hide the stream', + type: 'boolean', + }, + { + id: 'webDl', + name: 'Web DL', + description: 'Enable web DL', + type: 'boolean', + }, + ]; + + return { + ID: 'stremthruStore', + NAME: 'Stremthru Store', + LOGO: 'https://emojiapi.dev/api/v1/sparkles/256.png', + URL: Env.STREMTHRU_STORE_URL, + TIMEOUT: Env.DEFAULT_STREMTHRU_STORE_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: + Env.DEFAULT_STREMTHRU_STORE_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + REQUIRES_SERVICE: true, + DESCRIPTION: 'Stremthru Store preset', + OPTIONS: options, + TAGS: [constants.P2P_TAG, constants.DEBRID_TAG], + RESOURCES: [ + constants.STREAM_RESOURCE, + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ], + }; + } + + static async generateAddons( + userData: UserData, + options?: Record, + baseUrl?: string, + name?: string, + timeout?: number, + resources?: Resource[] + ): Promise { + // baseUrl can either be something like https://torrentio.com/ or it can be a custom manifest url. + // if it is a custom manifest url, return a single addon with the custom manifest url. + if (baseUrl?.endsWith('/manifest.json')) { + return [ + this.generateAddon( + userData, + undefined, + baseUrl, + timeout, + name, + resources + ), + ]; + } + + // get all services that are supported by the preset and enabled + let usableServices = userData.services?.filter( + (service) => + this.METADATA.SUPPORTED_SERVICES.includes(service.id) && service.enabled + ); + + // if user has specified services, filter the usable services to only include the specified services + if (options?.services) { + usableServices = usableServices?.filter((service) => + options.services.includes(service.id) + ); + } + + // if no services are usable, throw an error + if (!usableServices || usableServices.length === 0) { + throw new Error('No services are usable'); + } + + return usableServices.map((service) => + this.generateAddon( + userData, + service.id, + baseUrl, + timeout, + name, + resources, + options?.hideCatalog, + options?.hideStream, + options?.webDl + ) + ); + } + + private static generateAddon( + userData: UserData, + serviceId: ServiceId | undefined, + baseUrl?: string, + timeout?: number, + name?: string, + resources?: Resource[], + hideCatalog?: boolean, + hideStream?: boolean, + webDl?: boolean + ): Addon { + return { + name: name || this.METADATA.NAME, + manifestUrl: this.generateManifestUrl( + userData, + serviceId, + baseUrl, + hideCatalog, + hideStream, + webDl + ), + enabled: true, + resources: resources || this.METADATA.RESOURCES, + timeout: timeout || this.METADATA.TIMEOUT, + fromPresetId: this.METADATA.ID, + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + serviceId: ServiceId | undefined, + baseUrl?: string, + hideCatalog: boolean = false, + hideStream: boolean = false, + webDl: boolean = false + ) { + const url = baseUrl || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + if (!serviceId) { + throw new Error('Service is required'); + } + const configString = this.base64EncodeJSON({ + store_name: serviceId, + store_token: this.getServiceCredential(serviceId, userData), + hide_catalog: hideCatalog, + hide_stream: hideStream, + web_dl: webDl, + }); + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } + + private static getServiceCredential( + serviceId: ServiceId, + userData: UserData + ): string { + // Validate service exists + const service = constants.SERVICE_DETAILS[serviceId]; + if (!service) { + throw new Error(`Service ${serviceId} not found`); + } + + // Get credentials for service + const serviceCredentials = userData.services?.find( + (service) => service.id === serviceId + )?.credentials; + + if (!serviceCredentials) { + throw new Error(`No credentials found for service ${serviceId}`); + } + + // Handle put.io special case which requires both clientId and token + if ( + serviceId === constants.OFFCLOUD_SERVICE || + serviceId === constants.PIKPAK_SERVICE + ) { + const { email, password } = serviceCredentials; + if (!email || !password) { + throw new Error( + `Missing credentials for ${serviceId}. Please add an email and password.` + ); + } + return `${email}:${password}`; + } + + // Handle default case which requires just an API key + const { apiKey } = serviceCredentials; + if (!apiKey) { + throw new Error( + `Missing credentials for ${serviceId}. Please add an API key.` + ); + } + return apiKey; + } +} diff --git a/packages/core/src/presets/torrentio.ts b/packages/core/src/presets/torrentio.ts new file mode 100644 index 00000000..450b7e20 --- /dev/null +++ b/packages/core/src/presets/torrentio.ts @@ -0,0 +1,220 @@ +import { Addon, Option, UserData, Resource, Stream } from '../db'; +import { Preset } from './preset'; +import { Env } from '../utils'; +import { constants, ServiceId } from '../utils'; +import { StreamParser } from '../parser'; + +export class TorrentioParser extends StreamParser { + override getFolder(stream: Stream): string | undefined { + const description = stream.description || stream.title; + if (!description) { + return undefined; + } + const folderName = description.split('\n')[0]; + return folderName; + } +} + +export class TorrentioPreset extends Preset { + static override getParser(): typeof StreamParser { + return TorrentioParser; + } + + static override get METADATA() { + const supportedServices = [ + constants.REALDEBRID_SERVICE, + constants.PREMIUMIZE_SERVICE, + constants.ALLEDEBRID_SERVICE, + constants.TORBOX_SERVICE, + constants.EASYDEBRID_SERVICE, + constants.PUTIO_SERVICE, + constants.OFFCLOUD_SERVICE, + ]; + + const options: Option[] = [ + { + id: 'services', + name: 'Services', + description: 'The services to use', + type: 'multi-select', + required: true, + options: supportedServices.map((service) => ({ + value: service, + label: service, + })), + }, + { + id: 'useMultipleInstances', + name: 'Use Multiple Instances', + description: + 'When using multiple services, use a different Torrentio addon for each service, rather than using one instance for all services', + type: 'boolean', + default: false, + required: true, + }, + ]; + + return { + ID: 'torrentio', + NAME: 'Torrentio', + LOGO: `${Env.TORRENTIO_URL}/images/logo_v1.png`, + URL: Env.TORRENTIO_URL, + TIMEOUT: Env.DEFAULT_TORRENTIO_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_TORRENTIO_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: supportedServices, + REQUIRES_SERVICE: false, + DESCRIPTION: 'Torrentio preset', + OPTIONS: options, + TAGS: [constants.P2P_TAG, constants.DEBRID_TAG], + RESOURCES: [ + constants.STREAM_RESOURCE, + constants.META_RESOURCE, + constants.CATALOG_RESOURCE, + ], + }; + } + + static async generateAddons( + userData: UserData, + options?: Record, + baseUrl?: string, + name?: string, + timeout?: number, + resources?: Resource[] + ): Promise { + // baseUrl can either be something like https://torrentio.com/ or it can be a custom manifest url. + // if it is a custom manifest url, return a single addon with the custom manifest url. + if (baseUrl?.endsWith('/manifest.json')) { + return [ + this.generateAddon(userData, [], baseUrl, name, timeout, resources), + ]; + } + + // get all services that are supported by the preset and enabled + let usableServices = userData.services?.filter( + (service) => + this.METADATA.SUPPORTED_SERVICES.includes(service.id) && service.enabled + ); + + // if user has specified services, filter the usable services to only include the specified services + if (options?.services) { + usableServices = usableServices?.filter((service) => + options.services.includes(service.id) + ); + } + + // if no services are usable, return a single addon with no services + if (!usableServices || usableServices.length === 0) { + return [ + this.generateAddon(userData, [], baseUrl, name, timeout, resources), + ]; + } + + // if user has specified useMultipleInstances, return a single addon for each service + if (options?.useMultipleInstances) { + return usableServices.map((service) => + this.generateAddon( + userData, + [service.id], + baseUrl, + name, + timeout, + resources + ) + ); + } + + // return a single addon with all usable services + return [ + this.generateAddon( + userData, + usableServices.map((service) => service.id), + baseUrl, + name, + timeout + ), + ]; + } + + private static generateAddon( + userData: UserData, + services: ServiceId[], + baseUrl?: string, + name?: string, + timeout?: number, + resources?: Resource[] + ): Addon { + return { + name: name || this.METADATA.NAME, + manifestUrl: this.generateManifestUrl(userData, services, baseUrl), + enabled: true, + resources: resources || this.METADATA.RESOURCES, + timeout: timeout || this.METADATA.TIMEOUT, + fromPresetId: this.METADATA.ID, + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static generateManifestUrl( + userData: UserData, + services: ServiceId[], + baseUrl?: string + ) { + const url = baseUrl || this.METADATA.URL; + if (url.endsWith('/manifest.json')) { + return url; + } + const configString = services.length + ? this.urlEncodeKeyValuePairs( + services.map((service) => [ + service, + this.getServiceCredential(service, userData), + ]) + ) + : ''; + + return `${url}${configString ? '/' + configString : ''}/manifest.json`; + } + + private static getServiceCredential( + serviceId: ServiceId, + userData: UserData + ): string { + // Validate service exists + const service = constants.SERVICE_DETAILS[serviceId]; + if (!service) { + throw new Error(`Service ${serviceId} not found`); + } + + // Get credentials for service + const serviceCredentials = userData.services?.find( + (service) => service.id === serviceId + )?.credentials; + + if (!serviceCredentials) { + throw new Error(`No credentials found for service ${serviceId}`); + } + + // Handle put.io special case which requires both clientId and token + if (serviceId === constants.PUTIO_SERVICE) { + const { clientId, token } = serviceCredentials; + if (!clientId || !token) { + throw new Error( + `Missing credentials for ${serviceId}. Please add a client ID and token.` + ); + } + return `${clientId}@${token}`; + } + + // Handle default case which requires just an API key + const { apiKey } = serviceCredentials; + if (!apiKey) { + throw new Error( + `Missing credentials for ${serviceId}. Please add an API key.` + ); + } + return apiKey; + } +} diff --git a/packages/core/src/proxy/base.ts b/packages/core/src/proxy/base.ts new file mode 100644 index 00000000..85d1dc7c --- /dev/null +++ b/packages/core/src/proxy/base.ts @@ -0,0 +1,142 @@ +import { StreamProxyConfig } from '../db'; +import { Cache, createLogger, maskSensitiveInfo, Env } from '../utils'; + +const logger = createLogger('proxy'); +const cache = Cache.getInstance('publicIp'); + +export interface ProxyStream { + url: string; + filename?: string; + headers?: { + request?: Record; + response?: Record; + }; +} + +export abstract class BaseProxy { + protected readonly config: StreamProxyConfig; + private readonly PRIVATE_CIDR = + /^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/; + + constructor(config: StreamProxyConfig) { + // Apply any forced environment variables + this.config = { + enabled: + Env.FORCE_PROXY_ENABLED !== undefined + ? Env.FORCE_PROXY_ENABLED + : config.enabled, + id: Env.FORCE_PROXY_ID !== undefined ? Env.FORCE_PROXY_ID : config.id, + url: Env.FORCE_PROXY_URL !== undefined ? Env.FORCE_PROXY_URL : config.url, + credentials: + Env.FORCE_PROXY_CREDENTIALS !== undefined + ? Env.FORCE_PROXY_CREDENTIALS + : config.credentials, + publicIp: + Env.FORCE_PROXY_PUBLIC_IP !== undefined + ? Env.FORCE_PROXY_PUBLIC_IP + : config.publicIp, + proxiedAddons: + Env.FORCE_PROXY_PROXIED_ADDONS !== undefined + ? Env.FORCE_PROXY_PROXIED_ADDONS + : config.proxiedAddons, + proxiedServices: + Env.FORCE_PROXY_PROXIED_SERVICES !== undefined + ? Env.FORCE_PROXY_PROXIED_SERVICES + : config.proxiedServices, + }; + } + + public getConfig(): StreamProxyConfig { + return this.config; + } + + protected abstract generateProxyUrl(endpoint: string): URL; + protected abstract getPublicIpEndpoint(): string; + protected abstract getPublicIpFromResponse(data: any): string | null; + protected abstract generateStreamUrls( + streams: ProxyStream[] + ): Promise; + + public async getPublicIp(): Promise { + try { + if (!this.config.url) { + logger.error('Proxy URL is missing'); + throw new Error('Proxy URL is missing'); + } + + if (this.config.publicIp) { + return this.config.publicIp; + } + + const proxyUrl = new URL(this.config.url.replace(/\/$/, '')); + if (this.PRIVATE_CIDR.test(proxyUrl.hostname)) { + logger.error('Proxy URL is a private IP address, returning null'); + return null; + } + + const cacheKey = `${this.config.id}:${this.config.url}:${this.config.credentials}`; + const cachedPublicIp = cache ? cache.get(cacheKey) : null; + if (cachedPublicIp) { + logger.debug('Returning cached public IP'); + return cachedPublicIp; + } + + const ipUrl = this.generateProxyUrl(this.getPublicIpEndpoint()); + + if (Env.LOG_SENSITIVE_INFO) { + logger.debug(`GET ${ipUrl.toString()}`); + } else { + logger.debug( + `GET ${ipUrl.protocol}://${maskSensitiveInfo(ipUrl.hostname)}${ipUrl.port ? `:${ipUrl.port}` : ''}${ipUrl.pathname}` + ); + } + + const response = await fetch(ipUrl.toString(), { + method: 'GET', + headers: this.getHeaders(), + signal: AbortSignal.timeout(30000), // 30 second timeout + }); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + const publicIp = this.getPublicIpFromResponse(data); + + if (publicIp && cache) { + cache.set(cacheKey, publicIp, 900); // 15 minute cache + } else { + logger.error( + `Proxy did not respond with a public IP. Response: ${JSON.stringify(data)}` + ); + } + + return publicIp; + } catch (error: any) { + logger.error(`Failed to get public IP: ${error.message}`); + return null; + } + } + + protected abstract getHeaders(): Record; + + public async generateUrls(streams: ProxyStream[]): Promise { + if (!streams.length) { + return []; + } + + if (!this.config) { + throw new Error('Proxy configuration is missing'); + } + + try { + return await this.generateStreamUrls(streams); + } catch (error) { + logger.error( + `Failed to generate proxy URLs: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } +} diff --git a/packages/core/src/proxy/index.ts b/packages/core/src/proxy/index.ts new file mode 100644 index 00000000..919b6741 --- /dev/null +++ b/packages/core/src/proxy/index.ts @@ -0,0 +1,20 @@ +export * from './base'; +export * from './mediaflow'; +export * from './stremthru'; + +import { constants } from '../utils'; +import { BaseProxy } from './base'; +import { MediaFlowProxy } from './mediaflow'; +import { StremThruProxy } from './stremthru'; +import { StreamProxyConfig } from '../db'; + +export function createProxy(config: StreamProxyConfig): BaseProxy { + switch (config.id) { + case constants.MEDIAFLOW_SERVICE: + return new MediaFlowProxy(config); + case constants.STREMTHRU_SERVICE: + return new StremThruProxy(config); + default: + throw new Error(`Unknown proxy type: ${config.id}`); + } +} diff --git a/packages/core/src/proxy/mediaflow.ts b/packages/core/src/proxy/mediaflow.ts new file mode 100644 index 00000000..6341a78d --- /dev/null +++ b/packages/core/src/proxy/mediaflow.ts @@ -0,0 +1,90 @@ +import { BaseProxy, ProxyStream } from './base'; +import { createLogger, maskSensitiveInfo, Env } from '../utils'; +import path from 'path'; + +const logger = createLogger('mediaflow'); + +export class MediaFlowProxy extends BaseProxy { + protected generateProxyUrl(endpoint: string): URL { + const proxyUrl = new URL(this.config.url.replace(/\/$/, '')); + proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${endpoint}`; + return proxyUrl; + } + + protected getPublicIpEndpoint(): string { + return '/proxy/ip'; + } + + protected getPublicIpFromResponse(data: any): string | null { + return data.ip || null; + } + + protected getHeaders(): Record { + return { + 'Content-Type': 'application/json', + }; + } + + protected async generateStreamUrls( + streams: ProxyStream[] + ): Promise { + const proxyUrl = this.generateProxyUrl('/generate_urls'); + + const data = { + mediaflow_proxy_url: this.config.url.replace(/\/$/, ''), + api_password: Env.ENCRYPT_MEDIAFLOW_URLS + ? this.config.credentials + : undefined, + urls: streams.map((stream) => ({ + endpoint: '/proxy/stream', + filename: stream.filename || path.basename(stream.url), + query_params: Env.ENCRYPT_MEDIAFLOW_URLS + ? undefined + : { + api_password: this.config.credentials, + }, + destination_url: stream.url, + request_headers: stream.headers?.request, + response_headers: stream.headers?.response, + })), + }; + + if (Env.LOG_SENSITIVE_INFO) { + logger.debug(`POST ${proxyUrl.toString()}`); + } else { + logger.debug( + `POST ${proxyUrl.protocol}://${maskSensitiveInfo(proxyUrl.hostname)}${proxyUrl.port ? `:${proxyUrl.port}` : ''}/generate_urls` + ); + } + + const response = await fetch(proxyUrl.toString(), { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(data), + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + let responseData: any; + try { + responseData = await response.json(); + } catch (error) { + const text = await response.text(); + logger.debug(`Response body: ${text}`); + throw new Error('Failed to parse JSON response from MediaFlow'); + } + + if (responseData.error) { + throw new Error(responseData.error); + } + + if (responseData.urls) { + return responseData.urls; + } else { + throw new Error('No URLs were returned from MediaFlow'); + } + } +} diff --git a/packages/core/src/proxy/stremthru.ts b/packages/core/src/proxy/stremthru.ts new file mode 100644 index 00000000..a66c3c57 --- /dev/null +++ b/packages/core/src/proxy/stremthru.ts @@ -0,0 +1,98 @@ +import { BaseProxy, ProxyStream } from './base'; +import { createLogger, maskSensitiveInfo, Env } from '../utils'; + +const logger = createLogger('stremthru'); + +export class StremThruProxy extends BaseProxy { + protected generateProxyUrl(endpoint: string): URL { + const proxyUrl = new URL(this.config.url.replace(/\/$/, '')); + proxyUrl.pathname = `${proxyUrl.pathname === '/' ? '' : proxyUrl.pathname}${endpoint}`; + return proxyUrl; + } + + protected getPublicIpEndpoint(): string { + return '/v0/health/__debug__'; + } + + protected getPublicIpFromResponse(data: any): string | null { + return typeof data.data?.ip?.exposed === 'object' + ? data.data.ip.exposed['*'] || data.data.ip.machine + : data.data?.ip?.machine || null; + } + + protected getHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (Env.ENCRYPT_STREMTHRU_URLS) { + headers['X-StremThru-Authorization'] = `Basic ${this.config.credentials}`; + } + + return headers; + } + + protected async generateStreamUrls( + streams: ProxyStream[] + ): Promise { + const proxyUrl = this.generateProxyUrl('/v0/proxy'); + + if (!Env.ENCRYPT_STREMTHRU_URLS) { + proxyUrl.searchParams.set('token', this.config.credentials); + } + + const data = new URLSearchParams(); + + streams.forEach((stream, i) => { + data.append('url', stream.url); + let req_headers = ''; + if (stream.headers?.request) { + for (const [key, value] of Object.entries(stream.headers.request)) { + req_headers += `${key}: ${value}\n`; + } + } + data.append(`req_headers[${i}]`, req_headers); + if (stream.filename) { + data.append(`filename[${i}]`, stream.filename); + } + }); + + if (Env.LOG_SENSITIVE_INFO) { + logger.debug(`POST ${proxyUrl.toString()}`); + } else { + logger.debug( + `POST ${proxyUrl.protocol}://${maskSensitiveInfo(proxyUrl.hostname)}${proxyUrl.port ? `:${proxyUrl.port}` : ''}/v0/proxy` + ); + } + + const response = await fetch(proxyUrl.toString(), { + method: 'POST', + headers: this.getHeaders(), + body: data, + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`); + } + + let responseData: any; + try { + responseData = await response.json(); + } catch (error) { + const text = await response.text(); + logger.debug(`Response body: ${text}`); + throw new Error('Failed to parse JSON response from StremThru'); + } + + if (responseData.error) { + throw new Error(responseData.error); + } + + if (responseData.data?.items) { + return responseData.data.items; + } else { + throw new Error('No URLs were returned from StremThru'); + } + } +} diff --git a/packages/core/src/utils/api.ts b/packages/core/src/utils/api.ts new file mode 100644 index 00000000..42ade7a3 --- /dev/null +++ b/packages/core/src/utils/api.ts @@ -0,0 +1,95 @@ +import { Env } from './env'; +import { createLogger } from './logger'; +import { createErrorStream } from './stremio'; + +const logger = createLogger('server'); + +type ApiResponseOptions = { + success: boolean; + /** + * @deprecated Use detail instead + */ + message?: string; + detail?: string; + data?: any; + error?: { + code: string; + message: string; + }; +}; + +export function createResponse( + options: ApiResponseOptions, + path?: string, + adaptResponses?: boolean +) { + const { success, data, error } = options; + let type: string = 'api'; + // adapt responses for path specific response types + if (adaptResponses && path) { + if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/stream/)) { + type = 'stream'; + } else if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/catalog/)) { + type = 'catalog'; + } else if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/subtitles/)) { + type = 'subtitles'; + } else if (path.match(/\/stremio(?:\/[^\/]+){0,2}\/meta/)) { + type = 'meta'; + } + } + logger.debug('createResponse', { + success, + error, + type, + path, + adaptResponses, + }); + // return type === 'stream' && success === false + // ? { + // streams: [createErrorStream({ description: error?.message })], + // } + // : { + // success, + // message: options.message || null, + // data: data || null, + // error: error || null, + // }; + + switch (type) { + case 'stream': + if (success) { + return { + streams: data, + }; + } else if (error) { + return { + streams: [createErrorStream({ description: error?.message })], + }; + } + case 'subtitles': + if (success) { + return { + subtitles: data, + }; + } + case 'catalog': + if (success) { + return { + metas: data, + }; + } + case 'meta': + if (success) { + return { + meta: data, + }; + } + default: + return { + success, + detail: options.message || null, + data: data || null, + error: error || null, + }; + } +} diff --git a/packages/utils/src/cache.ts b/packages/core/src/utils/cache.ts similarity index 91% rename from packages/utils/src/cache.ts rename to packages/core/src/utils/cache.ts index 7a907ea9..a7876d41 100644 --- a/packages/utils/src/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -1,5 +1,5 @@ import { createLogger } from './logger'; -import { Settings } from './settings'; +import { Env } from './env'; const logger = createLogger('cache'); @@ -28,7 +28,7 @@ export class Cache { */ public static getInstance( name: string, - maxSize: number = Settings.MAX_CACHE_SIZE + maxSize: number = Env.MAX_CACHE_SIZE ): Cache { if (!this.instances.has(name)) { logger.debug(`Creating new cache instance: ${name}`); @@ -77,6 +77,12 @@ export class Cache { return undefined; } + /** + * Set a value in the cache with a specific TTL + * @param key The key to set the value for + * @param value The value to set + * @param ttl The TTL in seconds + */ set(key: K, value: V, ttl: number): void { if (this.cache.size >= this.maxSize) { this.evict(); diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts new file mode 100644 index 00000000..39d3b9a5 --- /dev/null +++ b/packages/core/src/utils/constants.ts @@ -0,0 +1,838 @@ +import { Option } from '../db'; + +export enum ErrorCode { + // User API + USER_NOT_FOUND = 'USER_NOT_FOUND', + USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS', + USER_INVALID_PASSWORD = 'USER_INVALID_PASSWORD', + USER_INVALID_CONFIG = 'USER_INVALID_CONFIG', + USER_ERROR = 'USER_ERROR', + USER_NEW_PASSWORD_TOO_SHORT = 'USER_NEW_PASSWORD_TOO_SHORT', + USER_NEW_PASSWORD_TOO_SIMPLE = 'USER_NEW_PASSWORD_TOO_SIMPLE', + // Format API + FORMAT_INVALID_FORMATTER = 'FORMAT_INVALID_FORMATTER', + FORMAT_INVALID_STREAM = 'FORMAT_INVALID_STREAM', + FORMAT_ERROR = 'FORMAT_ERROR', + // Other + MISSING_REQUIRED_FIELDS = 'MISSING_REQUIRED_FIELDS', + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', +} + +interface ErrorDetails { + statusCode: number; + message: string; +} + +export const ErrorMap: Record = { + [ErrorCode.MISSING_REQUIRED_FIELDS]: { + statusCode: 400, + message: 'Required fields are missing', + }, + [ErrorCode.USER_NOT_FOUND]: { + statusCode: 404, + message: 'User not found', + }, + [ErrorCode.USER_ALREADY_EXISTS]: { + statusCode: 409, + message: 'User already exists', + }, + [ErrorCode.USER_INVALID_PASSWORD]: { + statusCode: 401, + message: 'Invalid password', + }, + [ErrorCode.USER_INVALID_CONFIG]: { + statusCode: 400, + message: 'The config for this user is invalid', + }, + [ErrorCode.USER_ERROR]: { + statusCode: 500, + message: 'A generic error while processing the user request', + }, + [ErrorCode.USER_NEW_PASSWORD_TOO_SHORT]: { + statusCode: 400, + message: 'New password is too short', + }, + [ErrorCode.USER_NEW_PASSWORD_TOO_SIMPLE]: { + statusCode: 400, + message: 'New password is too simple', + }, + [ErrorCode.INTERNAL_SERVER_ERROR]: { + statusCode: 500, + message: 'An unexpected error occurred', + }, + [ErrorCode.METHOD_NOT_ALLOWED]: { + statusCode: 405, + message: 'Method not allowed', + }, + [ErrorCode.RATE_LIMIT_EXCEEDED]: { + statusCode: 429, + message: 'Too many requests from this IP, please try again later.', + }, + [ErrorCode.FORMAT_INVALID_FORMATTER]: { + statusCode: 400, + message: 'Invalid formatter', + }, + [ErrorCode.FORMAT_INVALID_STREAM]: { + statusCode: 400, + message: 'Invalid stream', + }, + [ErrorCode.FORMAT_ERROR]: { + statusCode: 500, + message: 'An error occurred while formatting the stream', + }, +}; + +export class APIError extends Error { + constructor( + public code: ErrorCode, + public statusCode: number = ErrorMap[code].statusCode, + message?: string + ) { + super(message || ErrorMap[code].message); + this.name = 'APIError'; + } +} + +const HEADERS_FOR_IP_FORWARDING = [ + 'X-Client-IP', + 'X-Forwarded-For', + 'X-Real-IP', + 'True-Client-IP', + 'X-Forwarded', + 'Forwarded-For', +]; + +const API_VERSION = 1; + +export const GDRIVE_FORMATTER = 'gdrive'; +export const LIGHT_GDRIVE_FORMATTER = 'lightgdrive'; +export const MINIMALISTIC_GDRIVE_FORMATTER = 'minimalisticgdrive'; +export const TORRENTIO_FORMATTER = 'torrentio'; +export const TORBOX_FORMATTER = 'torbox'; +export const CUSTOM_FORMATTER = 'custom'; + +export const FORMATTERS = [ + GDRIVE_FORMATTER, + LIGHT_GDRIVE_FORMATTER, + MINIMALISTIC_GDRIVE_FORMATTER, + TORRENTIO_FORMATTER, + TORBOX_FORMATTER, + CUSTOM_FORMATTER, +] as const; + +export type FormatterDetail = { + id: FormatterType; + name: string; + description: string; +}; + +export const FORMATTER_DETAILS: Record = { + [GDRIVE_FORMATTER]: { + id: GDRIVE_FORMATTER, + name: 'Google Drive', + description: 'Uses the formatting from the Stremio GDrive addon', + }, + [LIGHT_GDRIVE_FORMATTER]: { + id: LIGHT_GDRIVE_FORMATTER, + name: 'Light Google Drive', + description: + 'A lighter version of the GDrive formatter, focused on asthetics', + }, + [MINIMALISTIC_GDRIVE_FORMATTER]: { + id: MINIMALISTIC_GDRIVE_FORMATTER, + name: 'Minimalistic Google Drive', + description: + 'A minimalistic formatter for Google Drive which shows only the bare minimum', + }, + [TORRENTIO_FORMATTER]: { + id: TORRENTIO_FORMATTER, + name: 'Torrentio', + description: 'Uses the formatting from the Torrentio addon', + }, + [TORBOX_FORMATTER]: { + id: TORBOX_FORMATTER, + name: 'Torbox', + description: 'Uses the formatting from the TorBox Stremio addon', + }, + [CUSTOM_FORMATTER]: { + id: CUSTOM_FORMATTER, + name: 'Custom', + description: 'Define your own formatter', + }, +}; + +export type FormatterType = (typeof FORMATTERS)[number]; + +const REALDEBRID_SERVICE = 'realdebrid'; +const DEBRIDLINK_SERVICE = 'debridlink'; +const PREMIUMIZE_SERVICE = 'premiumize'; +const ALLEDEBRID_SERVICE = 'alldebrid'; +const TORBOX_SERVICE = 'torbox'; +const EASYDEBRID_SERVICE = 'easydebrid'; +const PUTIO_SERVICE = 'putio'; +const PIKPAK_SERVICE = 'pikpak'; +const OFFCLOUD_SERVICE = 'offcloud'; +const SEEDR_SERVICE = 'seedr'; +const EASYNEWS_SERVICE = 'easynews'; + +const SERVICES = [ + REALDEBRID_SERVICE, + DEBRIDLINK_SERVICE, + PREMIUMIZE_SERVICE, + ALLEDEBRID_SERVICE, + TORBOX_SERVICE, + EASYDEBRID_SERVICE, + PUTIO_SERVICE, + PIKPAK_SERVICE, + OFFCLOUD_SERVICE, + SEEDR_SERVICE, + EASYNEWS_SERVICE, +] as const; + +export type ServiceId = (typeof SERVICES)[number]; + +export const MEDIAFLOW_SERVICE = 'mediaflow' as const; +export const STREMTHRU_SERVICE = 'stremthru' as const; + +export const PROXY_SERVICES = [MEDIAFLOW_SERVICE, STREMTHRU_SERVICE] as const; +export type ProxyServiceId = (typeof PROXY_SERVICES)[number]; + +export const PROXY_SERVICE_DETAILS: Record< + ProxyServiceId, + { + id: ProxyServiceId; + name: string; + description: string; + url: string; + } +> = { + [MEDIAFLOW_SERVICE]: { + id: MEDIAFLOW_SERVICE, + name: 'MediaFlow', + description: + 'MediaFlow is a proxy service that allows you to proxy your requests to the server.', + url: 'https://github.com/mhdzumair/mediaflow-proxy', + }, + [STREMTHRU_SERVICE]: { + id: STREMTHRU_SERVICE, + name: 'StremThru', + description: + 'StremThru is a proxy service that allows you to proxy your requests to the server.', + url: 'https://github.com/MunifTanjim/stremthru', + }, +}; + +const SERVICE_DETAILS: Record< + ServiceId, + { + id: ServiceId; + name: string; + shortName: string; + knownNames: string[]; + signUpText: string; + credentials: Option[]; + } +> = { + [REALDEBRID_SERVICE]: { + id: REALDEBRID_SERVICE, + name: 'Real-Debrid', + shortName: 'RD', + knownNames: ['RD', 'Real Debrid', 'RealDebrid', 'Real-Debrid'], + signUpText: + "Don't have an account? [Sign up here](https://real-debrid.com/signup?aid=9483829)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'The API key for the Real-Debrid service. Obtain it from [here](https://real-debrid.com/settings/api)', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [ALLEDEBRID_SERVICE]: { + id: ALLEDEBRID_SERVICE, + name: 'All-Debrid', + shortName: 'AD', + knownNames: ['AD', 'All Debrid', 'AllDebrid', 'All-Debrid'], + signUpText: + "Don't have an account? [Sign up here](https://alldebrid.com/?uid=3n8qa&lang=en)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'The API key for the All-Debrid service. Create one [here](https://alldebrid.com/apikeys)', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [PREMIUMIZE_SERVICE]: { + id: PREMIUMIZE_SERVICE, + name: 'Premiumize', + shortName: 'PZ', + knownNames: ['PM', 'Premiumize'], + signUpText: + "Don't have an account? [Sign up here](https://www.premiumize.me/register)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Premiumize API key. Obtain it from [here](https://www.premiumize.me/account)', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [DEBRIDLINK_SERVICE]: { + id: DEBRIDLINK_SERVICE, + name: 'Debridlink', + shortName: 'DL', + knownNames: ['DL', 'Debrid Link', 'DebridLink', 'Debrid-Link'], + signUpText: + "Don't have an account? [Sign up here](https://debrid-link.com/id/EY0JO)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Debrid-Link API key. Obtain it from [here](https://debrid-link.com/webapp/apikey)', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [TORBOX_SERVICE]: { + id: TORBOX_SERVICE, + name: 'TorBox', + shortName: 'TB', + knownNames: ['TB', 'TorBox', 'Torbox'], + signUpText: + "Don't have an account? [Sign up here](https://torbox.app/subscription?referral=9ca21adb-dbcb-4fb0-9195-412a5f3519bc) or use my referral code `9ca21adb-dbcb-4fb0-9195-412a5f3519bc`.", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Torbox API key. Obtain it from [here](https://torbox.app/settings)', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [OFFCLOUD_SERVICE]: { + id: OFFCLOUD_SERVICE, + name: 'Offcloud', + shortName: 'OC', + knownNames: ['OC', 'Offcloud'], + signUpText: + "Don't have an account? [Sign up here](https://offcloud.com/?=06202a3d)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your Offcloud API key. Obtain it from [here](https://offcloud.com/settings)', + type: 'string', + required: true, + sensitive: true, + }, + { + id: 'email', + name: 'Email', + description: + 'Your Offcloud email. (These credentials are necessary for some addons)', + type: 'string', + required: true, + }, + { + id: 'password', + name: 'Password', + description: + 'Your Offcloud password. (These credentials are necessary for some addons)', + type: 'string', + required: true, + }, + ], + }, + [PUTIO_SERVICE]: { + id: PUTIO_SERVICE, + name: 'put.io', + shortName: 'P.IO', + knownNames: ['PO', 'put.io', 'putio'], + signUpText: "Don't have an account? [Sign up here](https://put.io/)", + credentials: [ + { + id: 'clientId', + name: 'Client ID', + description: + 'Your put.io Client ID. Obtain it from [here](https://put.io/oauth)', + type: 'string', + required: true, + sensitive: true, + }, + { + id: 'token', + name: 'Token', + description: + 'Your put.io Token. Obtain it from [here](https://put.io/oauth)', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [EASYNEWS_SERVICE]: { + id: EASYNEWS_SERVICE, + name: 'Easynews', + shortName: 'EN', + knownNames: ['EN', 'Easynews'], + signUpText: + "Don't have an account? [Sign up here](https://www.easynews.com/)", + credentials: [ + { + id: 'username', + name: 'Username', + description: 'Your Easynews username', + type: 'string', + required: true, + }, + { + id: 'password', + name: 'Password', + description: 'Your Easynews password', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [EASYDEBRID_SERVICE]: { + id: EASYDEBRID_SERVICE, + name: 'EasyDebrid', + shortName: 'ED', + knownNames: ['ED', 'EasyDebrid'], + signUpText: + "Don't have an account? [Sign up here](https://paradise-cloud.com/products/easydebrid)", + credentials: [ + { + id: 'apiKey', + name: 'API Key', + description: + 'Your EasyDebrid API key. Obtain it from [here](https://paradise-cloud.com/products/easydebrid)', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [PIKPAK_SERVICE]: { + id: PIKPAK_SERVICE, + name: 'PikPak', + shortName: 'PKP', + knownNames: ['PP', 'PikPak', 'PKP'], + signUpText: + "Don't have an account? [Sign up here](https://mypikpak.com/drive/activity/invited?invitation-code=72822731)", + credentials: [ + { + id: 'email', + name: 'Email', + description: 'Your PikPak email address', + type: 'string', + required: true, + }, + { + id: 'password', + name: 'Password', + description: 'Your PikPak password', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, + [SEEDR_SERVICE]: { + id: SEEDR_SERVICE, + name: 'Seedr', + shortName: 'SDR', + knownNames: ['SR', 'Seedr', 'SDR'], + signUpText: + "Don't have an account? [Sign up here](https://www.seedr.cc/?r=6542079)", + credentials: [ + { + id: 'apiKey', + name: 'Encoded Token', + description: + 'Please authorise at MediaFusion and copy the token into here.', + type: 'string', + required: true, + sensitive: true, + }, + ], + }, +}; + +const STRING_OPTION_TYPE = 'string'; +const NUMBER_OPTION_TYPE = 'number'; +const BOOLEAN_OPTION_TYPE = 'boolean'; +const SELECT_OPTION_TYPE = 'select'; +const MULTI_SELECT_OPTION_TYPE = 'multi-select'; + +const OPTION_TYPES = [ + STRING_OPTION_TYPE, + NUMBER_OPTION_TYPE, + BOOLEAN_OPTION_TYPE, + SELECT_OPTION_TYPE, + MULTI_SELECT_OPTION_TYPE, +] as const; + +const P2P_TAG = 'p2p'; +const DIRECT_TAG = 'direct'; +const DEBRID_TAG = 'debrid'; +const USENET_TAG = 'usenet'; +const PRESET_TAGS = [P2P_TAG, DIRECT_TAG, DEBRID_TAG, USENET_TAG] as const; + +export const DEDUPLICATOR_KEYS = ['filename', 'infoHash'] as const; + +const RESOLUTIONS = [ + '2160p', + '1440p', + '1080p', + '720p', + '576p', + '480p', + '360p', + '240p', + '144p', + 'Unknown', +] as const; + +const QUALITIES = [ + 'Bluray REMUX', + 'Bluray', + 'WEB-DL', + 'WEBRip', + 'HDRip', + 'HC HD-Rip', + 'DVDRip', + 'HDTV', + 'CAM', + 'TS', + 'TC', + 'SCR', + 'Unknown', +] as const; + +const VISUAL_TAGS = [ + 'HDR+DV', + 'HDR10+', + 'HDR10', + 'DV', + 'HDR', + '10bit', + '3D', + 'IMAX', + 'AI', + 'SDR', +] as const; + +const AUDIO_TAGS = [ + 'Atmos', + 'DD+', + 'DD', + 'DTS-HD MA', + 'DTS', + 'TrueHD', + '5.1', + '7.1', + 'FLAC', + 'AAC', + 'Unknown', +] as const; + +const ENCODES = [ + 'AV1', + 'HEVC', + 'AVC', + 'XviD', + 'DivX', + 'H-OU', + 'H-SBS', + 'Unknown', +] as const; + +const SORT_CRITERIA = [ + 'quality', + 'resolution', + 'language', + 'visualTag', + 'audioTag', + 'streamType', + 'size', + 'service', + 'seeders', + 'addon', + 'regexPatterns', + 'cached', + 'personal', +] as const; +const SORT_DIRECTIONS = ['asc', 'desc'] as const; + +const STREAM_TYPES = [ + 'p2p', + 'live', + 'usenet', + 'debrid', + 'http', + 'external', + 'youtube', +] as const; + +const STREAM_RESOURCE = 'stream' as const; +const SUBTITLES_RESOURCE = 'subtitles' as const; +const CATALOG_RESOURCE = 'catalog' as const; +const META_RESOURCE = 'meta' as const; +const ADDON_CATALOG_RESOURCE = 'addon_catalog' as const; + +export const MOVIE_TYPE = 'movie' as const; +export const SERIES_TYPE = 'series' as const; +export const CHANNEL_TYPE = 'channel' as const; +export const TV_TYPE = 'tv' as const; + +export const TYPES = [MOVIE_TYPE, SERIES_TYPE, CHANNEL_TYPE, TV_TYPE] as const; + +const RESOURCES = [ + STREAM_RESOURCE, + SUBTITLES_RESOURCE, + CATALOG_RESOURCE, + META_RESOURCE, + ADDON_CATALOG_RESOURCE, +] as const; + +// const LANGUAGE_EMOJI_MAPPING = { +// multi: '🌎', +// english: '🇬🇧', +// japanese: '🇯🇵', +// chinese: '🇨🇳', +// russian: '🇷🇺', +// arabic: '🇸🇦', +// portuguese: '🇵🇹', +// spanish: '🇪��', +// french: '🇫🇷', +// german: '🇩🇪', +// italian: '🇮🇹', +// korean: '🇰🇷', +// hindi: '🇮🇳', +// bengali: '🇧🇩', +// punjabi: '🇵🇰', +// marathi: '🇮🇳', +// gujarati: '🇮🇳', +// tamil: '🇮🇳', +// telugu: '🇮🇳', +// kannada: '🇮🇳', +// malayalam: '🇮🇳', +// thai: '🇹🇭', +// vietnamese: '🇻🇳', +// indonesian: '🇮🇩', +// turkish: '🇹🇷', +// hebrew: '🇮🇱', +// persian: '🇮🇷', +// ukrainian: '🇺🇦', +// greek: '🇬🇷', +// lithuanian: '🇱🇹', +// latvian: '🇱🇻', +// estonian: '🇪🇪', +// polish: '🇵🇱', +// czech: '🇨🇿', +// slovak: '🇸🇰', +// hungarian: '🇭🇺', +// romanian: '🇷🇴', +// bulgarian: '🇧🇬', +// serbian: '🇷🇸', +// croatian: '🇭🇷', +// slovenian: '🇸🇮', +// dutch: '🇳🇱', +// danish: '🇩🇰', +// finnish: '🇫🇮', +// swedish: '🇸🇪', +// norwegian: '🇳🇴', +// malay: '🇲🇾', +// latino: '💃🏻', +// Latino: '🇲🇽', +// }; + +// const ISO_639_1_LANGUAGE_MAPPING: Record = { +// EN: 'english', +// JA: 'japanese', +// ZH: 'chinese', +// RU: 'russian', +// AR: 'arabic', +// PT: 'portuguese', +// ES: 'spanish', +// FR: 'french', +// DE: 'german', +// IT: 'italian', +// KO: 'korean', +// HI: 'hindi', +// BN: 'bengali', +// PA: 'punjabi', +// MR: 'marathi', +// GU: 'gujarati', +// TA: 'tamil', +// TE: 'telugu', +// KN: 'kannada', +// ML: 'malayalam', +// TH: 'thai', +// VI: 'vietnamese', +// ID: 'indonesian', +// TR: 'turkish', +// HE: 'hebrew', +// FA: 'persian', +// UK: 'ukrainian', +// EL: 'greek', +// LT: 'lithuanian', +// LV: 'latvian', +// ET: 'estonian', +// PL: 'polish', +// CS: 'czech', +// SK: 'slovak', +// HU: 'hungarian', +// RO: 'romanian', +// BG: 'bulgarian', +// SR: 'serbian', +// HR: 'croatian', +// SL: 'slovenian', +// NL: 'dutch', +// DA: 'danish', +// FI: 'finnish', +// SV: 'swedish', +// NO: 'norwegian', +// MS: 'malay', +// LA: 'latino', +// MX: 'Latino', +// }; + +// // const LANGUAGES = Object.keys(LANGUAGE_EMOJI_MAPPING).map( +// // (lang) => lang.charAt(0).toUpperCase() + lang.slice(1) +// // ); + +const LANGUAGES = [ + 'english', + 'japanese', + 'chinese', + 'russian', + 'arabic', + 'portuguese', + 'spanish', + 'french', + 'german', + 'italian', + 'korean', + 'hindi', + 'bengali', + 'punjabi', + 'marathi', + 'gujarati', + 'tamil', + 'telugu', + 'kannada', + 'malayalam', + 'thai', + 'vietnamese', + 'indonesian', + 'turkish', + 'hebrew', + 'persian', + 'ukrainian', + 'greek', + 'lithuanian', + 'latvian', + 'estonian', + 'polish', + 'czech', + 'slovak', + 'hungarian', + 'romanian', + 'bulgarian', + 'serbian', + 'croatian', + 'slovenian', + 'dutch', + 'danish', + 'finnish', + 'swedish', + 'norwegian', + 'malay', + 'latino', +] as const; + +export const SNIPPETS = [ + { + name: 'Title Only', + description: 'Just the title of the stream', + value: '{title}', + }, + { + name: 'Title + Year', + description: 'Title and year', + value: '{title} ({year})', + }, + { + name: 'Full Info', + description: 'Title, year, resolution, quality', + value: '{title} ({year}) [{resolution}] [{quality}]', + }, +]; + +export { + API_VERSION, + SERVICES, + RESOLUTIONS, + QUALITIES, + VISUAL_TAGS, + AUDIO_TAGS, + ENCODES, + SORT_CRITERIA, + SORT_DIRECTIONS, + STREAM_TYPES, + LANGUAGES, + RESOURCES, + OPTION_TYPES, + PRESET_TAGS, + STREAM_RESOURCE, + SUBTITLES_RESOURCE, + CATALOG_RESOURCE, + META_RESOURCE, + ADDON_CATALOG_RESOURCE, + REALDEBRID_SERVICE, + PREMIUMIZE_SERVICE, + ALLEDEBRID_SERVICE, + TORBOX_SERVICE, + EASYDEBRID_SERVICE, + PUTIO_SERVICE, + PIKPAK_SERVICE, + OFFCLOUD_SERVICE, + SEEDR_SERVICE, + EASYNEWS_SERVICE, + SERVICE_DETAILS, + STRING_OPTION_TYPE, + NUMBER_OPTION_TYPE, + BOOLEAN_OPTION_TYPE, + SELECT_OPTION_TYPE, + MULTI_SELECT_OPTION_TYPE, + P2P_TAG, + DIRECT_TAG, + DEBRID_TAG, + USENET_TAG, + HEADERS_FOR_IP_FORWARDING, +}; diff --git a/packages/core/src/utils/crypto.ts b/packages/core/src/utils/crypto.ts new file mode 100644 index 00000000..bc25e56b --- /dev/null +++ b/packages/core/src/utils/crypto.ts @@ -0,0 +1,209 @@ +import { + randomBytes, + createCipheriv, + createDecipheriv, + createHash, + pbkdf2Sync, + randomUUID, +} from 'crypto'; +import { deflateSync, inflateSync } from 'zlib'; +import { Env } from './env'; +import { createLogger } from './logger'; + +const logger = createLogger('crypto'); + +const compressData = (data: string): Buffer => { + return deflateSync(Buffer.from(data, 'utf-8'), { + level: 9, + }); +}; + +const decompressData = (data: Buffer): string => { + return inflateSync(data).toString('utf-8'); +}; + +const encryptData = ( + secretKey: Buffer, + data: Buffer +): { iv: string; data: string } => { + // Then encrypt the compressed data + const iv = randomBytes(16); + const cipher = createCipheriv('aes-256-cbc', secretKey, iv); + + const encryptedData = Buffer.concat([cipher.update(data), cipher.final()]); + + return { + iv: iv.toString('base64'), + data: encryptedData.toString('base64'), + }; +}; + +const decryptData = ( + secretKey: Buffer, + encryptedData: Buffer, + iv: Buffer +): Buffer => { + const decipher = createDecipheriv('aes-256-cbc', secretKey, iv); + + // Decrypt the data + const decryptedData = Buffer.concat([ + decipher.update(encryptedData), + decipher.final(), + ]); + + return decryptedData; +}; + +type SuccessResponse = { + success: true; + data: string; + error: null; +}; + +type ErrorResponse = { + success: false; + error: string; + data: null; +}; + +export type Response = SuccessResponse | ErrorResponse; + +/** + * Encrypts a string using AES-256-CBC encryption, returns a string in the format "iv:encrypted" where + * iv and encrypted are url encoded. + * @param data Data to encrypt + * @param secretKey Secret key used for encryption + * @returns Encrypted data or error message + */ +export function encryptString(data: string, secretKey?: Buffer): Response { + if (!secretKey) { + secretKey = Buffer.from(Env.SECRET_KEY, 'hex'); + } + try { + const compressed = compressData(data); + const { iv, data: encrypted } = encryptData(secretKey, compressed); + return { + success: true, + data: `${encodeURIComponent(iv)}:${encodeURIComponent(encrypted)}`, + error: null, + }; + } catch (error: any) { + logger.error(`Failed to encrypt data: ${error.message}`); + return { + success: false, + error: error.message, + data: null, + }; + } +} + +/** + * Decrypts a string using AES-256-CBC encryption + * @param data Encrypted data to decrypt + * @param secretKey Secret key used for encryption + * @returns Decrypted data or error message + */ +export function decryptString(data: string, secretKey?: Buffer): Response { + if (!secretKey) { + secretKey = Buffer.from(Env.SECRET_KEY, 'hex'); + } + try { + const [ivHex, encryptedHex] = data.split(':').map(decodeURIComponent); + const iv = Buffer.from(ivHex, 'base64'); + const encrypted = Buffer.from(encryptedHex, 'base64'); + const decrypted = decryptData(secretKey, encrypted, iv); + const decompressed = decompressData(decrypted); + return { + success: true, + data: decompressed, + error: null, + }; + } catch (error: any) { + logger.error(`Failed to decrypt data: ${error.message}`); + return { + success: false, + error: error.message, + data: null, + }; + } +} + +export function getSimpleTextHash(text: string): string { + // Use SHA-256 for a simple hash for non-sensitive data + const hash = createHash('sha256').update(text).digest('hex'); + return hash; +} + +/** + * Creates a secure hash of text using PBKDF2 + * @param text Text to hash + * @param salt Optional salt, will be generated if not provided + * @param iterations Number of iterations for PBKDF2, defaults to 10000 + * @returns Object containing the hash and salt used + */ +export function getTextHash( + text: string, + salt?: string, + iterations: number = 10000 +): { hash: string; salt: string } { + // Generate a random salt if not provided + const usedSalt = salt || randomBytes(16).toString('hex'); + + // Use PBKDF2 with SHA-512 + const hash = createHash('sha512') + .update(text + usedSalt) + .digest('hex'); + + // Apply multiple iterations + let result = hash; + for (let i = 0; i < iterations; i++) { + result = createHash('sha512') + .update(result + usedSalt) + .digest('hex'); + } + + return { hash: result, salt: usedSalt }; +} + +/** + * Verifies if the provided text matches a previously generated hash + * @param text Text to verify + * @param storedHash Previously generated hash + * @param salt Salt used in the original hash + * @param iterations Number of iterations used in the original hash + * @returns Boolean indicating if the text matches the hash + */ +export function verifyHash( + text: string, + storedHash: string, + salt: string, + iterations: number = 10000 +): boolean { + const { hash } = getTextHash(text, salt, iterations); + return hash === storedHash; +} + +/** + * Derives a 64 character hex string from a password using PBKDF2 + * @param password Password to derive key from + * @param salt Optional salt, will be generated if not provided + * @returns Object containing the key and salt used + */ +export function deriveKey( + password: string, + salt?: string +): { key: Buffer; salt: string } { + salt = salt || randomBytes(16).toString('hex'); + const key = pbkdf2Sync( + Buffer.from(password, 'utf-8'), + Buffer.from(salt, 'hex'), + 100000, + 32, + 'sha512' + ); + return { key, salt }; +} + +export function generateUUID(): string { + return randomUUID(); +} diff --git a/packages/utils/src/settings.ts b/packages/core/src/utils/env.ts similarity index 54% rename from packages/utils/src/settings.ts rename to packages/core/src/utils/env.ts index 77fbf35b..b64aaf51 100644 --- a/packages/utils/src/settings.ts +++ b/packages/core/src/utils/env.ts @@ -1,6 +1,5 @@ import dotenv from 'dotenv'; import path from 'path'; -import p from '../package.json'; import { cleanEnv, str, @@ -12,73 +11,69 @@ import { EnvError, port, } from 'envalid'; +import { ResourceManager } from './resources'; +import * as constants from './constants'; try { - dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); + dotenv.config({ path: path.resolve(__dirname, '../../../../.env') }); } catch (error) { console.error('Error loading .env file', error); } -// define default timeouts and urls here so we can use them in the validators -// so we can use them in the validators -const DEFAULT_TIMEOUT = 15000; // 15 seconds +let metadata: any = undefined; +try { + metadata = ResourceManager.getResource('metadata.json') || {}; +} catch (error) { + console.error('Error loading metadata.json file', error); +} const secretKey = makeValidator((x) => { - if (typeof x !== 'string') { - throw new Error('Secret key must be a string'); + if (!/^[0-9a-fA-F]{64}$/.test(x)) { + throw new EnvError('Secret key must be a 64-character hex string'); } - // backwards compatibility for 32 character secret keys - if (x.length === 32) { - return x; + return x; +}); + +const regexes = makeValidator((x) => { + // json array of string + const parsed = JSON.parse(x); + if (!Array.isArray(parsed)) { + throw new EnvError('Regexes must be an array'); } - if (x.length === 64) { - if (!/^[0-9a-fA-F]{64}$/.test(x)) { - throw new EnvError('64-character secret key must be a hex string'); + // each element must be a string + parsed.forEach((x) => { + if (typeof x !== 'string') { + throw new EnvError('Regexes must be an array of strings'); } - return x; - } - throw new EnvError('Secret key must be a 64-character hex string'); -}); - -const regex = makeValidator((x) => { - if (typeof x !== 'string') { - throw new Error('Regex pattern must be a string'); - } - try { - new RegExp(x); - return x; - } catch (e) { - throw new Error(`Invalid regex pattern: ${x}`); - } -}); - -const multipleRegex = makeValidator((x) => { - if (typeof x !== 'string') { - throw new EnvError('Regex pattern must be a string'); - } - - const patterns = x.split(/\s+/).filter(Boolean); - if (patterns.length > parseInt(process.env.MAX_REGEX_SORT_PATTERNS || '30')) { - throw new EnvError( - `Too many regex sort patterns in environment variables (max is ${process.env.MAX_REGEX_SORT_PATTERNS || '30'})` - ); - } - // try compiling each pattern to check for validity - // each "pattern" is regexName::regexPattern, where regexName is optional - // we need to only extract the regexPattern part, - patterns.forEach((p) => { try { - const delimiterIndex = p.indexOf('<::>'); - if (delimiterIndex !== -1) { - p = p.slice(delimiterIndex + 2); - } - new RegExp(p); + new RegExp(x); } catch (e) { - throw new EnvError(`Invalid regex pattern: ${p}`); + throw new EnvError(`Invalid regex pattern: ${x}`); + } + }); + return parsed; +}); + +const namedRegexes = makeValidator((x) => { + // array of objects with properties name and pattern + const parsed = JSON.parse(x); + if (!Array.isArray(parsed)) { + throw new EnvError('Named regexes must be an array'); + } + // each element must be an object with properties name and pattern + parsed.forEach((x) => { + if (typeof x !== 'object' || !x.name || !x.pattern) { + throw new EnvError( + 'Named regexes must be an array of objects with properties name and pattern' + ); + } + try { + new RegExp(x.pattern); + } catch (e) { + throw new EnvError(`Invalid regex pattern: ${x.pattern}`); } }); - // return normal input - return x; + return parsed; }); const url = makeValidator((x) => { @@ -90,7 +85,8 @@ const url = makeValidator((x) => { } catch (e) { throw new EnvError(`Invalid URL: ${x}`); } - return x.endsWith('/') ? x : `${x}/`; + // remove trailing slash + return x.endsWith('/') ? x.slice(0, -1) : x; }); export const forcedPort = makeValidator((input: string) => { @@ -116,36 +112,88 @@ const userAgent = makeValidator((x) => { throw new Error('User agent must be a string'); } // replace {version} with the version of the addon - return x.replace(/{version}/g, p.version); + return x.replace(/{version}/g, metadata?.version || 'unknown'); }); -const customConfigs = makeValidator((x) => { +// comma separated list of alias:uuid +const aliasedUUIDs = makeValidator((x) => { try { - const parsed = JSON.parse(x); - if (typeof parsed !== 'object') { - throw new Error('Custom configs must be an object'); - } - // must be a simple key-value object, of string-string - for (const key in parsed) { - if (typeof parsed[key] !== 'string') { - throw new Error( - `Custom config ${key} must be a string, got ${typeof parsed[key]}` - ); + // const parsed = JSON.parse(x); + // if (typeof parsed !== 'object') { + // throw new Error('Custom configs must be an object'); + // } + // // must be a simple key-value object, of string-string + // for (const key in parsed) { + // if (typeof parsed[key] !== 'string') { + // throw new Error( + // `Custom config ${key} must be a string, got ${typeof parsed[key]}` + // ); + // } + // } + // return parsed; + const aliases: Record = {}; + const parsed = x.split(',').map((x) => { + const [alias, uuid] = x.split(':'); + if (!alias || !uuid) { + throw new Error('Invalid alias:uuid pair'); + } else if ( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( + uuid + ) + ) { + throw new Error('Invalid UUID'); } - } - return parsed; + aliases[alias] = uuid; + }); + return aliases; } catch (e) { throw new Error('Custom configs must be a valid JSON string'); } }); -export const Settings = cleanEnv(process.env, { +const readonly = makeValidator((x) => { + if (x) { + throw new EnvError('Readonly environment variable, cannot be set'); + } + return x; +}); + +export const Env = cleanEnv(process.env, { + VERSION: readonly({ + default: metadata?.version || 'unknown', + desc: 'Version of the addon', + }), + DESCRIPTION: readonly({ + default: metadata?.description || 'unknown', + desc: 'Description of the addon', + }), + NODE_ENV: str({ + default: 'production', + desc: 'Node environment of the addon', + choices: ['production', 'development', 'test'], + }), + GIT_COMMIT: readonly({ + default: metadata?.commitHash || 'unknown', + desc: 'Git commit hash of the addon', + }), + GIT_TAG: readonly({ + default: metadata?.latestTag || 'unknown', + desc: 'Git tag of the addon', + }), + BUILD_TIME: readonly({ + default: metadata?.buildTime || 'unknown', + desc: 'Build time of the addon', + }), + BUILD_COMMIT_TIME: readonly({ + default: metadata?.commitTime || 'unknown', + desc: 'Build commit time of the addon', + }), ADDON_NAME: str({ default: 'AIOStreams', desc: 'Name of the addon', }), ADDON_ID: str({ - default: 'aiostreams.viren070.com', + default: 'com.aiostreams.viren070', desc: 'ID of the addon', }), PORT: port({ @@ -157,8 +205,8 @@ export const Settings = cleanEnv(process.env, { desc: 'Branding for the addon', }), SECRET_KEY: secretKey({ - default: '', desc: 'Secret key for the addon, used for encryption and must be 64 characters of hex', + default: '', }), API_KEY: str({ default: '', @@ -172,19 +220,29 @@ export const Settings = cleanEnv(process.env, { default: true, desc: 'Deterministic addon ID', }), - + DATABASE_URI: str({ + default: 'sqlite://./data/db.sqlite', + desc: 'Database URI for the addon', + }), ADDON_PROXY: url({ - default: '', + default: undefined, desc: 'Proxy URL for the addon', }), ADDON_PROXY_CONFIG: str({ default: undefined, desc: 'Proxy config for the addon in format of comma separated hostname:boolean', }), - - CUSTOM_CONFIGS: customConfigs>({ + ALIASED_UUIDS: aliasedUUIDs({ default: {}, - desc: 'Custom configs for the addon in JSON key value format, using the key as the config name and the value as the config string', + desc: 'Comma separated list of alias:uuid pairs.', + }), + ADMIN_UUIDS: str({ + default: undefined, + desc: 'Comma separated list of admin UUIDs. Admin UUIDs can use regex features.', + }), + TMDB_ACCESS_TOKEN: str({ + default: undefined, + desc: 'TMDB Read Access Token. Used for fetching metadata for the strict title matching option.', }), DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE: bool({ default: false, @@ -199,22 +257,16 @@ export const Settings = cleanEnv(process.env, { LOG_LEVEL: str({ default: 'info', desc: 'Log level for the addon', - choices: ['info', 'debug', 'warn', 'error'], + choices: ['info', 'debug', 'warn', 'error', 'verbose', 'silly', 'http'], }), LOG_FORMAT: str({ default: 'text', desc: 'Log format for the addon', choices: ['text', 'json'], }), - - DISABLE_TORRENTIO: bool({ - default: false, - desc: 'Disable Torrentio addon', - }), - DISABLE_TORRENTIO_MESSAGE: str({ - default: - 'The Torrentio addon has been disabled, please remove it to use this addon.', - desc: 'Message to show when the Torrentio addon is disabled', + LOG_TIMEZONE: str({ + default: 'UTC', + desc: 'Timezone for log timestamps (e.g., America/New_York, Europe/London)', }), STREMIO_ADDONS_CONFIG_ISSUER: url({ @@ -227,7 +279,7 @@ export const Settings = cleanEnv(process.env, { }), DEFAULT_USER_AGENT: userAgent({ - default: `AIOStreams/${p.version}`, + default: `AIOStreams/${metadata?.version || 'unknown'}`, desc: 'Default user agent for the addon', }), @@ -281,58 +333,141 @@ export const Settings = cleanEnv(process.env, { desc: 'Default timeout for the addon', }), - DEFAULT_REGEX_EXCLUDE_PATTERN: regex({ - default: undefined, + DEFAULT_REQUIRED_REGEX_PATTERNS: regexes({ + default: [], desc: 'Default regex exclude pattern', }), - DEFAULT_REGEX_INCLUDE_PATTERN: regex({ - default: undefined, + DEFAULT_EXCLUDED_REGEX_PATTERNS: regexes({ + default: [], desc: 'Default regex include pattern', }), - DEFAULT_REGEX_SORT_PATTERNS: multipleRegex({ - default: undefined, + DEFAULT_PREFERRED_REGEX_PATTERNS: namedRegexes({ + default: [], desc: 'Default regex sort patterns', }), - // MediaFlow settings - DEFAULT_MEDIAFLOW_URL: url({ - default: '', - desc: 'Default MediaFlow URL', + FORCE_PROXY_ENABLED: bool({ + default: undefined, + desc: 'Force proxy enabled', }), - DEFAULT_MEDIAFLOW_API_PASSWORD: str({ - default: '', - desc: 'Default MediaFlow API password', + FORCE_PROXY_ID: str({ + default: undefined, + desc: 'Force proxy id', + choices: constants.PROXY_SERVICES, }), - DEFAULT_MEDIAFLOW_PUBLIC_IP: str({ - default: '', - desc: 'Default MediaFlow public IP', + FORCE_PROXY_URL: url({ + default: undefined, + desc: 'Force proxy url', }), - MEDIAFLOW_IP_TIMEOUT: num({ - default: 30000, - desc: 'MediaFlow IP timeout', + FORCE_PROXY_CREDENTIALS: str({ + default: undefined, + desc: 'Force proxy credentials', }), + FORCE_PROXY_PUBLIC_IP: str({ + default: undefined, + desc: 'Force proxy public ip', + }), + FORCE_PROXY_PROXIED_ADDONS: json({ + default: undefined, + desc: 'Force proxy proxied addons', + }), + FORCE_PROXY_PROXIED_SERVICES: json({ + default: undefined, + desc: 'Force proxy proxied services', + }), + + DEFAULT_PROXY_ENABLED: bool({ + default: undefined, + desc: 'Default proxy enabled', + }), + DEFAULT_PROXY_ID: str({ + default: undefined, + desc: 'Default proxy id', + }), + DEFAULT_PROXY_URL: url({ + default: undefined, + desc: 'Default proxy url', + }), + DEFAULT_PROXY_CREDENTIALS: str({ + default: undefined, + desc: 'Default proxy credentials', + }), + DEFAULT_PROXY_PUBLIC_IP: str({ + default: undefined, + desc: 'Default proxy public ip', + }), + DEFAULT_PROXY_PROXIED_ADDONS: json({ + default: undefined, + desc: 'Default proxy proxied addons', + }), + DEFAULT_PROXY_PROXIED_SERVICES: json({ + default: undefined, + desc: 'Default proxy proxied services', + }), + + // // MediaFlow settings + // FORCE_MEDIAFLOW_URL: url({ + // default: undefined, + // desc: 'Force MediaFlow URL', + // }), + // FORCE_MEDIAFLOW_API_PASSWORD: str({ + // default: undefined, + // desc: 'Force MediaFlow API password', + // }), + // FORCE_MEDIAFLOW_PUBLIC_IP: str({ + // default: undefined, + // desc: 'Force MediaFlow public IP', + // }), + // DEFAULT_MEDIAFLOW_URL: url({ + // default: '', + // desc: 'Default MediaFlow URL', + // }), + // DEFAULT_MEDIAFLOW_API_PASSWORD: str({ + // default: '', + // desc: 'Default MediaFlow API password', + // }), + // DEFAULT_MEDIAFLOW_PUBLIC_IP: str({ + // default: '', + // desc: 'Default MediaFlow public IP', + // }), + // MEDIAFLOW_IP_TIMEOUT: num({ + // default: 30000, + // desc: 'MediaFlow IP timeout', + // }), ENCRYPT_MEDIAFLOW_URLS: bool({ default: true, desc: 'Encrypt MediaFlow URLs', }), - // StremThru settings - DEFAULT_STREMTHRU_URL: url({ - default: '', - desc: 'Default StremThru URL', - }), - DEFAULT_STREMTHRU_CREDENTIAL: str({ - default: '', - desc: 'Default StremThru credential', - }), - DEFAULT_STREMTHRU_PUBLIC_IP: str({ - default: '', - desc: 'Default StremThru public IP', - }), - STREMTHRU_TIMEOUT: num({ - default: 30000, - desc: 'StremThru timeout', - }), + // // StremThru settings + // FORCE_STREMTHRU_URL: url({ + // default: undefined, + // desc: 'Force StremThru URL', + // }), + // FORCE_STREMTHRU_CREDENTIAL: str({ + // default: undefined, + // desc: 'Force StremThru credential', + // }), + // FORCE_STREMTHRU_PUBLIC_IP: str({ + // default: undefined, + // desc: 'Force StremThru public IP', + // }), + // DEFAULT_STREMTHRU_URL: url({ + // default: '', + // desc: 'Default StremThru URL', + // }), + // DEFAULT_STREMTHRU_CREDENTIAL: str({ + // default: '', + // desc: 'Default StremThru credential', + // }), + // DEFAULT_STREMTHRU_PUBLIC_IP: str({ + // default: '', + // desc: 'Default StremThru public IP', + // }), + // STREMTHRU_TIMEOUT: num({ + // default: 30000, + // desc: 'StremThru timeout', + // }), ENCRYPT_STREMTHRU_URLS: bool({ default: true, desc: 'Encrypt StremThru URLs', @@ -595,4 +730,56 @@ export const Settings = cleanEnv(process.env, { default: undefined, desc: 'Default GDrive user agent', }), + + // Rate limiting settings + DISABLE_RATE_LIMITS: bool({ + default: false, + desc: 'Disable rate limiting', + }), + // RATE_LIMIT_WINDOW: num({ + // default: 60, // 1 minute + // desc: 'Time window for rate limiting in seconds', + // }), + // RATE_LIMIT_MAX_REQUESTS: num({ + // default: 20, // allow 100 requests per IP per minute + // desc: 'Maximum number of requests allowed per IP within the time window', + // }) + STATIC_RATE_LIMIT_WINDOW: num({ + default: 60, // 1 minute + desc: 'Time window for static file serving rate limiting in seconds', + }), + STATIC_RATE_LIMIT_MAX_REQUESTS: num({ + default: 100, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), + USER_API_RATE_LIMIT_WINDOW: num({ + default: 60, // 1 minute + desc: 'Time window for user API rate limiting in seconds', + }), + USER_API_RATE_LIMIT_MAX_REQUESTS: num({ + default: 20, // allow 100 requests per IP per minute + }), + STREAM_API_RATE_LIMIT_WINDOW: num({ + default: 60, // 1 minute + desc: 'Time window for stream API rate limiting in seconds', + }), + STREAM_API_RATE_LIMIT_MAX_REQUESTS: num({ + default: 20, // allow 100 requests per IP per minute + }), + STREMIO_STREAM_RATE_LIMIT_WINDOW: num({ + default: 60, // 1 minute + desc: 'Time window for Stremio stream rate limiting in seconds', + }), + STREMIO_STREAM_RATE_LIMIT_MAX_REQUESTS: num({ + default: 100, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), + STREMIO_CATALOG_RATE_LIMIT_WINDOW: num({ + default: 60, // 1 minute + desc: 'Time window for Stremio catalog rate limiting in seconds', + }), + STREMIO_CATALOG_RATE_LIMIT_MAX_REQUESTS: num({ + default: 100, // allow 100 requests per IP per minute + desc: 'Maximum number of requests allowed per IP within the time window', + }), }); diff --git a/packages/core/src/utils/http.ts b/packages/core/src/utils/http.ts new file mode 100644 index 00000000..e8dfdcdc --- /dev/null +++ b/packages/core/src/utils/http.ts @@ -0,0 +1,68 @@ +import { HEADERS_FOR_IP_FORWARDING } from './constants'; +import { Env } from './env'; +import { createLogger } from './logger'; +import { fetch, ProxyAgent } from 'undici'; + +const logger = createLogger('http'); + +export function makeRequest( + url: string, + timeout: number, + headers: HeadersInit = {}, + forwardIp?: string +) { + const useProxy = shouldProxy(url); + headers = new Headers(headers); + if (forwardIp) { + for (const header of HEADERS_FOR_IP_FORWARDING) { + headers.set(header, forwardIp); + } + } + + let response = fetch(url, { + dispatcher: useProxy ? new ProxyAgent(Env.ADDON_PROXY!) : undefined, + method: 'GET', + headers: headers, + signal: AbortSignal.timeout(timeout), + }); + + return response; +} + +function shouldProxy(url: string) { + let shouldProxy = false; + let hostname: string; + + try { + hostname = new URL(url).hostname; + } catch (error) { + return false; + } + + if (!Env.ADDON_PROXY) { + return false; + } + + shouldProxy = true; + if (Env.ADDON_PROXY_CONFIG) { + for (const rule of Env.ADDON_PROXY_CONFIG.split(',')) { + const [ruleHostname, ruleShouldProxy] = rule.split(':'); + if (['true', 'false'].includes(ruleShouldProxy) === false) { + logger.error(`Invalid proxy config: ${rule}`); + continue; + } + if (ruleHostname === '*') { + shouldProxy = !(ruleShouldProxy === 'false'); + } else if (ruleHostname.startsWith('*')) { + if (hostname.endsWith(ruleHostname.slice(1))) { + shouldProxy = !(ruleShouldProxy === 'false'); + } + } + if (hostname === ruleHostname) { + shouldProxy = !(ruleShouldProxy === 'false'); + } + } + } + + return shouldProxy; +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 00000000..093fa2c7 --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,10 @@ +export * from './cache'; +export * from './constants'; +export * from './env'; +export * from './logger'; +export * from './resources'; +export * from './stremio'; +export * from './crypto'; +export * from './http'; +// export * from './metadata'; +export * as constants from './constants'; diff --git a/packages/core/src/utils/logger.ts b/packages/core/src/utils/logger.ts new file mode 100644 index 00000000..cbc032b4 --- /dev/null +++ b/packages/core/src/utils/logger.ts @@ -0,0 +1,143 @@ +import winston from 'winston'; +import moment from 'moment-timezone'; +import { Env } from './env'; + +// Map log levels to their full names +const levelMap: { [key: string]: string } = { + error: 'ERROR', + warn: 'WARNING', + info: 'INFO', + debug: 'DEBUG', + verbose: 'VERBOSE', + silly: 'SILLY', + http: 'HTTP', +}; + +const moduleMap: { [key: string]: string } = { + server: '🌐 SERVER', + wrappers: '📦 WRAPPERS', + crypto: '🔒 CRYPTO', + core: '⚡ CORE', + parser: '🔍 PARSER', + mediaflow: '🌊 MEDIAFLOW', + stremthru: '✨ STREMTHRU', + cache: '🗄️ CACHE', + regex: '🅰️ REGEX', + database: '🗃️ DATABASE', + users: '👤 USERS', +}; + +// Define colors for each log level using full names +const levelColors: { [key: string]: string } = { + ERROR: 'red', + WARNING: 'yellow', + INFO: 'cyan', + DEBUG: 'magenta', + HTTP: 'green', + VERBOSE: 'blue', + SILLY: 'grey', +}; + +const emojiLevelMap: { [key: string]: string } = { + error: '❌', + warn: '⚠️ ', + info: '🔵', + debug: '🐞', + verbose: '🔍', + silly: '🤪', + http: '🌐', +}; + +// Calculate the maximum level name length for padding +const MAX_LEVEL_LENGTH = Math.max( + ...Object.values(levelMap).map((level) => level.length) +); + +// Apply colors to Winston +winston.addColors(levelColors); + +export const createLogger = (module: string) => { + const isJsonFormat = Env.LOG_FORMAT === 'json'; + const timezone = Env.LOG_TIMEZONE; + + const timestampFormat = winston.format((info) => { + info.timestamp = moment().tz(timezone).format('YYYY-MM-DD HH:mm:ss.SSS z'); + return info; + }); + + return winston.createLogger({ + level: Env.LOG_LEVEL, + format: isJsonFormat + ? winston.format.combine(timestampFormat(), winston.format.json()) + : winston.format.combine( + timestampFormat(), + winston.format.printf(({ timestamp, level, message, ...rest }) => { + const emoji = emojiLevelMap[level] || ''; + const formattedModule = moduleMap[module] || module; + // Get full level name and pad it for centering + const fullLevel = levelMap[level] || level.toUpperCase(); + const padding = Math.floor( + (MAX_LEVEL_LENGTH - fullLevel.length) / 2 + ); + const paddedLevel = + ' '.repeat(padding) + + fullLevel + + ' '.repeat(MAX_LEVEL_LENGTH - fullLevel.length - padding); + + // Apply color to the padded level + const coloredLevel = winston.format + .colorize() + .colorize(fullLevel, paddedLevel); + + const formatLine = (line: unknown) => { + return `${emoji} | ${coloredLevel} | ${timestamp} | ${formattedModule} | ${line} ${ + rest ? `${formatJsonToStyledString(rest)}` : '' + }`; + }; + if (typeof message === 'string') { + return message.split('\n').map(formatLine).join('\n'); + } else if (typeof message === 'object') { + return formatLine(formatJsonToStyledString(message)); + } + return formatLine(message); + }) + ), + transports: [new winston.transports.Console()], + }); +}; + +function formatJsonToStyledString(json: any) { + // return json.formatted + if (json.formatted) { + return json.formatted; + } + // extract keys and values, display space separated key=value pairs + const keys = Object.keys(json); + const values = keys.map((key) => `${key}=${json[key]}`); + return values.join(' '); +} + +export function maskSensitiveInfo(message: string) { + if (Env.LOG_SENSITIVE_INFO) { + return message; + } + return ''; +} + +export const getTimeTakenSincePoint = (point: number) => { + const timeNow = new Date().getTime(); + const duration = timeNow - point; + // format duration and choose unit and return + const nanos = duration * 1_000_000; // Convert to nanoseconds + const micros = duration * 1_000; // Convert to microseconds + + if (nanos < 1) { + return `${nanos.toFixed(2)}ns`; + } else if (micros < 1) { + return `${micros.toFixed(2)}µs`; + } else if (duration < 1000) { + return `${duration.toFixed(2)}ms`; + } else { + return `${(duration / 1000).toFixed(2)}s`; + } +}; diff --git a/packages/core/src/utils/metadata.ts b/packages/core/src/utils/metadata.ts new file mode 100644 index 00000000..b7d71aba --- /dev/null +++ b/packages/core/src/utils/metadata.ts @@ -0,0 +1,64 @@ +// import { Env } from './env'; +// import { Cache } from './cache'; + +// export type Metadata = { +// title: string; +// year: string; +// imdbId: string; +// tmdbId: string; +// type: string; +// }; + +// const metadataCache = Cache.getInstance('metadata'); + +// const API_BASE_URL = 'https://api.themoviedb.org/3'; +// const FIND_BY_ID_PATH = '/find'; +// const MOVIE_DETAILS_PATH = '/movie'; +// const TV_DETAILS_PATH = '/tv'; + +// export class TMDBMetadata { +// private readonly TMDB_ID_REGEX = /^(?:tmdb):(\d+)$/; +// private readonly IMDB_ID_REGEX = /^(?:tt)\d+$/; +// private readonly SUPPORTED_ID_REGEXES = [this.TMDB_ID_REGEX, this.IMDB_ID_REGEX]; + +// public constructor(private readonly type: 'movie' | 'tv', private readonly id: string) { +// if (!Env.TMDB_ACCESS_TOKEN) { +// throw new Error('TMDB Access Token is not set'); +// } +// if (!this.isSupportedId(this.id)) { +// throw new Error('Invalid metadata id'); +// } +// } + +// private isSupportedId(id: string): boolean { +// return this.SUPPORTED_ID_REGEXES.some((regex) => regex.test(id)); +// } + +// public async fetch(): Promise { +// if (this.isTmdbId()) { +// return this.getTmdbMetadata(); +// } +// return this.getExternalMetadata(); +// } + +// private isTmdbId(): boolean { +// return this.TMDB_ID_REGEX.test(this.id); +// } + +// private getExternalMetadata(): Promise { +// const url = new URL(API_BASE_URL + FIND_BY_ID_PATH + `/${this.id}`); +// url.searchParams.set('external_source', 'imdb_id') + +// const response = await fetch(url, { +// headers: { +// Authorization: `Bearer ${Env.TMDB_ACCESS_TOKEN}`, +// }, +// }); + +// if (!response.ok) { +// throw new Error('Failed to fetch metadata'); +// } + +// const data = await response.json(); + +// } diff --git a/packages/core/src/utils/resources.ts b/packages/core/src/utils/resources.ts new file mode 100644 index 00000000..4abe572b --- /dev/null +++ b/packages/core/src/utils/resources.ts @@ -0,0 +1,18 @@ +import fs from 'fs'; +import path from 'path'; + +export class ResourceManager { + static getResource(resourceName: string) { + // check existence + const filePath = path.join( + __dirname, + '../../../../', + 'resources', + resourceName + ); + if (!fs.existsSync(filePath)) { + throw new Error(`Resource ${resourceName} not found at ${filePath}`); + } + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } +} diff --git a/packages/core/src/utils/stremio.ts b/packages/core/src/utils/stremio.ts new file mode 100644 index 00000000..6ec03173 --- /dev/null +++ b/packages/core/src/utils/stremio.ts @@ -0,0 +1,19 @@ +import { Env } from './env'; + +type ErrorStreamOptions = { + name?: string; + description?: string; + externalUrl?: string; +}; +export function createErrorStream(options: ErrorStreamOptions = {}) { + const { + name = `[❌] ${Env.ADDON_NAME}`, + description = 'Unknown error', + externalUrl = 'https://github.com/Viren070/AIOStreams', + } = options; + return { + name, + description, + externalUrl, + }; +} diff --git a/packages/core/src/wrapper.ts b/packages/core/src/wrapper.ts new file mode 100644 index 00000000..78393158 --- /dev/null +++ b/packages/core/src/wrapper.ts @@ -0,0 +1,222 @@ +import { + Addon, + AddonCatalog, + AddonCatalogResponse, + AddonCatalogResponseSchema, + CatalogResponse, + CatalogResponseSchema, + Manifest, + ManifestSchema, + Meta, + MetaPreview, + MetaResponse, + MetaResponseSchema, + ParsedStream, + Resource, + Stream, + StreamResponse, + StreamResponseSchema, + Subtitle, + SubtitleResponse, + SubtitleResponseSchema, +} from './db/schemas'; +import { + Cache, + makeRequest, + createLogger, + constants, + maskSensitiveInfo, +} from './utils'; +import { PresetManager } from './presets'; +import { StreamParser } from './parser'; +import { z } from 'zod'; + +const logger = createLogger('wrappers'); +// const cache = Cache.getInstance('wrappers'); +const manifestCache = Cache.getInstance('manifest'); +const resourceCache = Cache.getInstance('resources'); + +const RESOURCE_TTL = 5 * 60; +const MANIFEST_TTL = 10 * 60; + +type ResourceParams = { + type: string; + id: string; + extras?: string; +}; + +export class Wrapper { + private readonly baseUrl: string; + private readonly addon: Addon; + + constructor(addon: Addon) { + this.addon = addon; + this.baseUrl = this.addon.manifestUrl.split('/').slice(0, -1).join('/'); + } + + private makeLogSafeUrl(url: string): string { + // make the url safe for logging. + // use maskSensitiveInfo on the componenent that is the longest + // and only mask the longest if it is longer than 10 characters + const urlObj = new URL(url); + const path = urlObj.pathname; + // get the longest component + const longestComponent = path + .split('/') + .reduce((a, b) => (a.length > b.length ? a : b)); + if (longestComponent.length > 10) { + return `${urlObj.protocol}//${urlObj.host}${urlObj.port ? `:${urlObj.port}` : ''}${path.replace(longestComponent, maskSensitiveInfo(longestComponent))}`; + } + return url; + } + + async getManifest(): Promise { + return manifestCache.wrap( + async () => { + logger.debug( + `Fetching manifest for ${this.addon.name} (${this.makeLogSafeUrl(this.addon.manifestUrl)})` + ); + const res = await makeRequest( + this.addon.manifestUrl, + this.addon.timeout, + this.addon.headers + ); + if (!res.ok) { + logger.error( + `Failed to fetch manifest for ${this.addon.name}: ${res.status} - ${res.statusText}` + ); + throw new Error(`Failed to fetch manifest for ${this.addon.name}`); + } + const manifest = ManifestSchema.safeParse(await res.json()); + if (!manifest.success) { + logger.error(`Manifest response was unexpected`); + logger.error(manifest.error); + throw new Error(`Failed to parse manifest for ${this.addon.name}`); + } + return manifest.data; + }, + this.addon.manifestUrl, + MANIFEST_TTL + ); + } + + async getStreams(type: string, id: string): Promise { + const streams: StreamResponse = await this.makeResourceRequest( + 'stream', + { type, id }, + StreamResponseSchema + ); + const Parser = this.addon.fromPresetId + ? PresetManager.fromId(this.addon.fromPresetId).getParser() + : StreamParser; + const parser = new Parser(this.addon); + return streams.streams.map((stream: Stream) => parser.parse(stream)); + } + + async getCatalog( + type: string, + id: string, + extras?: string + ): Promise { + const catalog: CatalogResponse = await this.makeResourceRequest( + 'catalog', + { type, id, extras }, + CatalogResponseSchema, + true + ); + return catalog.metas; + } + + async getMeta(type: string, id: string): Promise { + const meta: MetaResponse = await this.makeResourceRequest( + 'meta', + { type, id }, + MetaResponseSchema, + true + ); + return meta.meta; + } + + async getSubtitles( + type: string, + id: string, + extras?: string + ): Promise { + const subtitles: SubtitleResponse = await this.makeResourceRequest( + 'subtitles', + { type, id, extras }, + SubtitleResponseSchema, + true + ); + return subtitles.subtitles; + } + + async getAddonCatalog(type: string, id: string): Promise { + const addonCatalog: AddonCatalogResponse = await this.makeResourceRequest( + 'addon_catalog', + { type, id }, + AddonCatalogResponseSchema + ); + return addonCatalog.addons; + } + + private async makeResourceRequest( + resource: Resource, + params: ResourceParams, + schema: z.ZodSchema, + cache: boolean = false + ) { + const { type, id, extras } = params; + const url = this.buildResourceUrl(resource, type, id, extras); + if (cache) { + const cached = resourceCache.get(url); + if (cached) { + logger.debug( + `Returning cached ${resource} for ${this.addon.name} (${this.makeLogSafeUrl(url)})` + ); + return cached; + } + } + logger.debug( + `Fetching ${resource} of type ${type} with id ${id} and extras ${extras} (${this.makeLogSafeUrl(url)})` + ); + const res = await makeRequest( + url, + this.addon.timeout, + this.addon.headers, + this.addon.ip + ); + if (!res.ok) { + logger.error( + `Failed to fetch ${resource} resource for ${this.addon.name}: ${res.status} - ${res.statusText}` + ); + + throw new Error( + `Failed to fetch ${resource} resource for ${this.addon.name}` + ); + } + const data = await res.json(); + const parsed = schema.safeParse(data); + if (!parsed.success) { + logger.error(`Resource response was unexpected`); + logger.error(JSON.stringify(parsed.error, null, 2)); + throw new Error( + `Failed to parse ${resource} resource for ${this.addon.name}` + ); + } + if (cache) { + resourceCache.set(url, parsed.data, RESOURCE_TTL); + } + return parsed.data; + } + + private buildResourceUrl( + resource: Resource, + type: string, + id: string, + extras?: string + ): string { + const extrasPath = extras ? `/${extras}` : ''; + return `${this.baseUrl}/${resource}/${type}/${encodeURIComponent(id)}${extrasPath}.json`; + } +} diff --git a/packages/utils/tsconfig.json b/packages/core/tsconfig.json similarity index 82% rename from packages/utils/tsconfig.json rename to packages/core/tsconfig.json index e714c60d..e82c17b1 100644 --- a/packages/utils/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "dist", "rootDir": "src", + "outDir": "dist", "resolveJsonModule": true - } + }, + "include": ["src/**/*"] } diff --git a/packages/formatters/package.json b/packages/formatters/package.json deleted file mode 100644 index 39e1df28..00000000 --- a/packages/formatters/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@aiostreams/formatters", - "version": "1.21.1", - "main": "./dist/index.js", - "scripts": { - "test": "vitest run --passWithNoTests", - "build": "tsc" - }, - "description": "Library to take parsed information and return a formatted Stremio stream name and description", - "dependencies": { - "@aiostreams/types": "^1.0.0", - "@aiostreams/utils": "^1.0.0" - } -} diff --git a/packages/formatters/src/custom.ts b/packages/formatters/src/custom.ts deleted file mode 100644 index 5eff3271..00000000 --- a/packages/formatters/src/custom.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { Config, CustomFormatter, ParsedStream } from '@aiostreams/types'; -import { serviceDetails, Settings } from '@aiostreams/utils'; -import { formatDuration, formatSize, languageToEmoji } from './utils'; - -/** - * - * The custom formatter code in this file was adapted from https://github.com/diced/zipline/blob/trunk/src/lib/parser/index.ts - * - * The original code is licensed under the MIT License. - * - * MIT License - * - * Copyright (c) 2023 dicedtomato - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -export function customFormat( - stream: ParsedStream, - customFormatter: CustomFormatter -): { - name: string; - description: string; -} { - let name: string = ''; - let description: string = ''; - - // name - - const templateName = - parseString( - customFormatter.name || '', - convertStreamToParseValue(stream) - ) || ''; - - // description - const templateDescription = - parseString( - customFormatter.description || '', - convertStreamToParseValue(stream) - ) || ''; - - // Replace placeholders in the template with actual values - name = templateName; - - description = templateDescription; - - return { name, description }; -} - -export type ParseValue = { - config?: { - addonName: string | null; - showDie: boolean | null; - }; - stream?: { - /** @deprecated Use filename instead */ - name: string | null; - filename: string | null; - folderName: string | null; - size: number | null; - personal: boolean | null; - quality: string | null; - resolution: string | null; - languages: string[] | null; - languageEmojis: string[] | null; - visualTags: string[] | null; - audioTags: string[] | null; - releaseGroup: string | null; - regexMatched: string | null; - encode: string | null; - indexer: string | null; - year: string | null; - title: string | null; - season: number | null; - seasons: number[] | null; - episode: number | null; - seeders: number | null; - age: string | null; - duration: number | null; - infoHash: string | null; - message: string | null; - proxied: boolean | null; - }; - provider?: { - id: string | null; - shortName: string | null; - name: string | null; - cached: boolean | null; - }; - addon?: { - id: string; - name: string; - }; - debug?: { - json: string | null; - jsonf: string | null; - }; -}; - -const convertStreamToParseValue = (stream: ParsedStream): ParseValue => { - return { - config: { - addonName: Settings.ADDON_NAME, - showDie: Settings.SHOW_DIE, - }, - stream: { - filename: stream.filename || null, - name: stream.filename || null, - folderName: stream.folderName || null, - size: stream.size || null, - personal: stream.personal !== undefined ? stream.personal : null, - quality: stream.quality === 'Unknown' ? null : stream.quality, - resolution: stream.resolution === 'Unknown' ? null : stream.resolution, - languages: stream.languages || null, - languageEmojis: stream.languages - ? stream.languages - .map((lang) => languageToEmoji(lang) || lang) - .filter((value, index, self) => self.indexOf(value) === index) - : null, - visualTags: stream.visualTags, - audioTags: stream.audioTags, - releaseGroup: - stream.releaseGroup === 'Unknown' ? null : stream.releaseGroup, - regexMatched: stream.regexMatched?.name || null, - encode: stream.encode === 'Unknown' ? null : stream.encode, - indexer: stream.indexers || null, - seeders: stream.torrent?.seeders || null, - year: stream.year || null, - title: stream.title || null, - season: stream.season || null, - seasons: stream.seasons || null, - episode: stream.episode || null, - age: stream.usenet?.age || null, - duration: stream.duration || null, - infoHash: stream.torrent?.infoHash || null, - message: stream.message || null, - proxied: stream.proxied !== undefined ? stream.proxied : null, - }, - addon: { - id: stream.addon.id, - name: stream.addon.name, - }, - provider: { - id: stream.provider?.id || null, - shortName: stream.provider?.id - ? serviceDetails.find((service) => service.id === stream.provider?.id) - ?.shortName || null - : null, - name: stream.provider?.id - ? serviceDetails.find((service) => service.id === stream.provider?.id) - ?.name || null - : null, - cached: - stream.provider?.cached !== undefined ? stream.provider?.cached : null, - }, - }; -}; - -function parseString(str: string, value: ParseValue) { - if (!str) return null; - - const replacer = (key: string, value: unknown) => { - return value; - }; - - const data = { - stream: value.stream, - provider: value.provider, - addon: value.addon, - config: value.config, - }; - - value.debug = { - json: JSON.stringify(data, replacer), - jsonf: JSON.stringify(data, replacer, 2), - }; - - const re = - /\{(?stream|provider|debug|addon|config)\.(?\w+)(::(?(\w+(\([^)]*\))?|<|<=|=|>=|>|\^|\$|~|\/)+))?((::(?\S+?))|(?\[(?".*?")\|\|(?".*?")\]))?\}/gi; - let matches: RegExpExecArray | null; - - while ((matches = re.exec(str))) { - if (!matches.groups) continue; - - const index = matches.index as number; - - const getV = value[matches.groups.type as keyof ParseValue]; - - if (!getV) { - str = replaceCharsFromString(str, '{unknown_type}', index, re.lastIndex); - re.lastIndex = index; - continue; - } - - const v = - getV[ - matches.groups.prop as - | keyof ParseValue['stream'] - | keyof ParseValue['provider'] - | keyof ParseValue['addon'] - ]; - - if (v === undefined) { - str = replaceCharsFromString(str, '{unknown_value}', index, re.lastIndex); - re.lastIndex = index; - continue; - } - - if (matches.groups.mod) { - str = replaceCharsFromString( - str, - modifier( - matches.groups.mod, - v, - matches.groups.mod_tzlocale ?? undefined, - matches.groups.mod_check_true ?? undefined, - matches.groups.mod_check_false ?? undefined, - value - ), - index, - re.lastIndex - ); - re.lastIndex = index; - continue; - } - - str = replaceCharsFromString(str, v, index, re.lastIndex); - re.lastIndex = index; - } - - return str - .replace(/\\n/g, '\n') - .split('\n') - .filter( - (line) => line.trim() !== '' && !line.includes('{tools.removeLine}') - ) - .join('\n') - .replace(/\{tools.newLine\}/g, '\n'); -} - -function modifier( - mod: string, - value: unknown, - tzlocale?: string, - check_true?: string, - check_false?: string, - _value?: ParseValue -): string { - mod = mod.toLowerCase(); - check_true = check_true?.slice(1, -1); - check_false = check_false?.slice(1, -1); - - if (Array.isArray(value)) { - switch (true) { - case mod === 'join': - return value.join(', '); - case mod.startsWith('join(') && mod.endsWith(')'): - // Extract the separator from join(separator) - // e.g. join(' - ') - const separator = mod - .substring(5, mod.length - 1) - .replace(/^['"]|['"]$/g, ''); - return value.join(separator); - case mod == 'length': - return value.length.toString(); - case mod == 'first': - return value.length > 0 ? String(value[0]) : ''; - case mod == 'last': - return value.length > 0 ? String(value[value.length - 1]) : ''; - case mod == 'random': - return value.length > 0 - ? String(value[Math.floor(Math.random() * value.length)]) - : ''; - case mod == 'sort': - return [...value].sort().join(', '); - case mod == 'reverse': - return [...value].reverse().join(', '); - case mod == 'exists': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_array_modifier(${mod})}`; - - if (_value) { - return value.length > 0 - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value.length > 0 ? check_true : check_false; - } - default: - return `{unknown_array_modifier(${mod})}`; - } - } else if (typeof value === 'string') { - switch (true) { - case mod == 'upper': - return value.toUpperCase(); - case mod == 'lower': - return value.toLowerCase(); - case mod == 'title': - return value.charAt(0).toUpperCase() + value.slice(1); - case mod == 'length': - return value.length.toString(); - case mod == 'reverse': - return value.split('').reverse().join(''); - case mod == 'base64': - return btoa(value); - case mod == 'string': - return value; - case mod == 'exists': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value != 'null' && value - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value != 'null' && value ? check_true : check_false; - } - case mod.startsWith('='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('=', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase() == check - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value.toLowerCase() == check ? check_true : check_false; - } - case mod.startsWith('$'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('$', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase().startsWith(check) - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value.toLowerCase().startsWith(check) ? check_true : check_false; - } - case mod.startsWith('^'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('^', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase().endsWith(check) - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value.toLowerCase().endsWith(check) ? check_true : check_false; - } - case mod.startsWith('~'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('~', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase().includes(check) - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value.toLowerCase().includes(check) ? check_true : check_false; - } - default: - return `{unknown_str_modifier(${mod})}`; - } - } else if (typeof value === 'number') { - switch (true) { - case mod == 'comma': - return value.toLocaleString(); - case mod == 'hex': - return value.toString(16); - case mod == 'octal': - return value.toString(8); - case mod == 'binary': - return value.toString(2); - case mod == 'bytes': - return formatSize(value); - case mod == 'string': - return value.toString(); - case mod == 'time': - return formatDuration(value); - case mod.startsWith('>='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('>=', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value >= check - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value >= check ? check_true : check_false; - } - case mod.startsWith('>'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('>', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value > check - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value > check ? check_true : check_false; - } - case mod.startsWith('='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('=', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value == check - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value == check ? check_true : check_false; - } - case mod.startsWith('<='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('<=', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value <= check - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value <= check ? check_true : check_false; - } - case mod.startsWith('<'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('<', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value < check - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value < check ? check_true : check_false; - } - default: - return `{unknown_int_modifier(${mod})}`; - } - } else if (typeof value === 'boolean') { - switch (true) { - case mod == 'istrue': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_bool_modifier(${mod})}`; - - if (_value) { - return value - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return value ? check_true : check_false; - } - case mod == 'isfalse': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_bool_modifier(${mod})}`; - - if (_value) { - return !value - ? parseString(check_true, _value) || check_true - : parseString(check_false, _value) || check_false; - } - - return !value ? check_true : check_false; - } - default: - return `{unknown_bool_modifier(${mod})}`; - } - } - - if ( - typeof check_false == 'string' && - (['>', '>=', '=', '<=', '<', '~', '$', '^'].some((modif) => - mod.startsWith(modif) - ) || - ['istrue', 'exists', 'isfalse'].includes(mod)) - ) { - if (_value) return parseString(check_false, _value) || check_false; - return check_false; - } - - return `{unknown_modifier(${mod})}`; -} - -function replaceCharsFromString( - str: string, - replace: string, - start: number, - end: number -): string { - return str.slice(0, start) + replace + str.slice(end); -} diff --git a/packages/formatters/src/gdrive.ts b/packages/formatters/src/gdrive.ts deleted file mode 100644 index 93fc85c0..00000000 --- a/packages/formatters/src/gdrive.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ParsedStream } from '@aiostreams/types'; -import { formatDuration, formatSize, languageToEmoji } from './utils'; -import { serviceDetails, Settings } from '@aiostreams/utils'; - -export function gdriveFormat( - stream: ParsedStream, - minimalistic: boolean = false -): { - name: string; - description: string; -} { - let name: string = ''; - - if (stream.provider) { - const cacheStatus = stream.provider.cached - ? '⚡' - : stream.provider.cached === undefined - ? '❓' - : '⏳'; - const serviceShortName = - serviceDetails.find((service) => service.id === stream.provider!.id) - ?.shortName || stream.provider.id; - name += `[${serviceShortName}${cacheStatus}] `; - } - - if (stream.torrent?.infoHash) { - name += `[P2P] `; - } - - name += `${stream.addon.name} ${stream.personal ? '(Your Media) ' : ''}`; - if (!minimalistic) { - name += stream.resolution; - } else { - name += stream.resolution !== 'Unknown' ? stream.resolution + '' : ''; - } - - // let description: string = `${stream.quality !== 'Unknown' ? '🎥 ' + stream.quality + ' ' : ''}${stream.encode !== 'Unknown' ? '🎞️ ' + stream.encode : ''}`; - let description: string = ''; - if ( - stream.quality || - stream.encode || - (stream.releaseGroup && !minimalistic) - ) { - description += stream.quality !== 'Unknown' ? `🎥 ${stream.quality} ` : ''; - description += stream.encode !== 'Unknown' ? `🎞️ ${stream.encode} ` : ''; - description += - stream.releaseGroup !== 'Unknown' && !minimalistic - ? `🏷️ ${stream.releaseGroup}` - : ''; - description += '\n'; - } - - if (stream.visualTags.length > 0 || stream.audioTags.length > 0) { - description += - stream.visualTags.length > 0 - ? `📺 ${stream.visualTags.join(' | ')} ` - : ''; - description += - stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : ''; - description += '\n'; - } - if ( - stream.size || - (stream.torrent?.seeders && !minimalistic) || - (minimalistic && stream.torrent?.seeders && !stream.provider?.cached) || - stream.usenet?.age || - stream.duration - ) { - description += `📦 ${formatSize(stream.size || 0)} `; - description += stream.duration - ? `⏱️ ${formatDuration(stream.duration)} ` - : ''; - description += - (stream.torrent?.seeders !== undefined && !minimalistic) || - (minimalistic && stream.torrent?.seeders && !stream.provider?.cached) - ? `👥 ${stream.torrent.seeders} ` - : ''; - - description += stream.usenet?.age ? `📅 ${stream.usenet.age} ` : ''; - description += - stream.indexers && !minimalistic ? `🔍 ${stream.indexers}` : ''; - description += '\n'; - } - - if (stream.languages.length !== 0) { - let languages = stream.languages; - if (minimalistic) { - languages = languages.map( - (language) => languageToEmoji(language) || language - ); - } - description += `🌎 ${languages.join(minimalistic ? ' / ' : ' | ')}`; - description += '\n'; - } - - if (!minimalistic && (stream.filename || stream.folderName)) { - description += stream.folderName ? `📁 ${stream.folderName}\n` : ''; - description += stream.filename ? `📄 ${stream.filename}\n` : '📄 Unknown\n'; - } - - if (stream.message) { - description += `📢 ${stream.message}`; - } - - if (stream.proxied) { - name = `🕵️‍♂️ ${name}`; - } else if (Settings.SHOW_DIE) { - name = `🎲 ${name}`; - } - - description = description.trim(); - name = name.trim(); - return { name, description }; -} diff --git a/packages/formatters/src/imposter.ts b/packages/formatters/src/imposter.ts deleted file mode 100644 index 8e875fea..00000000 --- a/packages/formatters/src/imposter.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ParsedStream } from '@aiostreams/types'; -import { formatDuration, formatSize } from './utils'; - -const imposters = [ - 'Disney+', - 'Netflix', - 'HBO', - 'Amazon Prime Video', - 'Hulu', - 'Apple TV+', - 'Peacock', - 'Paramount+', -]; - -export function imposterFormat(stream: ParsedStream): { - name: string; - description: string; -} { - let name: string = ''; - - if (stream.torrent?.infoHash) { - name += `[P2P] `; - } - const chosenImposter = - imposters[Math.floor(Math.random() * imposters.length)]; - name += `${chosenImposter} ${stream.personal ? '(Your Media) ' : ''}`; - - name += stream.resolution !== 'Unknown' ? stream.resolution + '' : ''; - - let description: string = `${stream.quality !== 'Unknown' ? '🎥 ' + stream.quality + ' ' : ''}${stream.encode !== 'Unknown' ? '🎞️ ' + stream.encode : ''}`; - - if (stream.visualTags.length > 0 || stream.audioTags.length > 0) { - description += '\n'; - - description += - stream.visualTags.length > 0 - ? `📺 ${stream.visualTags.join(' | ')} ` - : ''; - description += - stream.audioTags.length > 0 ? `🎧 ${stream.audioTags.join(' | ')}` : ''; - } - if ( - stream.size || - stream.torrent?.seeders || - stream.usenet?.age || - stream.duration - ) { - description += '\n'; - - description += `📦 ${formatSize(stream.size || 0)} `; - description += stream.duration - ? `⏱️ ${formatDuration(stream.duration)} ` - : ''; - description += stream.torrent?.seeders - ? `👥 ${stream.torrent.seeders}` - : ''; - - description += stream.usenet?.age ? `📅 ${stream.usenet.age}` : ''; - } - - if (stream.languages.length !== 0) { - let languages = stream.languages; - description += `\n🔊 ${languages.join(' | ')}`; - } - - description += `\n📄 ${stream.filename ? stream.filename : 'Unknown'}`; - if (stream.message) { - description += `\n📢${stream.message}`; - } - return { name, description }; -} diff --git a/packages/formatters/src/index.ts b/packages/formatters/src/index.ts deleted file mode 100644 index f65350d7..00000000 --- a/packages/formatters/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './gdrive'; -export * from './utils'; -export * from './torrentio'; -export * from './torbox'; -export * from './imposter'; -export * from './custom'; diff --git a/packages/formatters/src/torbox.ts b/packages/formatters/src/torbox.ts deleted file mode 100644 index 7c57e13c..00000000 --- a/packages/formatters/src/torbox.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ParsedStream } from '@aiostreams/types'; -import { formatSize } from './utils'; -import { serviceDetails, Settings } from '@aiostreams/utils'; - -export function torboxFormat(stream: ParsedStream): { - name: string; - description: string; -} { - let name: string = ''; - - name += `${stream.addon.name} `; - if (stream.provider) { - const serviceShortName = - serviceDetails.find((service) => service.id === stream.provider!.id) - ?.shortName || stream.provider.id; - name += `(${stream.provider.cached === undefined ? 'Unknown' : stream.provider.cached ? 'Instant' : ''} ${serviceShortName}) `; - } - - if (stream.torrent?.infoHash) { - name += `(P2P) `; - } - - name += `${stream.personal ? '(Your Media) ' : ''}(${stream.resolution})`; - - let description: string = ''; - - let streamType = ''; - if (stream?.torrent?.seeders) { - streamType = 'Torrent'; - } else if (stream?.usenet?.age) { - streamType = 'Usenet'; - } - - description += `Quality: ${stream.quality}\nName: ${stream.filename || 'Unknown'}\nSize: ${stream.size ? formatSize(stream.size) : 'Unknown'}${stream.indexers ? ` | Source: ${stream.indexers}` : ''}\nLanguage: ${stream.languages.length > 0 ? stream.languages.join(', ') : 'Unknown'}`; - - if (streamType === 'Torrent' || streamType === 'Usenet') { - description += `\nType: ${streamType} | ${streamType === 'Torrent' ? 'Seeders' : 'Age'}: ${streamType === 'Torrent' ? stream.torrent?.seeders : stream.usenet?.age}`; - } - - if (stream.message) { - description += `\n${stream.message}`; - } - - if (stream.proxied) { - name = `🕵️‍♂️ ${name}`; - } else if (Settings.SHOW_DIE) { - name = `🎲 ${name}`; - } - - return { name, description }; -} diff --git a/packages/formatters/src/torrentio.ts b/packages/formatters/src/torrentio.ts deleted file mode 100644 index 4787e2c3..00000000 --- a/packages/formatters/src/torrentio.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ParsedStream } from '@aiostreams/types'; -import { formatSize, languageToEmoji } from './utils'; -import { serviceDetails, Settings } from '@aiostreams/utils'; - -export function torrentioFormat(stream: ParsedStream): { - name: string; - description: string; -} { - let name: string = ''; - - if (stream.provider) { - const cacheStatus = stream.provider.cached - ? '+' - : stream.provider.cached === undefined - ? ' Unknown' - : ' download'; - const serviceShortName = - serviceDetails.find((service) => service.id === stream.provider!.id) - ?.shortName || stream.provider.id; - name += `[${serviceShortName}${cacheStatus}] `; - } - - if (stream.torrent?.infoHash) { - name += '[P2P] '; - } - - name += `${stream.addon.name} ${stream.personal ? '(Your Media) ' : ''}${stream.resolution} `; - - if (stream.visualTags.length > 0) { - name += stream.visualTags.join(' | '); - } - let description = ''; - - if (stream.message) { - description += `\n${stream.message}`; - } - - if (stream.filename || stream.folderName) { - description += `\n${stream.folderName ? stream.folderName : ''}/${stream.filename ? stream.filename : ''}`; - } - if ( - stream.size || - stream.torrent?.seeders || - stream.usenet?.age || - stream.indexers - ) { - description += '\n'; - - description += - stream.torrent?.seeders !== undefined - ? `👤 ${stream.torrent.seeders} ` - : ''; - - description += stream.usenet?.age ? `📅 ${stream.usenet.age} ` : ''; - - description += `💾 ${formatSize(stream.size || 0)} `; - - description += stream.indexers ? `⚙️ ${stream.indexers} ` : ''; - } - const languageEmojis = stream.languages.map((lang) => { - const emoji = languageToEmoji(lang); - return emoji ? emoji : lang; - }); - if (languageEmojis.length > 0) { - description += `\n${languageEmojis.join(' / ')}`; - } - - if (stream.proxied) { - name = `🕵️‍♂️ ${name}`; - } else if (Settings.SHOW_DIE) { - name = `🎲 ${name}`; - } - - return { name, description }; -} diff --git a/packages/formatters/tsconfig.json b/packages/formatters/tsconfig.json deleted file mode 100644 index 7ff8cfcb..00000000 --- a/packages/formatters/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "resolveJsonModule": true - }, - "references": [ - { - "path": "../types" - }, - { - "path": "../utils" - } - ] -} diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/frontend/README.md b/packages/frontend/README.md index e215bc4c..ef0e47e3 100644 --- a/packages/frontend/README.md +++ b/packages/frontend/README.md @@ -1,4 +1,4 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app). ## Getting Started @@ -16,16 +16,20 @@ bun dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. + +The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages. + +This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! @@ -33,4 +37,4 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details. diff --git a/packages/frontend/eslint.config.mjs b/packages/frontend/eslint.config.mjs deleted file mode 100644 index 7f86eca7..00000000 --- a/packages/frontend/eslint.config.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { FlatCompat } from '@eslint/eslintrc'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends('next/core-web-vitals', 'next/typescript'), -]; - -export default eslintConfig; diff --git a/packages/frontend/next.config.ts b/packages/frontend/next.config.ts index 9483e03b..0b22fc36 100644 --- a/packages/frontend/next.config.ts +++ b/packages/frontend/next.config.ts @@ -1,35 +1,16 @@ import type { NextConfig } from 'next'; -import dotenv from 'dotenv'; -import path from 'path'; - -try { - dotenv.config({ path: path.resolve(__dirname, '../../.env') }); -} catch (error) { - console.error('Error loading .env file:', error); -} - -const branding = - process.env.NEXT_PUBLIC_ELFHOSTED_BRANDING ?? process.env.BRANDING; - -if (branding) { - console.log(`Branding set`); -} else { - console.log('No branding was set'); -} const nextConfig: NextConfig = { + /* config options here */ + reactStrictMode: false, output: 'export', images: { unoptimized: true, }, - env: { - NEXT_PUBLIC_BRANDING: branding, - }, webpack(config) { config.resolve.fallback = { - ...config.resolve.fallback, // if you miss it, all the other options in fallback, specified - // by next.js will be dropped. Doesn't make much sense, but how it is - fs: false, // the solution + ...config.resolve.fallback, + fs: false, }; return config; }, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 1b75b067..d1530a5f 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,31 +1,53 @@ { "name": "@aiostreams/frontend", - "version": "1.21.1", + "version": "2.0.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", + "start": "next start", "lint": "next lint" }, "dependencies": { - "@aiostreams/addon": "^1.0.0", - "@aiostreams/formatters": "^1.0.0", - "@aiostreams/types": "^1.0.0", - "@aiostreams/wrappers": "^1.0.0", - "dotenv": "^16.4.7", - "next": "15.1.2", + "@aiostreams/core": "^2", + "@headlessui/react": "^2.2.4", + "@headlessui/tailwindcss": "^0.2.2", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tooltip": "^1.2.7", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/typography": "^0.5.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.12.2", + "lucide-react": "^0.511.0", + "next": "15.3.2", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-select": "^5.9.0", - "react-toastify": "^11.0.2" + "react-icons": "^5.5.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.3.0", + "tailwind-scrollbar-hide": "~1.1.7", + "tailwindcss-animate": "~1.0.7" }, "devDependencies": { "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "autoprefixer": "^10.4.21", "eslint": "^9", - "eslint-config-next": "15.1.2", + "eslint-config-next": "15.3.2", + "postcss": "^8.5.3", + "postcss-import": "^16.1.0", + "postcss-nesting": "^13.0.1", + "tailwindcss": "^3.4.17", "typescript": "^5" } } diff --git a/packages/frontend/postcss.config.js b/packages/frontend/postcss.config.js new file mode 100644 index 00000000..9cadf1bb --- /dev/null +++ b/packages/frontend/postcss.config.js @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': 'postcss-nesting', + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/frontend/postcss.config.mjs b/packages/frontend/postcss.config.mjs new file mode 100644 index 00000000..4f3e1750 --- /dev/null +++ b/packages/frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ['@tailwindcss/postcss', 'autoprefixer', 'tailwindcss'], +}; + +export default config; diff --git a/packages/frontend/public/assets/background.png b/packages/frontend/public/assets/background.png deleted file mode 100644 index 94cb135a..00000000 Binary files a/packages/frontend/public/assets/background.png and /dev/null differ diff --git a/packages/frontend/public/file.svg b/packages/frontend/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/packages/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/public/globe.svg b/packages/frontend/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/packages/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/public/assets/logo.png b/packages/frontend/public/logo.png similarity index 100% rename from packages/frontend/public/assets/logo.png rename to packages/frontend/public/logo.png diff --git a/packages/frontend/public/next.svg b/packages/frontend/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/packages/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/public/vercel.svg b/packages/frontend/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/packages/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/public/window.svg b/packages/frontend/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/packages/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/app/configure/page.module.css b/packages/frontend/src/app/configure/page.module.css deleted file mode 100644 index 793d3b09..00000000 --- a/packages/frontend/src/app/configure/page.module.css +++ /dev/null @@ -1,276 +0,0 @@ -.container { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - min-height: 100vh; -} - -.header { - text-align: center; - max-width: 800px; - border-radius: var(--borderRadius); - margin: 0 auto; /* Center the header within the content */ -} - -.content { - position: relative; - background-color: #000000; - border-radius: var(--borderRadius); - box-shadow: 0 8px 16px rgb(0, 0, 0); - padding: 20px; - width: 80%; - max-width: 700px; - margin: 20px; -} - -.branding { - text-align: center; - margin: 20px; - font-size: 16px; -} - -@media (max-width: 1000px) { - .content { - margin: 0; - border-radius: 0; - width: 100%; - } -} -.section { - margin-bottom: 20px; - border-width: 1px; - border-style: solid; - border-radius: var(--borderRadius); - padding: 10px; - border-color: #777777; -} - -.setting { - display: flex; - justify-content: space-between; - align-items: center; -} - -.settingDescription { - flex: 1; -} - -.settingInput { - display: flex; - flex-direction: column; - justify-content: flex-end; - align-items: flex-end; - margin: 0 10px; -} - -.settingInput select { - height: 30px; -} - -.slidersContainer { - display: flex; - flex-direction: column; - padding: 10px; -} - -.slidersSetting { - display: flex; - flex-direction: column; - width: 100%; -} - -.sliderValue { - margin-right: auto; - padding: 5px; -} - -.installButtons { - display: flex; - flex-direction: column; - justify-content: center; - gap: 20px; -} - -.installButton { - background-color: #ffffff; /* Green */ - border: none; - color: black; - padding: 15px 32px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 16px; - margin: 4px 2px; - cursor: pointer; - border-radius: var(--borderRadius); - box-shadow: 0 4px 8px rgba(255, 255, 255, 0.111); - transition: - transform 0.2s, - box-shadow 0.2s, - opacity 0.2s, - background-color 0.2s; -} - -.installButton:hover { - transform: scale(1.02); - box-shadow: 0 8px 16px rgba(255, 255, 255, 0.225); -} - -.installButton:active { - transform: scale(0.98); -} - -.installButton:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; - background-color: #ddddddbe; -} - -.version { - position: absolute; - top: -5px; - right: -60px; - font-size: 14px; - color: black; - background-color: rgb(241, 241, 241); - font-weight: bold; - padding: 5px; - -webkit-tap-highlight-color: transparent; - border-radius: var(--borderRadius); - transition: - scale 0.2s ease, - background-color 0.2s ease, - transform 0.2s ease; -} - -@media (pointer: fine) { - .version:hover { - background-color: rgb(221, 221, 221); - transform: scale(1.1); - cursor: pointer; - } -} - -.version:active { - transform: scale(0.9); -} - -.mediaFlowConfig { - transition: - margin-top 0.6s, - opacity 0.6s, - transform 1s; -} - -.mediaFlowConfig.hidden { - opacity: 0; - margin-top: -655px; - transform: scale(0); - transition: - margin-top 0.5s, - opacity 0.2s, - transform 0.4s; -} - -.mediaFlowSection { - padding: 10px; - margin-top: 10px; - border-width: 1px; - border-style: solid; - border-radius: var(--borderRadius); - border-color: #777777; -} - -.stremThruConfig { - transition: - margin-top 0.6s, - opacity 0.6s, - transform 1s; -} - -.stremThruConfig.hidden { - opacity: 0; - margin-top: -626px; - transform: scale(0); - transition: - margin-top 0.5s, - opacity 0.2s, - transform 0.4s; -} - -.stremThruSection { - padding: 10px; - margin-top: 10px; - border-width: 1px; - border-style: solid; - border-radius: var(--borderRadius); - border-color: #777777; -} - -.supportMeButton { - position: absolute; - top: 20px; - right: 20px; - background-color: #080808; - border-style: solid; - border-width: 2px; - box-shadow: none; - margin: 1px; - color: white; - border-radius: var(--borderRadius); - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; /* Space between the heart and text */ - padding: 8px 16px; /* Adjust padding as needed */ - border: 1px solid rgba(255, 255, 255, 0.2); /* Subtle border */ - font-size: 14px; /* Text size */ - font-family: Arial, sans-serif; /* Font family */ - transition: - background-color 0.3s, - color 0.3s, - border-color 0.3s; -} - -@media (pointer: fine) { - .supportMeButton:hover { - background-color: rgba( - 255, - 255, - 255, - 0.1 - ); /* Slight background highlight on hover */ - border-color: rgba(255, 255, 255, 0.815); /* More visible border on hover */ - } -} - -.supportMeButton:active { - background-color: rgba( - 255, - 255, - 255, - 0.2 - ); /* Slight background highlight on click */ - border-color: rgba(255, 255, 255, 0.815); /* More visible border on click */ -} - -.input { - width: 100%; - padding: 8px; - margin: 4px 0; - border: 1px solid #ccc; - border-radius: 4px; - font-family: monospace; -} - -.helpText { - font-size: 0.8em; - color: #666; - margin-top: 4px; - font-family: monospace; - white-space: pre-line; -} diff --git a/packages/frontend/src/app/configure/page.tsx b/packages/frontend/src/app/configure/page.tsx deleted file mode 100644 index d9c72963..00000000 --- a/packages/frontend/src/app/configure/page.tsx +++ /dev/null @@ -1,1701 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -'use client'; - -import { useState, useEffect } from 'react'; -import Image from 'next/image'; -import styles from './page.module.css'; -import { - Config, - Resolution, - SortBy, - Quality, - VisualTag, - AudioTag, - Encode, - ServiceDetail, - ServiceCredential, - StreamType, -} from '@aiostreams/types'; -import SortableCardList from '../../components/SortableCardList'; -import ServiceInput from '../../components/ServiceInput'; -import AddonsList from '../../components/AddonsList'; -import { Slide, ToastContainer, toast } from 'react-toastify'; -import showToast, { toastOptions } from '@/components/Toasts'; -import addonPackage from '../../../package.json'; -import { formatSize } from '@aiostreams/formatters'; -import { - allowedFormatters, - allowedLanguages, - validateConfig, -} from '@aiostreams/config'; -import { - addonDetails, - isValueEncrypted, - serviceDetails, - Settings, -} from '@aiostreams/utils'; - -import Slider from '@/components/Slider'; -import CredentialInput from '@/components/CredentialInput'; -import CreateableSelect from '@/components/CreateableSelect'; -import MultiSelect from '@/components/MutliSelect'; -import InstallWindow from '@/components/InstallWindow'; -import FormatterPreview from '@/components/FormatterPreview'; -import CustomFormatter from '@/components/CustomFormatter'; - -const version = addonPackage.version; - -interface Option { - label: string; - value: string; -} - -const defaultQualities: Quality[] = [ - { 'BluRay REMUX': true }, - { BluRay: true }, - { 'WEB-DL': true }, - { WEBRip: true }, - { HDRip: true }, - { 'HC HD-Rip': true }, - { DVDRip: true }, - { HDTV: true }, - { CAM: true }, - { TS: true }, - { TC: true }, - { SCR: true }, - { Unknown: true }, -]; - -const defaultVisualTags: VisualTag[] = [ - { 'HDR+DV': true }, - { 'HDR10+': true }, - { DV: true }, - { HDR10: true }, - { HDR: true }, - { '10bit': true }, - { '3D': true }, - { IMAX: true }, - { AI: true }, - { SDR: true }, -]; - -const defaultAudioTags: AudioTag[] = [ - { Atmos: true }, - { 'DD+': true }, - { DD: true }, - { 'DTS-HD MA': true }, - { 'DTS-HD': true }, - { DTS: true }, - { TrueHD: true }, - { '5.1': true }, - { '7.1': true }, - { FLAC: true }, - { AAC: true }, -]; - -const defaultEncodes: Encode[] = [ - { AV1: true }, - { HEVC: true }, - { AVC: true }, - { Xvid: true }, - { DivX: true }, - { 'H-OU': true }, - { 'H-SBS': true }, - { Unknown: true }, -]; - -const defaultSortCriteria: SortBy[] = [ - { cached: true, direction: 'desc' }, - { personal: true, direction: 'desc' }, - { resolution: true }, - { language: true }, - { size: true, direction: 'desc' }, - { streamType: false }, - { visualTag: false }, - { service: false }, - { audioTag: false }, - { encode: false }, - { quality: false }, - { seeders: false, direction: 'desc' }, - { addon: false }, - { regexSort: false, direction: 'desc' }, -]; - -const defaultResolutions: Resolution[] = [ - { '2160p': true }, - { '1440p': true }, - { '1080p': true }, - { '720p': true }, - { '480p': true }, - { Unknown: true }, -]; - -const defaultServices = serviceDetails.map((service) => ({ - name: service.name, - id: service.id, - enabled: false, - credentials: {}, -})); - -const defaultStreamTypes: StreamType[] = [ - { usenet: true }, - { debrid: true }, - { unknown: true }, - { p2p: true }, - { live: true }, -]; - -export default function Configure() { - const [formatterOptions, setFormatterOptions] = useState( - allowedFormatters.filter((f) => f !== 'imposter') - ); - const [streamTypes, setStreamTypes] = - useState(defaultStreamTypes); - const [resolutions, setResolutions] = - useState(defaultResolutions); - const [qualities, setQualities] = useState(defaultQualities); - const [visualTags, setVisualTags] = useState(defaultVisualTags); - const [audioTags, setAudioTags] = useState(defaultAudioTags); - const [encodes, setEncodes] = useState(defaultEncodes); - const [sortCriteria, setSortCriteria] = - useState(defaultSortCriteria); - const [formatter, setFormatter] = useState(); - const [services, setServices] = useState(defaultServices); - const [onlyShowCachedStreams, setOnlyShowCachedStreams] = - useState(false); - const [prioritisedLanguages, setPrioritisedLanguages] = useState< - string[] | null - >(null); - const [excludedLanguages, setExcludedLanguages] = useState( - null - ); - const [addons, setAddons] = useState([]); - /* - const [maxSize, setMaxSize] = useState(null); - const [minSize, setMinSize] = useState(null); - */ - const [maxMovieSize, setMaxMovieSize] = useState(null); - const [minMovieSize, setMinMovieSize] = useState(null); - const [maxEpisodeSize, setMaxEpisodeSize] = useState(null); - const [minEpisodeSize, setMinEpisodeSize] = useState(null); - const [cleanResults, setCleanResults] = useState(false); - const [maxResultsPerResolution, setMaxResultsPerResolution] = useState< - number | null - >(null); - const [excludeFilters, setExcludeFilters] = useState([]); - const [strictIncludeFilters, setStrictIncludeFilters] = useState< - readonly Option[] - >([]); - /* - const [prioritiseIncludeFilters, setPrioritiseIncludeFilters] = useState< - readonly Option[] - >([]); - */ - const [mediaFlowEnabled, setMediaFlowEnabled] = useState(false); - const [mediaFlowProxyUrl, setMediaFlowProxyUrl] = useState(''); - const [mediaFlowApiPassword, setMediaFlowApiPassword] = useState(''); - const [mediaFlowPublicIp, setMediaFlowPublicIp] = useState(''); - const [mediaFlowProxiedAddons, setMediaFlowProxiedAddons] = useState< - string[] | null - >(null); - const [mediaFlowProxiedServices, setMediaFlowProxiedServices] = useState< - string[] | null - >(null); - - const [stremThruEnabled, setStremThruEnabled] = useState(false); - const [stremThruUrl, setStremThruUrl] = useState(''); - const [stremThruCredential, setStremThruCredential] = useState(''); - const [stremThruPublicIp, setStremThruPublicIp] = useState(''); - const [stremThruProxiedAddons, setStremThruProxiedAddons] = useState< - string[] | null - >(null); - const [stremThruProxiedServices, setStremThruProxiedServices] = useState< - string[] | null - >(null); - - const [overrideName, setOverrideName] = useState(''); - const [apiKey, setApiKey] = useState(''); - - const [disableButtons, setDisableButtons] = useState(false); - const [maxMovieSizeSlider, setMaxMovieSizeSlider] = useState( - Settings.MAX_MOVIE_SIZE - ); - const [maxEpisodeSizeSlider, setMaxEpisodeSizeSlider] = useState( - Settings.MAX_EPISODE_SIZE - ); - const [choosableAddons, setChoosableAddons] = useState( - addonDetails.map((addon) => addon.id) - ); - const [showApiKeyInput, setShowApiKeyInput] = useState(false); - const [manifestUrl, setManifestUrl] = useState(null); - const [regexFilters, setRegexFilters] = useState<{ - excludePattern?: string; - includePattern?: string; - }>({}); - const [regexSortPatterns, setRegexSortPatterns] = useState(''); - - useEffect(() => { - // get config from the server - fetch('/get-addon-config') - .then((res) => res.json()) - .then((data) => { - if (data.success) { - setMaxMovieSizeSlider(data.maxMovieSize); - setMaxEpisodeSizeSlider(data.maxEpisodeSize); - setShowApiKeyInput(data.apiKeyRequired); - // filter out 'torrentio' from choosableAddons if torrentioDisabled is true - if (data.torrentioDisabled) { - setChoosableAddons( - addonDetails - .map((addon) => addon.id) - .filter((id) => id !== 'torrentio') - ); - } - } - }); - }, []); - - const createConfig = (): Config => { - const config = { - apiKey: apiKey, - overrideName, - streamTypes, - resolutions, - qualities, - visualTags, - audioTags, - encodes, - sortBy: sortCriteria, - onlyShowCachedStreams, - prioritisedLanguages, - excludedLanguages, - maxMovieSize, - minMovieSize, - maxEpisodeSize, - minEpisodeSize, - cleanResults, - maxResultsPerResolution, - strictIncludeFilters: - strictIncludeFilters.length > 0 - ? strictIncludeFilters.map((filter) => filter.value) - : null, - excludeFilters: - excludeFilters.length > 0 - ? excludeFilters.map((filter) => filter.value) - : null, - formatter: formatter || 'gdrive', - mediaFlowConfig: { - mediaFlowEnabled: mediaFlowEnabled && !stremThruEnabled, - proxyUrl: mediaFlowProxyUrl, - apiPassword: mediaFlowApiPassword, - publicIp: mediaFlowPublicIp, - proxiedAddons: mediaFlowProxiedAddons, - proxiedServices: mediaFlowProxiedServices, - }, - stremThruConfig: { - stremThruEnabled: stremThruEnabled && !mediaFlowEnabled, - url: stremThruUrl, - credential: stremThruCredential, - publicIp: stremThruPublicIp, - proxiedAddons: stremThruProxiedAddons, - proxiedServices: stremThruProxiedServices, - }, - addons, - services, - regexFilters: - regexFilters.excludePattern || regexFilters.includePattern - ? { - excludePattern: regexFilters.excludePattern || undefined, - includePattern: regexFilters.includePattern || undefined, - } - : undefined, - regexSortPatterns: regexSortPatterns, - }; - return config; - }; - - const fetchWithTimeout = async ( - url: string, - options: RequestInit | undefined, - timeoutMs = 30000 - ) => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - console.log('Fetching', url, `with data: ${options?.body}`); - const res = await fetch(url, { ...options, signal: controller.signal }); - clearTimeout(timeout); - return res; - } catch { - console.log('Clearing timeout'); - return clearTimeout(timeout); - } - }; - - const getManifestUrl = async ( - protocol = window.location.protocol, - root = window.location.host - ): Promise<{ - success: boolean; - manifest: string | null; - message: string | null; - }> => { - const config = createConfig(); - const { valid, errorMessage } = validateConfig(config, 'client'); - if (!valid) { - return { - success: false, - manifest: null, - message: errorMessage || 'Invalid config', - }; - } - console.log('Config', config); - setDisableButtons(true); - - try { - const encryptPath = `/encrypt-user-data`; - const response = await fetchWithTimeout(encryptPath, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: JSON.stringify(config) }), - }); - if (!response) { - throw new Error('encrypt-user-data failed: no response within timeout'); - } - - if (!response.ok) { - throw new Error( - `encrypt-user-data failed with status ${response.status} and statusText ${response.statusText}` - ); - } - - const data = await response.json(); - if (!data.success) { - if (data.error) { - return { - success: false, - manifest: null, - message: data.error || 'Failed to generate config', - }; - } - throw new Error(`Encryption service failed, ${data.message}`); - } - - const configString = data.data; - return { - success: true, - manifest: `${protocol}//${root}/${configString}/manifest.json`, - message: null, - }; - } catch (error: any) { - console.error(error); - return { - success: false, - manifest: null, - message: error.message || 'Failed to encrypt config', - }; - } - }; - - const loadValidValuesFromObject = ( - object: { [key: string]: boolean }[] | undefined, - validValues: { [key: string]: boolean }[] - ) => { - if (!object) { - return validValues; - } - - const mergedValues = object.filter((value) => - validValues.some((validValue) => - Object.keys(validValue).includes(Object.keys(value)[0]) - ) - ); - - for (const validValue of validValues) { - if ( - !mergedValues.some( - (value) => Object.keys(value)[0] === Object.keys(validValue)[0] - ) - ) { - mergedValues.push(validValue); - } - } - - return mergedValues; - }; - - const loadValidSortCriteria = (sortCriteria: Config['sortBy']) => { - if (!sortCriteria) { - return defaultSortCriteria; - } - - const mergedValues = sortCriteria - .map((sort) => { - const defaultSort = defaultSortCriteria.find( - (defaultSort) => Object.keys(defaultSort)[0] === Object.keys(sort)[0] - ); - if (!defaultSort) { - return null; - } - return { - ...sort, - direction: defaultSort?.direction // only load direction if it exists in the defaultSort - ? sort.direction || defaultSort.direction - : undefined, - }; - }) - .filter((sort) => sort !== null); - - defaultSortCriteria.forEach((defaultSort) => { - if ( - !mergedValues.some( - (sort) => Object.keys(sort)[0] === Object.keys(defaultSort)[0] - ) - ) { - mergedValues.push({ - ...defaultSort, - direction: defaultSort.direction || undefined, - }); - } - }); - - return mergedValues; - }; - - const validateValue = (value: string | null, validValues: string[]) => { - if (!value) { - return null; - } - return validValues.includes(value) ? value : null; - }; - - const loadValidServices = (services: Config['services']) => { - if (!services) { - return defaultServices; - } - - const mergedServices = services - // filter out services that are not in serviceDetails - .filter((service) => defaultServices.some((ds) => ds.id === service.id)) - .map((service) => { - const defaultService = defaultServices.find( - (ds) => ds.id === service.id - ); - if (!defaultService) { - return null; - } - - // only load enabled and credentials from the previous config - return { - ...defaultService, - enabled: service.enabled, - credentials: service.credentials, - }; - }) - .filter((service) => service !== null); - - // add any services that are in defaultServices but not in services - defaultServices.forEach((defaultService) => { - if (!mergedServices.some((service) => service.id === defaultService.id)) { - mergedServices.push(defaultService); - } - }); - - return mergedServices; - }; - - const loadValidAddons = (addons: Config['addons']) => { - if (!addons) { - return []; - } - return addons.filter((addon) => - addonDetails.some((detail) => detail.id === addon.id) - ); - }; - - // Load config from the window path if it exists - useEffect(() => { - async function decodeConfig(config: string) { - let decodedConfig: Config; - if (isValueEncrypted(config) || config.startsWith('B-')) { - throw new Error('Encrypted Config Not Supported'); - } else { - decodedConfig = JSON.parse( - Buffer.from(decodeURIComponent(config), 'base64').toString('utf-8') - ); - } - return decodedConfig; - } - - function loadFromConfig(decodedConfig: Config) { - console.log('Loaded config', decodedConfig); - setOverrideName(decodedConfig.overrideName || ''); - setStreamTypes( - loadValidValuesFromObject(decodedConfig.streamTypes, defaultStreamTypes) - ); - setResolutions( - loadValidValuesFromObject(decodedConfig.resolutions, defaultResolutions) - ); - setQualities( - loadValidValuesFromObject(decodedConfig.qualities, defaultQualities) - ); - setVisualTags( - loadValidValuesFromObject(decodedConfig.visualTags, defaultVisualTags) - ); - setAudioTags( - loadValidValuesFromObject(decodedConfig.audioTags, defaultAudioTags) - ); - setEncodes( - loadValidValuesFromObject(decodedConfig.encodes, defaultEncodes) - ); - setSortCriteria(loadValidSortCriteria(decodedConfig.sortBy)); - setOnlyShowCachedStreams(decodedConfig.onlyShowCachedStreams || false); - // create an array for prioritised languages. if the old prioritiseLanguage is set, add it to the array - const finalPrioritisedLanguages = - decodedConfig.prioritisedLanguages || []; - if (decodedConfig.prioritiseLanguage) { - finalPrioritisedLanguages.push(decodedConfig.prioritiseLanguage); - } - setPrioritisedLanguages( - finalPrioritisedLanguages.filter((lang) => - allowedLanguages.includes(lang) - ) || null - ); - setExcludedLanguages( - decodedConfig.excludedLanguages?.filter((lang) => - allowedLanguages.includes(lang) - ) || null - ); - setStrictIncludeFilters( - decodedConfig.strictIncludeFilters?.map((filter) => ({ - label: filter, - value: filter, - })) || [] - ); - setExcludeFilters( - decodedConfig.excludeFilters?.map((filter) => ({ - label: filter, - value: filter, - })) || [] - ); - setRegexFilters(decodedConfig.regexFilters || {}); - setRegexSortPatterns(decodedConfig.regexSortPatterns || ''); - - setServices(loadValidServices(decodedConfig.services)); - setMaxMovieSize( - decodedConfig.maxMovieSize || decodedConfig.maxSize || null - ); - setMinMovieSize( - decodedConfig.minMovieSize || decodedConfig.minSize || null - ); - setMaxEpisodeSize( - decodedConfig.maxEpisodeSize || decodedConfig.maxSize || null - ); - setMinEpisodeSize( - decodedConfig.minEpisodeSize || decodedConfig.minSize || null - ); - setAddons(loadValidAddons(decodedConfig.addons)); - setCleanResults(decodedConfig.cleanResults || false); - setMaxResultsPerResolution(decodedConfig.maxResultsPerResolution || null); - setMediaFlowEnabled( - decodedConfig.mediaFlowConfig?.mediaFlowEnabled || false - ); - setMediaFlowProxyUrl(decodedConfig.mediaFlowConfig?.proxyUrl || ''); - setMediaFlowApiPassword(decodedConfig.mediaFlowConfig?.apiPassword || ''); - setMediaFlowPublicIp(decodedConfig.mediaFlowConfig?.publicIp || ''); - setMediaFlowProxiedAddons( - decodedConfig.mediaFlowConfig?.proxiedAddons || null - ); - setMediaFlowProxiedServices( - decodedConfig.mediaFlowConfig?.proxiedServices || null - ); - setStremThruEnabled( - decodedConfig.stremThruConfig?.stremThruEnabled || false - ); - setStremThruUrl(decodedConfig.stremThruConfig?.url || ''); - setStremThruCredential(decodedConfig.stremThruConfig?.credential || ''); - setStremThruPublicIp(decodedConfig.stremThruConfig?.publicIp || ''); - setApiKey(decodedConfig.apiKey || ''); - - // set formatter - const formatterValue = validateValue( - decodedConfig.formatter, - allowedFormatters - ); - if ( - decodedConfig.formatter.startsWith('custom') && - decodedConfig.formatter.length > 7 - ) { - setFormatter(decodedConfig.formatter); - } else if (formatterValue) { - setFormatter(formatterValue); - } - } - - const path = window.location.pathname; - try { - const configMatch = path.match(/\/([^/]+)\/configure/); - - if (configMatch) { - const config = configMatch[1]; - decodeConfig(config).then(loadFromConfig); - } - } catch (error) { - console.error('Failed to load config', error); - } - }, []); - - return ( -
-
- -
- AIOStreams Logo -
- setOverrideName(e.target.value)} - style={{ - border: 'none', - backgroundColor: 'black', - color: 'white', - fontWeight: 'bold', - background: 'black', - height: '30px', - textAlign: 'center', - fontSize: '30px', - padding: '0', - maxWidth: '300px', - width: 'auto', - margin: '0 auto', - }} - size={overrideName?.length < 8 ? 8 : overrideName?.length || 8} - > - { - window.open( - `https://github.com/Viren070/AIOStreams/releases/tag/v${version}`, - '_blank', - 'noopener noreferrer' - ); - }} - > - v{version} - -
- {process.env.NEXT_PUBLIC_BRANDING && ( -
- )} -

- AIOStreams, the all-in-one streaming addon for Stremio. Combine your - streams from all your addons into one and filter them by resolution, - quality, visual tags and more. -
-
- This addon will return any result from the addons you enable. These - can be P2P results, direct links, or anything else. Results that are - P2P are marked as P2P, however. -
-
- This addon also has no persistence. Nothing you enter here is - stored. They are encrypted within the manifest URL and are only used - to retrieve streams from any addons you enable. -

-

- - Configuration Guide - - {' | '} - - GitHub - - - {' | '} - - Stremio Guide - -

-
- -
-

Services

-

- Enable the services you have accounts with and enter your - credentials. -

- {services.map((service, index) => ( - { - const newServices = [...services]; - const serviceIndex = newServices.findIndex( - (s) => s.id === service.id - ); - newServices[serviceIndex] = { ...service, enabled }; - setServices(newServices); - }} - fields={ - serviceDetails - .find((detail: ServiceDetail) => detail.id === service.id) - ?.credentials.map((credential: ServiceCredential) => ({ - label: credential.label, - link: credential.link, - value: service.credentials[credential.id] || '', - setValue: (value) => { - const newServices = [...services]; - const serviceIndex = newServices.findIndex( - (s) => s.id === service.id - ); - newServices[serviceIndex] = { - ...service, - credentials: { - ...service.credentials, - [credential.id]: value, - }, - }; - setServices(newServices); - }, - })) || [] - } - moveService={(direction) => { - const newServices = [...services]; - const serviceIndex = newServices.findIndex( - (s) => s.id === service.id - ); - const [movedService] = newServices.splice(serviceIndex, 1); - if (direction === 'up' && serviceIndex > 0) { - newServices.splice(serviceIndex - 1, 0, movedService); - } else if ( - direction === 'down' && - serviceIndex < newServices.length - ) { - newServices.splice(serviceIndex + 1, 0, movedService); - } - setServices(newServices); - }} - canMoveUp={index > 0} - canMoveDown={index < services.length - 1} - signUpLink={ - serviceDetails.find((detail) => detail.id === service.id) - ?.signUpLink - } - /> - ))} - -
-
-
-

Only Show Cached Streams

-

- Only show streams that are cached by the enabled services. -

-
-
- setOnlyShowCachedStreams(e.target.checked)} - // move to the right - style={{ - marginLeft: 'auto', - marginRight: '20px', - width: '25px', - height: '25px', - }} - /> -
-
-
-
- -
-

Addons

- -
- -
-

Stream Types

-

- Choose which stream types you want to see and reorder their priority - if needed. You can uncheck P2P to remove P2P streams from the - results. -

- -
- -
-

Resolutions

-

- Choose which resolutions you want to see and reorder their priority - if needed. -

- -
- -
-

Qualities

-

- Choose which qualities you want to see and reorder their priority if - needed. -

- -
- -
-

Visual Tags

-

- Choose which visual tags you want to see and reorder their priority - if needed. -

- -
- -
-

Audio Tags

-

- Choose which audio tags you want to see and reorder their priority - if needed. -

- -
- -
-

Encodes

-

- Choose which encodes you want to see and reorder their priority if - needed. -

- -
- -
-

Sort By

-

- Choose the criteria by which to sort streams. -

- -
- -
-

Languages

-

- Choose which languages you want to prioritise and exclude from the - results -

-
-
-

Prioritise Languages

-

- Any results that are detected to have one of the prioritised - languages will be sorted according to your sort criteria. You - must have the Langage sort criteria enabled for - this to work. If there are multiple results with a different - prioritised language, the order is determined by the order of - the prioritised languages. -

-
-
- a.localeCompare(b)) - .map((language) => ({ value: language, label: language }))} - setValues={setPrioritisedLanguages} - values={prioritisedLanguages || []} - /> -
-
-
-
-

Exclude Languages

-

- Any results that are detected to have an excluded language will - be removed from the results. A result will only be excluded if - it only has one of or more of the excluded languages. If it - contains a language that is not excluded, it will still be - included. -

-
-
- a.localeCompare(b)) - .map((language) => ({ value: language, label: language }))} - setValues={setExcludedLanguages} - values={excludedLanguages || []} - /> -
-
-
- -
-
-
-

Keyword Filter

-

- Filter streams by keywords. You can exclude streams that contain - specific keywords or only include streams that contain specific - keywords. -

-
-
-
-

Exclude Filter

-

- Enter keywords to filter streams by. Streams that contain any - of the keywords will be excluded. -

- -
-
-

Include Filter

-

- Enter keywords to filter streams by. Streams that do not - contain any of the keywords will be excluded. -

- -
-
-
-
- - {showApiKeyInput && ( -
-
-

- Regex Filtering -

-

- Configure regex patterns to filter streams. These filters will - be applied in addition to keyword filters. -

-
-
-
-

Exclude Pattern

-

- Enter a regex pattern to exclude streams. Streams will be - excluded if their filename OR indexers match this pattern. -

- - setRegexFilters({ - ...regexFilters, - excludePattern: e.target.value, - }) - } - placeholder="Example: \b(0neshot|1XBET)\b" - className={styles.input} - /> -

- Example patterns: -
- - \b(0neshot|1XBET|24xHD)\b (exclude 0neshot, 1XBET, and 24xHD - releases) -
- ^.*Hi10.*$ (exclude Hi10 profile releases) -

-
-
-

Include Pattern

-

- Enter a regex pattern to include streams. Only streams whose - filename or indexers match this pattern will be included. -

- - setRegexFilters({ - ...regexFilters, - includePattern: e.target.value, - }) - } - placeholder="Example: \b(3L|BiZKiT)\b" - className={styles.input} - /> -

- Example patterns: -
- \b(3L|BiZKiT|BLURANiUM)\b (only include 3L, BiZKiT, - and BLURANiUM releases) -

-
-
-
- )} - - {showApiKeyInput && ( -
-

Regex Sort Patterns

-

- Enter a space separated list of regex patterns, optionally with a - name, to sort streams by. Streams will be sorted based on the - order of matching patterns. Matching files will come first in - descending order, and last in ascending order for each pattern. - You can give each regex a name using the following syntax: -
-
- regexName{`<::>`}regexPattern -
-
- For example, 3L{`<::>`}\b(3L|BiZKiT)\b will sort - streams matching the regex \b(3L|BiZKiT)\b first and - those streams will have the {`regexMatched`} property - with the value 3L in the custom formatter. -

- setRegexSortPatterns(e.target.value)} - placeholder="Example: \b(3L|BiZKiT)\b \b(FraMeSToR)\b" - style={{ - width: '97.5%', - padding: '5px', - marginLeft: '5px', - }} - className={styles.input} - /> -

- Example patterns: -
- \b(3L|BiZKiT|BLURANiUM)\b \b(FraMeSToR)\b (sort - 3L/BiZKiT/BLURANiUM releases first, then FraMeSToR releases) -

-
- )} -
-
-
-

Size Filter

-

- Filter streams by size. Leave the maximum and minimum size - sliders at opposite ends to disable the filter. -

-
-
- -
- Minimum movie size: {formatSize(minMovieSize || 0)} -
- -
- Maximum movie size:{' '} - {maxMovieSize === null ? 'Unlimited' : formatSize(maxMovieSize)} -
- -
- Minimum episode size: {formatSize(minEpisodeSize || 0)} -
- -
- Maximum episode size:{' '} - {maxEpisodeSize === null - ? 'Unlimited' - : formatSize(maxEpisodeSize)} -
-
-
-
- -
-
-
-

Limit results per resolution

-

- Limit the number of results per resolution. Leave empty to show - all results. -

-
-
- - setMaxResultsPerResolution( - e.target.value ? parseInt(e.target.value) : null - ) - } - style={{ - width: '100px', - height: '30px', - }} - /> -
-
-
- -
-
-
-

Formatter

-

- Change how your stream results are f - { - if (formatterOptions.includes('imposter')) { - return; - } - showToast( - "What's this doing here....?", - 'info', - 'ImposterFormatter' - ); - setFormatterOptions([...formatterOptions, 'imposter']); - }} - > - ◌ - - rmatted. -

-
-
- -
-
- {formatter?.startsWith('custom') && ( - - )} - -
- -
-
-
-

Clean Results

-

- Attempt to remove duplicate results. For a given file with - duplicate streams: one uncached stream from all uncached streams - is selected per provider. One cached stream from only one - provider is selected. For duplicates without a provider, one - stream is selected at random. -

-
-
- setCleanResults(e.target.checked)} - // move to the right - style={{ - marginLeft: 'auto', - marginRight: '20px', - width: '25px', - height: '25px', - }} - /> -
-
-
- -
-
-
-

MediaFlow

-

- Use MediaFlow to proxy your streams -

-
-
- { - setMediaFlowEnabled(e.target.checked); - }} - style={{ - width: '25px', - height: '25px', - }} - /> -
-
- { -
-
-
-
-

Proxy URL

-

- The URL of the MediaFlow proxy server -

-
-
- -
-
-
-
-

API Password

-

- Your MediaFlow's API password -

-
-
- -
-
-
-
-

Public IP (Optional)

-

- Configure this only when running MediaFlow locally with a - proxy service. Leave empty if MediaFlow is configured - locally without a proxy server or if it's hosted on a - remote server. -

-
-
- -
-
-
-
-
-
-

Proxy Addons (Optional)

-

- By default, all streams from every addon are proxied. - Choose specific addons here to proxy only their streams. -

-
-
- ({ - value: `${addon.id}-${JSON.stringify(addon.options)}`, - label: - addon.options.addonName || - addon.options.overrideName || - addon.options.name || - addon.id.charAt(0).toUpperCase() + - addon.id.slice(1), - })) || [] - } - setValues={(selectedAddons) => { - setMediaFlowProxiedAddons( - selectedAddons.length === 0 ? null : selectedAddons - ); - }} - values={mediaFlowProxiedAddons || undefined} - /> -
-
-
-
-

- Proxy Services (Optional) -

-

- By default, all streams whether they are from a serivce or - not are proxied. Choose which services you want to proxy - through MediaFlow. Selecting None will also proxy streams - that are not (detected to be) from a service. -

-
-
- ({ - value: service.id, - label: service.name, - })), - ]} - setValues={(selectedServices) => { - setMediaFlowProxiedServices( - selectedServices.length === 0 - ? null - : selectedServices - ); - }} - values={mediaFlowProxiedServices || undefined} - /> -
-
-
-
- } -
- -
-
-
-

StremThru

-

- Use StremThru to proxy your streams -

-
-
- { - setStremThruEnabled(e.target.checked); - }} - style={{ - width: '25px', - height: '25px', - }} - /> -
-
- { -
-
-
-
-

StremThru URL

-

- The URL of the StremThru server -

-
-
- -
-
-
-
-

Credential

-

Your StremThru Credential

-
-
- -
-
-
-
-

Public IP (Optional)

-

- Set the publicly exposed IP for StremThru server. -

-
-
- -
-
-
-
-
-
-

Proxy Addons (Optional)

-

- By default, all streams from every addon are proxied. - Choose specific addons here to proxy only their streams. -

-
-
- ({ - value: `${addon.id}-${JSON.stringify(addon.options)}`, - label: - addon.options.addonName || - addon.options.overrideName || - addon.options.name || - addon.id.charAt(0).toUpperCase() + - addon.id.slice(1), - })) || [] - } - setValues={(selectedAddons) => { - setStremThruProxiedAddons( - selectedAddons.length === 0 ? null : selectedAddons - ); - }} - values={stremThruProxiedAddons || undefined} - /> -
-
-
-
-

- Proxy Services (Optional) -

-

- By default, all streams whether they are from a serivce or - not are proxied. Choose which services you want to proxy - through StremThru. Selecting None will also proxy streams - that are not (detected to be) from a service. -

-
-
- ({ - value: service.id, - label: service.name, - })), - ]} - setValues={(selectedServices) => { - setStremThruProxiedServices( - selectedServices.length === 0 - ? null - : selectedServices - ); - }} - values={stremThruProxiedServices || undefined} - /> -
-
-
-
- } -
- - {showApiKeyInput && ( -
-
-
-

API Key

-

- Enter your AIOStreams API Key to install and use this addon. - You need to enter the one that is set in the{' '} - API_KEY environment variable. -

-
-
- -
-
-
- )} - -
- - -
-
- -
- ); -} diff --git a/packages/frontend/src/app/custom-config-generator/page.module.css b/packages/frontend/src/app/custom-config-generator/page.module.css deleted file mode 100644 index e11e7ca0..00000000 --- a/packages/frontend/src/app/custom-config-generator/page.module.css +++ /dev/null @@ -1,156 +0,0 @@ -.container { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - min-height: 100vh; -} - -.header { - text-align: center; - max-width: 800px; - border-radius: var(--borderRadius); - margin: 0 auto; /* Center the header within the content */ -} - -.content { - background-color: #000000; /* Slightly lighter black */ - border-radius: var(--borderRadius); - box-shadow: 0 8px 16px rgb(0, 0, 0); - padding: 20px; - width: 80%; - max-width: 700px; - margin: 20px; -} - -.section { - margin-bottom: 20px; - border-width: 1px; - border-style: solid; - border-radius: var(--borderRadius); - padding: 10px; - border-color: #777777; -} - -.row { - display: flex; - justify-content: space-between; - align-items: center; -} - -.keyInput { - flex: 0 0 20%; -} - -.valueInput { - flex: 0 0 70%; -} - -.input { - flex: 1; - margin-right: 10px; - padding: 5px; - border-radius: var(--borderRadius); - border: 1px solid #777777; - background-color: #1a1a1a; - color: white; -} - -.icon { - background-color: transparent; - padding-top: 5px; - padding-left: 5px; - border: none; - transition: transform 0.2s; -} - -.icon:hover { - transform: scale(1.2); - cursor: pointer; -} - -.deleteButton { - background-color: #ff4d4d; - border: none; - color: white; - padding: 5px 10px; - border-radius: var(--borderRadius); - cursor: pointer; - transition: background-color 0.3s ease; -} - -.deleteButton:hover { - background-color: #ff1a1a; -} - -.help { - background-color: #121212; - border: 1px solid #333333; - border-radius: var(--borderRadius); - padding: 10px; - margin: 5px; -} - -.buttonContainer { - display: flex; - flex-direction: column; - justify-content: center; - gap: 20px; -} - -.button { - background-color: #ffffff; /* Green */ - border: none; - color: black; - padding: 15px 32px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 16px; - margin: 4px 2px; - cursor: pointer; - border-radius: var(--borderRadius); - box-shadow: 0 4px 8px rgba(255, 255, 255, 0.111); - transition: - transform 0.2s, - box-shadow 0.2s, - opacity 0.2s, - background-color 0.2s; -} - -.outputContainer { - display: flex; - flex-direction: column; - gap: 10px; -} - -.output { - background-color: #1a1a1a; - border-radius: var(--borderRadius); - padding: 10px; - color: white; - resize: vertical; - width: 99%; - overflow-x: auto; -} - -.envSection { - padding: 15px; - font-size: 1.1em; -} - -.envCommand { - display: flex; - align-items: center; - margin-top: 10px; -} - -.envInput { - flex: 1; - padding: 5px; - border-radius: var(--borderRadius); - border: 1px solid #777777; - background-color: #1a1a1a; - color: white; - margin-right: 10px; -} diff --git a/packages/frontend/src/app/custom-config-generator/page.tsx b/packages/frontend/src/app/custom-config-generator/page.tsx deleted file mode 100644 index 651ce0e4..00000000 --- a/packages/frontend/src/app/custom-config-generator/page.tsx +++ /dev/null @@ -1,415 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import styles from './page.module.css'; -import { Slide, ToastContainer, toast, ToastOptions } from 'react-toastify'; -import { isValueEncrypted } from '@aiostreams/utils'; - -interface CustomConfig { - key: string; - value: string; -} - -const toastOptions: ToastOptions = { - autoClose: 5000, - hideProgressBar: true, - closeOnClick: false, - pauseOnHover: true, - draggable: 'touch', - style: { - borderRadius: '8px', - backgroundColor: '#ededed', - color: 'black', - }, -}; - -const showToast = ( - message: string, - type: 'success' | 'error' | 'info' | 'warning', - id?: string -) => { - toast[type](message, { - ...toastOptions, - toastId: id, - }); -}; - -const isValidBase64 = (value: string): boolean => { - try { - JSON.parse(atob(value)); - return true; - } catch { - return false; - } -}; - -const isValidBase64Compressed = (value: string): boolean => { - if (value.startsWith('B-')) return true; - return false; -}; - -const isValidConfigFormat = (value: string): boolean => { - return value - ? isValueEncrypted(value) || - isValidBase64(value) || - isValidBase64Compressed(value) - : false; -}; - -const handleCopyEvent = (text: string) => { - navigator.clipboard - .writeText(text) - .then(() => { - showToast('Copied to clipboard!', 'success'); - }) - .catch((error: Error) => { - console.error(error); - showToast('Failed to copy to clipboard.', 'error'); - }); -}; - -const CopyButton = ({ text }: { text: string }) => ( - -); - -export default function CustomConfigGenerator() { - const [configs, setConfigs] = useState([]); - const [newConfig, setNewConfig] = useState({ - key: '', - value: '', - }); - const [output, setOutput] = useState(null); - - const extractConfigValue = (value: string): string => { - try { - const url = new URL(value); - const pathParts = url.pathname.split('/'); - const longUniqueId = pathParts[pathParts.length - 2]; - return longUniqueId; - } catch { - return value; - } - }; - - const validateKeyValuePair = (key: string, value: string) => { - if (!key || !value) { - showToast('Both key and value are required.', 'error', 'requiredFields'); - return false; - } - if (!isValidConfigFormat(value)) { - showToast( - 'Invalid configuration format.', - 'error', - 'invalidConfigFormat' - ); - return false; - } - return true; - }; - - const handleAddRow = () => { - if ( - !validateKeyValuePair(newConfig.key, extractConfigValue(newConfig.value)) - ) - return; - if (configs.some((config) => config.key === newConfig.key)) { - showToast('Key already exists.', 'error', 'uniqueKeyConstraintViolation'); - return false; - } - newConfig.value = extractConfigValue(newConfig.value); - setConfigs([...configs, newConfig]); - setNewConfig({ key: '', value: '' }); - }; - - const handleDeleteRow = (index: number) => { - const newConfigs = configs.filter((_, i) => i !== index); - setConfigs(newConfigs); - }; - - const handleChange = ( - index: number, - field: 'key' | 'value', - value: string - ) => { - const newConfigs = configs.map((config, i) => - i === index ? { ...config, [field]: value } : config - ); - setConfigs(newConfigs); - }; - - const generateJson = () => { - if (configs.length === 0) { - showToast('No configurations to generate.', 'error', 'noConfigs'); - setOutput(null); - return; - } - configs.forEach(({ key, value }) => { - if (!validateKeyValuePair(key, value)) { - setOutput(null); - return; - } - }); - - const json = configs.reduce( - (acc, { key, value }) => { - if (key) acc[key] = value; - return acc; - }, - {} as { [key: string]: string } - ); - // double stringify to escape the quotes - setOutput(JSON.stringify(json)); - }; - - return ( -
-
-
-
-

AIOStreams

-
-

- This tool allows you to generate the value needed for the{' '} - CUSTOM_CONFIGS environment variable. -

-
-
-

Your Configurations

-

- Add your configurations below. Put the name of the configuration in - the key field and the manifest URL in the value field. -

-
-

- How to get the config value? -

-

- Once you have configured AIOStreams, upon clicking the{' '} - Generate Manifest URL button, make sure to click the{' '} - Copy URL button at the configuration page. You then - paste that URL in the value field below. -

-
- - {configs.map((config, index) => ( -
- handleChange(index, 'key', e.target.value)} - className={styles.keyInput} - /> - handleChange(index, 'value', e.target.value)} - className={styles.valueInput} - /> - -
- ))} -
- - setNewConfig({ ...newConfig, key: e.target.value }) - } - className={styles.keyInput} - /> - - setNewConfig({ ...newConfig, value: e.target.value }) - } - className={styles.valueInput} - /> - -
-
-
-

Generate JSON Output

-

- Click the Generate button to generate the JSON output. - This is the value you need to set for the{' '} - CUSTOM_CONFIGS environment variable. -

-
- -
- - {output && ( -
-

Output

- -
- )} - - {output && ( -
- -
- )} -
- {output && ( -
-

Setting the environment variable

-

- Set the CUSTOM_CONFIGS environment variable to the - value generated above. You can either manually use the value above - or use the following commands to set it in a .env{' '} - file. Ensure you are running these commands in the root directory - of AIOStreams. -

-

- Windows: -

-
- - -
-

- Linux/Mac: -

-
- > .env`} - className={styles.envInput} - /> - > .env`} - /> -
-
- )} -
- -
- ); -} diff --git a/packages/frontend/src/app/favicon.ico b/packages/frontend/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/packages/frontend/src/app/favicon.ico differ diff --git a/packages/frontend/src/app/globals.css b/packages/frontend/src/app/globals.css index aed1333b..a2b8ef2d 100644 --- a/packages/frontend/src/app/globals.css +++ b/packages/frontend/src/app/globals.css @@ -1,68 +1,327 @@ -:root { - --background: linear-gradient(200deg, #181818, #111111); - --foreground: #ededed; - --borderRadius: 8px; -} +@tailwind base; +@tailwind components; +@tailwind utilities; -html, -body { - max-width: 100vw; - overflow-x: hidden; -} +@layer base { + :root { + --media-cue-backdrop: blur(0px); + --media-captions-padding: 0%; + --video-captions-offset: 0px; + /*--brand-color-50: #f2f0ff;*/ + /*--brand-color-100: #eeebff;*/ + /*--brand-color-200: #d4d0ff;*/ + /*--brand-color-300: #c7c2ff;*/ + /*--brand-color-400: #9f92ff;*/ + /*--brand-color-500: #6152df;*/ + /*--brand-color-600: #5243cb;*/ + /*--brand-color-700: #3f2eb2;*/ + /*--brand-color-800: #312887;*/ + /*--brand-color-900: #231c6b;*/ + /*--brand-color-950: #1a144f;*/ + /*--brand-color-default: #6152df;*/ -body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} + --color-brand-50: 242 240 255; + --color-brand-100: 238 235 255; + --color-brand-200: 212 208 255; + --color-brand-300: 199 194 255; + --color-brand-400: 159 146 255; + --color-brand-500: 97 82 223; + --color-brand-600: 82 67 203; + --color-brand-700: 63 46 178; + --color-brand-800: 49 40 135; + --color-brand-900: 35 28 107; + --color-brand-950: 26 20 79; + --color-brand-default: 97 82 223; -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} + /*--gray-color-50: #FAFAFA;*/ + /*--gray-color-100: #F5F5F5;*/ + /*--gray-color-200: #E5E5E5;*/ + /*--gray-color-300: #D4D4D4;*/ + /*--gray-color-400: #A3A3A3;*/ + /*--gray-color-500: #737373;*/ + /*--gray-color-600: #525252;*/ + /*--gray-color-700: #404040;*/ + /*--gray-color-800: #262626;*/ + /*--gray-color-900: #171717;*/ + /*--gray-color-950: #101010;*/ + /*--gray-color-default: #737373;*/ -a { - color: #979797; - text-decoration: underline; -} + /* --color-gray-50: 250 250 250; + --color-gray-100: 245 245 245; + --color-gray-200: 229 229 229; + --color-gray-300: 212 212 212; + --color-gray-400: 163 163 163; + --color-gray-500: 115 115 115; + --color-gray-600: 82 82 82; + --color-gray-700: 64 64 64; + --color-gray-800: 38 38 38; + --color-gray-900: 23 23 23; + --color-gray-950: 16 16 16; + --color-gray-default: 115 115 115;*/ -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; + --color-gray-50: 230 230 230; + --color-gray-100: 225 225 225; + --color-gray-200: 209 209 209; + --color-gray-300: 202 202 202; + --color-gray-400: 143 143 143; + --color-gray-500: 90 90 90; + --color-gray-600: 72 72 72; + --color-gray-700: 54 54 54; + --color-gray-800: 28 28 28; + --color-gray-900: 16 16 16; + --color-gray-950: 11 11 11; + --color-gray-default: 105 105 105; + + /*--radius: 0.375rem;*/ + --radius: 0.5rem; + --radius-md: 0.5rem; + + --titlebar-h: theme('height.10'); + + --foreground: theme('colors.gray.800'); + --background: white; + + --brand: theme('colors.brand.300'); + --slate: theme('colors.slate.500'); + --gray: theme('colors.gray.500'); + --zinc: theme('colors.zinc.500'); + --neutral: theme('colors.neutral.500'); + --stone: theme('colors.stone.500'); + --red: theme('colors.red.500'); + --orange: theme('colors.orange.500'); + --amber: theme('colors.amber.500'); + --yellow: theme('colors.yellow.500'); + --lime: theme('colors.lime.500'); + --green: theme('colors.green.500'); + --emerald: theme('colors.emerald.500'); + --teal: theme('colors.teal.500'); + --cyan: theme('colors.cyan.500'); + --sky: theme('colors.sky.500'); + --blue: theme('colors.blue.500'); + --indigo: theme('colors.indigo.500'); + --violet: theme('colors.violet.500'); + --purple: theme('colors.purple.500'); + --fuchsia: theme('colors.fuchsia.500'); + --pink: theme('colors.pink.500'); + --rose: theme('colors.rose.500'); + + --border: theme('colors.gray.200'); + --ring: theme('colors.brand.500'); + + --muted: theme('colors.gray.500'); + --muted-highlight: theme('colors.gray.700'); + + --paper: theme('colors.white'); + --subtle: rgba(0, 0, 0, 0.04); + --subtle-highlight: rgba(0, 0, 0, 0.06); + + --media-card-popup-background: theme('colors.gray.950'); + --hover-from-background-color: theme('colors.gray.900'); + } + + .dark, + [data-mode='dark'] { + --foreground: theme('colors.gray.200'); + --background: #070707; + + /*--brand: theme('colors.brand.300');*/ + --slate: theme('colors.slate.300'); + --gray: theme('colors.gray.300'); + --zinc: theme('colors.zinc.300'); + --neutral: theme('colors.neutral.300'); + --stone: theme('colors.stone.300'); + --red: theme('colors.red.300'); + --orange: theme('colors.orange.300'); + --amber: theme('colors.amber.300'); + --yellow: theme('colors.yellow.300'); + --lime: theme('colors.lime.300'); + --green: theme('colors.green.300'); + --emerald: theme('colors.emerald.300'); + --teal: theme('colors.teal.300'); + --cyan: theme('colors.cyan.300'); + --sky: theme('colors.sky.300'); + --blue: theme('colors.blue.300'); + --indigo: theme('colors.indigo.300'); + --violet: theme('colors.violet.300'); + --purple: theme('colors.purple.300'); + --fuchsia: theme('colors.fuchsia.300'); + --pink: theme('colors.pink.300'); + --rose: theme('colors.rose.300'); + + --border: rgba(255, 255, 255, 0.1); + --ring: theme('colors.brand.200'); + + --muted: rgba(255, 255, 255, 0.4); + --muted-highlight: rgba(255, 255, 255, 0.6); + + --paper: theme('colors.gray.950'); + --paper-highlighter: theme('colors.gray.950'); + --subtle: rgba(255, 255, 255, 0.06); + --subtle-highlight: rgba(255, 255, 255, 0.08); } } -button { - -webkit-tap-highlight-color: transparent; +html { + background-color: var(--background); + color: var(--foreground); } -input[type='checkbox'] { - margin-right: 10px; - margin-left: 10px; - width: 25px; - height: 25px; - cursor: pointer; - accent-color: #ffffff; /* White color */ - -webkit-tap-highlight-color: transparent; - vertical-align: middle; +html * { + border-color: var(--border); } -input[type='url'], -input[type='text'], -input[type='number'], -input[type='password'], -select { - padding: 5px; - border-radius: var(--borderRadius); - margin: 4px 0 0px 0; - border: 1px solid #555; - height: 30px; - background-color: #eeeeee; - font-size: 16px; +/*h1, h2, h3, h4, h5, h6 {*/ +/* @apply text-gray-800 dark:text-gray-100*/ +/*}*/ + +h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; +} + +h2 { + @apply scroll-m-20 text-3xl font-bold tracking-tight first:mt-0; +} + +h3 { + @apply scroll-m-20 text-2xl font-semibold tracking-tight; +} + +h4 { + @apply scroll-m-20 text-xl font-semibold tracking-tight; +} + +h5 { + @apply scroll-m-20 text-lg font-semibold tracking-tight; +} + +h6 { + @apply scroll-m-20 text-base font-semibold tracking-tight; +} + +/* width */ +::-webkit-scrollbar { + width: 10px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: var(--background); +} + +/* Handle */ +::-webkit-scrollbar-thumb { + @apply bg-gray-700 rounded-full; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + @apply bg-gray-600; +} + +.code { + @apply bg-gray-800 rounded-[--radius-md] px-2 py-1 text-sm font-mono border; +} + +.JASSUB { + position: absolute !important; width: 100%; - color: rgb(0, 0, 0); - cursor: pointer; +} + +.force-hidden { + display: none !important; +} + +/*body[data-scroll-locked] {*/ +/* --removed-body-scroll-bar-size: 0 !important;*/ +/* margin-right: 0 !important;*/ +/* overflow-y: auto !important;*/ +/*}*/ + +body[data-scroll-locked] .scroll-locked-offset { + padding-right: 10px; +} + +body[data-scroll-locked] .media-page-header-scroll-locked { + /*right: 10px;*/ +} + +/**/ + +pre { + overflow-x: auto; +} + +/*div[data-media-player][data-controls]:not([data-hocus]) .vds-controls {*/ +/* @apply lg:opacity-0*/ +/*}*/ + +.discrete-controls[data-media-player][data-playing][data-controls]:not( + :has(.vds-controls-group:hover) + ) + .vds-controls { + @apply lg:opacity-0; +} + +.discrete-controls[data-media-player][data-buffering][data-controls]:not( + :has(.vds-controls-group:hover) + ) + .vds-controls { + @apply lg:opacity-0; +} + +.discrete-controls[data-media-player][data-playing][data-seeking][data-controls]:not( + :has(.vds-controls-group:hover) + ) { + @apply cursor-none; +} + +.halo:after { + opacity: 0.1; + background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 50%), + radial-gradient(at 97% 21%, #9772fe 0, transparent 70%), + radial-gradient(at 52% 99%, #fd3a4e 0, transparent 50%), + radial-gradient(at 10% 29%, #fc5ab0 0, transparent 50%), + radial-gradient(at 97% 96%, #e4c795 0, transparent 50%), + radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%), + radial-gradient(at 79% 53%, #eea5ba 0, transparent 50%); + position: absolute; + content: ''; + width: 100%; + height: 100%; + filter: blur(100px) saturate(150%); + z-index: -1; + top: 50px; + left: 0; + transform: translateZ(0); +} + +.halo-2:after { + opacity: 0.1; + background-image: radial-gradient(at 27% 37%, #fd3a67 0, transparent 30%), + radial-gradient(at 97% 21%, #9772fe 0, transparent 70%), + radial-gradient(at 52% 99%, #fd3a4e 0, transparent 20%), + radial-gradient(at 10% 29%, #fc5ab0 0, transparent 20%), + radial-gradient(at 97% 96%, #e4c795 0, transparent 20%), + radial-gradient(at 33% 50%, #8ca8e8 0, transparent 50%), + radial-gradient(at 79% 53%, #eea5ba 0, transparent 20%); + position: absolute; + content: ''; + width: 100%; + height: 100%; + filter: blur(100px) saturate(150%); + z-index: -1; + top: 50px; + left: 0; + transform: translateZ(0); +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ } diff --git a/packages/frontend/src/app/icon.ico b/packages/frontend/src/app/icon.ico deleted file mode 100644 index 93478d43..00000000 Binary files a/packages/frontend/src/app/icon.ico and /dev/null differ diff --git a/packages/frontend/src/app/layout.tsx b/packages/frontend/src/app/layout.tsx index d34e2be9..b23d423f 100644 --- a/packages/frontend/src/app/layout.tsx +++ b/packages/frontend/src/app/layout.tsx @@ -1,33 +1,18 @@ -import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], -}); - -export const metadata: Metadata = { +export const metadata = { title: 'AIOStreams', - description: - 'Combine all your streams into one addon and display them with consistent formatting, sorting, and filtering.', + description: 'The all in one addon for Stremio.', }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - - {children} - + {children} ); } diff --git a/packages/frontend/src/app/main-sidebar.tsx b/packages/frontend/src/app/main-sidebar.tsx new file mode 100644 index 00000000..db88ff0f --- /dev/null +++ b/packages/frontend/src/app/main-sidebar.tsx @@ -0,0 +1,250 @@ +'use client'; + +import React from 'react'; +import { AppSidebar, useAppSidebarContext } from '@/components/ui/app-layout'; +import { cn } from '@/components/ui/core/styling'; +import { VerticalMenu, VerticalMenuItem } from '@/components/ui/vertical-menu'; +import { useStatus } from '@/context/status'; +import { useMenu, MenuId } from '@/context/menu'; +import { useUserData } from '@/context/userData'; +import { ConfigModal } from '@/components/config-modal'; +import { + CloudIcon, + FunnelIcon, + HeartIcon, + ImportIcon, + InfoIcon, + PuzzleIcon, + SaveIcon, + SettingsIcon, + SortAscIcon, + KeyIcon, + LogOutIcon, + LogInIcon, + PenIcon, +} from 'lucide-react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useDisclosure } from '@/hooks/disclosure'; +import { + ConfirmationDialog, + useConfirmationDialog, +} from '@/components/shared/confirmation-dialog'; +import { Modal } from '@/components/ui/modal'; +import { TextInput } from '@/components/ui/text-input'; + +type MenuItem = VerticalMenuItem & { + id: MenuId; +}; + +export function MainSidebar() { + const ctx = useAppSidebarContext(); + const [expandedSidebar, setExpandSidebar] = React.useState(false); + const isCollapsed = !ctx.isBelowBreakpoint && !expandedSidebar; + const { selectedMenu, setSelectedMenu } = useMenu(); + const pathname = usePathname(); + + const user = useUserData(); + const signInModal = useDisclosure(false); + const [initialUuid, setInitialUuid] = React.useState(null); + + React.useEffect(() => { + const uuidMatch = pathname.match( + /stremio\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/.*\/configure/ + ); + if (uuidMatch) { + const extractedUuid = uuidMatch[1]; + setInitialUuid(extractedUuid); + signInModal.open(); + } + }, [pathname]); + + const { status, error, loading } = useStatus(); + + const confirmClearConfig = useConfirmationDialog({ + title: 'Sign Out', + description: 'Are you sure you want to sign out?', + onConfirm: () => { + user.setUserData(null); + user.setUuid(null); + }, + }); + + const topMenuItems: MenuItem[] = [ + { + name: 'About', + iconType: InfoIcon, + isCurrent: selectedMenu === 'about', + id: 'about', + }, + { + name: 'Services', + iconType: CloudIcon, + isCurrent: selectedMenu === 'services', + id: 'services', + }, + { + name: 'Addons', + iconType: PuzzleIcon, + isCurrent: selectedMenu === 'addons', + id: 'addons', + }, + { + name: 'Filters', + iconType: FunnelIcon, + isCurrent: selectedMenu === 'filters', + id: 'filters', + }, + { + name: 'Sorting', + iconType: SortAscIcon, + isCurrent: selectedMenu === 'sorting', + id: 'sorting', + }, + { + name: 'Formatter', + iconType: PenIcon, + isCurrent: selectedMenu === 'formatter', + id: 'formatter', + }, + { + name: 'Miscellaneous', + iconType: SettingsIcon, + isCurrent: selectedMenu === 'miscellaneous', + id: 'miscellaneous', + }, + { + name: 'Save & Install', + iconType: SaveIcon, + isCurrent: selectedMenu === 'save-install', + id: 'save-install', + }, + ]; + + const bottomMenuItems: MenuItem[] = [ + ...(user.uuid && user.password + ? [ + { + name: 'Sign Out', + iconType: LogOutIcon, + isCurrent: false, + id: 'unload-config' as MenuId, + onClick: () => { + confirmClearConfig.open(); + }, + }, + ] + : [ + { + name: 'Login', + iconType: LogInIcon, + isCurrent: signInModal.isOpen, + id: 'sign-in' as MenuId, + onClick: () => { + signInModal.open(); + }, + }, + ]), + ]; + + const handleExpandSidebar = () => { + if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) { + setExpandSidebar(true); + } + }; + const handleUnexpandedSidebar = () => { + if (expandedSidebar && ts.expandSidebarOnHover) { + setExpandSidebar(false); + } + }; + + const ts = { + expandSidebarOnHover: false, + disableSidebarTransparency: false, + }; + + return ( + <> + + {!ctx.isBelowBreakpoint && + ts.expandSidebarOnHover && + ts.disableSidebarTransparency && ( +
+ )} + +
+
+ logo + + {status ? `v${status.version}` : ''} + +
+ { + setSelectedMenu((item as MenuItem).id); + ctx.setOpen(false); + }} + /> +
+ +
+
+ {}} + onMouseLeave={() => {}} + onItemSelect={(item) => { + const menuItem = item as MenuItem; + if (menuItem.onClick) { + menuItem.onClick(); + } else { + setSelectedMenu(menuItem.id); + ctx.setOpen(false); + } + }} + items={bottomMenuItems} + /> +
+
+
+ + { + signInModal.close(); + }} + onOpenChange={(v) => { + if (!v) { + signInModal.close(); + } + }} + initialUuid={initialUuid || undefined} + /> + + + + ); +} diff --git a/packages/frontend/src/app/page.tsx b/packages/frontend/src/app/page.tsx new file mode 100644 index 00000000..80e31f80 --- /dev/null +++ b/packages/frontend/src/app/page.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useStatus } from '@/context/status'; +import { StatusProvider } from '@/context/status'; +import { Toaster } from '@/components/ui/toaster'; +import { + AppLayout, + AppLayoutContent, + AppLayoutSidebar, + AppSidebarProvider, +} from '@/components/ui/app-layout'; +import { MainSidebar } from './main-sidebar'; +import { LoadingOverlayWithLogo } from '@/components/shared/loading-overlay'; +import { MenuProvider } from '@/context/menu'; +import { MenuContent } from '../components/menu-content'; +import { ThemeProvider } from 'next-themes'; +import { LoadingOverlay } from '@/components/ui/loading-spinner'; +import Image from 'next/image'; +import { TopNavbar } from './top-navbar'; +import { Button } from '@/components/ui/button'; +import { UserDataProvider } from '@/context/userData'; +import { LuffyError } from '@/components/shared/luffy-error'; +import { TextGenerateEffect } from '@/components/shared/text-generate-effect'; + +function ErrorOverlay({ error }: { error: string | null }) { + return ( + + +

{error}

+
+
+ ); +} + +function AppContent() { + const { status, loading, error } = useStatus(); + + if (loading) { + return ( + + + + ); + } + + if (error || !status) { + return ; + } + + return ( + + + + + + + + +
+ +
+ +
+
+
+
+
+
+ +
+ ); +} + +export default function Home() { + return ( + + + + + + + + ); +} diff --git a/packages/frontend/src/app/splashscreen/page.tsx b/packages/frontend/src/app/splashscreen/page.tsx new file mode 100644 index 00000000..79fcf54d --- /dev/null +++ b/packages/frontend/src/app/splashscreen/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { LoadingOverlay } from '@/components/ui/loading-spinner'; +import Image from 'next/image'; +import React from 'react'; + +export default function Page() { + return ( + + Launching... + Launching... + + ); +} diff --git a/packages/frontend/src/app/top-navbar.tsx b/packages/frontend/src/app/top-navbar.tsx new file mode 100644 index 00000000..805ff618 --- /dev/null +++ b/packages/frontend/src/app/top-navbar.tsx @@ -0,0 +1,46 @@ +// import { OfflineTopMenu } from '@/app/(main)/(offline)/offline/_components/offline-top-menu'; +import { LayoutHeaderBackground } from '@/components/layout-header-background'; +import { useStatus } from '@/context/status'; +import { AppSidebarTrigger } from '@/components/ui/app-layout'; +import { cn } from '@/components/ui/core/styling'; + +import React from 'react'; +type TopNavbarProps = { + children?: React.ReactNode; +}; + +export function TopNavbar(props: TopNavbarProps) { + const { children, ...rest } = props; + + const serverStatus = useStatus(); + const isOffline = !serverStatus.status; + + return ( + <> +
+
+
+ +
+
+
+ +
+ + ); +} diff --git a/packages/frontend/src/components/AddonsList.module.css b/packages/frontend/src/components/AddonsList.module.css deleted file mode 100644 index 3e998976..00000000 --- a/packages/frontend/src/components/AddonsList.module.css +++ /dev/null @@ -1,111 +0,0 @@ -.container { - display: flex; - flex-direction: column; - gap: 10px; -} - -.addonSelector { - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 10px; -} - -.addonSelector button { - padding: 5px 10px; - width: 100%; - height: 40px; - border-radius: var(--borderRadius); - border: none; - background-color: #ffffff; - color: #000; - cursor: pointer; - box-shadow: 0 4px 8px rgba(255, 255, 255, 0.041); - transition: - background-color 0.3s, - transform 0.2s, - box-shadow 0.3s; -} - -.addonSelector button:hover { - background-color: #f1f1f1; - transform: scale(1.01); - box-shadow: 0 4px 8px rgba(206, 206, 206, 0.19); -} - -.addonSelector button:active { - transform: scale(0.99); -} - -.card { - background-color: #1a1a1a; /* Slightly lighter black */ - border-radius: var(--borderRadius); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - padding: 10px; - display: flex; - flex-direction: column; - gap: 10px; -} - -.cardHeader { - display: flex; - justify-content: space-between; - align-items: center; -} - -.cardBody { - display: flex; - flex-direction: column; - gap: 10px; -} - -.option { - display: flex; - flex-direction: column; - border: 1px solid #a0a0a0; - border-radius: var(--borderRadius); - padding: 10px; -} - -.option label { - margin-bottom: 5px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.option small { - color: #aaa; - margin-top: 5px; -} - -.required { - color: #ffffff; - margin-left: 5px; -} - -.actions { - display: flex; - align-items: center; -} - -.actionButton { - background-color: transparent; - border-radius: var(--borderRadius); - display: flex; - margin: 5px; - font-size: 1.2em; - align-items: center; - border-width: 0; - transition: transform 0.2s; - -webkit-tap-highlight-color: transparent; -} - -.actionButton:hover { - transform: scale(1.1); - cursor: pointer; -} - -.actionButton:active { - transform: scale(0.9); -} diff --git a/packages/frontend/src/components/AddonsList.tsx b/packages/frontend/src/components/AddonsList.tsx deleted file mode 100644 index 4e2e9197..00000000 --- a/packages/frontend/src/components/AddonsList.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import React, { useState } from 'react'; -import styles from './AddonsList.module.css'; -import { AddonDetail, Config } from '@aiostreams/types'; -import CredentialInput from './CredentialInput'; -import MultiSelect from './MutliSelect'; - -interface AddonsListProps { - choosableAddons: string[]; - addonDetails: AddonDetail[]; - addons: Config['addons']; - setAddons: (addons: Config['addons']) => void; -} - -const AddonsList: React.FC = ({ - choosableAddons, - addonDetails, - addons, - setAddons, -}) => { - const [selectedAddon, setSelectedAddon] = useState(''); - - const addAddon = () => { - if (selectedAddon) { - setAddons([...addons, { id: selectedAddon, options: {} }]); - setSelectedAddon(''); - } - }; - - const removeAddon = (index: number) => { - const newAddons = [...addons]; - newAddons.splice(index, 1); - setAddons(newAddons); - }; - - const updateOption = ( - addonIndex: number, - optionKey: string, - value?: string - ) => { - const newAddons = [...addons]; - newAddons[addonIndex].options[optionKey] = value; - setAddons(newAddons); - }; - - const moveAddon = (index: number, direction: 'up' | 'down') => { - const newAddons = [...addons]; - const [movedAddon] = newAddons.splice(index, 1); - newAddons.splice(direction === 'up' ? index - 1 : index + 1, 0, movedAddon); - setAddons(newAddons); - }; - - return ( -
-
- - -
- {addons.map((addon, index) => { - const details = addonDetails.find((detail) => detail.id === addon.id); - return ( -
-
- - {details?.name} - -
- {index > 0 && ( - - )} - {index < addons.length - 1 && ( - - )} - -
-
-
- {details?.options - ?.filter((option) => option.type !== 'deprecated') - ?.map((option) => ( -
- - {option.description && {option.description}} - {option.type === 'text' && - (option.secret ? ( - - updateOption( - index, - option.id, - value ? value : undefined - ) - } - /> - ) : ( - - updateOption( - index, - option.id, - e.target.value ? e.target.value : undefined - ) - } - className={styles.textInput} - /> - ))} - {option.type === 'select' && ( - - )} - {option.type === 'number' && ( - - updateOption( - index, - option.id, - e.target.value ? e.target.value : undefined - ) - } - className={styles.textInput} - /> - )} - {option.type === 'multiSelect' && ( - - updateOption(index, option.id, values.join(',')) - } - values={addon.options[option.id]?.split(',') || []} - /> - )} -
- ))} -
-
- ); - })} -
- ); -}; - -export default AddonsList; diff --git a/packages/frontend/src/components/CreateableSelect.tsx b/packages/frontend/src/components/CreateableSelect.tsx deleted file mode 100644 index 340eaf71..00000000 --- a/packages/frontend/src/components/CreateableSelect.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { KeyboardEventHandler } from 'react'; - -import CreatableSelect from 'react-select/creatable'; -import showToast from './Toasts'; -import { selectStyles } from './MutliSelect'; -import FakeSelect from './FakeSelect'; - -const components = { - DropdownIndicator: null, -}; - -interface Option { - readonly label: string; - readonly value: string; -} - -const createOption = (label: string) => ({ - label, - value: label, -}); - -interface CreateableSelectProps { - value: readonly Option[]; - setValue: React.Dispatch>; -} -const CreateableSelect: React.FC = ({ - value, - setValue, -}) => { - const [inputValue, setInputValue] = React.useState(''); - const [isClient, setIsClient] = React.useState(false); - - React.useEffect(() => { - setIsClient(true); - }, []); - - const handleKeyDown: KeyboardEventHandler = (event) => { - if (!inputValue) return; - if (inputValue.length > 20) { - showToast('Value is too long', 'error', 'longValue'); - setInputValue(''); - } - switch (event.key) { - case 'Enter': - case 'Tab': - const cleanedInputValue = inputValue; - if (!cleanedInputValue.length) { - showToast('Invalid value', 'error', 'invalidValue'); - return; - } - if (value.find((v) => v.label === cleanedInputValue)) { - showToast('Value already exists', 'error', 'existingValue'); - return; - } - if (cleanedInputValue.length > 50) { - showToast('Value is too long', 'error', 'longValue'); - return; - } - setValue((prev) => [...prev, createOption(cleanedInputValue)]); - setInputValue(''); - event.preventDefault(); - } - }; - - return ( - <> - {isClient ? ( - setValue(newValue as readonly Option[])} - onInputChange={(newValue) => setInputValue(newValue)} - onKeyDown={handleKeyDown} - placeholder="Type something and press enter..." - value={value} - styles={selectStyles} - /> - ) : ( - - )} - - ); -}; - -export default CreateableSelect; diff --git a/packages/frontend/src/components/CredentialInput.module.css b/packages/frontend/src/components/CredentialInput.module.css deleted file mode 100644 index 68becc4d..00000000 --- a/packages/frontend/src/components/CredentialInput.module.css +++ /dev/null @@ -1,41 +0,0 @@ -.credentialsInputContainer { - display: flex; -} -.resetCredentialButton { - margin-top: 5px; - margin-left: 5px; - top: 6px; - padding: 0; - background-color: transparent; - border: none; - color: rgb(255, 255, 255); - border-radius: var(--borderRadius); - cursor: pointer; - transition: transform 0.2s; -} - -.resetCredentialButton:hover { - transform: scale(1.2); -} - -.resetCredentialButton:active { - transform: scale(0.9); -} - -.showHideButton { - background-color: transparent; - padding-top: 5px; - padding-left: 5px; - border: none; - transform: scale(1); - transition: transform 0.2s; -} - -.showHideButton:hover { - transform: scale(1.2); - cursor: pointer; -} - -.showHideButton:active { - transform: scale(0.9); -} diff --git a/packages/frontend/src/components/CredentialInput.tsx b/packages/frontend/src/components/CredentialInput.tsx deleted file mode 100644 index 53ed0487..00000000 --- a/packages/frontend/src/components/CredentialInput.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React from 'react'; -import styles from './CredentialInput.module.css'; -import { isValueEncrypted } from '@aiostreams/utils'; - -interface CredentialInputProps { - credential: string; - setCredential: (credential: string) => void; - inputProps?: React.InputHTMLAttributes; -} - -const CredentialInput: React.FC = ({ - credential, - setCredential, - inputProps = {}, -}) => { - const [showPassword, setShowPassword] = React.useState(false); - return ( -
- setCredential(e.target.value.trim())} - className={styles.credentialInput} - {...inputProps} - disabled={ - isValueEncrypted(credential) ? true : inputProps.disabled || false - } - /> - {!isValueEncrypted(credential) && ( - - )} - - {isValueEncrypted(credential) && ( - - )} -
- ); -}; - -export default CredentialInput; diff --git a/packages/frontend/src/components/CustomFormatter.module.css b/packages/frontend/src/components/CustomFormatter.module.css deleted file mode 100644 index 372d6110..00000000 --- a/packages/frontend/src/components/CustomFormatter.module.css +++ /dev/null @@ -1,96 +0,0 @@ -.customFormatterContainer { - background-color: #121212; - border-radius: var(--borderRadius); - border: 1px solid #333; - margin-top: 15px; - padding: 0; - overflow: hidden; - transition: all 0.3s ease; -} - -.customFormatterTitle { - padding: 12px; - margin: 0; - font-size: 16px; - font-weight: 600; - color: #aaa; - cursor: pointer; - display: flex; - justify-content: space-between; - align-items: center; - transition: background-color 0.2s ease; -} - -.customFormatterTitle:hover { - background-color: #1a1a1a; - color: #fff; -} - -.expandIcon { - font-size: 12px; - transition: transform 0.2s ease; -} - -.customFormatterContent { - padding: 15px; - border-top: 1px solid #333; - background-color: #121212; -} - -.customFormatterDescription { - font-size: 14px; - color: #aaa; - margin-bottom: 15px; - line-height: 1.5; -} - -.customFormatterDescription code { - background-color: #1e1e1e; - padding: 2px 4px; - border-radius: 3px; - margin: 0 2px; - font-family: monospace; - color: #e0e0e0; -} - -.formGroup { - margin-bottom: 12px; -} - -.label { - display: block; - font-size: 14px; - color: #aaa; - margin-bottom: 5px; -} - -.syntaxInput { - width: 100%; - background-color: #1e1e1e; - border: 1px solid #333; - border-radius: var(--borderRadius); - color: white; - padding: 10px; - font-family: monospace; - resize: vertical; - transition: - border-color 0.2s ease, - box-shadow 0.2s ease, - background-color 0.2s ease; -} - -.syntaxInput:focus { - border-color: #555; - box-shadow: 0 0 10px rgba(255, 255, 255, 0.1); - outline: none; -} - -.syntaxInput:hover { - background-color: #252525; - border-color: #444; - box-shadow: 0 0 10px rgba(255, 255, 255, 0.2); -} - -.syntaxInput::placeholder { - color: #666; -} diff --git a/packages/frontend/src/components/CustomFormatter.tsx b/packages/frontend/src/components/CustomFormatter.tsx deleted file mode 100644 index 3ced8a64..00000000 --- a/packages/frontend/src/components/CustomFormatter.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import styles from './CustomFormatter.module.css'; - -interface CustomFormatterProps { - formatter: string; - setFormatter: (formatter: string) => void; -} - -const CustomFormatter: React.FC = ({ - formatter, - setFormatter, -}) => { - const [isExpanded, setIsExpanded] = useState(false); - let initialName = ''; - let initialDesc = ''; - if (formatter.startsWith('custom:') && formatter.length > 7) { - const formatterData = JSON.parse(formatter.substring(7)); - initialName = formatterData.name || ''; - initialDesc = formatterData.description || ''; - } - - const [customNameSyntax, setCustomNameSyntax] = useState(initialName); - const [customDescSyntax, setCustomDescSyntax] = useState(initialDesc); - - // Load the existing formatter on component mount - useEffect(() => { - const formatterData = { - name: customNameSyntax, - description: customDescSyntax, - }; - setFormatter(`custom:${JSON.stringify(formatterData)}`); - }, [customNameSyntax, customDescSyntax, setFormatter]); - - return ( -
-

setIsExpanded(!isExpanded)} - > - Custom Formatter - {isExpanded ? '▼' : '►'} -

- - {isExpanded && ( -
-

- Define a custom formatter syntax. Write - {'{debug.jsonf}'} to see the available variables. -
- For a more detailed explanation, check the{' '} - - wiki - -
-

- -
- -