From 9a9d3d2775965dca6854d98c78fb25ca2d622e12 Mon Sep 17 00:00:00 2001 From: Viren070 Date: Mon, 29 Sep 2025 19:38:57 +0100 Subject: [PATCH] feat: use urlsafe encoded configs, add requiredFields, adjust playback URL handling, and sql cache store. --- packages/core/src/builtins/base/debrid.ts | 20 +- .../builtins/torbox-search/source-handlers.ts | 16 +- packages/core/src/db/schemas.ts | 7 + packages/core/src/debrid/utils.ts | 43 +++- packages/core/src/main.ts | 2 +- packages/core/src/parser/go-ptt.ts | 2 +- packages/core/src/presets/animetosho.ts | 2 +- packages/core/src/presets/bitmagnet.ts | 2 +- packages/core/src/presets/gdrive.ts | 21 +- packages/core/src/presets/jackett.ts | 2 +- packages/core/src/presets/knaben.ts | 3 +- packages/core/src/presets/mediafusion.ts | 3 +- packages/core/src/presets/newznab.ts | 2 +- packages/core/src/presets/nzbhydra.ts | 2 +- packages/core/src/presets/preset.ts | 22 +- packages/core/src/presets/prowlarr.ts | 2 +- packages/core/src/presets/streamasia.ts | 2 +- packages/core/src/presets/torboxSearch.ts | 2 +- packages/core/src/presets/torrentGalaxy.ts | 3 +- packages/core/src/presets/torznab.ts | 2 +- packages/core/src/presets/zilean.ts | 2 +- packages/core/src/transformers/api.ts | 141 +++++++----- packages/core/src/utils/cache-adapter.ts | 211 +++++++++++++++++- packages/core/src/utils/cache.ts | 25 ++- packages/core/src/utils/crypto.ts | 27 +-- packages/core/src/utils/general.ts | 29 +++ packages/core/src/utils/http.ts | 2 +- packages/core/src/utils/regex.ts | 6 +- packages/server/src/routes/api/debrid.ts | 36 ++- packages/server/src/routes/api/search.ts | 34 ++- packages/server/src/routes/builtins/gdrive.ts | 16 +- packages/server/src/routes/builtins/knaben.ts | 23 +- .../server/src/routes/builtins/newznab.ts | 26 ++- .../server/src/routes/builtins/prowlarr.ts | 22 +- .../src/routes/builtins/torbox-search.ts | 25 ++- .../src/routes/builtins/torrent-galaxy.ts | 21 +- .../server/src/routes/builtins/torznab.ts | 22 +- 37 files changed, 600 insertions(+), 228 deletions(-) diff --git a/packages/core/src/builtins/base/debrid.ts b/packages/core/src/builtins/base/debrid.ts index 089d945f..ed5e9723 100644 --- a/packages/core/src/builtins/base/debrid.ts +++ b/packages/core/src/builtins/base/debrid.ts @@ -20,6 +20,7 @@ import { UnprocessedTorrent, ServiceAuth, DebridError, + generatePlaybackUrl, } from '../../debrid/index.js'; import { processTorrents, processNZBs } from '../utils/debrid.js'; import { calculateAbsoluteEpisode } from '../utils/general.js'; @@ -211,11 +212,10 @@ export abstract class BaseDebridAddon { ), ]); - const resultStreams = [ - ...processedTorrents.results, - ...processedNzbs.results, - ].map((result) => - this._createStream(result, this.userData, searchMetadata) + const resultStreams = await Promise.all( + [...processedTorrents.results, ...processedNzbs.results].map((result) => + this._createStream(result, this.userData, searchMetadata) + ) ); const processingErrors = [ @@ -470,11 +470,11 @@ export abstract class BaseDebridAddon { return { url: torrentOrNzb.service - ? `${Env.BASE_URL}/api/v1/debrid/playback/${encodeURIComponent( - Buffer.from(JSON.stringify(storeAuth)).toString('base64') - )}/${encodeURIComponent( - Buffer.from(JSON.stringify(playbackInfo)).toString('base64') - )}/${encodeURIComponent(torrentOrNzb.file.name || torrentOrNzb.title || 'unknown')}` + ? generatePlaybackUrl( + storeAuth!, + playbackInfo!, + torrentOrNzb.file.name || torrentOrNzb.title || 'unknown' + ) : undefined, name, description, diff --git a/packages/core/src/builtins/torbox-search/source-handlers.ts b/packages/core/src/builtins/torbox-search/source-handlers.ts index ddb102c9..39514369 100644 --- a/packages/core/src/builtins/torbox-search/source-handlers.ts +++ b/packages/core/src/builtins/torbox-search/source-handlers.ts @@ -23,6 +23,7 @@ import { processNZBs, processTorrents } from '../utils/debrid.js'; import { NZBWithSelectedFile, TorrentWithSelectedFile, + generatePlaybackUrl, } from '../../debrid/utils.js'; import { DebridFile, PlaybackInfo } from '../../debrid/index.js'; import { getTraktAliases } from '../../metadata/trakt.js'; @@ -83,9 +84,10 @@ abstract class SourceHandler { } const storeAuth = { id: torrentOrNZB.service.id, - credential: userData.services.find( - (service) => service.id === torrentOrNZB.service!.id - )?.credential, + credential: + userData.services.find( + (service) => service.id === torrentOrNZB.service!.id + )?.credential ?? '', }; // const playbackInfo: PlaybackInfo = { @@ -122,7 +124,13 @@ abstract class SourceHandler { const description = `${torrentOrNZB.title}\n${torrentOrNZB.file.name}\n${torrentOrNZB.indexer ? `🔍 ${torrentOrNZB.indexer}` : ''} ${torrentOrNZB.seeders ? `👤 ${torrentOrNZB.seeders}` : ''} ${torrentOrNZB.age && torrentOrNZB.age !== '0d' ? `🕒 ${torrentOrNZB.age}` : ''}`; return { - url: `${Env.BASE_URL}/api/v1/debrid/playback/${encodeURIComponent(Buffer.from(JSON.stringify(storeAuth)).toString('base64'))}/${encodeURIComponent(Buffer.from(JSON.stringify(playbackInfo)).toString('base64'))}/${encodeURIComponent(torrentOrNZB.file.name || torrentOrNZB.title || 'unknown')}`, + url: torrentOrNZB.service + ? generatePlaybackUrl( + storeAuth!, + playbackInfo!, + torrentOrNZB.file.name || torrentOrNZB.title || 'unknown' + ) + : undefined, name, description, type: torrentOrNZB.type, diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index a2cb427f..53e71193 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -488,6 +488,13 @@ export const TABLES = { expires_at BIGINT NOT NULL, result TEXT `, + cache: ` + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP + `, }; const strictManifestResourceSchema = z.object({ diff --git a/packages/core/src/debrid/utils.ts b/packages/core/src/debrid/utils.ts index a3a62215..4667898d 100644 --- a/packages/core/src/debrid/utils.ts +++ b/packages/core/src/debrid/utils.ts @@ -1,6 +1,19 @@ import { z } from 'zod'; -import { constants, createLogger, BuiltinServiceId } from '../utils/index.js'; -import { DebridFile, DebridDownload } from './base.js'; +import { + constants, + createLogger, + BuiltinServiceId, + Env, + Cache, + getSimpleTextHash, + encryptString, +} from '../utils/index.js'; +import { + DebridFile, + DebridDownload, + PlaybackInfo, + ServiceAuth, +} from './base.js'; import { normaliseTitle, titleMatch } from '../parser/utils.js'; const logger = createLogger('debrid'); @@ -296,3 +309,29 @@ export function isVideoFile(file: DebridFile): boolean { videoExtensions.some((ext) => file.name?.endsWith(ext) ?? false) ); } + +export const pbiCache = () => { + const prefix = 'pbi'; + if (Env.REDIS_URI) { + return Cache.getInstance( + prefix, + 1_000_000_000, + 'redis' + ); + } + return Cache.getInstance(prefix, 1_000_000_000, 'sql'); +}; + +export function generatePlaybackUrl( + storeAuth: ServiceAuth, + playbackInfo: PlaybackInfo, + filename: string +) { + const encryptedStoreAuth = encryptString(JSON.stringify(storeAuth)); + if (!encryptedStoreAuth.success) { + throw new Error('Failed to encrypt store auth'); + } + const playbackId = getSimpleTextHash(JSON.stringify(playbackInfo)); + pbiCache().set(playbackId, playbackInfo, 2 * 24 * 60 * 60); + return `${Env.BASE_URL}/api/v1/debrid/playback/${encryptedStoreAuth.data}/${playbackId}/${encodeURIComponent(filename)}`; +} diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index ed5e1ced..e2e6b83a 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -52,7 +52,7 @@ const shuffleCache = Cache.getInstance('shuffle'); const precacheCache = Cache.getInstance( 'precache', undefined, - true + 'memory' ); export interface AIOStreamsError { diff --git a/packages/core/src/parser/go-ptt.ts b/packages/core/src/parser/go-ptt.ts index 592911ab..1af4de89 100644 --- a/packages/core/src/parser/go-ptt.ts +++ b/packages/core/src/parser/go-ptt.ts @@ -13,7 +13,7 @@ const logger = createLogger('parser'); const parseCache = Cache.getInstance( 'ptt', 10000, - true + 'memory' ); class PTT { diff --git a/packages/core/src/presets/animetosho.ts b/packages/core/src/presets/animetosho.ts index 15fae219..17e10153 100644 --- a/packages/core/src/presets/animetosho.ts +++ b/packages/core/src/presets/animetosho.ts @@ -84,7 +84,7 @@ export class AnimeToshoPreset extends TorznabPreset { apiPath: '/api', }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${Env.INTERNAL_URL}/builtins/torznab/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/bitmagnet.ts b/packages/core/src/presets/bitmagnet.ts index 560e1c77..1a50f710 100644 --- a/packages/core/src/presets/bitmagnet.ts +++ b/packages/core/src/presets/bitmagnet.ts @@ -73,7 +73,7 @@ export class BitmagnetPreset extends TorznabPreset { forceQuerySearch: true, }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${Env.INTERNAL_URL}/builtins/torznab/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/gdrive.ts b/packages/core/src/presets/gdrive.ts index b029d60e..d806fb8b 100644 --- a/packages/core/src/presets/gdrive.ts +++ b/packages/core/src/presets/gdrive.ts @@ -188,15 +188,18 @@ export class GDrivePreset extends Preset { ); } } - const config = this.base64EncodeJSON({ - refreshToken: options.refreshToken, - metadataSource: options.metadataSource || 'imdb', - includeAudioFiles: options.includeAudioFiles ?? false, - tmdbReadAccessToken: - options.metadataSource === 'tmdb' - ? userData.tmdbAccessToken || Env.TMDB_ACCESS_TOKEN - : undefined, - }); + const config = this.base64EncodeJSON( + { + refreshToken: options.refreshToken, + metadataSource: options.metadataSource || 'imdb', + includeAudioFiles: options.includeAudioFiles ?? false, + tmdbReadAccessToken: + options.metadataSource === 'tmdb' + ? userData.tmdbAccessToken || Env.TMDB_ACCESS_TOKEN + : undefined, + }, + 'urlSafe' + ); return `${this.METADATA.URL}/${config}/manifest.json`; } } diff --git a/packages/core/src/presets/jackett.ts b/packages/core/src/presets/jackett.ts index 963cbbeb..706ccc39 100644 --- a/packages/core/src/presets/jackett.ts +++ b/packages/core/src/presets/jackett.ts @@ -134,7 +134,7 @@ export class JackettPreset extends TorznabPreset { forceQuerySearch: true, }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${Env.INTERNAL_URL}/builtins/torznab/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/knaben.ts b/packages/core/src/presets/knaben.ts index c3314f52..609b4bfd 100644 --- a/packages/core/src/presets/knaben.ts +++ b/packages/core/src/presets/knaben.ts @@ -63,7 +63,8 @@ export class KnabenPreset extends TorznabPreset { options: Record ): string { return `${Env.INTERNAL_URL}/builtins/knaben/${this.base64EncodeJSON( - this.getBaseConfig(userData, services) + this.getBaseConfig(userData, services), + 'urlSafe' )}/manifest.json`; } } diff --git a/packages/core/src/presets/mediafusion.ts b/packages/core/src/presets/mediafusion.ts index f89820ac..dedb3c91 100644 --- a/packages/core/src/presets/mediafusion.ts +++ b/packages/core/src/presets/mediafusion.ts @@ -521,8 +521,7 @@ export class MediaFusionPreset extends Preset { contribution_streams: options.contributorStreams ?? false, mdblist_config: null, }, - false, - true + 'urlSafe' ); return encodedUserData; diff --git a/packages/core/src/presets/newznab.ts b/packages/core/src/presets/newznab.ts index bf196262..31baf3e0 100644 --- a/packages/core/src/presets/newznab.ts +++ b/packages/core/src/presets/newznab.ts @@ -137,7 +137,7 @@ export class NewznabPreset extends BuiltinAddonPreset { forceQuerySearch: options.forceQuerySearch ?? false, }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${this.METADATA.URL}/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/nzbhydra.ts b/packages/core/src/presets/nzbhydra.ts index e407ae94..4a46f680 100644 --- a/packages/core/src/presets/nzbhydra.ts +++ b/packages/core/src/presets/nzbhydra.ts @@ -109,7 +109,7 @@ export class NZBHydraPreset extends NewznabPreset { forceQuerySearch: options.forceQuerySearch ?? true, }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${this.METADATA.URL}/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/preset.ts b/packages/core/src/presets/preset.ts index 603c7972..9e6141d0 100644 --- a/packages/core/src/presets/preset.ts +++ b/packages/core/src/presets/preset.ts @@ -8,7 +8,7 @@ import { Addon, } from '../db/index.js'; import { StreamParser } from '../parser/index.js'; -import { Env, ServiceId, constants } from '../utils/index.js'; +import { Env, ServiceId, constants, toUrlSafeBase64 } from '../utils/index.js'; /** * * What modifications are needed for each preset: @@ -143,19 +143,17 @@ export abstract class Preset { */ protected static base64EncodeJSON( json: any, - urlEncode: boolean = false, // url encode the string - makeUrlSafe: boolean = false // replace + with -, / with _ and = with nothing + mode: 'urlEncode' | 'urlSafe' | 'default' = 'default' ) { - let encoded = Buffer.from(JSON.stringify(json)).toString('base64'); - if (makeUrlSafe) { - encoded = encoded - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - } else if (urlEncode) { - encoded = encodeURIComponent(encoded); + let jsonStr = JSON.stringify(json); + switch (mode) { + case 'urlEncode': + return encodeURIComponent(Buffer.from(jsonStr).toString('base64')); + case 'urlSafe': + return toUrlSafeBase64(jsonStr); + case 'default': + return Buffer.from(jsonStr).toString('base64'); } - return encoded; } protected static urlEncodeJSON(json: any) { diff --git a/packages/core/src/presets/prowlarr.ts b/packages/core/src/presets/prowlarr.ts index 6ed20168..4e4c6263 100644 --- a/packages/core/src/presets/prowlarr.ts +++ b/packages/core/src/presets/prowlarr.ts @@ -198,7 +198,7 @@ export class ProwlarrPreset extends BuiltinAddonPreset { tags: typeof options.tags === 'string' ? options.tags.split(',') : [], }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${this.METADATA.URL}/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/streamasia.ts b/packages/core/src/presets/streamasia.ts index de51f5b8..7a1e407a 100644 --- a/packages/core/src/presets/streamasia.ts +++ b/packages/core/src/presets/streamasia.ts @@ -446,7 +446,7 @@ there is no need to provide these details here. hideUnsupportedHosters: options.hideUnsupportedHosters, version: '1.3.1', }, - true + 'urlEncode' ); return encodedUserData; diff --git a/packages/core/src/presets/torboxSearch.ts b/packages/core/src/presets/torboxSearch.ts index e4b2b5f2..5df9f6d8 100644 --- a/packages/core/src/presets/torboxSearch.ts +++ b/packages/core/src/presets/torboxSearch.ts @@ -249,7 +249,7 @@ export class TorBoxSearchPreset extends StremThruPreset { })), }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${this.METADATA.URL}/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/torrentGalaxy.ts b/packages/core/src/presets/torrentGalaxy.ts index 16e44a7c..5353ae83 100644 --- a/packages/core/src/presets/torrentGalaxy.ts +++ b/packages/core/src/presets/torrentGalaxy.ts @@ -64,7 +64,8 @@ export class TorrentGalaxyPreset extends TorznabPreset { options: Record ): string { return `${Env.INTERNAL_URL}/builtins/torrent-galaxy/${this.base64EncodeJSON( - this.getBaseConfig(userData, services) + this.getBaseConfig(userData, services), + 'urlSafe' )}/manifest.json`; } } diff --git a/packages/core/src/presets/torznab.ts b/packages/core/src/presets/torznab.ts index 73114efe..11ece822 100644 --- a/packages/core/src/presets/torznab.ts +++ b/packages/core/src/presets/torznab.ts @@ -154,7 +154,7 @@ export class TorznabPreset extends BuiltinAddonPreset { forceQuerySearch: options.forceQuerySearch ?? false, }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${this.METADATA.URL}/${configString}/manifest.json`; } } diff --git a/packages/core/src/presets/zilean.ts b/packages/core/src/presets/zilean.ts index 4094564b..94c874d8 100644 --- a/packages/core/src/presets/zilean.ts +++ b/packages/core/src/presets/zilean.ts @@ -67,7 +67,7 @@ export class ZileanPreset extends TorznabPreset { apiPath: '/api', }; - const configString = this.base64EncodeJSON(config); + const configString = this.base64EncodeJSON(config, 'urlSafe'); return `${Env.INTERNAL_URL}/builtins/torznab/${configString}/manifest.json`; } } diff --git a/packages/core/src/transformers/api.ts b/packages/core/src/transformers/api.ts index 86b36b70..48c9b4d5 100644 --- a/packages/core/src/transformers/api.ts +++ b/packages/core/src/transformers/api.ts @@ -1,40 +1,53 @@ -import { ParsedStream, Resource, Subtitle, UserData } from '../db/index.js'; +import { + ParsedStream, + Resource, + SubtitleSchema, + UserData, +} from '../db/index.js'; import { AIOStreamsResponse } from '../main.js'; -export interface ApiSearchResponseData { - results: ApiSearchResult[]; +export interface SearchApiResponseData { + results: SearchApiResult[]; + filtered: number; errors: { title: string; description: string; }[]; } -interface ApiSearchResult { - infoHash: string | null; - seeders: number | null; - age: string | null; - sources: string[] | null; - ytId: string | null; - externalUrl: string | null; - fileIdx: number | null; - url: string | null; - proxied: boolean; - filename: string | null; - folderName: string | null; - size: number | null; - folderSize: number | null; - message: string | null; - library: boolean; - type: string; - indexer: string | null; - addon: string | null; - duration: number | null; - videoHash: string | null; - subtitles: Subtitle[]; - countryWhitelist: string[]; - requestHeaders: Record; - responseHeaders: Record; -} +import { z } from 'zod'; + +const SearchApiResultSchema = z.object({ + infoHash: z.string().nullable(), + seeders: z.number().nullable(), + age: z.string().nullable(), + sources: z.array(z.string()).nullable(), + ytId: z.string().nullable(), + externalUrl: z.string().nullable(), + fileIdx: z.number().nullable(), + url: z.string().nullable(), + proxied: z.boolean(), + filename: z.string().nullable(), + folderName: z.string().nullable(), + size: z.number().nullable(), + folderSize: z.number().nullable(), + message: z.string().nullable(), + library: z.boolean(), + type: z.string(), + indexer: z.string().nullable(), + addon: z.string().nullable(), + duration: z.number().nullable(), + videoHash: z.string().nullable(), + subtitles: z.array(SubtitleSchema), + countryWhitelist: z.array(z.string()), + requestHeaders: z.partialRecord(z.string(), z.string()), + responseHeaders: z.partialRecord(z.string(), z.string()), +}); + +export type SearchApiResult = z.infer; + +export type SearchApiResultField = keyof SearchApiResult; +export const SearchApiResultField = z.keyof(SearchApiResultSchema); export class ApiTransformer { constructor(private readonly userData: UserData) {} @@ -43,36 +56,50 @@ export class ApiTransformer { response: AIOStreamsResponse<{ streams: ParsedStream[]; statistics: { title: string; description: string }[]; - }> - ): Promise { + }>, + requiredFields: SearchApiResultField[] + ): Promise { const { data, errors } = response; - const results: ApiSearchResult[] = data.streams.map((stream) => ({ - infoHash: stream.torrent?.infoHash ?? null, - url: stream.url ?? null, - seeders: stream.torrent?.seeders ?? null, - age: stream.age ?? null, - sources: stream.torrent?.sources ?? null, - ytId: stream.ytId ?? null, - externalUrl: stream.externalUrl ?? null, - fileIdx: stream.torrent?.fileIdx ?? null, - proxied: stream.proxied ?? false, - filename: stream.filename ?? null, - folderName: stream.folderName ?? null, - size: stream.size ?? null, - folderSize: stream.folderSize ?? null, - message: stream.message ?? null, - library: stream.library ?? false, - addon: stream.addon.name ?? null, - type: stream.type ?? '', - indexer: stream.indexer ?? null, - duration: stream.duration ?? null, - videoHash: stream.videoHash ?? null, - subtitles: stream.subtitles ?? [], - countryWhitelist: stream.countryWhitelist ?? [], - requestHeaders: stream.requestHeaders ?? {}, - responseHeaders: stream.responseHeaders ?? {}, - })); + let filteredCount = 0; + const results: SearchApiResult[] = data.streams + .map((stream) => ({ + infoHash: stream.torrent?.infoHash ?? null, + url: stream.url ?? null, + seeders: stream.torrent?.seeders ?? null, + age: stream.age ?? null, + sources: stream.torrent?.sources ?? null, + ytId: stream.ytId ?? null, + externalUrl: stream.externalUrl ?? null, + fileIdx: stream.torrent?.fileIdx ?? null, + proxied: stream.proxied ?? false, + filename: stream.filename ?? null, + folderName: stream.folderName ?? null, + size: stream.size ?? null, + folderSize: stream.folderSize ?? null, + message: stream.message ?? null, + library: stream.library ?? false, + addon: stream.addon.name ?? null, + type: stream.type ?? '', + indexer: stream.indexer ?? null, + duration: stream.duration ?? null, + videoHash: stream.videoHash ?? null, + subtitles: stream.subtitles ?? [], + countryWhitelist: stream.countryWhitelist ?? [], + requestHeaders: stream.requestHeaders ?? {}, + responseHeaders: stream.responseHeaders ?? {}, + })) + ?.filter((result) => { + const hasRequiredFields = requiredFields.every( + (field) => result[field] !== null + ); + if (!hasRequiredFields) { + filteredCount++; + } + return hasRequiredFields; + }); + return { + filtered: filteredCount, results, errors: errors.map((error) => ({ title: error.title ?? '', diff --git a/packages/core/src/utils/cache-adapter.ts b/packages/core/src/utils/cache-adapter.ts index b3db5b87..de7250c6 100644 --- a/packages/core/src/utils/cache-adapter.ts +++ b/packages/core/src/utils/cache-adapter.ts @@ -1,6 +1,7 @@ import { RedisClientType } from 'redis'; import { REDIS_PREFIX, Env } from './index.js'; import { createLogger } from './logger.js'; +import { DB } from '../db/db.js'; const logger = createLogger('cache'); @@ -271,7 +272,215 @@ export class RedisCacheBackend implements CacheBackend { } } -// Item stored in memory cache +// SQL cache implementation +export class SQLCacheBackend implements CacheBackend { + private db: DB; + private prefix: string; + private maxSize: number; + static maintenanceStarted: boolean = false; + + constructor( + prefix: string = '', + maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE + ) { + this.db = DB.getInstance(); + this.prefix = prefix; + this.maxSize = maxSize; + this.startMaintenance(); + } + + private startMaintenance() { + if (SQLCacheBackend.maintenanceStarted) return; + logger.debug('Starting SQL cache maintenance'); + SQLCacheBackend.maintenanceStarted = true; + setInterval( + () => { + this.db + .execute('DELETE FROM cache WHERE expires_at < ?', [Date.now()]) + .then((result) => { + logger.debug( + `${result.changed || result.rowCount || 0} stale entries removed from SQL cache` + ); + }) + .catch((err) => { + logger.error(`Error during SQL cache maintenance: ${err}`); + }); + }, + 1 * 60 * 60 * 1000 // hourly + ); + } + + private getKey(key: K): string { + return `${this.prefix}${String(key)}`; + } + + async get(key: K, updateTTL: boolean = false): Promise { + const sqlKey = this.getKey(key); + const now = Date.now(); + + try { + // Get the value and check expiration + const result = await this.db.query( + 'SELECT value, expires_at FROM cache WHERE key = ?', + [sqlKey] + ); + + if (!result.length) { + return undefined; + } + + const row = result[0]; + if (now > row.expires_at) { + // Remove expired entry + await this.db.execute('DELETE FROM cache WHERE key = ?', [sqlKey]); + return undefined; + } + + if (updateTTL) { + const ttl = Math.max(0, row.expires_at - now); + const timestampFunc = this.db.isSQLite() + ? 'CURRENT_TIMESTAMP' + : 'NOW()'; + await this.db.execute( + `UPDATE cache SET expires_at = ?, last_accessed = ${timestampFunc} WHERE key = ?`, + [now + ttl, sqlKey] + ); + } else { + const timestampFunc = this.db.isSQLite() + ? 'CURRENT_TIMESTAMP' + : 'NOW()'; + await this.db.execute( + `UPDATE cache SET last_accessed = ${timestampFunc} WHERE key = ?`, + [sqlKey] + ); + } + + return JSON.parse(row.value) as V; + } catch (err) { + logger.error(`Error getting key ${String(key)} from SQL cache: ${err}`); + return undefined; + } + } + + async set(key: K, value: V, ttl: number): Promise { + if (ttl === 0) return; + + const sqlKey = this.getKey(key); + const expiresAt = Date.now() + ttl * 1000; + const jsonValue = JSON.stringify(value); + + try { + // Check current cache size + const countResult = await this.db.query( + 'SELECT COUNT(*) as count FROM cache' + ); + const currentSize = countResult[0].count; + + if (currentSize >= this.maxSize) { + // Remove oldest accessed entry + if (this.db.isSQLite()) { + await this.db.execute( + 'DELETE FROM cache WHERE key IN (SELECT key FROM cache ORDER BY last_accessed ASC LIMIT 1)' + ); + } else { + // PostgreSQL compatible version + await this.db.execute( + 'DELETE FROM cache WHERE key = (SELECT key FROM cache ORDER BY last_accessed ASC LIMIT 1)' + ); + } + } + + // Upsert the new value + if (this.db.isSQLite()) { + await this.db.execute( + 'INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)', + [sqlKey, jsonValue, expiresAt] + ); + } else { + const timestampFunc = this.db.isSQLite() + ? 'CURRENT_TIMESTAMP' + : 'NOW()'; + await this.db.execute( + `INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at, last_accessed = ${timestampFunc}`, + [sqlKey, jsonValue, expiresAt] + ); + } + } catch (err) { + logger.error(`Error setting key ${String(key)} in SQL cache: ${err}`); + } + } + + async update(key: K, value: V): Promise { + const sqlKey = this.getKey(key); + + try { + const result = await this.db.query( + 'SELECT expires_at FROM cache WHERE key = ?', + [sqlKey] + ); + + if (!result.length) return; + + const row = result[0]; + if (Date.now() > row.expires_at) { + await this.db.execute('DELETE FROM cache WHERE key = ?', [sqlKey]); + return; + } + + const timestampFunc = this.db.isSQLite() ? 'CURRENT_TIMESTAMP' : 'NOW()'; + await this.db.execute( + `UPDATE cache SET value = ?, last_accessed = ${timestampFunc} WHERE key = ?`, + [JSON.stringify(value), sqlKey] + ); + } catch (err) { + logger.error(`Error updating key ${String(key)} in SQL cache: ${err}`); + } + } + + async clear(): Promise { + try { + if (this.prefix) { + await this.db.execute('DELETE FROM cache WHERE key LIKE ?', [ + `${this.prefix}%`, + ]); + } else { + await this.db.execute('DELETE FROM cache'); + } + } catch (err) { + logger.error(`Error clearing SQL cache: ${err}`); + } + } + + async getTTL(key: K): Promise { + const sqlKey = this.getKey(key); + const now = Date.now(); + + try { + const result = await this.db.query( + 'SELECT expires_at FROM cache WHERE key = ?', + [sqlKey] + ); + + if (!result.length) return 0; + + const ttl = Math.max(0, Math.floor((result[0].expires_at - now) / 1000)); + return ttl; + } catch (err) { + logger.error( + `Error getting TTL for key ${String(key)} from SQL cache: ${err}` + ); + return 0; + } + } + + async waitUntilReady(): Promise { + if (!this.db.isInitialised()) { + throw new Error('Database is not initialized'); + } + return Promise.resolve(); + } +} + class CacheItem { constructor( public value: T, diff --git a/packages/core/src/utils/cache.ts b/packages/core/src/utils/cache.ts index 5898238b..e14f17e7 100644 --- a/packages/core/src/utils/cache.ts +++ b/packages/core/src/utils/cache.ts @@ -2,6 +2,7 @@ import { CacheBackend, MemoryCacheBackend, RedisCacheBackend, + SQLCacheBackend, } from './cache-adapter.js'; import { createLogger, Env } from './index.js'; import { createClient, RedisClientType } from 'redis'; @@ -30,12 +31,20 @@ export class Cache { // Redis client singleton private static redisClient: RedisClientType | null = null; - private constructor(name: string, maxSize: number, forceMemory: boolean) { + private constructor( + name: string, + maxSize: number, + store?: 'redis' | 'sql' | 'memory' + ) { this.name = name; this.maxSize = maxSize; - // Initialize the appropriate backend based on environment configuration - if (Env.REDIS_URI && !forceMemory) { + // Initialize the appropriate backend based on environment configuration and store preference + if (store === 'sql') { + this.backend = new SQLCacheBackend(`${name}:`, maxSize); + logger.debug(`Created SQL cache backend for ${name}`); + } else if (Env.REDIS_URI && (!store || store === 'redis')) { + // use redis if provided and no store preference or redis is specified this.backend = new RedisCacheBackend( Cache.getRedisClient(), `${name}:`, @@ -151,11 +160,11 @@ export class Cache { public static getInstance( name: string, maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE, - forceMemory: boolean = false + store?: 'redis' | 'sql' | 'memory' ): Cache { if (!this.instances.has(name)) { logger.debug(`Creating new cache instance: ${name}`); - this.instances.set(name, new Cache(name, maxSize, forceMemory)); + this.instances.set(name, new Cache(name, maxSize, store)); } return this.instances.get(name) as Cache; } @@ -280,7 +289,9 @@ export class Cache { return this.backend.waitUntilReady(); } - getType(): 'memory' | 'redis' { - return this.backend instanceof MemoryCacheBackend ? 'memory' : 'redis'; + getType(): 'memory' | 'redis' | 'sql' { + if (this.backend instanceof MemoryCacheBackend) return 'memory'; + if (this.backend instanceof RedisCacheBackend) return 'redis'; + return 'sql'; } } diff --git a/packages/core/src/utils/crypto.ts b/packages/core/src/utils/crypto.ts index 330c1f9f..2c2e234c 100644 --- a/packages/core/src/utils/crypto.ts +++ b/packages/core/src/utils/crypto.ts @@ -10,37 +10,18 @@ import { genSalt, hash, compare } from 'bcrypt'; import { deflateSync, inflateSync } from 'zlib'; import { Env } from './index.js'; import { createLogger } from './logger.js'; - +import { fromUrlSafeBase64, toUrlSafeBase64 } from './general.js'; const logger = createLogger('crypto'); const saltRounds = 10; -function base64UrlSafe(data: string): string { - return Buffer.from(data) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - -function fromUrlSafeBase64(data: string): string { - // Add padding if needed - const padding = data.length % 4; - const paddedData = padding ? data + '='.repeat(4 - padding) : data; - - return Buffer.from( - paddedData.replace(/-/g, '+').replace(/_/g, '/'), - 'base64' - ).toString('utf-8'); -} - -const compressData = (data: string): Buffer => { +export const compressData = (data: string): Buffer => { return deflateSync(Buffer.from(data, 'utf-8'), { level: 9, }); }; -const decompressData = (data: Buffer): string => { +export const decompressData = (data: Buffer): string => { return inflateSync(data).toString('utf-8'); }; @@ -116,7 +97,7 @@ export function encryptString(data: string, secretKey?: Buffer): Response { const { iv, data: encrypted } = encryptData(secretKey, compressed); return { success: true, - data: base64UrlSafe( + data: toUrlSafeBase64( JSON.stringify({ iv, encrypted, type: 'aioEncrypt' }) ), error: null, diff --git a/packages/core/src/utils/general.ts b/packages/core/src/utils/general.ts index 480038d1..87610302 100644 --- a/packages/core/src/utils/general.ts +++ b/packages/core/src/utils/general.ts @@ -83,3 +83,32 @@ export async function withRetry( // This line should never be reached due to the throw in the catch block throw new Error('Unexpected state in retry logic'); } + +/** + * Base64 URL safe encoding + * @param data - The data to encode + * @returns The base64 URL safe encoded data + */ +export function toUrlSafeBase64(string: string): string { + return Buffer.from(string) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Base64 URL safe decoding + * @param data - The data to decode + * @returns The base64 URL safe decoded data + */ +export function fromUrlSafeBase64(data: string): string { + // Add padding if needed + const padding = data.length % 4; + const paddedData = padding ? data + '='.repeat(4 - padding) : data; + + return Buffer.from( + paddedData.replace(/-/g, '+').replace(/_/g, '/'), + 'base64' + ).toString('utf-8'); +} diff --git a/packages/core/src/utils/http.ts b/packages/core/src/utils/http.ts index 45d6e14a..44fde8c3 100644 --- a/packages/core/src/utils/http.ts +++ b/packages/core/src/utils/http.ts @@ -21,7 +21,7 @@ const logger = createLogger('http'); const urlCount = Cache.getInstance( 'url-count', undefined, - true + 'memory' ); export class PossibleRecursiveRequestError extends Error { diff --git a/packages/core/src/utils/regex.ts b/packages/core/src/utils/regex.ts index 644e1bb3..603d718e 100644 --- a/packages/core/src/utils/regex.ts +++ b/packages/core/src/utils/regex.ts @@ -4,7 +4,11 @@ import { getSimpleTextHash } from './crypto.js'; import { createLogger } from './logger.js'; const DEFAULT_TIMEOUT = 1000; // 1 second timeout -const regexCache = Cache.getInstance('regexCache', 1_000, true); +const regexCache = Cache.getInstance( + 'regexCache', + 1_000, + 'memory' +); const resultCache = Cache.getInstance( 'regexResultCache', 1_000_000 diff --git a/packages/server/src/routes/api/debrid.ts b/packages/server/src/routes/api/debrid.ts index 61d3f475..1e750894 100644 --- a/packages/server/src/routes/api/debrid.ts +++ b/packages/server/src/routes/api/debrid.ts @@ -8,6 +8,12 @@ import { PlaybackInfoSchema, getDebridService, ServiceAuthSchema, + fromUrlSafeBase64, + Cache, + PlaybackInfo, + ServiceAuth, + decryptString, + pbiCache, } from '@aiostreams/core'; import { ZodError } from 'zod'; import { StaticFiles } from '../../app.js'; @@ -24,24 +30,38 @@ router.use((req: Request, res: Response, next: NextFunction) => { }); router.get( - '/playback/:encodedStoreAuth/:encodedPlaybackInfo/:filename', + '/playback/:encryptedStoreAuth/:playbackId/:filename', async (req: Request, res: Response, next: NextFunction) => { try { - const { encodedStoreAuth, encodedPlaybackInfo, filename } = req.params; - if (!encodedStoreAuth || !encodedPlaybackInfo || !filename) { + const { encryptedStoreAuth, playbackId, filename } = req.params; + if (!playbackId || !filename) { throw new APIError( constants.ErrorCode.BAD_REQUEST, undefined, - 'Store auth, playback info and filename are required' + 'Encrypted store auth, playback info and filename are required' + ); + } + + const decryptedStoreAuth = decryptString(encryptedStoreAuth); + if (!decryptedStoreAuth.success) { + throw new APIError( + constants.ErrorCode.BAD_REQUEST, + undefined, + 'Failed to decrypt store auth' ); } - const playbackInfo = PlaybackInfoSchema.parse( - JSON.parse(Buffer.from(encodedPlaybackInfo, 'base64').toString('utf-8')) - ); const storeAuth = ServiceAuthSchema.parse( - JSON.parse(Buffer.from(encodedStoreAuth, 'base64').toString('utf-8')) + JSON.parse(decryptedStoreAuth.data) ); + const playbackInfo = await pbiCache().get(playbackId); + if (!playbackInfo) { + throw new APIError( + constants.ErrorCode.BAD_REQUEST, + undefined, + 'Playback info not found' + ); + } const debridInterface = getDebridService( storeAuth.id, diff --git a/packages/server/src/routes/api/search.ts b/packages/server/src/routes/api/search.ts index db5c8c4c..7af7fc0d 100644 --- a/packages/server/src/routes/api/search.ts +++ b/packages/server/src/routes/api/search.ts @@ -13,7 +13,8 @@ import { decryptString, createLogger, ApiTransformer, - ApiSearchResponseData, + SearchApiResponseData, + SearchApiResultField, } from '@aiostreams/core'; import { streamApiRateLimiter } from '../../middlewares/ratelimit.js'; import { ApiResponse, createResponse } from '../../utils/responses.js'; @@ -24,20 +25,32 @@ const logger = createLogger('server'); router.use(streamApiRateLimiter); +const SearchApiRequestSchema = z.object({ + type: z.string(), + id: z.string(), + requiredFields: z + .union([z.array(SearchApiResultField), SearchApiResultField]) + .optional() + .default([]) + .transform((val) => { + if (Array.isArray(val)) { + return val; + } + return [val]; + }), +}); + router.get( '/', async ( req: Request, - res: Response>, + res: Response>, next ) => { try { - const { type, id } = z - .object({ - type: z.string(), - id: z.string(), - }) - .parse(req.query); + const { type, id, requiredFields } = SearchApiRequestSchema.parse( + req.query + ); let encodedUserData: string | undefined = z .string() .optional() @@ -161,12 +174,13 @@ router.get( const transformer = new ApiTransformer(userData); res.status(200).json( - createResponse({ + createResponse({ success: true, data: await transformer.transformStreams( await ( await new AIOStreams(userData).initialise() - ).getStreams(id, type) + ).getStreams(id, type), + requiredFields ), }) ); diff --git a/packages/server/src/routes/builtins/gdrive.ts b/packages/server/src/routes/builtins/gdrive.ts index b14a03cf..7d8919cf 100644 --- a/packages/server/src/routes/builtins/gdrive.ts +++ b/packages/server/src/routes/builtins/gdrive.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { createLogger, GDriveAddon } from '@aiostreams/core'; +import { createLogger, fromUrlSafeBase64, GDriveAddon } from '@aiostreams/core'; const router: Router = Router(); const logger = createLogger('server'); @@ -9,7 +9,7 @@ router.get( async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig } = req.params; const config = encodedConfig - ? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) : undefined; try { @@ -27,9 +27,7 @@ router.get( '/:encodedConfig/meta/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); + const config = JSON.parse(fromUrlSafeBase64(encodedConfig)); try { const addon = new GDriveAddon(config); @@ -47,9 +45,7 @@ router.get( '/:encodedConfig/catalog/:type/:id{/:extras}.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id, extras } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); + const config = JSON.parse(fromUrlSafeBase64(encodedConfig)); try { const addon = new GDriveAddon(config); @@ -67,9 +63,7 @@ router.get( '/:encodedConfig/stream/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); + const config = JSON.parse(fromUrlSafeBase64(encodedConfig)); try { const addon = new GDriveAddon(config); diff --git a/packages/server/src/routes/builtins/knaben.ts b/packages/server/src/routes/builtins/knaben.ts index 49d6ca2a..f4c85a09 100644 --- a/packages/server/src/routes/builtins/knaben.ts +++ b/packages/server/src/routes/builtins/knaben.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { AIOStreams, AIOStreamResponse, KnabenAddon } from '@aiostreams/core'; +import { KnabenAddon, fromUrlSafeBase64 } from '@aiostreams/core'; import { createLogger } from '@aiostreams/core'; const router: Router = Router(); @@ -9,12 +9,13 @@ router.get( '/:encodedConfig/manifest.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig } = req.params; - const config = encodedConfig - ? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) - : undefined; - try { - const manifest = new KnabenAddon(config, req.userIp).getManifest(); + const manifest = new KnabenAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ).getManifest(); res.json(manifest); } catch (error) { next(error); @@ -26,12 +27,14 @@ router.get( '/:encodedConfig/stream/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); try { - const addon = new KnabenAddon(config, req.userIp); + const addon = new KnabenAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ); const streams = await addon.getStreams(type, id); res.json({ streams: streams, diff --git a/packages/server/src/routes/builtins/newznab.ts b/packages/server/src/routes/builtins/newznab.ts index 1ccf2a45..6ebe8778 100644 --- a/packages/server/src/routes/builtins/newznab.ts +++ b/packages/server/src/routes/builtins/newznab.ts @@ -1,5 +1,9 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { NewznabAddon, createLogger } from '@aiostreams/core'; +import { + NewznabAddon, + createLogger, + fromUrlSafeBase64, +} from '@aiostreams/core'; const router: Router = Router(); const logger = createLogger('server'); @@ -8,12 +12,14 @@ router.get( '/:encodedConfig/manifest.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig } = req.params; - const config = encodedConfig - ? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) - : undefined; try { - const manifest = new NewznabAddon(config, req.userIp).getManifest(); + const manifest = new NewznabAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ).getManifest(); res.json(manifest); } catch (error) { next(error); @@ -25,12 +31,14 @@ router.get( '/:encodedConfig/stream/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); try { - const addon = new NewznabAddon(config, req.userIp); + const addon = new NewznabAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ); const streams = await addon.getStreams(type, id); res.json({ streams: streams, diff --git a/packages/server/src/routes/builtins/prowlarr.ts b/packages/server/src/routes/builtins/prowlarr.ts index be3093ea..460cd922 100644 --- a/packages/server/src/routes/builtins/prowlarr.ts +++ b/packages/server/src/routes/builtins/prowlarr.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { ProwlarrAddon } from '@aiostreams/core'; +import { ProwlarrAddon, fromUrlSafeBase64 } from '@aiostreams/core'; import { createLogger } from '@aiostreams/core'; const router: Router = Router(); @@ -9,12 +9,14 @@ router.get( '/:encodedConfig/manifest.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig } = req.params; - const config = encodedConfig - ? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) - : undefined; try { - const manifest = new ProwlarrAddon(config, req.userIp).getManifest(); + const manifest = new ProwlarrAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ).getManifest(); res.json(manifest); } catch (error) { next(error); @@ -26,12 +28,14 @@ router.get( '/:encodedConfig/stream/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); try { - const addon = new ProwlarrAddon(config, req.userIp); + const addon = new ProwlarrAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ); const streams = await addon.getStreams(type, id); res.json({ streams: streams, diff --git a/packages/server/src/routes/builtins/torbox-search.ts b/packages/server/src/routes/builtins/torbox-search.ts index 6ac8c7ec..f993a507 100644 --- a/packages/server/src/routes/builtins/torbox-search.ts +++ b/packages/server/src/routes/builtins/torbox-search.ts @@ -3,6 +3,7 @@ import { createLogger, TorBoxSearchAddon, TorBoxSearchAddonError, + fromUrlSafeBase64, } from '@aiostreams/core'; import { createResponse } from '../../utils/responses.js'; const router: Router = Router(); @@ -13,14 +14,14 @@ router.get( '{/:encodedConfig}/manifest.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig } = req.params; - - const config = encodedConfig - ? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) - : undefined; - try { - const manifest = config - ? new TorBoxSearchAddon(config, req.userIp).getManifest() + const manifest = encodedConfig + ? new TorBoxSearchAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ).getManifest() : TorBoxSearchAddon.getManifest(); res.json(manifest); } catch (error) { @@ -45,12 +46,14 @@ router.get( '/:encodedConfig/stream/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); try { - const addon = new TorBoxSearchAddon(config, req.userIp); + const addon = new TorBoxSearchAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ); const streams = await addon.getStreams(type as any, id); res.json({ streams: streams, diff --git a/packages/server/src/routes/builtins/torrent-galaxy.ts b/packages/server/src/routes/builtins/torrent-galaxy.ts index efc10175..2f3b9bc5 100644 --- a/packages/server/src/routes/builtins/torrent-galaxy.ts +++ b/packages/server/src/routes/builtins/torrent-galaxy.ts @@ -3,6 +3,7 @@ import { AIOStreams, AIOStreamResponse, TorrentGalaxyAddon, + fromUrlSafeBase64, } from '@aiostreams/core'; import { createLogger } from '@aiostreams/core'; const router: Router = Router(); @@ -13,12 +14,14 @@ router.get( '/:encodedConfig/manifest.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig } = req.params; - const config = encodedConfig - ? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) - : undefined; try { - const manifest = new TorrentGalaxyAddon(config, req.userIp).getManifest(); + const manifest = new TorrentGalaxyAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ).getManifest(); res.json(manifest); } catch (error) { next(error); @@ -30,12 +33,14 @@ router.get( '/:encodedConfig/stream/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); try { - const addon = new TorrentGalaxyAddon(config, req.userIp); + const addon = new TorrentGalaxyAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ); const streams = await addon.getStreams(type, id); res.json({ streams: streams, diff --git a/packages/server/src/routes/builtins/torznab.ts b/packages/server/src/routes/builtins/torznab.ts index a3d707ed..ef086ec6 100644 --- a/packages/server/src/routes/builtins/torznab.ts +++ b/packages/server/src/routes/builtins/torznab.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { AIOStreams, AIOStreamResponse, TorznabAddon } from '@aiostreams/core'; +import { TorznabAddon, fromUrlSafeBase64 } from '@aiostreams/core'; import { createLogger } from '@aiostreams/core'; const router: Router = Router(); @@ -9,12 +9,14 @@ router.get( '/:encodedConfig/manifest.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig } = req.params; - const config = encodedConfig - ? JSON.parse(Buffer.from(encodedConfig, 'base64').toString('utf-8')) - : undefined; try { - const manifest = new TorznabAddon(config, req.userIp).getManifest(); + const manifest = new TorznabAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ).getManifest(); res.json(manifest); } catch (error) { next(error); @@ -26,12 +28,14 @@ router.get( '/:encodedConfig/stream/:type/:id.json', async (req: Request, res: Response, next: NextFunction) => { const { encodedConfig, type, id } = req.params; - const config = JSON.parse( - Buffer.from(encodedConfig, 'base64').toString('utf-8') - ); try { - const addon = new TorznabAddon(config, req.userIp); + const addon = new TorznabAddon( + encodedConfig + ? JSON.parse(fromUrlSafeBase64(encodedConfig)) + : undefined, + req.userIp + ); const streams = await addon.getStreams(type, id); res.json({ streams: streams,