import { ParsedStream, UserData } from '../db/schemas.js'; import * as constants from '../utils/constants.js'; import { createLogger } from '../logging/logger.js'; import { formatBytes, formatSmartBytes, formatBitrate, formatDuration, formatHours, makeSmall, formatSmartBitrate, } from './utils.js'; import { languageToCode, languageToEmoji } from '../utils/languages.js'; import { config as appConfig } from '../config/index.js'; const logger = createLogger('formatter'); const MAX_TEMPLATE_DEPTH = 5; /** * * The custom formatter code in this file was adapted from https://github.com/diced/zipline/blob/trunk/src/lib/parser/index.ts * * The original code is licensed under the MIT License. * * MIT License * * Copyright (c) 2023 dicedtomato * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ export interface FormatterConfig { name: string; description: string; } export interface ParseValue { config?: { addonName: string | null; }; stream?: { filename: string | null; folderName: string | null; size: number | null; bitrate: number | null; folderSize: number | null; library: boolean; quality: string | null; resolution: string | null; subbed: boolean; dubbed: boolean; languages: string[] | null; uLanguages: string[] | null; subtitles: string[] | null; uSubtitles: string[] | null; languageEmojis: string[] | null; uLanguageEmojis: string[] | null; subtitleEmojis: string[] | null; uSubtitleEmojis: string[] | null; languageCodes: string[] | null; uLanguageCodes: string[] | null; subtitleCodes: string[] | null; uSubtitleCodes: string[] | null; smallLanguageCodes: string[] | null; uSmallLanguageCodes: string[] | null; smallSubtitleCodes: string[] | null; uSmallSubtitleCodes: string[] | null; wedontknowwhatakilometeris: string[] | null; uWedontknowwhatakilometeris: string[] | null; visualTags: string[] | null; audioTags: string[] | null; releaseGroup: string | null; regexMatched: string | null; rankedRegexMatched: string[]; regexScore: number | null; nRegexScore: number | null; // normalised (0-100) regex score encode: string | null; audioChannels: string[] | null; edition: string | null; editions: string[] | null; remastered: null; regraded: boolean; repack: boolean; uncensored: boolean; unrated: boolean; upscaled: boolean; hasChapters: boolean; network: string | null; container: string | null; extension: string | null; indexer: string | null; year: string | null; title: string | null; date: string | null; folderSeasons: number[] | null; formattedFolderSeasons: string | null; seasons: number[] | null; season: number | null; formattedSeasons: string | null; episodes: number[] | null; episode: number | null; formattedEpisodes: string | null; folderEpisodes: number[] | null; formattedFolderEpisodes: string | null; seasonEpisode: string[] | null; seasonPack: boolean; seeders: number | null; private: boolean; freeleech: boolean | null; age: string | null; ageHours: number | null; duration: number | null; infoHash: string | null; type: string | null; message: string | null; proxied: boolean; seadex: boolean; seadexBest: boolean; seScore: number | null; nSeScore: number | null; // normalised (0-100) based on max and min scores (neg scores become 0) seMatched: string | null; rseMatched: string[]; }; metadata?: { queryType: string | null; title: string | null; runtime: number | null; genres: string[] | null; year: number | null; episodeRuntime: number | null; }; service?: { id: string | null; shortName: string | null; name: string | null; cached: boolean | null; }; addon?: { name: string | null; presetId: string | null; manifestUrl: string | null; }; debug?: { json: string | null; jsonf: string | null; } & typeof DebugToolReplacementConstants; } /** * Pre-compiled function that takes ParseValue and returns formatted string */ type CompiledParseFunction = (parseValue: ParseValue) => string; type CompiledVariableWInsertFn = { resultFn: (parseValue: ParseValue) => ResolvedVariable; insertIndex: number; }; /** * Pre-compiled function that takes ParseValue and returns `ResolvedVariable` (future: and the variable's context for caching purposes) * * Retrieves the resolved variable (including modifiers) given a ParseValue (e.g. `stream.cached:istrue` -> `{result: true}` or `stream.languages::istrue` -> `{error: "unknown_array_modifier(istrue)"}`) */ type CompiledModifiedVariableFn = (parseValue: ParseValue) => ResolvedVariable; export interface FormatterContext { userData: UserData; // From ExpressionContext type?: string; isAnime?: boolean; queryType?: string; season?: number; episode?: number; title?: string; titles?: string[]; year?: number; yearEnd?: number; genres?: string[]; runtime?: number; episodeRuntime?: number; absoluteEpisode?: number; relativeAbsoluteEpisode?: number; originalLanguage?: string; daysSinceRelease?: number; hasNextEpisode?: boolean; daysUntilNextEpisode?: number; daysSinceFirstAired?: number; daysSinceLastAired?: number; latestSeason?: number; anilistId?: number; malId?: number; hasSeaDex?: boolean; maxSeScore?: number; maxRegexScore?: number; } export abstract class BaseFormatter { protected config: FormatterConfig; protected userData: UserData; protected formatterContext: FormatterContext; private regexBuilder: BaseFormatterRegexBuilder; private precompiledNameFunction: CompiledParseFunction | null = null; private precompiledDescriptionFunction: CompiledParseFunction | null = null; private _compilationPromise: Promise; constructor(config: FormatterConfig, ctx: FormatterContext) { this.config = config; this.userData = ctx.userData; this.formatterContext = ctx; this.regexBuilder = new BaseFormatterRegexBuilder( this.convertStreamToParseValue({} as ParsedStream) ); // Start template compilation asynchronously in the background this._compilationPromise = this.compileTemplatesAsync(); } private async compileTemplatesAsync(): Promise { this.precompiledNameFunction = await this.compileTemplate(this.config.name); this.precompiledDescriptionFunction = await this.compileTemplate( this.config.description ); } public async format( stream: ParsedStream ): Promise<{ name: string; description: string }> { // Wait for template compilation to complete if it hasn't already await this._compilationPromise; if (!this.precompiledNameFunction || !this.precompiledDescriptionFunction) { throw new Error('Template compilation failed - formatter not ready'); } const parseValue = this.convertStreamToParseValue(stream); return { name: this.precompiledNameFunction(parseValue), description: this.precompiledDescriptionFunction(parseValue), }; } protected convertStreamToParseValue(stream: ParsedStream): ParseValue { // Get original language from formatter context instead of from the stream's languages array hack const getPaddedNumber = (number: number, length: number) => number.toString().padStart(length, '0'); const formattedSeasonString = stream.parsedFile?.seasons?.length ? stream.parsedFile.seasons.length === 1 ? `S${getPaddedNumber(stream.parsedFile.seasons[0], 2)}` : `S${getPaddedNumber(stream.parsedFile.seasons[0], 2)}-${getPaddedNumber(stream.parsedFile.seasons[stream.parsedFile.seasons.length - 1], 2)}` : undefined; const formattedEpisodeString = stream.parsedFile?.episodes?.length ? stream.parsedFile.episodes.length === 1 ? `E${getPaddedNumber(stream.parsedFile.episodes[0], 2)}` : `E${getPaddedNumber(stream.parsedFile.episodes[0], 2)}-${getPaddedNumber(stream.parsedFile.episodes[stream.parsedFile.episodes.length - 1], 2)}` : undefined; const seasonEpisode = [ formattedSeasonString, formattedEpisodeString, ].filter((v) => v !== undefined); const formattedFolderSeasonString = stream.parsedFile?.folderSeasons?.length ? stream.parsedFile.folderSeasons.length === 1 ? `S${getPaddedNumber(stream.parsedFile.folderSeasons[0], 2)}` : `S${getPaddedNumber(stream.parsedFile.folderSeasons[0], 2)}-${getPaddedNumber(stream.parsedFile.folderSeasons[stream.parsedFile.folderSeasons.length - 1], 2)}` : undefined; const formattedFolderEpisodesString = stream.parsedFile?.folderEpisodes ?.length ? stream.parsedFile.folderEpisodes.length === 1 ? `E${getPaddedNumber(stream.parsedFile.folderEpisodes[0], 2)}` : `E${getPaddedNumber(stream.parsedFile.folderEpisodes[0], 2)}-${getPaddedNumber(stream.parsedFile.folderEpisodes[stream.parsedFile.folderEpisodes.length - 1], 2)}` : undefined; const getFieldValues = (field: string): string[] => { // capitalise first letter const key = field.charAt(0).toUpperCase() + field.slice(1); return [ ...((this.userData[`preferred${key}` as keyof UserData] || []) as string[]), ...((this.userData[`required${key}` as keyof UserData] || []) as string[]), ...((this.userData[`included${key}` as keyof UserData] || []) as string[]), ]; }; const sortByUserPreference = ( items: T[] | undefined, userPrefs: string[] ): T[] | null => { if (!items) return null; if (!userPrefs.length) return items; return [...items].sort((a, b) => { const aIndex = userPrefs.indexOf(a); const bIndex = userPrefs.indexOf(b); const aInPrefs = aIndex !== -1; const bInPrefs = bIndex !== -1; if (aInPrefs && bInPrefs) { return aIndex - bIndex; } return aInPrefs ? -1 : bInPrefs ? 1 : 0; }); }; const userSpecifiedLanguages = [ ...new Set( getFieldValues('languages').map((lang) => lang === 'Original' && this.formatterContext.originalLanguage ? this.formatterContext.originalLanguage : lang ) ), ]; const userSpecifiedSubtitles = [ ...new Set( getFieldValues('subtitles').map((lang) => lang === 'Original' && this.formatterContext.originalLanguage ? this.formatterContext.originalLanguage : lang ) ), ]; const buildLanguageVariants = ( values: string[] | undefined, userSpecifiedValues: string[] ) => { const sortedValues = sortByUserPreference(values, userSpecifiedValues); const userValues = sortedValues ? sortedValues.filter((value) => userSpecifiedValues.includes(value as any) ) : null; const applyModifiers = ( list: string[] | null, ...modifiers: Array<(value: string) => string | undefined> ): string[] | null => { if (!list) return null; const modified = list.map((value) => modifiers.reduce( (acc, modifier) => acc !== undefined ? (modifier(acc) ?? acc) : undefined, value ) ); return [...new Set(modified.filter(Boolean) as string[])]; }; const emojis = applyModifiers(sortedValues, languageToEmoji); const userEmojis = applyModifiers(userValues, languageToEmoji); const codes = applyModifiers( sortedValues, (value) => languageToCode(value) || value.toUpperCase() ); const userCodes = applyModifiers( userValues, (value) => languageToCode(value) || value.toUpperCase() ); const smallCodes = applyModifiers( sortedValues, languageToCode, makeSmall ); const userSmallCodes = applyModifiers( userValues, languageToCode, makeSmall ); const usEmojis = applyModifiers(sortedValues, languageToEmoji, (emoji) => emoji.replace('🇬🇧', '🇺🇸🦅') ); const userUsEmojis = applyModifiers( userValues, languageToEmoji, (emoji) => emoji.replace('🇬🇧', '🇺🇸🦅') ); return { sortedValues, userValues, emojis, userEmojis, codes, userCodes, smallCodes, userSmallCodes, usEmojis, userUsEmojis, }; }; const languageVariants = buildLanguageVariants( stream.parsedFile?.languages, userSpecifiedLanguages ); const subtitleVariants = buildLanguageVariants( stream.parsedFile?.subtitles, userSpecifiedSubtitles?.length ? userSpecifiedSubtitles : userSpecifiedLanguages ); const sortedAudioChannels = sortByUserPreference( stream.parsedFile?.audioChannels, getFieldValues('audioChannels') ); const sortedAudioTags = sortByUserPreference( stream.parsedFile?.audioTags, getFieldValues('audioTags') ); const sortedVisualTags = sortByUserPreference( stream.parsedFile?.visualTags, getFieldValues('visualTags') ); const formattedAge = stream.age ? formatHours(stream.age) : null; const parseValue: ParseValue = { config: { addonName: this.userData.addonName || appConfig.branding.addonName, }, stream: { filename: stream.filename || null, folderName: stream.folderName || null, size: stream.size || null, folderSize: stream.folderSize || null, library: stream.library ?? false, quality: stream.parsedFile?.quality || null, resolution: stream.parsedFile?.resolution || null, subbed: stream.parsedFile?.subbed || !!stream.parsedFile?.subtitles?.length, dubbed: stream.parsedFile?.dubbed || false, languages: languageVariants.sortedValues, uLanguages: languageVariants.userValues, subtitles: subtitleVariants.sortedValues, uSubtitles: subtitleVariants.userValues, languageEmojis: languageVariants.emojis, uLanguageEmojis: languageVariants.userEmojis, subtitleEmojis: subtitleVariants.emojis, uSubtitleEmojis: subtitleVariants.userEmojis, languageCodes: languageVariants.codes, uLanguageCodes: languageVariants.userCodes, subtitleCodes: subtitleVariants.codes, uSubtitleCodes: subtitleVariants.userCodes, smallLanguageCodes: languageVariants.smallCodes, uSmallLanguageCodes: languageVariants.userSmallCodes, smallSubtitleCodes: subtitleVariants.smallCodes, uSmallSubtitleCodes: subtitleVariants.userSmallCodes, wedontknowwhatakilometeris: languageVariants.usEmojis, uWedontknowwhatakilometeris: languageVariants.userUsEmojis, visualTags: sortedVisualTags, audioTags: sortedAudioTags, releaseGroup: stream.parsedFile?.releaseGroup || null, regexMatched: stream.regexMatched?.name || stream.rankedRegexesMatched?.[0] || null, rankedRegexMatched: stream.rankedRegexesMatched?.filter( (name): name is string => typeof name === 'string' ) || [], regexScore: stream.regexScore ?? null, nRegexScore: stream.regexScore != undefined && this.formatterContext.maxRegexScore != undefined && this.formatterContext.maxRegexScore > 0 ? Math.max( 0, Math.min( 100, Math.round( (stream.regexScore / this.formatterContext.maxRegexScore) * 100 ) ) ) : null, encode: stream.parsedFile?.encode || null, audioChannels: sortedAudioChannels || null, indexer: stream.indexer || null, seeders: stream.torrent?.seeders ?? null, private: stream.torrent?.private ?? false, freeleech: stream.torrent?.freeleech ?? null, year: stream.parsedFile?.year || null, type: stream.type || null, title: stream.parsedFile?.title || null, date: stream.parsedFile?.date || null, season: stream.parsedFile?.seasons?.[0] || null, formattedSeasons: formattedSeasonString || null, seasons: stream.parsedFile?.seasons || null, folderSeasons: stream.parsedFile?.folderSeasons || null, formattedFolderSeasons: formattedFolderSeasonString || null, episode: stream.parsedFile?.episodes?.[0] || null, formattedEpisodes: formattedEpisodeString || null, episodes: stream.parsedFile?.episodes || null, formattedFolderEpisodes: formattedFolderEpisodesString || null, folderEpisodes: stream.parsedFile?.folderEpisodes || null, seasonEpisode: seasonEpisode || null, seasonPack: stream.parsedFile?.seasonPack ?? false, duration: stream.duration || null, bitrate: stream.bitrate ?? null, infoHash: stream.torrent?.infoHash || null, age: formattedAge, ageHours: stream.age || null, message: stream.message || null, proxied: stream.proxied ?? false, edition: stream.parsedFile?.editions?.[0] || null, editions: stream.parsedFile?.editions || null, regraded: stream.parsedFile?.regraded ?? false, remastered: null, repack: stream.parsedFile?.repack ?? false, uncensored: stream.parsedFile?.uncensored ?? false, unrated: stream.parsedFile?.unrated ?? false, upscaled: stream.parsedFile?.upscaled ?? false, hasChapters: stream.parsedFile?.hasChapters ?? false, network: stream.parsedFile?.network || null, container: stream.parsedFile?.container || null, extension: stream.parsedFile?.extension || null, seadex: stream.seadex?.isSeadex ?? false, seadexBest: stream.seadex?.isBest ?? false, nSeScore: stream.streamExpressionScore != undefined && this.formatterContext.maxSeScore != undefined && this.formatterContext.maxSeScore > 0 ? Math.max( 0, Math.min( 100, Math.round( (stream.streamExpressionScore / this.formatterContext.maxSeScore) * 100 ) ) ) : null, seScore: stream.streamExpressionScore ?? null, seMatched: stream.streamExpressionMatched?.name || null, rseMatched: stream.rankedStreamExpressionsMatched?.filter( (name): name is string => typeof name === 'string' ) || [], }, metadata: { queryType: this.formatterContext.queryType || null, title: this.formatterContext.title || null, runtime: this.formatterContext.runtime || null, episodeRuntime: this.formatterContext.episodeRuntime || null, genres: this.formatterContext.genres || null, year: this.formatterContext.year || null, }, addon: { name: stream.addon?.name || null, presetId: stream.addon?.preset?.type || null, manifestUrl: stream.addon?.manifestUrl || null, }, service: { id: stream.service?.id || null, shortName: stream.service?.id ? Object.values(constants.SERVICE_DETAILS).find( (service) => service.id === stream.service?.id )?.shortName || null : null, name: stream.service?.id ? Object.values(constants.SERVICE_DETAILS).find( (service) => service.id === stream.service?.id )?.name || null : null, cached: stream.service?.cached !== undefined ? stream.service?.cached : null, }, }; parseValue.debug = { ...DebugToolReplacementConstants, json: JSON.stringify({ ...parseValue, debug: undefined }), jsonf: JSON.stringify( { ...parseValue, debug: undefined }, (_, value) => value, 2 ), }; return parseValue; } protected async compileTemplate(str: string): Promise { const compiledHelper = await this.compileTemplateHelper(str, 0); return (parseValue: ParseValue) => { const resultStr = compiledHelper(parseValue); // final post-processing of the result string return resultStr .replace(/\\n/g, '\n') .split('\n') .filter( (line) => line.trim() !== '' && !line.includes('{tools.removeLine}') ) .join('\n') .replace(/\{tools.newLine\}/g, '\n'); }; } protected async compileTemplateHelper( str: string, depth: number = 0 ): Promise { if (depth > MAX_TEMPLATE_DEPTH) { logger.warn( `Template nesting depth exceeded (max ${MAX_TEMPLATE_DEPTH}). Returning literal text.` ); const literalStr = str; return (_parseValue: ParseValue) => literalStr; } const re = this.regexBuilder.buildRegexExpression(); let matches: RegExpExecArray | null; let compiledMatchTemplateFns: CompiledVariableWInsertFn[] = []; for (const key in DebugToolReplacementConstants) { str = str.replace( `{debug.${key}}`, DebugToolReplacementConstants[ key as keyof typeof DebugToolReplacementConstants ] ); } const placeHolder = ' '; // Iterate through all {...} matches while ((matches = re.exec(str))) { if (!matches.groups) continue; const index = matches.index as number; // looks like variableType.propertyName(::)* (no timezone or check) let matchWithoutSuffix = matches[0].substring( 1, matches[0].length - 1 - (matches.groups.suffix ?? '').length ); // Split {>::::>...} into variableWithModifiers array and comparators array const splitOnComparators = matchWithoutSuffix.split( RegExp(this.regexBuilder.buildComparatorRegexPattern(), 'gi') ); const variableWithModifiers = splitOnComparators.filter( (_, i) => i % 2 == 0 ); const comparators = splitOnComparators.filter((_, i) => i % 2 != 0); const foundComparators = comparators.map( (c) => c as keyof typeof ComparatorConstants.comparatorKeyToFuncs ); let precompiledResolvedVariableFns: CompiledModifiedVariableFn[] = variableWithModifiers.map((baseString) => this.parseModifiedVariable(baseString, { mod_tzlocale: matches?.groups?.mod_tzlocale ?? undefined, }) ); // COMPARATOR logic: compare all ResolvedVariables against each other to make one ResolvedVariable (as precompiled wrapper function (parseValue) => ResolvedVariable) let precompiledResolvedVariableFn = ( parseValue: ParseValue ): ResolvedVariable => { if (precompiledResolvedVariableFns.length == 1) return precompiledResolvedVariableFns[0](parseValue); // Check if we can safely use short-circuit evaluation // Only short-circuit when all operators are the same type (all 'and' or all 'or') const allOperatorsSame = foundComparators.every( (op, idx, arr) => op === arr[0] ); const canShortCircuit = allOperatorsSame && (foundComparators[0] === 'and' || foundComparators[0] === 'or'); // Use lazy evaluation with short-circuit logic for AND/OR operations when safe let result: ResolvedVariable = precompiledResolvedVariableFns[0](parseValue); for (let i = 1; i < precompiledResolvedVariableFns.length; i++) { // If previous result has error, propagate it if (result.error !== undefined) return result; const compareKey = foundComparators[ i - 1 ] as keyof typeof ComparatorConstants.comparatorKeyToFuncs; // Short-circuit evaluation only when all operators are the same if (canShortCircuit) { if (compareKey === 'and' && result.result === false) { return { result: false }; } else if (compareKey === 'or' && result.result === true) { return { result: true }; } } const nextResolved = precompiledResolvedVariableFns[i](parseValue); // If next result has error, propagate it if (nextResolved.error !== undefined) return nextResolved; const comparatorFn = ComparatorConstants.comparatorKeyToFuncs[compareKey]; try { result = { result: comparatorFn(result.result, nextResolved.result), }; } catch (e) { return { error: `{unable_to_compare(<${result.result}>::${compareKey}::<${nextResolved.result}>, ${e})}`, }; } } return result; }; // end of COMPARATOR logic // CHECK TRUE/FALSE logic: compile the true/false templates and apply them to the resolved variable if (matches.groups.mod_check !== undefined) { const check_trueFn = await this.compileTemplateHelper( matches?.groups?.mod_check_true ?? '', depth + 1 ); const check_falseFn = await this.compileTemplateHelper( matches?.groups?.mod_check_false ?? '', depth + 1 ); const _compiledResolvedVariableFn = precompiledResolvedVariableFn; precompiledResolvedVariableFn = ( parseValue: ParseValue ): ResolvedVariable => { const resolved = _compiledResolvedVariableFn(parseValue); if (![true, false].includes(resolved.result)) { return { error: `{cannot_coerce_boolean_for_check_from(${resolved.result})}`, }; } return { result: resolved.result ? check_trueFn(parseValue) : check_falseFn(parseValue), }; }; } // end of CHECK TRUE/FALSE logic str = str.slice(0, index) + placeHolder + str.slice(re.lastIndex); re.lastIndex = index + placeHolder.length; compiledMatchTemplateFns.push({ resultFn: precompiledResolvedVariableFn, insertIndex: index, }); } // end of while loop compiledMatchTemplateFns = compiledMatchTemplateFns.sort( (a, b) => b.insertIndex - a.insertIndex ); return (parseValue: ParseValue) => { let resultStr = str; // Sort by startIndex to process in reverse order for (const { resultFn, insertIndex } of compiledMatchTemplateFns) { const resolvedResult = resultFn(parseValue); const replacement = resolvedResult.error ?? resolvedResult.result?.toString() ?? ''; resultStr = resultStr.slice(0, insertIndex) + replacement + resultStr.slice(insertIndex + placeHolder.length); } return resultStr; }; } /** * @param baseString - string to parse, e.g. `.(::)*` * @param value - ParseValue object * @param fullStringModifiers - modifiers that are applied to the entire string (e.g. `::`) * * @returns (parseValue) => `{ result: }` or `{ error: "" }` */ protected parseModifiedVariable( baseString: string, fullStringModifiers: { mod_tzlocale: string | undefined; } ): CompiledModifiedVariableFn { // get variableType and propertyName from baseString without regex const variableType = baseString.split('.')[0]; baseString = baseString.substring(variableType.length + 1); const propertyName = baseString.split('::')[0]; const allModifiers = baseString.substring(propertyName.length); let sortedModMatches: string[] = []; if (allModifiers.length) { const singleModTerminator = '(?=::|$)'; // :: if there's multiple modifiers, or $ for the end of the string const singleValidModRe = new RegExp( `${this.regexBuilder.buildModifierRegexPattern()}${singleModTerminator}`, 'g' ); sortedModMatches = [...allModifiers.matchAll(singleValidModRe)] .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) .map( (regExpExecArray) => regExpExecArray[1] /* First capture group, aka the modifier name */ ); } return (parseValue: ParseValue) => { // PARSE VARIABLE logic const variableDict = parseValue[variableType as keyof ParseValue]; if (!variableDict) return { error: `{unknown_variableType(${variableType})}` }; // should never happen const property = variableDict![ propertyName as keyof typeof variableDict ] as any; if (property === undefined) return { error: `{unknown_propertyName(${variableType}.${propertyName})}`, }; // should never happen // end of PARSE VARIABLE logic // APPLY MULTIPLE MODIFIERS logic let result = property; for (const lastModMatched of sortedModMatches) { result = this.applySingleModifier( result, lastModMatched, fullStringModifiers, parseValue ); if (result === undefined) { let getErrorResult = () => { switch (typeof property) { case 'string': case 'number': case 'boolean': return { error: `{unknown_${typeof property}_modifier(${lastModMatched})}`, }; case 'object': if (property == null) { return { error: `{cannot_apply_modifier_to_null(${lastModMatched})}`, }; } return { error: `{unknown_array_modifier(${lastModMatched})}` }; default: return { error: `{unknown_modifier(${lastModMatched})}` }; } }; return getErrorResult(); } } // end of APPLY MULTIPLE MODIFIERS logic return { result: result } as ResolvedVariable; }; } /** * @param variable - the variable to apply the modifier to (e.g. `123`, `"TorBox"`, `["English", "Italian"]`, etc.) * @param mod - the modifier to apply * @param fullStringModifiers - modifiers that are applied to the entire string (e.g. `::`) * @returns `{ result: }` or `{ error: "" }` */ protected applySingleModifier( variable: any, mod: string, fullStringModifiers: { mod_tzlocale: string | undefined; }, parseValue?: ParseValue ): string | boolean | any[] | undefined { const _mod = mod; mod = mod.toLowerCase(); // CONDITIONAL MODIFIERS const isExact = Object.keys( ModifierConstants.conditionalModifiers.exact ).includes(mod); const isPrefix = Object.keys( ModifierConstants.conditionalModifiers.prefix ).some((key) => mod.startsWith(key)); if (isExact || isPrefix) { // try to coerce true/false value from modifier let conditional: boolean | undefined; try { // PRE-CHECK(s) -- skip resolving conditional modifier if value DNE, defaulting to false conditional if (!ModifierConstants.conditionalModifiers.exact.exists(variable)) { conditional = false; } // EXACT else if (isExact) { const modAsKey = mod as keyof typeof ModifierConstants.conditionalModifiers.exact; conditional = ModifierConstants.conditionalModifiers.exact[modAsKey](variable); } // PREFIX else if (isPrefix) { // get the longest prefix match const modPrefix = Object.keys( ModifierConstants.conditionalModifiers.prefix ) .sort((a, b) => b.length - a.length) .find((key) => mod.startsWith(key))!!; // Pre-process string value and check to allow for intuitive comparisons const arrayValue = Array.isArray(variable) && variable.every((item) => typeof item === 'string') ? variable.map((item) => item.toLowerCase()) : undefined; const stringValue = variable.toString().toLowerCase(); let stringCheck = mod.substring(modPrefix.length).toLowerCase(); // remove whitespace from stringCheck if it isn't in stringValue stringCheck = !/\s/.test(stringValue) ? stringCheck.replace(/\s/g, '') : stringCheck; // parse value/check as if they're numbers (123,456 -> 123456) const [parsedNumericValue, parsedNumericCheck] = [ Number(stringValue.replace(/,\s/g, '')), Number(stringCheck.replace(/,\s/g, '')), ]; const isNumericComparison = ['<', '<=', '>', '>=', '='].includes(modPrefix) && !isNaN(parsedNumericValue) && !isNaN(parsedNumericCheck); const isArraySupported = ['$', '^', '~'].includes(modPrefix); conditional = ModifierConstants.conditionalModifiers.prefix[ modPrefix as keyof typeof ModifierConstants.conditionalModifiers.prefix ]( isNumericComparison ? (parsedNumericValue as any) : (isArraySupported ? arrayValue : undefined) || stringValue, isNumericComparison ? (parsedNumericCheck as any) : stringCheck ); } } catch (error) { conditional = false; } return conditional; } // --- STRING MODIFIERS --- else if (typeof variable === 'string') { if (mod in ModifierConstants.stringModifiers) return ModifierConstants.stringModifiers[ mod as keyof typeof ModifierConstants.stringModifiers ](variable); // handle hardcoded modifiers here switch (true) { case mod.startsWith('replace(') && mod.endsWith(')'): { const findStartChar = mod.charAt(8); // either " or ' const findEndChar = mod.charAt(mod.length - 2); // either " or ' // Extract the separator from replace(['"]..., ['"]...) const content = _mod.substring(9, _mod.length - 2); // split on findStartChar,findEndChar const [key, replaceKey, shouldBeUndefined] = content.split( new RegExp(`${findStartChar}\\s*,\\s*${findEndChar}`) ); if ( shouldBeUndefined === undefined && key && replaceKey !== undefined ) { let resolvedKey = key; if (key.startsWith('{') && key.endsWith('}') && parseValue) { // When the first argument to replace(...) is a {variable} expression, resolve it // before using it as the search key. For example: // replace({config.addonName}, 'NewName') // will first resolve {config.addonName} to its current value and then replace all // occurrences of that resolved value with 'NewName'. const innerVar = resolvedKey.slice(1, -1); const resolvedFn = this.parseModifiedVariable( innerVar, fullStringModifiers ); const resolved = resolvedFn(parseValue); if (resolved.error !== undefined || resolved.result == null) { return variable; } resolvedKey = String(resolved.result); if (resolvedKey.length === 0) { return variable; // don't replace empty string keys to avoid replacing every character } } return variable.replaceAll(resolvedKey, replaceKey); } } case mod.startsWith('remove(') && mod.endsWith(')'): { const content = _mod.substring(7, _mod.length - 1); // Extract options from remove("...", "...", ...) const regex = /"([^"]*)"|'([^']*)'/g; const args: string[] = []; let match; while ((match = regex.exec(content)) !== null) { args.push(match[1] ?? match[2] ?? ''); } if (args.length === 0) return undefined; let result = variable; for (const arg of args) { if (arg) result = result.replaceAll(arg, ''); } return result; } case mod.startsWith('truncate(') && mod.endsWith(')'): { const inside = _mod.substring('truncate('.length, _mod.length - 1); const n = parseInt(inside, 10); if (!isNaN(n) && n >= 0) { const graphemes = [...new Intl.Segmenter().segment(variable)]; if (graphemes.length > n) { const truncated = graphemes .slice(0, n) .map((s) => s.segment) .join('') .replace(/\s+$/, ''); return truncated + '…'; } return variable; } } } } // --- ARRAY MODIFIERS --- else if (Array.isArray(variable)) { if (mod in ModifierConstants.arrayModifiers) return ModifierConstants.arrayModifiers[ mod as keyof typeof ModifierConstants.arrayModifiers ](variable); // handle hardcoded modifiers here switch (true) { case mod.startsWith('slice(') && mod.endsWith(')'): { // Extract the start and end indices from slice(start, end) const args = _mod .substring(6, _mod.length - 1) .split(',') .map((arg) => parseInt(arg.trim(), 10)); const start = args[0]; const end = args.length > 1 && !isNaN(args[1]) ? args[1] : undefined; if (!isNaN(start)) { return variable.slice(start, end); } return variable; } case mod.startsWith('join(') && mod.endsWith(')'): { // Extract the separator from join('separator') or join("separator") const separator = _mod.substring(6, _mod.length - 2); return variable.join(separator); } case mod.startsWith('remove(') && mod.endsWith(')'): { const content = _mod.substring(7, _mod.length - 1); // Extract options from remove("...", "...", ...) const regex = /"([^"]*)"|'([^']*)'/g; const args: string[] = []; let match; while ((match = regex.exec(content)) !== null) { args.push(match[1] ?? match[2] ?? ''); } if (args.length === 0) return undefined; return variable.filter((v) => !args.includes(v)); } } } // --- NUMBER MODIFIERS --- else if (typeof variable === 'number') { if (mod in ModifierConstants.numberModifiers) return ModifierConstants.numberModifiers[ mod as keyof typeof ModifierConstants.numberModifiers ](variable); } return undefined; } } /** * Used to store the actual value of a parsed, and potentially modified, variable * or an error message if the parsed/modified result becomes invalid for any reason */ type ResolvedVariable = { result?: any; error?: string | undefined; }; class BaseFormatterRegexBuilder { private hardcodedParseValueKeysForRegexMatching: ParseValue; constructor(hardcodedParseValueKeysForRegexMatching: ParseValue) { this.hardcodedParseValueKeysForRegexMatching = hardcodedParseValueKeysForRegexMatching; } /** * RegEx Capture Pattern: `.` * * (no named capture group) */ public buildVariableRegexPattern(): string { // Get all valid variable names (keys as well as subkeys) from ParseValue structure const validVariableNames = Object.keys( this.hardcodedParseValueKeysForRegexMatching ).flatMap((sectionKey) => { const section = this.hardcodedParseValueKeysForRegexMatching[ sectionKey as keyof ParseValue ]; if (section && typeof section === 'object' && section !== null) { return Object.keys(section).map((key) => `${sectionKey}\\.${key}`); } return []; // @flatMap }); return `(${validVariableNames.join('|')})`; } /** * RegEx Capture Pattern: `::` * * (no named capture group) */ public buildModifierRegexPattern(): string { const validModifiers = Object.keys(ModifierConstants.modifiers).map((key) => key.replace(/[\(\)\'\"\$\^\~\=\>\<]/g, '\\$&') ); let pattern = `::(${validModifiers.join('|')})`; // replace .*? so matches can't bleed past quotes, ::, or bracket boundaries // allow embedded quotes (e.g. Director's Cut) — only treat ' as terminator when followed by , ) or whitespace pattern = pattern.replace(/\.\*\?(?=\\')/g, "[^']*(?:'(?![,)\\s])[^']*)*"); pattern = pattern.replace(/\.\*\?(?=\\")/g, '[^"]*(?:"(?![,)\\s])[^"]*)*'); pattern = pattern.replace(/\.\*\?/g, '(?:(?!::)[^}\\[\\]])*'); return pattern; } /** * RegEx Capture Pattern: `::::` * * (no named capture group) */ public buildComparatorRegexPattern(): string { const comparatorKeys = Object.keys( ComparatorConstants.comparatorKeyToFuncs ); return `::(${comparatorKeys.join('|')})::`; } /** * RegEx Capture Pattern: `::` * * (with named capture group `tzLocale`) */ public buildTZLocaleRegexPattern(): string { // TZ Locale pattern (e.g. 'UTC', 'GMT', 'EST', 'PST', 'en-US', 'en-GB', 'Europe/London', 'America/New_York') return `::(?[A-Za-z]{2,3}(?:-[A-Z]{2})?|[A-Za-z]+?/[A-Za-z_]+?)`; } /** * RegEx Capture Pattern: `["||"]` * * (with named capture group `` and `` and `mod_check`=`"||"`) */ public buildCheckRegexPattern(): string { // Build the conditional check pattern separately // Use [^"]* to capture anything except quotes, making it non-greedy const checkTrue = `"(?[^"]*)"`; const checkFalse = `"(?[^"]*)"`; return `\\[(?${checkTrue}\\|\\|${checkFalse})\\]`; } /** * RegEx Captures: `{ (::::)* (?) (<[t||f]>?) }` */ public buildRegexExpression(): RegExp { const variable = this.buildVariableRegexPattern(); const modifier = this.buildModifierRegexPattern(); const comparator = this.buildComparatorRegexPattern(); const modTZLocale = this.buildTZLocaleRegexPattern(); const checkTF = this.buildCheckRegexPattern(); const variableAndModifiers = `${variable}(${modifier})*`; const regexPattern = `\\{${variableAndModifiers}(${comparator}${variableAndModifiers})*(?(${modTZLocale})?(${checkTF})?)\\}`; return new RegExp(regexPattern, 'gi'); } } /** * Static Constants */ class ModifierConstants { static stringModifiers = { upper: (value: string) => value.toUpperCase(), lower: (value: string) => value.toLowerCase(), title: (value: string) => value .split(' ') .map((word) => word.toLowerCase()) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '), length: (value: string) => value.length.toString(), reverse: (value: string) => value.split('').reverse().join(''), base64: (value: string) => btoa(value), string: (value: string) => value, smallcaps: (value: string) => makeSmall(value), }; static arrayModifierGetOrDefault = (value: string[], i: number) => value.length > 0 ? String(value[i]) : ''; static getSortModifier = (ascending: boolean) => { return (value: string[] | number[]) => [...value].sort((a, b) => { let result: number; if (typeof a === 'number' && typeof b === 'number') { result = a - b; } else { const strA = String(a); const strB = String(b); result = strA.localeCompare(strB, undefined, { numeric: true }); } return ascending ? result : -result; }); }; static getStarModifier = (padWithEmpty: boolean) => { return (value: number) => { const enum Star { Full = '★', Half = '⯪', Empty = '☆', } const fullStars = Math.floor(value / 20); const halfStars = value % 20 >= 10 ? 1 : 0; const emptyStars = 5 - fullStars - halfStars; return ( Star.Full.repeat(fullStars) + Star.Half.repeat(halfStars) + (padWithEmpty ? Star.Empty.repeat(emptyStars) : '') ); }; }; static arrayModifiers = { join: (value: string[]) => value.join(', '), length: (value: string[]) => value.length.toString(), first: (value: string[]) => this.arrayModifierGetOrDefault(value, 0), last: (value: string[]) => this.arrayModifierGetOrDefault(value, value.length - 1), random: (value: string[]) => this.arrayModifierGetOrDefault( value, Math.floor(Math.random() * value.length) ), sort: this.getSortModifier(true), rsort: this.getSortModifier(false), lsort: (value: any[]) => [...value].sort(), reverse: (value: string[]) => [...value].reverse(), string: (value: string[]) => value.toString(), }; static numberModifiers = { comma: (value: number) => value.toLocaleString(), hex: (value: number) => value.toString(16), octal: (value: number) => value.toString(8), binary: (value: number) => value.toString(2), bytes: (value: number) => formatBytes(value, 1000), sbytes: (value: number) => formatSmartBytes(value, 1000), sbytes10: (value: number) => formatSmartBytes(value, 1000), sbytes2: (value: number) => formatSmartBytes(value, 1024), rbytes: (value: number) => formatBytes(value, 1000, true), bytes10: (value: number) => formatBytes(value, 1000), rbytes10: (value: number) => formatBytes(value, 1000, true), bytes2: (value: number) => formatBytes(value, 1024), rbytes2: (value: number) => formatBytes(value, 1024, true), bitrate: (value: number) => formatBitrate(value), rbitrate: (value: number) => formatBitrate(value, true), sbitrate: (value: number) => formatSmartBitrate(value), string: (value: number) => value.toString(), time: (value: number) => formatDuration(value), star: this.getStarModifier(false), pstar: this.getStarModifier(true), }; static conditionalModifiers = { exact: { istrue: (value: any) => value === true, isfalse: (value: any) => value === false, exists: (value: any) => { // Handle null, undefined, empty strings, empty arrays if (value === undefined || value === null) return false; if (typeof value === 'string') return /\S/.test(value); // has at least one non-whitespace character if (Array.isArray(value)) return value.length > 0; // For other types (numbers, booleans, objects), consider them as "existing" return true; }, }, prefix: { $: (value: string | string[], check: string) => typeof value === 'string' ? value.startsWith(check) : value?.[0] === check, '^': (value: string | string[], check: string) => typeof value === 'string' ? value.endsWith(check) : value?.[value.length - 1] === check, '~': (value: string | string[], check: string) => value.includes(check), '=': (value: string, check: string) => value === check, '>=': (value: string | number, check: string | number) => value >= check, '>': (value: string | number, check: string | number) => value > check, '<=': (value: string | number, check: string | number) => value <= check, '<': (value: string | number, check: string | number) => value < check, }, }; static hardcodedModifiersForRegexMatching = { 'remove(.*?)': null, "replace('.*?'\\s*?,\\s*?'.*?')": null, 'replace(".*?"\\s*?,\\s*?\'.*?\')': null, 'replace(\'.*?\'\\s*?,\\s*?".*?")': null, 'replace(".*?"\\s*?,\\s*?\".*?\")': null, "join('.*?')": null, 'join(".*?")': null, 'truncate(\\d+)': null, 'slice(\\s*\\d+\\s*)': null, 'slice(\\s*\\d+\\s*,\\s*\\d+\\s*)': null, '$.*?': null, '^.*?': null, '~.*?': null, '=.*?': null, '>=.*?': null, '>.*?': null, '<=.*?': null, '<.*?': null, }; static modifiers = { ...this.hardcodedModifiersForRegexMatching, ...this.stringModifiers, ...this.numberModifiers, ...this.arrayModifiers, ...this.conditionalModifiers.exact, ...this.conditionalModifiers.prefix, }; } class ComparatorConstants { static comparatorKeyToFuncs = { and: (v1: any, v2: any) => v1 && v2, or: (v1: any, v2: any) => v1 || v2, xor: (v1: any, v2: any) => (v1 || v2) && !(v1 && v2), neq: (v1: any, v2: any) => v1 !== v2, equal: (v1: any, v2: any) => v1 === v2, left: (v1: any, _: any) => v1, right: (_: any, v2: any) => v2, }; } const DebugToolReplacementConstants = { modifier: ` String: {config.addonName} ::upper {config.addonName::upper} ::lower {config.addonName::lower} ::title {config.addonName::title} ::length {config.addonName::length} ::reverse {config.addonName::reverse} {tools.newLine} Number: {stream.size} ::bytes {stream.size::bytes} ::time {stream.size::time} ::hex {stream.size::hex} ::octal {stream.size::octal} ::binary {stream.size::binary} ::bitrate {stream.bitrate::bitrate} {tools.newLine} Array: {stream.languages} ::join('-separator-') {stream.languages::join("-separator-")} ::length {stream.languages::length} ::first {stream.languages::first} ::last {stream.languages::last} {tools.newLine} Conditional: String: {stream.filename} filename::exists {stream.filename::exists["true"||"false"]} filename::$Movie {stream.filename::$Movie["true"||"false"]} filename::^mkv {stream.filename::^mkv["true"||"false"]} filename::~Title {stream.filename::~Title["true"||"false"]} filename::=test {stream.filename::=test["true"||"false"]} Number: {stream.size} filesize::>=100 {stream.size::>=100["true"||"false"]} filesize::>50 {stream.size::>50["true"||"false"]} filesize::<=200 {stream.size::<=200["true"||"false"]} filesize::<150 {stream.size::<150["true"||"false"]} Boolean: {stream.proxied} ::istrue {stream.proxied::istrue["true"||"false"]} ::isfalse {stream.proxied::isfalse["true"||"false"]} {tools.newLine} [Advanced] Multiple modifiers ::reverse::title::reverse {config.addonName} -> {config.addonName::reverse::title::reverse} ::string::reverse {stream.size} -> {stream.size::string::reverse} ::string::reverse {stream.languages} -> {stream.languages::join("::")::reverse} ::length::>=2 {stream.languages} -> {stream.languages::length::>=2["true"||"false"]} `, comparator: ` Comparators: ::comparator:: ::and:: {stream.library::and::stream.proxied["true"||"false"]} ::or:: {stream.library::or::stream.proxied["true"||"false"]} ::xor:: {stream.library::xor::stream.proxied["true"||"false"]} ::neq:: {stream.library::neq::stream.proxied["true"||"false"]} ::equal:: {stream.library::equal::stream.proxied["true"||"false"]} ::left:: {stream.library::left::stream.proxied["true"||"false"]} ::right:: {stream.library::right::stream.proxied["true"||"false"]} {tools.newLine} [Advanced] Multiple Comparators Is English stream.languages::~English::or::stream.languages::~dub::and::stream.languages::length::>0["Yes"||"Unknown"] -> {stream.languages::~English::or::stream.languages::~dub::and::stream.languages::length::>0["Yes"||"Unknown"]} Is Fast Enough Link service.cached::or::stream.library::or::stream.seeders::>10["true"||"false"] -> {service.cached::istrue::or::stream.library::or::stream.seeders::>10["true"||"false"]} `, };