import { Addon, AddonCatalog, AddonCatalogResponse, AddonCatalogResponseSchema, AddonCatalogSchema, CatalogResponse, CatalogResponseSchema, Manifest, ManifestSchema, Meta, MetaPreview, MetaPreviewSchema, MetaResponse, MetaResponseSchema, MetaSchema, ParsedStream, Resource, Stream, StreamResponse, StreamResponseSchema, StreamSchema, Subtitle, SubtitleResponse, SubtitleResponseSchema, SubtitleSchema, } from './db/schemas'; import { Cache, makeRequest, createLogger, constants, maskSensitiveInfo, makeUrlLogSafe, formatZodError, PossibleRecursiveRequestError, Env, } 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; type ResourceParams = { type: string; id: string; extras?: string; }; export class Wrapper { private readonly baseUrl: string; private readonly addon: Addon; private readonly manifestUrl: string; constructor(addon: Addon) { this.addon = addon; this.manifestUrl = this.addon.manifestUrl.replace('stremio://', 'https://'); this.baseUrl = this.manifestUrl.split('/').slice(0, -1).join('/'); } /** * Validates an array of items against a schema, filtering out invalid ones * @param data The data to validate * @param schema The Zod schema to validate against * @param resourceName Name of the resource for error messages * @returns Array of validated items * @throws Error if all items are invalid */ private validateArray( data: unknown, schema: z.ZodSchema, resourceName: string ): T[] { if (!Array.isArray(data)) { throw new Error(`${resourceName} is not an array`); } if (data.length === 0) { // empty array is valid return []; } const validItems = data .map((item) => { const parsed = schema.safeParse(item); if (!parsed.success) { logger.error( `An item in the response for ${resourceName} was invalid, filtering it out: ${formatZodError(parsed.error)}` ); return null; } return parsed.data; }) .filter((item): item is T => item !== null); if (validItems.length === 0) { throw new Error(`No valid ${resourceName} found`); } return validItems; } async getManifest(): Promise { return await manifestCache.wrap( async () => { logger.debug( `Fetching manifest for ${this.addon.name} ${this.addon.displayIdentifier || this.addon.identifier} (${makeUrlLogSafe(this.manifestUrl)})` ); try { const res = await makeRequest( this.manifestUrl, this.addon.timeout, this.addon.headers, this.addon.ip ); if (!res.ok) { throw new Error(`${res.status} - ${res.statusText}`); } const data = await res.json(); const manifest = ManifestSchema.safeParse(data); if (!manifest.success) { logger.error(`Manifest response was unexpected`); logger.error(formatZodError(manifest.error)); logger.error(JSON.stringify(data, null, 2)); throw new Error( `Failed to parse manifest for ${this.getAddonName(this.addon)}` ); } return manifest.data; } catch (error: any) { logger.error( `Failed to fetch manifest for ${this.getAddonName(this.addon)}: ${error.message}` ); if (error instanceof PossibleRecursiveRequestError) { throw error; } throw new Error( `Failed to fetch manifest for ${this.getAddonName(this.addon)}: ${error.message}` ); } }, this.manifestUrl, Env.MANIFEST_CACHE_TTL ); } async getStreams(type: string, id: string): Promise { const validator = (data: any): Stream[] => { return this.validateArray(data.streams, StreamSchema, 'streams'); }; const streams = await this.makeResourceRequest( 'stream', { type, id }, validator, Env.STREAM_CACHE_TTL != -1, Env.STREAM_CACHE_TTL ); const Parser = this.addon.presetType ? PresetManager.fromId(this.addon.presetType).getParser() : StreamParser; const parser = new Parser(this.addon); return streams.map((stream: Stream) => parser.parse(stream)); } async getCatalog( type: string, id: string, extras?: string ): Promise { const validator = (data: any): MetaPreview[] => { return this.validateArray(data.metas, MetaPreviewSchema, 'catalog items'); }; return await this.makeResourceRequest( 'catalog', { type, id, extras }, validator, Env.CATALOG_CACHE_TTL != -1, Env.CATALOG_CACHE_TTL ); } async getMeta(type: string, id: string): Promise { const validator = (data: any): Meta => { const parsed = MetaSchema.safeParse(data.meta); if (!parsed.success) { logger.error(formatZodError(parsed.error)); throw new Error( `Failed to parse meta for ${this.getAddonName(this.addon)}` ); } return parsed.data; }; const meta: Meta = await this.makeResourceRequest( 'meta', { type, id }, validator, Env.META_CACHE_TTL != -1, Env.META_CACHE_TTL ); return meta; } async getSubtitles( type: string, id: string, extras?: string ): Promise { const validator = (data: any): Subtitle[] => { return this.validateArray(data.subtitles, SubtitleSchema, 'subtitles'); }; return await this.makeResourceRequest( 'subtitles', { type, id, extras }, validator, Env.SUBTITLE_CACHE_TTL != -1, Env.SUBTITLE_CACHE_TTL ); } async getAddonCatalog(type: string, id: string): Promise { const validator = (data: any): AddonCatalog[] => { return this.validateArray( data.addons, AddonCatalogSchema, 'addon catalog items' ); }; return await this.makeResourceRequest( 'addon_catalog', { type, id }, validator, Env.ADDON_CATALOG_CACHE_TTL != -1, Env.ADDON_CATALOG_CACHE_TTL ); } async makeRequest(url: string) { return await makeRequest( url, this.addon.timeout, this.addon.headers, this.addon.ip ); } private async makeResourceRequest( resource: Resource, params: ResourceParams, validator: (data: unknown) => T, cache: boolean = false, cacheTtl: number = RESOURCE_TTL ) { const { type, id, extras } = params; const url = this.buildResourceUrl(resource, type, id, extras); if (cache) { const cached = resourceCache.get(url); if (cached) { logger.info( `Returning cached ${resource} for ${this.getAddonName(this.addon)} (${makeUrlLogSafe(url)})` ); return cached; } } logger.info( `Fetching ${resource} of type ${type} with id ${id} and extras ${extras} (${makeUrlLogSafe(url)})` ); try { 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.getAddonName(this.addon)}: ${res.status} - ${res.statusText}` ); throw new Error(`${res.status} - ${res.statusText}`); } const data: unknown = await res.json(); const validated = validator(data); if (cache) { resourceCache.set(url, validated, cacheTtl); } return validated; } catch (error: any) { logger.error( `Failed to fetch ${resource} resource for ${this.getAddonName(this.addon)}: ${error.message}` ); throw error; } } private buildResourceUrl( resource: Resource, type: string, id: string, extras?: string ): string { const extrasPath = extras ? `/${extras}` : ''; return `${this.baseUrl}/${resource}/${type}/${encodeURIComponent(id)}${extrasPath}.json`; } private getAddonName(addon: Addon): string { return `${addon.name}${addon.displayIdentifier || addon.identifier ? ` ${addon.displayIdentifier || addon.identifier}` : ''}`; } }