import { Parser } from 'expr-eval'; import { ParsedStream, ParsedStreams, ParsedStreamSchema, PassthroughStage, } from '../db/schemas.js'; import bytes from 'bytes'; import { formatZodError } from '../utils/config.js'; import { ZodError } from 'zod'; import { PASSTHROUGH_STAGES } from '../utils/constants.js'; import { parseBitrate } from './utils.js'; import { createLogger } from '../logging/logger.js'; import { ExpressionContext } from '../streams/context.js'; import { formRegexFromKeywordsSync } from '../utils/regex.js'; const logger = createLogger('stream-expression'); export abstract class StreamExpressionEngine { protected parser: Parser; protected _pinInstructions: Map = new Map(); constructor() { // only allow comparison and logical operators this.parser = new Parser({ operators: { comparison: true, logical: true, add: true, concatenate: false, conditional: true, divide: true, factorial: false, multiply: true, power: false, remainder: false, subtract: true, sin: false, cos: false, tan: false, asin: false, acos: false, atan: false, sinh: false, cosh: false, tanh: false, asinh: false, acosh: false, atanh: false, sqrt: true, log: false, ln: false, lg: false, log10: false, abs: false, ceil: false, floor: false, round: false, trunc: false, exp: false, length: false, in: true, random: true, min: false, max: false, assignment: false, fndef: false, cbrt: false, expm1: false, log1p: false, sign: false, log2: false, }, }); this.setupParserFunctions(); } protected setupExpressionContextConstants(context: ExpressionContext) { this.parser.consts.queryType = context.queryType ?? ''; this.parser.consts.isAnime = context.isAnime ?? false; this.parser.consts.season = context.season ?? -1; this.parser.consts.episode = context.episode ?? -1; this.parser.consts.genres = context.genres ?? []; this.parser.consts.title = context.title ?? ''; this.parser.consts.year = context.year ?? 0; this.parser.consts.yearEnd = context.yearEnd ?? 0; this.parser.consts.daysSinceRelease = context.daysSinceRelease ?? -1; this.parser.consts.runtime = context.runtime ?? 0; this.parser.consts.absoluteEpisode = context.absoluteEpisode ?? -1; this.parser.consts.originalLanguage = context.originalLanguage ?? ''; this.parser.consts.hasSeaDex = context.hasSeaDex ?? false; this.parser.consts.hasNextEpisode = context.hasNextEpisode ?? false; this.parser.consts.daysUntilNextEpisode = context.daysUntilNextEpisode ?? -1; this.parser.consts.daysSinceFirstAired = context.daysSinceFirstAired ?? -1; this.parser.consts.daysSinceLastAired = context.daysSinceLastAired ?? -1; this.parser.consts.latestSeason = context.latestSeason ?? -1; this.parser.consts.ongoingSeason = context.hasNextEpisode && context.season === context.latestSeason; } public getPinInstructions(): Map { return this._pinInstructions; } private setupParserFunctions() { this.setupMathFunctions(); this.setupStreamFunctions(); } private setupMathFunctions() { type NumberInput = number[] | number; const toNumberArray = ( args: NumberInput, rest: number[], functionName: string ): number[] => { const arr = Array.isArray(args) ? args : [args, ...rest]; if (!Array.isArray(arr)) { throw new Error( `${functionName} requires a number or array of numbers` ); } if (arr.some((n) => typeof n !== 'number' || isNaN(n))) { throw new Error( `${functionName} requires all values to be valid numbers` ); } return arr; }; const eMath = { mean(numbers: number[]): number { if (numbers.length === 0) return 0; let sum = 0; for (let i = 0; i < numbers.length; i++) { sum += numbers[i]; } return sum / numbers.length; }, variance(numbers: number[]): number { if (numbers.length === 0) return 0; const m = eMath.mean(numbers); let sumSquaredDiff = 0; for (let i = 0; i < numbers.length; i++) { const diff = numbers[i] - m; sumSquaredDiff += diff * diff; } return sumSquaredDiff / numbers.length; }, sort(numbers: number[]): number[] { return [...numbers].sort((a, b) => a - b); }, percentile(numbers: number[], p: number): number { if (numbers.length === 0) return 0; if (numbers.length === 1) return numbers[0]; const sorted = eMath.sort(numbers); if (p === 0) return sorted[0]; if (p === 100) return sorted[sorted.length - 1]; const pos = ((sorted.length - 1) * p) / 100; const base = Math.floor(pos); const rest = pos - base; if (rest === 0 || base === sorted.length - 1) { return sorted[base]; } return sorted[base] + rest * (sorted[base + 1] - sorted[base]); }, }; this.parser.functions.max = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'max'); if (values.length === 0) return 0; let max = values[0]; for (let i = 1; i < values.length; i++) { if (values[i] > max) max = values[i]; } return max; }; this.parser.functions.min = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'min'); if (values.length === 0) return 0; let min = values[0]; for (let i = 1; i < values.length; i++) { if (values[i] < min) min = values[i]; } return min; }; this.parser.functions.avg = function ( args: NumberInput, ...rest: number[] ): number { return eMath.mean(toNumberArray(args, rest, 'avg')); }; this.parser.functions.mean = this.parser.functions.avg; this.parser.functions.sum = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'sum'); if (values.length === 0) return 0; let sum = 0; for (let i = 0; i < values.length; i++) { sum += values[i]; } return sum; }; this.parser.functions.percentile = function ( numbers: number[], p: number ): number { const values = toNumberArray(numbers, [], 'percentile'); if (typeof p !== 'number' || isNaN(p)) { throw new Error('Percentile value must be a number'); } if (p < 0 || p > 100) { throw new Error('Percentile must be between 0 and 100'); } return eMath.percentile(values, p); }; this.parser.functions.q1 = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'q1'); return eMath.percentile(values, 25); }; this.parser.functions.median = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'median'); if (values.length === 0) return 0; if (values.length === 1) return values[0]; const sorted = eMath.sort(values); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; }; this.parser.functions.q2 = this.parser.functions.median; this.parser.functions.q3 = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'q3'); return eMath.percentile(values, 75); }; this.parser.functions.iqr = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'iqr'); const q3 = eMath.percentile(values, 75); const q1 = eMath.percentile(values, 25); return q3 - q1; }; this.parser.functions.variance = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'variance'); return eMath.variance(values); }; this.parser.functions.stddev = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'stddev'); return Math.sqrt(eMath.variance(values)); }; this.parser.functions.range = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'range'); if (values.length === 0) return 0; if (values.length === 1) return 0; let min = values[0]; let max = values[0]; for (let i = 1; i < values.length; i++) { if (values[i] < min) min = values[i]; if (values[i] > max) max = values[i]; } return max - min; }; this.parser.functions.mode = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'mode'); if (values.length === 0) return 0; if (values.length === 1) return values[0]; const frequency: Record = {}; let maxFreq = 0; let mode = values[0]; for (let i = 0; i < values.length; i++) { const val = values[i]; frequency[val] = (frequency[val] || 0) + 1; if (frequency[val] > maxFreq) { maxFreq = frequency[val]; mode = val; } } return mode; }; this.parser.functions.skewness = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'skewness'); if (values.length < 3) return 0; const mean = eMath.mean(values); const variance = eMath.variance(values); const stdDev = Math.sqrt(variance); if (stdDev === 0) return 0; let sumCubedDiff = 0; for (let i = 0; i < values.length; i++) { const diff = values[i] - mean; sumCubedDiff += diff * diff * diff; } const n = values.length; return sumCubedDiff / n / (stdDev * stdDev * stdDev); }; this.parser.functions.kurtosis = function ( args: NumberInput, ...rest: number[] ): number { const values = toNumberArray(args, rest, 'kurtosis'); if (values.length < 4) return 0; const mean = eMath.mean(values); const variance = eMath.variance(values); const stdDev = Math.sqrt(variance); if (stdDev === 0) return 0; let sumFourthPower = 0; for (let i = 0; i < values.length; i++) { const diff = values[i] - mean; sumFourthPower += diff * diff * diff * diff; } const n = values.length; return sumFourthPower / n / (variance * variance) - 3; }; } private setupStreamFunctions() { this.parser.functions.values = function ( streams: ParsedStream[], attr: string ) { if (!Array.isArray(streams)) { throw new Error('Your streams input must be an array of streams'); } if (typeof attr !== 'string') { throw new Error('You must provide a string path for the attribute'); } const getStreamProperty = ( stream: ParsedStream, key: string ): number | undefined => { switch (key) { case 'bitrate': return stream.bitrate; case 'size': return stream.size; case 'folderSize': return stream.folderSize; case 'age': return stream.age; case 'duration': return stream.duration; case 'seeders': return stream.torrent?.seeders; case 'seScore': return stream.streamExpressionScore; case 'regexScore': return stream.regexScore; default: throw new Error(`Invalid attribute for values: '${key}'`); } }; return streams .map((stream) => getStreamProperty(stream, attr)) .filter((val) => typeof val === 'number' && !isNaN(val)); }; this.parser.functions.regexMatched = function ( streams: ParsedStream[], ...regexNames: string[] ) { if (regexNames.length === 0) { return streams.filter( (stream) => stream.regexMatched || stream.rankedRegexesMatched?.length ); } return streams.filter((stream) => regexNames.some( (regexName) => stream.regexMatched?.name === regexName || stream.rankedRegexesMatched?.some((r) => r === regexName) ) ); }; // gets all streams that have a regex matched with an index in the range of min and max this.parser.functions.regexMatchedInRange = function ( streams: ParsedStream[], min: number, max: number ) { return streams.filter((stream) => { if (!stream.regexMatched) { return false; } else if ( stream.regexMatched.index < min || stream.regexMatched.index > max ) { return false; } return true; }); }; // Filter streams by one or more keywords, restricted to the specified // stream attribute(s). Uses the same regex shape as the Keyword UI // filters (Required / Excluded / Included / Preferred Keywords) so the // matching behavior is identical -- the only difference is that the user // chooses which attributes to test against. // // `attributes` is a comma-separated string of attribute names. Allowed // values: 'filename', 'folderName', 'indexer', 'releaseGroup' (the same // set the Keyword UI filters check). Use 'all' or '*' to match every // attribute. this.parser.functions.keyword = function ( streams: ParsedStream[], attributes: string, ...keywords: string[] ) { const ALLOWED_ATTRIBUTES = [ 'filename', 'folderName', 'indexer', 'releaseGroup', ] as const; type KeywordAttribute = (typeof ALLOWED_ATTRIBUTES)[number]; if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } if (typeof attributes !== 'string' || attributes.trim().length === 0) { throw new Error( "keyword: attributes must be a non-empty string (comma-separated, or 'all' / '*')" ); } if ( keywords.length === 0 || keywords.some((k) => typeof k !== 'string') ) { throw new Error( 'keyword: you must provide one or more keyword string parameters' ); } const trimmed = attributes.trim(); let resolved: KeywordAttribute[]; if (trimmed === '*' || trimmed.toLowerCase() === 'all') { resolved = [...ALLOWED_ATTRIBUTES]; } else { resolved = []; const seen = new Set(); for (const part of trimmed .split(',') .map((p) => p.trim()) .filter((p) => p.length > 0)) { const match = ALLOWED_ATTRIBUTES.find( (attr) => attr.toLowerCase() === part.toLowerCase() ); if (!match) { throw new Error( `keyword: invalid attribute '${part}'. Allowed values: ${ALLOWED_ATTRIBUTES.join( ', ' )} (or 'all' / '*' for all)` ); } if (!seen.has(match)) { seen.add(match); resolved.push(match); } } if (resolved.length === 0) { throw new Error( 'keyword: attributes must contain at least one attribute name' ); } } const pattern = formRegexFromKeywordsSync(keywords); return streams.filter((stream) => { for (const attribute of resolved) { let value: string | undefined; switch (attribute) { case 'filename': value = stream.filename; break; case 'folderName': value = stream.folderName; break; case 'indexer': value = stream.indexer; break; case 'releaseGroup': value = stream.parsedFile?.releaseGroup; break; } if (value !== undefined && pattern.test(value)) { return true; } } return false; }); }; this.parser.functions.keywords = this.parser.functions.keyword; this.parser.functions.seMatched = function ( streams: ParsedStream[], ...seNames: string[] ) { if (seNames.length === 0) { return streams.filter((stream) => stream.streamExpressionMatched); } return streams.filter((stream) => seNames.some( (seName) => stream.streamExpressionMatched?.name === seName ) ); }; this.parser.functions.seMatchedInRange = function ( streams: ParsedStream[], min: number, max: number ) { return streams.filter((stream) => { if (!stream.streamExpressionMatched) { return false; } else if ( stream.streamExpressionMatched.index < min || stream.streamExpressionMatched.index > max ) { return false; } return true; }); }; this.parser.functions.rseMatched = function ( streams: ParsedStream[], ...rseNames: string[] ) { if (rseNames.length === 0) { return streams.filter( (stream) => stream.rankedStreamExpressionsMatched?.length ); } return streams.filter((stream) => rseNames.some((rseName) => stream.rankedStreamExpressionsMatched?.some((r) => r === rseName) ) ); }; this.parser.functions.indexer = function ( streams: ParsedStream[], ...indexers: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( indexers.length === 0 || indexers.some((i) => typeof i !== 'string') ) { throw new Error('You must provide one or more indexer strings'); } return streams.filter((stream) => indexers.includes(stream.indexer || 'Unknown') ); }; this.parser.functions.resolution = function ( streams: ParsedStream[], ...resolutions: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( resolutions.length === 0 || resolutions.some((r) => typeof r !== 'string') ) { throw new Error('You must provide one or more resolution strings'); } return streams.filter((stream) => resolutions .map((r) => r.toLowerCase()) .includes(stream.parsedFile?.resolution?.toLowerCase() || 'unknown') ); }; this.parser.functions.quality = function ( streams: ParsedStream[], ...qualities: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( qualities.length === 0 || qualities.some((q) => typeof q !== 'string') ) { throw new Error('You must provide one or more quality strings'); } return streams.filter((stream) => qualities .map((q) => q.toLowerCase()) .includes(stream.parsedFile?.quality?.toLowerCase() || 'unknown') ); }; this.parser.functions.encode = function ( streams: ParsedStream[], ...encodes: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( encodes.length === 0 || encodes.some((e) => typeof e !== 'string') ) { throw new Error('You must provide one or more encode strings'); } return streams.filter((stream) => encodes .map((encode) => encode.toLowerCase()) .includes(stream.parsedFile?.encode?.toLowerCase() || 'unknown') ); }; this.parser.functions.type = function ( streams: ParsedStream[], ...types: string[] ) { if (!Array.isArray(streams)) { throw new Error('Your streams input must be an array of streams'); } else if ( types.length === 0 || types.some((t) => typeof t !== 'string') ) { throw new Error('You must provide one or more type string parameters'); } return streams.filter((stream) => types.map((t) => t.toLowerCase()).includes(stream.type.toLowerCase()) ); }; this.parser.functions.visualTag = function ( streams: ParsedStream[], ...visualTags: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( visualTags.length === 0 || visualTags.some((v) => typeof v !== 'string') ) { throw new Error( 'You must provide one or more visual tag string parameters' ); } return streams.filter((stream) => (stream.parsedFile?.visualTags.length ? stream.parsedFile.visualTags : ['Unknown'] ).some((v) => visualTags.map((vt) => vt.toLowerCase()).includes(v.toLowerCase()) ) ); }; this.parser.functions.audioTag = function ( streams: ParsedStream[], ...audioTags: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( audioTags.length === 0 || audioTags.some((a) => typeof a !== 'string') ) { throw new Error( 'You must provide one or more audio tag string parameters' ); } return streams.filter((stream) => audioTags .map((a) => a.toLowerCase()) .some((a) => (stream.parsedFile?.audioTags.length ? stream.parsedFile.audioTags : ['Unknown'] ) .map((at) => at.toLowerCase()) .includes(a) ) ); }; this.parser.functions.audioChannels = function ( streams: ParsedStream[], ...audioChannels: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( audioChannels.length === 0 || audioChannels.some((a) => typeof a !== 'string') ) { throw new Error( 'You must provide one or more audio channel string parameters' ); } return streams.filter((stream) => audioChannels .map((a) => a.toLowerCase()) .some((a) => (stream.parsedFile?.audioChannels.length ? stream.parsedFile.audioChannels : ['Unknown'] ) ?.map((ac) => ac.toLowerCase()) .includes(a) ) ); }; this.parser.functions.language = function ( streams: ParsedStream[], ...languages: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( languages.length === 0 || languages.some((l) => typeof l !== 'string') ) { throw new Error( 'You must provide one or more language string parameters' ); } return streams.filter((stream) => languages .map((l) => l.toLowerCase()) .some((l) => (stream.parsedFile?.languages.length ? stream.parsedFile.languages : ['Unknown'] ) ?.map((lang) => lang.toLowerCase()) .includes(l) ) ); }; this.parser.functions.subtitle = function ( streams: ParsedStream[], ...subtitles: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( subtitles.length === 0 || subtitles.some((s) => typeof s !== 'string') ) { throw new Error( 'You must provide one or more subtitle string parameters' ); } return streams.filter((stream) => subtitles .map((s) => s.toLowerCase()) .some((s) => (stream.parsedFile?.subtitles?.length ? stream.parsedFile.subtitles : ['Unknown'] ) ?.map((sub) => sub.toLowerCase()) .includes(s) ) ); }; this.parser.functions.subtitles = this.parser.functions.subtitle; this.parser.functions.seeders = function ( streams: ParsedStream[], minSeeders?: number, maxSeeders?: number ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( typeof minSeeders !== 'number' && typeof maxSeeders !== 'number' ) { throw new Error('Min and max seeders must be a number'); } // select streams with seeders that lie within the range. return streams.filter((stream) => { if (minSeeders && (stream.torrent?.seeders ?? 0) < minSeeders) { return false; } if (maxSeeders && (stream.torrent?.seeders ?? 0) > maxSeeders) { return false; } return true; }); }; this.parser.functions.age = function ( streams: ParsedStream[], minAge?: number, maxAge?: number ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if (typeof minAge !== 'number' && typeof maxAge !== 'number') { throw new Error('Min and max age must be a number'); } else if (minAge && minAge < 0) { throw new Error('Min age cannot be negative'); } else if (maxAge && maxAge < 0) { throw new Error('Max age cannot be negative'); } else if (minAge && maxAge && maxAge < minAge) { throw new Error('Max age cannot be less than min age'); } // select streams with age that lie within the range. return streams.filter((stream) => { if (minAge && (stream.age ?? 0) < minAge) { return false; } if (maxAge && (stream.age ?? 0) > maxAge) { return false; } return true; }); }; this.parser.functions.size = function ( streams: ParsedStream[], minSize?: string | number, maxSize?: string | number ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( (minSize !== undefined && typeof minSize !== 'number' && typeof minSize !== 'string') || (maxSize !== undefined && typeof maxSize !== 'number' && typeof maxSize !== 'string') ) { throw new Error('Min and max size must be a number or string'); } else if (minSize === undefined && maxSize === undefined) { throw new Error('You must provide at least one size boundary'); } // use the bytes library to ensure we get a number const minSizeInBytes = typeof minSize === 'string' ? bytes.parse(minSize) : minSize; const maxSizeInBytes = typeof maxSize === 'string' ? bytes.parse(maxSize) : maxSize; return streams.filter((stream) => { if (minSize && minSizeInBytes && (stream.size ?? 0) < minSizeInBytes) { return false; } if (maxSize && maxSizeInBytes && (stream.size ?? 0) > maxSizeInBytes) { return false; } return true; }); }; this.parser.functions.bitrate = function ( streams: ParsedStream[], minBitrate?: string | number, maxBitrate?: string | number ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( (minBitrate !== undefined && typeof minBitrate !== 'number' && typeof minBitrate !== 'string') || (maxBitrate !== undefined && typeof maxBitrate !== 'number' && typeof maxBitrate !== 'string') ) { throw new Error('Min and max bitrate must be a number or string'); } else if (minBitrate === undefined && maxBitrate === undefined) { throw new Error('You must provide at least one bitrate boundary'); } const minBps = typeof minBitrate === 'string' ? parseBitrate(minBitrate) : minBitrate; const maxBps = typeof maxBitrate === 'string' ? parseBitrate(maxBitrate) : maxBitrate; if (typeof minBitrate === 'string' && minBps === undefined) { throw new Error(`Invalid min bitrate: ${minBitrate}`); } if (typeof maxBitrate === 'string' && maxBps === undefined) { throw new Error(`Invalid max bitrate: ${maxBitrate}`); } return streams.filter((stream) => { if (minBps !== undefined && (stream.bitrate ?? 0) < minBps) { return false; } if (maxBps !== undefined && (stream.bitrate ?? 0) > maxBps) { return false; } return true; }); }; this.parser.functions.service = function ( streams: ParsedStream[], ...services: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( services.length === 0 || services.some((s) => typeof s !== 'string') ) { throw new Error( 'You must provide one or more service string parameters' ); } else if ( services.length === 0 || services.some((s) => typeof s !== 'string') ) { throw new Error( 'You must provide one or more service string parameters' ); } else if ( !services.every((s) => [ 'realdebrid', 'debridlink', 'alldebrid', 'torbox', 'pikpak', 'seedr', 'offcloud', 'premiumize', 'easynews', 'nzbdav', 'altmount', 'stremio_nntp', 'easydebrid', 'debrider', ].includes(s) ) ) { throw new Error( 'Service must be a string and one of: realdebrid, debridlink, alldebrid, torbox, pikpak, seedr, offcloud, premiumize, easynews, nzbdav, altmount, easydebrid, debrider' ); } return streams.filter((stream) => services.some((s) => stream.service?.id === s) ); }; this.parser.functions.cached = function (streams: ParsedStream[]) { if (!Array.isArray(streams)) { throw new Error( "Please use one of 'totalStreams' or 'previousStreams' as the first argument" ); } return streams.filter((stream) => stream.service?.cached === true); }; this.parser.functions.uncached = function (streams: ParsedStream[]) { if (!Array.isArray(streams)) { throw new Error( "Please use one of 'totalStreams' or 'previousStreams' as the first argument" ); } return streams.filter((stream) => stream.service?.cached === false); }; this.parser.functions.releaseGroup = function ( streams: ParsedStream[], ...releaseGroups: string[] ) { if (!Array.isArray(streams)) { throw new Error( "Please use one of 'totalStreams' or 'previousStreams' as the first argument" ); } else if (releaseGroups.some((r) => typeof r !== 'string')) { throw new Error('All provided release groups must be strings'); } return streams.filter((stream) => releaseGroups.length === 0 ? !!stream.parsedFile?.releaseGroup : releaseGroups.some((r) => stream.parsedFile?.releaseGroup === r) ); }; this.parser.functions.seasonPack = function ( streams: ParsedStream[], mode: 'seasonPack' | 'onlySeasons' = 'onlySeasons' ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if (mode !== 'seasonPack' && mode !== 'onlySeasons') { throw new Error("Mode must be either 'seasonPack' or 'onlySeasons'"); } return streams.filter((stream) => mode === 'seasonPack' ? stream.parsedFile?.seasonPack : // when there are only seasons and no episodes stream.parsedFile?.seasons && stream.parsedFile?.seasons.length > 0 && (!stream.parsedFile.episodes || !stream.parsedFile?.episodes?.length) ); }; this.parser.functions.multiEpisode = function ( streams: ParsedStream[], minEpisodes: number = 2 ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if (typeof minEpisodes !== 'number' || minEpisodes < 1) { throw new Error('minEpisodes must be a positive number'); } return streams.filter( (stream) => stream.parsedFile?.episodes && stream.parsedFile.episodes.length >= minEpisodes ); }; this.parser.functions.addon = function ( streams: ParsedStream[], ...addons: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( addons.length === 0 || addons.some((a) => typeof a !== 'string') ) { throw new Error('You must provide one or more addon string parameters'); } return streams.filter((stream) => addons.includes(stream.addon.name)); }; this.parser.functions.library = function (streams: ParsedStream[]) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } return streams.filter((stream) => stream.library); }; this.parser.functions.seadex = function ( streams: ParsedStream[], filterType?: string ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { const nonStream = streams.find((s) => typeof s !== 'object' || !s.type); console.error('Invalid stream object:', nonStream); throw new Error('Your streams input must be an array of streams'); } const filter = filterType?.toLowerCase() || 'all'; if (filter === 'best') { // Only return SeaDex "best" releases return streams.filter((stream) => stream.seadex?.isBest === true); } // Return all SeaDex releases (includes group fallback matches) return streams.filter((stream) => stream.seadex?.isSeadex === true); }; this.parser.functions.seScore = function ( streams: ParsedStream[], minScore?: number, maxScore?: number ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( (minScore !== undefined && typeof minScore !== 'number') || (maxScore !== undefined && typeof maxScore !== 'number') ) { throw new Error('Score boundaries must be numbers if provided'); } return streams.filter((stream) => { const score = stream.streamExpressionScore; if (score === undefined) return false; if (minScore !== undefined && score < minScore) { return false; } if (maxScore !== undefined && score > maxScore) { return false; } return true; }); }; this.parser.functions.streamExpressionScore = this.parser.functions.seScore; this.parser.functions.regexScore = function ( streams: ParsedStream[], minScore?: number, maxScore?: number ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( (minScore !== undefined && typeof minScore !== 'number') || (maxScore !== undefined && typeof maxScore !== 'number') ) { throw new Error('Score boundaries must be numbers if provided'); } return streams.filter((stream) => { const score = stream.regexScore; if (score === undefined) return false; if (minScore !== undefined && score < minScore) { return false; } if (maxScore !== undefined && score > maxScore) { return false; } return true; }); }; this.parser.functions.message = function ( streams: ParsedStream[], mode: 'exact' | 'includes', ...messages: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } else if ( messages.length === 0 || messages.some((m) => typeof m !== 'string') ) { throw new Error( 'You must provide one or more message string parameters' ); } else if (mode !== 'exact' && mode !== 'includes') { throw new Error("Mode must be either 'exact' or 'includes'"); } return streams.filter((stream) => mode == 'exact' ? messages.includes(stream.message || '') : messages.some((m) => (stream.message || '').includes(m)) ); }; this.parser.functions.passthrough = function ( streams: ParsedStream[], ...stages: string[] ) { if (!Array.isArray(streams) || streams.some((stream) => !stream.type)) { throw new Error('Your streams input must be an array of streams'); } // Validate stages if provided if (stages.length > 0) { const validStages = PASSTHROUGH_STAGES as readonly string[]; const invalidStages = stages.filter((s) => !validStages.includes(s)); if (invalidStages.length > 0) { throw new Error( `Invalid passthrough stage(s): ${invalidStages.join(', ')}. Valid stages are: ${PASSTHROUGH_STAGES.join(', ')}` ); } } for (const stream of streams) { if (stages.length === 0) { // No stages specified = passthrough all stream.passthrough = true; } else { // Merge with existing passthrough stages if any const existingStages: PassthroughStage[] = Array.isArray( stream.passthrough ) ? stream.passthrough : []; const newStages = new Set([ ...existingStages, ...(stages as PassthroughStage[]), ]); stream.passthrough = Array.from(newStages) as PassthroughStage[]; } } return streams; }; this.parser.functions.count = function (streams: ParsedStream[]) { if (!Array.isArray(streams)) { throw new Error( "Please use one of 'totalStreams' or 'previousStreams' as the first argument" ); } return streams.length; }; this.parser.functions.negate = function ( streams: ParsedStream[], originalStreams: ParsedStream[] ) { if (!Array.isArray(originalStreams) || !Array.isArray(streams)) { throw new Error( "Both arguments of the 'negate' function must be arrays of streams" ); } const streamIds = new Set(streams.map((stream) => stream.id)); return originalStreams.filter((stream) => !streamIds.has(stream.id)); }; this.parser.functions.merge = function ( ...streamArrays: ParsedStream[][] ): ParsedStream[] { const seen = new Set(); const merged: ParsedStream[] = []; for (const array of streamArrays) { for (const stream of array) { if (!seen.has(stream.id)) { seen.add(stream.id); merged.push(stream); } } } return merged; }; this.parser.functions.slice = function ( streams: ParsedStream[], start: number, end?: number ) { if (!Array.isArray(streams)) { throw new Error('Your streams input must be an array of streams'); } return streams.slice(start, end); }; this.parser.functions.perGroup = function ( streams: ParsedStream[], attribute: string, n: number, ...filterValues: string[] ): ParsedStream[] { if (!Array.isArray(streams)) { throw new Error('perGroup: first argument must be an array of streams'); } if (typeof attribute !== 'string' || attribute.length === 0) { throw new Error( 'perGroup: second argument must be a non-empty attribute string' ); } if ( typeof n !== 'number' || !Number.isFinite(n) || !Number.isInteger(n) || n < 1 ) { throw new Error('perGroup: third argument must be a positive integer'); } if (filterValues.some((v) => typeof v !== 'string')) { throw new Error('perGroup: filter values must be strings'); } const normalised = filterValues.map((v) => v.toLowerCase()); /** Return the group keys for a stream under the chosen attribute. */ const getKeys = (stream: ParsedStream): string[] => { switch (attribute) { case 'resolution': return [stream.parsedFile?.resolution?.toLowerCase() || 'unknown']; case 'quality': return [stream.parsedFile?.quality?.toLowerCase() || 'unknown']; case 'encode': return [stream.parsedFile?.encode?.toLowerCase() || 'unknown']; case 'type': return [stream.type.toLowerCase()]; case 'service': return [(stream.service?.id || 'none').toLowerCase()]; case 'indexer': return [(stream.indexer || 'unknown').toLowerCase()]; case 'releaseGroup': return [ (stream.parsedFile?.releaseGroup || 'unknown').toLowerCase(), ]; case 'visualTag': return ( stream.parsedFile?.visualTags.length ? stream.parsedFile.visualTags : ['Unknown'] ).map((v) => v.toLowerCase()); case 'audioTag': return ( stream.parsedFile?.audioTags.length ? stream.parsedFile.audioTags : ['Unknown'] ).map((v) => v.toLowerCase()); case 'audioChannel': return ( stream.parsedFile?.audioChannels?.length ? stream.parsedFile.audioChannels : ['Unknown'] ).map((v) => v.toLowerCase()); case 'language': return ( stream.parsedFile?.languages?.length ? stream.parsedFile.languages : ['Unknown'] ).map((v) => v.toLowerCase()); case 'subtitle': return ( stream.parsedFile?.subtitles?.length ? stream.parsedFile.subtitles : ['Unknown'] ).map((v) => v.toLowerCase()); default: throw new Error( `perGroup: unsupported attribute '${attribute}'. Supported: resolution, quality, encode, type, service, indexer, releaseGroup, visualTag, audioTag, audioChannel, language, subtitle` ); } }; const buckets = new Map(); const groupOrder: string[] = []; // Deduplicate across groups (for multi-value attributes a stream could // match multiple groups) const added = new Set(); for (const stream of streams) { const keys = getKeys(stream); let assignedKey: string | undefined; if (normalised.length > 0) { assignedKey = keys.find((k) => normalised.includes(k)); } else { assignedKey = keys[0]; } if (assignedKey === undefined) continue; // filtered out if (!buckets.has(assignedKey)) { buckets.set(assignedKey, []); groupOrder.push(assignedKey); } const bucket = buckets.get(assignedKey)!; if (bucket.length < n && !added.has(stream.id)) { bucket.push(stream); added.add(stream.id); } } // interleave the buckets const result: ParsedStream[] = []; let round = 0; while (result.length < added.size) { let anyAdded = false; for (const key of groupOrder) { const bucket = buckets.get(key)!; if (round < bucket.length) { result.push(bucket[round]); anyAdded = true; } } if (!anyAdded) break; round++; } return result; }; this.parser.functions.pin = ( matchedStreams: ParsedStream[], position: string = 'top', returnMatched: boolean = false ) => { if ( !Array.isArray(matchedStreams) || matchedStreams.some((stream) => !stream.type) ) { throw new Error( 'The first argument must be a filtered subset of streams to pin' ); } if (position !== 'top' && position !== 'bottom') { throw new Error("Position must be 'top' or 'bottom'"); } for (const stream of matchedStreams) { this._pinInstructions.set(stream.id, position as 'top' | 'bottom'); } return returnMatched ? matchedStreams : []; }; } protected async evaluateCondition(condition: string): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Condition parsing timed out')); }, 1); const start = Date.now(); try { const result = this.parser.evaluate(condition); clearTimeout(timeout); const elapsed = Date.now() - start; logger.silly( `Expression evaluated in ${elapsed}ms: "${condition.length > 100 ? condition.substring(0, 100) + '...' : condition}"` ); resolve(result); } catch (error) { clearTimeout(timeout); const elapsed = Date.now() - start; logger.debug( `Expression failed after ${elapsed}ms: "${condition.length > 100 ? condition.substring(0, 100) + '...' : condition}"` ); if (error instanceof Error) { error.message = `Expression could not be evaluated: ${error.message}`; } reject(error); } }); } protected createTestStream( overrides: Partial = {} ): ParsedStream { const defaultStream: ParsedStream = { id: '1', type: 'http', addon: { preset: { type: 'test-preset', id: 'test-preset', options: {}, }, manifestUrl: 'https://example.com/manifest.json', enabled: true, name: 'Test Addon', timeout: 30000, }, service: { id: 'realdebrid', cached: true, }, indexer: 'Test Indexer', parsedFile: { title: 'Test Title', year: '2024', seasons: [1], episodes: [1], resolution: '1080p', quality: 'BluRay', encode: 'x264', releaseGroup: 'TEST', visualTags: ['HDR'], audioTags: ['AAC'], audioChannels: ['2.0'], languages: ['English'], subtitles: [], }, size: 1073741824, // 1GB in bytes folderSize: 2147483648, // 2GB in bytes library: false, url: 'https://example.com/stream.mkv', filename: 'test.mkv', folderName: 'Test Folder', duration: 7200, // 2 hours in seconds age: 24, // 1 day in hours message: 'Test message', torrent: { infoHash: 'test-hash', fileIdx: 0, seeders: 100, sources: ['https://tracker.example.com'], }, countryWhitelist: ['USA'], notWebReady: false, bingeGroup: 'test-group', requestHeaders: { 'User-Agent': 'Test' }, responseHeaders: { 'Content-Type': 'video/mp4' }, videoHash: 'test-video-hash', subtitles: [], proxied: false, regexMatched: { name: 'test-regex', pattern: 'test', index: 0, }, keywordMatched: false, ytId: undefined, externalUrl: undefined, error: undefined, originalName: 'Original Test Name', originalDescription: 'Original Test Description', }; return { ...defaultStream, ...overrides }; } } export class ExitConditionEvaluator extends StreamExpressionEngine { constructor( private totalStreams: ParsedStream[], private totalTimeTaken: number, private queryType: string, private queriedAddons: string[], private allAddons: string[] ) { super(); this.parser.consts.totalStreams = this.totalStreams; this.parser.consts.totalTimeTaken = this.totalTimeTaken; this.parser.consts.queryType = this.queryType; this.parser.consts.queriedAddons = this.queriedAddons; this.parser.consts.allAddons = this.allAddons; } async evaluate(condition: string) { return await this.evaluateCondition(condition); } static async testEvaluate(condition: string) { const parser = new ExitConditionEvaluator( [], 200, 'movie', ['Test Addon'], ['Test Addon'] ); return await parser.evaluate(condition); } } export class GroupConditionEvaluator extends StreamExpressionEngine { private previousStreams: ParsedStream[]; private totalStreams: ParsedStream[]; private previousGroupTimeTaken: number; private totalTimeTaken: number; constructor( previousStreams: ParsedStream[], totalStreams: ParsedStream[], previousGroupTimeTaken: number, totalTimeTaken: number, queryType: string ) { super(); this.previousStreams = previousStreams; this.totalStreams = totalStreams; this.previousGroupTimeTaken = previousGroupTimeTaken; this.totalTimeTaken = totalTimeTaken; // Set up constants for this specific parser this.parser.consts.previousStreams = this.previousStreams; this.parser.consts.totalStreams = this.totalStreams; this.parser.consts.queryType = queryType; this.parser.consts.previousGroupTimeTaken = this.previousGroupTimeTaken; this.parser.consts.totalTimeTaken = this.totalTimeTaken; } async evaluate(condition: string) { return await this.evaluateCondition(condition); } static async testEvaluate(condition: string) { const parser = new GroupConditionEvaluator([], [], 0, 0, 'movie'); return await parser.evaluate(condition); } } export class StreamSelector extends StreamExpressionEngine { constructor(context: ExpressionContext) { super(); this.setupExpressionContextConstants(context); } async select( streams: ParsedStream[], condition: string ): Promise { // Set the streams constant for this filter operation this.parser.consts.streams = streams; let selectedStreams: ParsedStream[] = []; selectedStreams = await this.evaluateCondition(condition); // if the result is a boolean value, convert it to the appropriate type // true = all streams, false = no streams if (typeof selectedStreams === 'boolean') { selectedStreams = selectedStreams ? streams : []; } // attempt to parse the result try { selectedStreams = ParsedStreams.parse(selectedStreams); } catch (error) { throw new Error( `Result could not be parsed as stream array: ${formatZodError(error as ZodError)}` ); } return selectedStreams; } static async testSelect(condition: string): Promise { const parser = new StreamSelector({ queryType: 'movie' }); const streams = [ parser.createTestStream({ type: 'debrid' }), parser.createTestStream({ type: 'debrid' }), parser.createTestStream({ type: 'debrid' }), parser.createTestStream({ type: 'usenet' }), parser.createTestStream({ type: 'p2p' }), parser.createTestStream({ type: 'p2p' }), ]; return await parser.select(streams, condition); } } /** * Extracts names from comments in a stream expression. * Names are extracted from block comments that don't start with #. * @param expression The stream expression to extract names from * @returns Array of extracted names, or undefined if none found */ export function extractNamesFromExpression( expression: string, ignoreHashPrefixed = true ): string[] | undefined { const regex = /\/\*\s*(.*?)\s*\*\//g; const names: string[] = []; let match; while ((match = regex.exec(expression)) !== null) { const content = match[1]; if (content.startsWith('#')) { if (!ignoreHashPrefixed) { names.push(content.slice(1).trim()); } } else { names.push(content); } } return names.length > 0 ? names : undefined; }