Spaces:
Paused
Paused
| import { | |
| UserData, | |
| UserDataSchema, | |
| PresetObject, | |
| Service, | |
| Option, | |
| StreamProxyConfig, | |
| Group, | |
| PresetMetadata, | |
| } from '../db/schemas.js'; | |
| import { AIOStreams } from '../main/index.js'; | |
| import { Preset, PresetManager } from '../presets/index.js'; | |
| import { createProxy } from '../proxy/index.js'; | |
| import { TMDBMetadata } from '../metadata/tmdb.js'; | |
| import { | |
| isEncrypted, | |
| decryptString, | |
| encryptString, | |
| Env, | |
| RPDB, | |
| AIOratings, | |
| FeatureControl, | |
| RegexAccess, | |
| compileRegex, | |
| constants, | |
| SelAccess, | |
| createPosterService, | |
| APIError, | |
| } from './index.js'; | |
| import { assertConfigAccessKey } from './auth.js'; | |
| import { parseSyncedUrl } from './sync.js'; | |
| import { ZodError } from 'zod'; | |
| import { | |
| formatZodError as formatZodErrorImpl, | |
| type FormatZodErrorOptions, | |
| } from './format-zod-error.js'; | |
| import { | |
| ExitConditionEvaluator, | |
| GroupConditionEvaluator, | |
| StreamSelector, | |
| } from '../parser/streamExpression.js'; | |
| import { createLogger } from '../logging/logger.js'; | |
| import { TVDBMetadata } from '../metadata/tvdb.js'; | |
| import { FIELD_META } from './fieldMeta.js'; | |
| import { config as appConfig } from '../config/index.js'; | |
| const logger = createLogger('core'); | |
| export const formatZodError = ( | |
| error: ZodError | unknown, | |
| options?: FormatZodErrorOptions | |
| ): string => formatZodErrorImpl(error, options); | |
| function getServiceCredentialDefault( | |
| serviceId: constants.ServiceId, | |
| credentialId: string | |
| ) { | |
| return appConfig.services.defaultCredentials[serviceId]?.[credentialId]; | |
| } | |
| function getServiceCredentialForced( | |
| serviceId: constants.ServiceId, | |
| credentialId: string | |
| ) { | |
| return appConfig.services.forcedCredentials[serviceId]?.[credentialId]; | |
| } | |
| export function getEnvironmentServiceDetails(): typeof constants.SERVICE_DETAILS { | |
| return Object.fromEntries( | |
| Object.entries(constants.SERVICE_DETAILS) | |
| .filter(([id, _]) => !FeatureControl.disabledServices.has(id)) | |
| .map(([id, service]) => [ | |
| id as constants.ServiceId, | |
| { | |
| id: service.id, | |
| name: service.name, | |
| shortName: service.shortName, | |
| knownNames: service.knownNames, | |
| signUpText: service.signUpText, | |
| credentials: service.credentials.map((cred) => ({ | |
| id: cred.id, | |
| name: cred.name, | |
| description: cred.description, | |
| type: cred.type, | |
| intent: cred.intent, | |
| required: cred.required, | |
| default: getServiceCredentialDefault(service.id, cred.id) | |
| ? encryptString(getServiceCredentialDefault(service.id, cred.id)!) | |
| .data | |
| : null, | |
| forced: getServiceCredentialForced(service.id, cred.id) | |
| ? encryptString(getServiceCredentialForced(service.id, cred.id)!) | |
| .data | |
| : null, | |
| constraints: cred.required | |
| ? { | |
| min: 1, | |
| } | |
| : undefined, | |
| })), | |
| }, | |
| ]) | |
| ) as typeof constants.SERVICE_DETAILS; | |
| } | |
| export interface ValidateConfigOptions { | |
| skipErrorsFromAddonsOrProxies?: boolean; | |
| decryptValues?: boolean; | |
| increasedManifestTimeout?: boolean; | |
| bypassManifestCache?: boolean; | |
| } | |
| export async function validateConfig( | |
| data: any, | |
| options?: ValidateConfigOptions | |
| ): Promise<UserData> { | |
| const { | |
| success, | |
| data: config, | |
| error, | |
| } = UserDataSchema.safeParse( | |
| removeInvalidPresetReferences(applyMigrations(data)) | |
| ); | |
| if (!success) { | |
| throw new Error(formatZodError(error)); | |
| } | |
| assertConfigAccessKey(config); | |
| validateSyncedRegexUrls(config, options?.skipErrorsFromAddonsOrProxies); | |
| validateSyncedSelUrls(config, options?.skipErrorsFromAddonsOrProxies); | |
| validateSyncedPlaceholders(config); | |
| let excludedStreamExpressions: { expression: string; enabled: boolean }[] = | |
| []; | |
| let requiredStreamExpressions: { expression: string; enabled: boolean }[] = | |
| []; | |
| let preferredStreamExpressions: { expression: string; enabled: boolean }[] = | |
| []; | |
| let includedStreamExpressions: { expression: string; enabled: boolean }[] = | |
| []; | |
| let rankedStreamExpressions: { | |
| expression: string; | |
| score: number; | |
| enabled: boolean; | |
| }[] = []; | |
| try { | |
| const result = | |
| await SelAccess.resolveSyncedExpressionsForValidation(config); | |
| excludedStreamExpressions = result.excluded; | |
| requiredStreamExpressions = result.required; | |
| preferredStreamExpressions = result.preferred; | |
| includedStreamExpressions = result.included; | |
| rankedStreamExpressions = result.ranked; | |
| } catch (error) { | |
| if (!options?.skipErrorsFromAddonsOrProxies) { | |
| throw error; | |
| } | |
| logger.warn( | |
| { err: error instanceof Error ? error.message : String(error) }, | |
| 'failed to resolve synced stream expressions' | |
| ); | |
| // Use the expressions from the config directly | |
| excludedStreamExpressions = config.excludedStreamExpressions || []; | |
| requiredStreamExpressions = config.requiredStreamExpressions || []; | |
| preferredStreamExpressions = config.preferredStreamExpressions || []; | |
| includedStreamExpressions = config.includedStreamExpressions || []; | |
| rankedStreamExpressions = config.rankedStreamExpressions || []; | |
| } | |
| // Validate total stream expressions count across all filter types | |
| const totalStreamExpressions = | |
| (excludedStreamExpressions?.length || 0) + | |
| (requiredStreamExpressions?.length || 0) + | |
| (preferredStreamExpressions?.length || 0) + | |
| (includedStreamExpressions?.length || 0); | |
| if (totalStreamExpressions > appConfig.userLimits.sel.maxExpressions) { | |
| throw new Error( | |
| `You have ${totalStreamExpressions} total stream expressions across all filter types, but the maximum is ${appConfig.userLimits.sel.maxExpressions}` | |
| ); | |
| } | |
| // Validate total character count across all stream expressions | |
| const allExpressions: string[] = [ | |
| ...(excludedStreamExpressions?.map((e) => e.expression) || []), | |
| ...(requiredStreamExpressions?.map((e) => e.expression) || []), | |
| ...(preferredStreamExpressions?.map((e) => e.expression) || []), | |
| ...(includedStreamExpressions?.map((e) => e.expression) || []), | |
| ...(rankedStreamExpressions?.map((r) => r.expression) || []), | |
| ]; | |
| const totalCharacters = allExpressions.reduce( | |
| (sum, expr) => sum + expr.length, | |
| 0 | |
| ); | |
| if (totalCharacters > appConfig.userLimits.sel.maxExpressionCharacters) { | |
| throw new Error( | |
| `Your stream expressions have ${totalCharacters} total characters, but the maximum is ${appConfig.userLimits.sel.maxExpressionCharacters}` | |
| ); | |
| } | |
| const validations = { | |
| 'excluded keywords': [ | |
| config.excludedKeywords, | |
| appConfig.userLimits.maxKeywordFilters, | |
| ], | |
| 'included keywords': [ | |
| config.includedKeywords, | |
| appConfig.userLimits.maxKeywordFilters, | |
| ], | |
| 'required keywords': [ | |
| config.requiredKeywords, | |
| appConfig.userLimits.maxKeywordFilters, | |
| ], | |
| 'preferred keywords': [ | |
| config.preferredKeywords, | |
| appConfig.userLimits.maxKeywordFilters, | |
| ], | |
| groups: [config.groups, appConfig.userLimits.maxGroups], | |
| }; | |
| for (const [name, [items, max]] of Object.entries(validations)) { | |
| if (items && max && (items as any[]).length > (max as number)) { | |
| throw new Error( | |
| `You have ${(items as any[]).length} ${name}, but the maximum is ${max}` | |
| ); | |
| } | |
| } | |
| // validate merged catalogs source limits | |
| if (config.mergedCatalogs) { | |
| for (const mergedCatalog of config.mergedCatalogs) { | |
| if ( | |
| mergedCatalog.catalogIds.length > | |
| appConfig.userLimits.maxMergedCatalogSources | |
| ) { | |
| throw new Error( | |
| `Merged catalog "${mergedCatalog.name}" has ${mergedCatalog.catalogIds.length} source catalogs, but the maximum is ${appConfig.userLimits.maxMergedCatalogSources}` | |
| ); | |
| } | |
| } | |
| } | |
| // validate NZB failover count against the server limit | |
| if ( | |
| config.nzbFailover?.count && | |
| config.nzbFailover.count > appConfig.userLimits.maxNzbFailoverCount | |
| ) { | |
| if (options?.skipErrorsFromAddonsOrProxies) { | |
| config.nzbFailover.count = appConfig.userLimits.maxNzbFailoverCount; | |
| } else { | |
| throw new Error( | |
| `NZB failover count is ${config.nzbFailover.count}, but the maximum allowed is ${appConfig.userLimits.maxNzbFailoverCount}` | |
| ); | |
| } | |
| } | |
| // now, validate preset options and service credentials. | |
| if (config.presets) { | |
| // ensure uniqenesss of instanceIds | |
| const instanceIds = new Set<string>(); | |
| for (const preset of config.presets) { | |
| if (preset.instanceId && instanceIds.has(preset.instanceId)) { | |
| throw new Error(`Preset instanceId ${preset.instanceId} is not unique`); | |
| } | |
| if (preset.instanceId.includes('.')) { | |
| throw new Error( | |
| `Preset instanceId ${preset.instanceId} cannot contain a dot` | |
| ); | |
| } | |
| instanceIds.add(preset.instanceId); | |
| try { | |
| validatePreset(preset); | |
| } catch (error) { | |
| if (!options?.skipErrorsFromAddonsOrProxies) { | |
| throw error; | |
| } | |
| logger.warn( | |
| { | |
| preset: preset.instanceId, | |
| err: error instanceof Error ? error.message : String(error), | |
| }, | |
| 'invalid preset' | |
| ); | |
| } | |
| } | |
| } | |
| if (config.groups?.groupings) { | |
| for (const group of config.groups.groupings) { | |
| await validateGroup(group); | |
| } | |
| } | |
| if (config.dynamicAddonFetching?.enabled) { | |
| try { | |
| if (!config.dynamicAddonFetching.condition) { | |
| throw new Error('Missing condition'); | |
| } | |
| await ExitConditionEvaluator.testEvaluate( | |
| config.dynamicAddonFetching.condition | |
| ); | |
| } catch (error) { | |
| throw new Error(`Invalid dynamic addon fetching condition: ${error}`); | |
| } | |
| } | |
| // validate excluded filter condition | |
| const expressionsToValidate: string[] = [ | |
| ...(config.excludedStreamExpressions?.map((e) => e.expression) ?? []), | |
| ...(config.requiredStreamExpressions?.map((e) => e.expression) ?? []), | |
| ...(config.preferredStreamExpressions?.map((e) => e.expression) ?? []), | |
| ...(config.includedStreamExpressions?.map((e) => e.expression) ?? []), | |
| ...(config.rankedStreamExpressions?.map((r) => r.expression) ?? []), | |
| ].filter((expr) => !parseSyncedUrl(expr)); | |
| for (const expression of expressionsToValidate) { | |
| try { | |
| await StreamSelector.testSelect(expression); | |
| } catch (error) { | |
| throw new Error(`Invalid stream expression: ${expression}: ${error}`); | |
| } | |
| } | |
| // validate precache selector | |
| if (config.precacheSelector) { | |
| try { | |
| await StreamSelector.testSelect(config.precacheSelector); | |
| } catch (error) { | |
| throw new Error(`Invalid precache selector: ${error}`); | |
| } | |
| } | |
| if (config.services) { | |
| config.services = config.services.map((service: Service) => | |
| validateService(service, options?.decryptValues) | |
| ); | |
| } | |
| config.proxy = await validateProxy( | |
| config, | |
| options?.skipErrorsFromAddonsOrProxies, | |
| options?.decryptValues | |
| ); | |
| const posterService = createPosterService(config); | |
| if (config.posterService && posterService) { | |
| try { | |
| await posterService.validateApiKey(); | |
| } catch (error) { | |
| if (!options?.skipErrorsFromAddonsOrProxies) { | |
| throw new Error(`Invalid Poster API key: ${error}`); | |
| } | |
| logger.warn( | |
| { err: error instanceof Error ? error.message : String(error) }, | |
| 'invalid poster api key' | |
| ); | |
| } | |
| } | |
| const tmdbAuth = | |
| config.tmdbApiKey || | |
| config.tmdbAccessToken || | |
| appConfig.metadata.tmdb.apiKey || | |
| appConfig.metadata.tmdb.accessToken; | |
| const needTmdb = | |
| config.titleMatching?.enabled || | |
| config.yearMatching?.enabled || | |
| config.digitalReleaseFilter?.enabled || | |
| config.bitrate?.useMetadataRuntime; | |
| if (needTmdb && !tmdbAuth) { | |
| throw new Error( | |
| 'A TMDB API key or access token is required for the following features: title matching, year matching, and digital release filter' | |
| ); | |
| } | |
| // validate tmdb auth if it is needed or if it is provided in the config. | |
| if (needTmdb || config.tmdbAccessToken || config.tmdbApiKey) { | |
| try { | |
| const tmdb = new TMDBMetadata({ | |
| accessToken: config.tmdbAccessToken, | |
| apiKey: config.tmdbApiKey, | |
| }); | |
| await tmdb.validateAuthorisation(); | |
| } catch (error) { | |
| if (!options?.skipErrorsFromAddonsOrProxies) { | |
| throw new Error( | |
| `Failed to validate TMDB API Key/Access Token: ${error instanceof Error ? error.message : String(error)}` | |
| ); | |
| } | |
| logger.warn( | |
| { err: error instanceof Error ? error.message : String(error) }, | |
| 'failed to validate tmdb key' | |
| ); | |
| } | |
| } | |
| if (config.tvdbApiKey) { | |
| try { | |
| const tvdb = new TVDBMetadata({ | |
| apiKey: config.tvdbApiKey, | |
| }); | |
| await tvdb.validateApiKey(); | |
| } catch (error) { | |
| if (!options?.skipErrorsFromAddonsOrProxies) { | |
| throw new Error(`Invalid TVDB API key: ${error}`); | |
| } | |
| logger.warn( | |
| { err: error instanceof Error ? error.message : String(error) }, | |
| 'invalid tvdb api key' | |
| ); | |
| } | |
| } | |
| if (FeatureControl.disabledServices.size > 0) { | |
| for (const service of config.services ?? []) { | |
| if (FeatureControl.disabledServices.has(service.id)) { | |
| service.enabled = false; | |
| } | |
| } | |
| } | |
| await validateRegexes(config, options?.skipErrorsFromAddonsOrProxies); | |
| await new AIOStreams(ensureDecrypted(config), { | |
| skipFailedAddons: options?.skipErrorsFromAddonsOrProxies ?? false, | |
| increasedManifestTimeout: options?.increasedManifestTimeout ?? false, | |
| bypassManifestCache: options?.bypassManifestCache ?? false, | |
| }).initialise(); | |
| return config; | |
| } | |
| function removeInvalidPresetReferences(config: UserData) { | |
| // remove references to non-existent presets in options: | |
| const existingPresetIds = config.presets?.map((preset) => preset.instanceId); | |
| if (config.proxy) { | |
| config.proxy.proxiedAddons = config.proxy.proxiedAddons?.filter((addon) => | |
| existingPresetIds?.includes(addon) | |
| ); | |
| } | |
| if (config.yearMatching) { | |
| config.yearMatching.addons = config.yearMatching.addons?.filter((addon) => | |
| existingPresetIds?.includes(addon) | |
| ); | |
| } | |
| if (config.titleMatching) { | |
| config.titleMatching.addons = config.titleMatching.addons?.filter((addon) => | |
| existingPresetIds?.includes(addon) | |
| ); | |
| } | |
| if (config.seasonEpisodeMatching) { | |
| config.seasonEpisodeMatching.addons = | |
| config.seasonEpisodeMatching.addons?.filter((addon) => | |
| existingPresetIds?.includes(addon) | |
| ); | |
| } | |
| if (config.groups?.groupings) { | |
| config.groups.groupings = config.groups.groupings.map((group) => ({ | |
| ...group, | |
| addons: group.addons?.filter((addon) => | |
| existingPresetIds?.includes(addon) | |
| ), | |
| })); | |
| } | |
| if (config.serviceWrap?.presets) { | |
| config.serviceWrap.presets = config.serviceWrap.presets.filter((preset) => | |
| existingPresetIds?.includes(preset) | |
| ); | |
| } | |
| return config; | |
| } | |
| export function applyMigrations(config: any): UserData { | |
| if ( | |
| config && | |
| config.addonPassword !== undefined && | |
| config.accessToken === undefined | |
| ) { | |
| config.accessToken = config.addonPassword; | |
| } | |
| if (config && config.addonPassword !== undefined) { | |
| delete config.addonPassword; | |
| } | |
| if ( | |
| config && | |
| config.accessToken !== undefined && | |
| config.accessKey === undefined | |
| ) { | |
| config.accessKey = config.accessToken; | |
| } | |
| if (config && config.accessToken !== undefined) { | |
| delete config.accessToken; | |
| } | |
| if ( | |
| config.deduplicator && | |
| typeof config.deduplicator.multiGroupBehaviour === 'string' | |
| ) { | |
| switch (config.deduplicator.multiGroupBehaviour as string) { | |
| case 'remove_uncached': | |
| config.deduplicator.multiGroupBehaviour = 'aggressive'; | |
| break; | |
| case 'remove_uncached_same_service': | |
| config.deduplicator.multiGroupBehaviour = 'conservative'; | |
| break; | |
| case 'remove_nothing': | |
| config.deduplicator.multiGroupBehaviour = 'keep_all'; | |
| break; | |
| } | |
| } | |
| if (typeof config.digitalReleaseFilter === 'boolean') { | |
| const oldValue = config.digitalReleaseFilter; | |
| config.digitalReleaseFilter = { | |
| enabled: oldValue, | |
| tolerance: 1, | |
| requestTypes: ['movie', 'series', 'anime'], | |
| addons: [], | |
| }; | |
| } | |
| if (config.titleMatching?.matchYear) { | |
| config.yearMatching = { | |
| enabled: true, | |
| tolerance: config.titleMatching.yearTolerance | |
| ? config.titleMatching.yearTolerance | |
| : 1, | |
| requestTypes: config.titleMatching.requestTypes ?? [], | |
| addons: config.titleMatching.addons ?? [], | |
| }; | |
| delete config.titleMatching.matchYear; | |
| } | |
| if (Array.isArray(config.groups)) { | |
| config.groups = { | |
| enabled: config.disableGroups ? false : true, | |
| groupings: config.groups, | |
| behaviour: 'parallel', | |
| }; | |
| } | |
| if (config.showStatistics || config.statisticsPosition) { | |
| config.statistics = { | |
| enabled: config.showStatistics ?? false, | |
| position: config.statisticsPosition ?? 'bottom', | |
| statsToShow: ['addon', 'filter', 'timing'], | |
| ...(config.statistics ?? {}), | |
| }; | |
| delete config.showStatistics; | |
| delete config.statisticsPosition; | |
| } | |
| const migrateHOSBS = ( | |
| type: 'preferred' | 'required' | 'excluded' | 'included' | |
| ) => { | |
| if (Array.isArray(config[type + 'Encodes'])) { | |
| config[type + 'Encodes'] = config[type + 'Encodes'].filter( | |
| (encode: string) => { | |
| if (encode === 'H-OU' || encode === 'H-SBS') { | |
| // add H-OU and H-SBS to visual tags if in encodes. | |
| config[type + 'VisualTags'] = [ | |
| ...(config[type + 'VisualTags'] ?? []), | |
| encode, | |
| ]; | |
| // filter out H-OU and H-SBS from encodes | |
| return false; | |
| } | |
| return true; | |
| } | |
| ); | |
| } | |
| }; | |
| migrateHOSBS('preferred'); | |
| migrateHOSBS('required'); | |
| migrateHOSBS('excluded'); | |
| migrateHOSBS('included'); | |
| // migrate comparisons of queryType to 'anime' to 'anime.series' or 'anime.movie' | |
| const migrateAnimeQueryTypeInExpression = (expr?: string) => { | |
| if (typeof expr !== 'string') return expr as any; | |
| // Replace equality comparisons | |
| let updated = expr.replace( | |
| /queryType\s*==\s*(["'])anime\1/g, | |
| "(queryType == 'anime.series' or queryType == 'anime.movie')" | |
| ); | |
| updated = updated.replace( | |
| /(["'])anime\1\s*==\s*queryType/g, | |
| "(queryType == 'anime.series' or queryType == 'anime.movie')" | |
| ); | |
| // Replace inequality comparisons | |
| updated = updated.replace( | |
| /queryType\s*!=\s*(["'])anime\1/g, | |
| "(queryType != 'anime.series' and queryType != 'anime.movie')" | |
| ); | |
| updated = updated.replace( | |
| /(["'])anime\1\s*!=\s*queryType/g, | |
| "(queryType != 'anime.series' and queryType != 'anime.movie')" | |
| ); | |
| return updated; | |
| }; | |
| const expressionLists = [ | |
| 'excludedStreamExpressions', | |
| 'requiredStreamExpressions', | |
| 'includedStreamExpressions', | |
| 'preferredStreamExpressions', | |
| ] as const; | |
| for (const key of expressionLists) { | |
| if (Array.isArray((config as any)[key])) { | |
| (config as any)[key] = (config as any)[key].map((expr: unknown) => { | |
| if (typeof expr === 'string') { | |
| return migrateAnimeQueryTypeInExpression(expr); | |
| } | |
| if (typeof expr === 'object' && expr !== null && 'expression' in expr) { | |
| return { | |
| ...(expr as any), | |
| expression: migrateAnimeQueryTypeInExpression( | |
| (expr as any).expression | |
| ), | |
| }; | |
| } | |
| return expr; | |
| }); | |
| } | |
| } | |
| if (config.dynamicAddonFetching?.condition) { | |
| config.dynamicAddonFetching.condition = migrateAnimeQueryTypeInExpression( | |
| config.dynamicAddonFetching.condition | |
| ); | |
| } | |
| if (config.groups?.groupings) { | |
| config.groups.groupings = config.groups.groupings.map((group: any) => ({ | |
| ...group, | |
| condition: migrateAnimeQueryTypeInExpression(group.condition), | |
| })); | |
| } | |
| // migrate rpdbUseRedirectApi to usePosterRedirectApi | |
| if ( | |
| config.rpdbUseRedirectApi !== undefined && | |
| config.usePosterRedirectApi === undefined | |
| ) { | |
| config.usePosterRedirectApi = config.rpdbUseRedirectApi; | |
| delete config.rpdbUseRedirectApi; | |
| } | |
| // migrate 'rpdb' to 'usePosterService' in all catalog modifications | |
| if (Array.isArray(config.catalogModifications)) { | |
| for (const mod of config.catalogModifications) { | |
| if (mod.usePosterService === undefined && mod.rpdb === true) { | |
| mod.usePosterService = true; | |
| } | |
| delete mod.rpdb; | |
| } | |
| } | |
| // migrate alwaysPrecache to precacheCondition, then precacheCondition to precacheSelector | |
| if (config.precacheSelector === undefined && config.precacheNextEpisode) { | |
| // First handle the old precacheCondition field | |
| if (config.precacheCondition !== undefined) { | |
| // Convert condition to selector format | |
| config.precacheSelector = `${config.precacheCondition} ? uncached(streams) : []`; | |
| } else { | |
| // Handle even older alwaysPrecache field | |
| config.precacheSelector = | |
| config.alwaysPrecache === true | |
| ? 'true ? uncached(streams) : []' | |
| : constants.DEFAULT_PRECACHE_SELECTOR; | |
| } | |
| } | |
| delete config.alwaysPrecache; | |
| delete config.precacheCondition; | |
| // migrate stream expressions from string[] to {expression, enabled}[] | |
| const streamExpressionKeys = [ | |
| 'excludedStreamExpressions', | |
| 'requiredStreamExpressions', | |
| 'preferredStreamExpressions', | |
| 'includedStreamExpressions', | |
| ] as const; | |
| for (const key of streamExpressionKeys) { | |
| if (Array.isArray(config[key])) { | |
| config[key] = config[key].map((item: unknown) => | |
| typeof item === 'string' ? { expression: item, enabled: true } : item | |
| ); | |
| } | |
| } | |
| // migrate forceToTop at addon level to pinPosition set to 'top' | |
| if (config.presets && Array.isArray(config.presets)) { | |
| config.presets = config.presets.map((preset: any) => { | |
| if ( | |
| preset.options?.forceToTop === true && | |
| preset.options.pinPosition === undefined | |
| ) { | |
| delete preset.options.forceToTop; | |
| return { | |
| ...preset, | |
| options: { | |
| ...preset.options, | |
| pinPosition: 'top', | |
| }, | |
| }; | |
| } | |
| return preset; | |
| }); | |
| } | |
| if (config.formatter && config.formatter.definition) { | |
| config.formatter.definitions = { | |
| ...(config.formatter.definitions ?? {}), | |
| custom: config.formatter.definition, | |
| }; | |
| delete config.formatter.definition; | |
| } | |
| return config; | |
| } | |
| async function validateRegexes(config: UserData, skipErrors: boolean = false) { | |
| // Resolve synced URL patterns for validation only — does not modify config. | |
| let synced = { | |
| excluded: [] as string[], | |
| included: [] as string[], | |
| required: [] as string[], | |
| preferred: [] as { name: string; pattern: string; score?: number }[], | |
| ranked: [] as { name?: string; pattern: string; score: number }[], | |
| }; | |
| try { | |
| synced = await RegexAccess.resolveSyncedRegexesForValidation(config); | |
| } catch (error) { | |
| if (!skipErrors) throw error; | |
| logger.warn( | |
| { err: error instanceof Error ? error.message : String(error) }, | |
| 'failed to resolve synced regex patterns' | |
| ); | |
| } | |
| // All patterns to validate: synced (from URLs) + direct (from config), deduplicated. | |
| const regexes = [ | |
| ...new Set([ | |
| ...synced.excluded, | |
| ...(config.excludedRegexPatterns || []), | |
| ...synced.included, | |
| ...(config.includedRegexPatterns || []), | |
| ...synced.required, | |
| ...(config.requiredRegexPatterns || []), | |
| ...synced.preferred.map((r) => r.pattern), | |
| ...(config.preferredRegexPatterns || []).map((r) => r.pattern), | |
| ...synced.ranked.map((r) => r.pattern), | |
| ...(config.rankedRegexPatterns || []).map((r) => r.pattern), | |
| ]), | |
| ].filter((pattern) => !parseSyncedUrl(pattern)); | |
| if (regexes.length === 0) return; | |
| const regexAllowed = await RegexAccess.isRegexAllowed(config, regexes); | |
| if (!regexAllowed) { | |
| if (!skipErrors) { | |
| const allowedPatterns = (await RegexAccess.allowedRegexPatterns()) | |
| .patterns; | |
| const notAllowed = regexes.filter((r) => !allowedPatterns.includes(r)); | |
| if (notAllowed.length === regexes.length) { | |
| throw new Error( | |
| 'You do not have permission to use regex filters, please remove them from your config' | |
| ); | |
| } | |
| throw new Error( | |
| `You are only permitted to use specific regex patterns, you have ${notAllowed.length} / ${regexes.length} regexes that are not allowed. Please remove them from your config.` | |
| ); | |
| } | |
| return; | |
| } | |
| await Promise.all( | |
| regexes.map(async (regex) => { | |
| try { | |
| await compileRegex(regex); | |
| } catch (error: any) { | |
| logger.error({ regex, err: error.message }, 'invalid regex pattern'); | |
| throw new Error(`Invalid regex: ${regex}: ${error.message}`); | |
| } | |
| }) | |
| ); | |
| } | |
| function validateSyncedRegexUrls( | |
| config: UserData, | |
| skipErrors: boolean = false | |
| ) { | |
| const regexAccess = appConfig.userLimits.regex.access; | |
| const isUnrestricted = | |
| regexAccess === 'all' || (regexAccess === 'trusted' && config.trusted); | |
| if (isUnrestricted) return; | |
| const allowedUrls = RegexAccess.getAllowedUrls(); | |
| const urlsToCheck = [ | |
| ...(config.syncedIncludedRegexUrls || []), | |
| ...(config.syncedExcludedRegexUrls || []), | |
| ...(config.syncedRequiredRegexUrls || []), | |
| ...(config.syncedPreferredRegexUrls || []), | |
| ...(config.syncedRankedRegexUrls || []), | |
| ]; | |
| const invalidUrls = urlsToCheck.filter((url) => !allowedUrls.includes(url)); | |
| if (invalidUrls.length > 0) { | |
| if (!skipErrors) { | |
| throw new Error( | |
| `Forbidden URL(s) in regex configuration: ${invalidUrls.join(', ')}` | |
| ); | |
| } | |
| } | |
| } | |
| function validateSyncedSelUrls(config: UserData, skipErrors: boolean = false) { | |
| const selAccess = appConfig.userLimits.sel.access; | |
| const isUnrestricted = | |
| selAccess === 'all' || (selAccess === 'trusted' && config.trusted); | |
| if (isUnrestricted) return; | |
| const allowedUrls = SelAccess.getAllowedUrls(); | |
| const urlsToCheck = [ | |
| ...(config.syncedIncludedStreamExpressionUrls || []), | |
| ...(config.syncedExcludedStreamExpressionUrls || []), | |
| ...(config.syncedRequiredStreamExpressionUrls || []), | |
| ...(config.syncedPreferredStreamExpressionUrls || []), | |
| ...(config.syncedRankedStreamExpressionUrls || []), | |
| ]; | |
| const invalidUrls = urlsToCheck.filter((url) => !allowedUrls.includes(url)); | |
| if (invalidUrls.length > 0) { | |
| if (!skipErrors) { | |
| throw new Error( | |
| `Forbidden URL(s) in stream expression sync configuration: ${invalidUrls.join(', ')}` | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * Validate that every `<SYNCED: url>` placeholder in a values array | |
| * references a URL present in the corresponding synced URLs array. | |
| */ | |
| function validateSyncedPlaceholders(config: UserData) { | |
| const checks: { | |
| valuesKey: keyof UserData; | |
| syncedKey: keyof UserData; | |
| extract: (item: any) => string; | |
| }[] = [ | |
| { | |
| valuesKey: 'excludedRegexPatterns', | |
| syncedKey: 'syncedExcludedRegexUrls', | |
| extract: (v) => v, | |
| }, | |
| { | |
| valuesKey: 'includedRegexPatterns', | |
| syncedKey: 'syncedIncludedRegexUrls', | |
| extract: (v) => v, | |
| }, | |
| { | |
| valuesKey: 'requiredRegexPatterns', | |
| syncedKey: 'syncedRequiredRegexUrls', | |
| extract: (v) => v, | |
| }, | |
| { | |
| valuesKey: 'preferredRegexPatterns', | |
| syncedKey: 'syncedPreferredRegexUrls', | |
| extract: (v) => v.pattern, | |
| }, | |
| { | |
| valuesKey: 'rankedRegexPatterns', | |
| syncedKey: 'syncedRankedRegexUrls', | |
| extract: (v) => v.pattern, | |
| }, | |
| { | |
| valuesKey: 'excludedStreamExpressions', | |
| syncedKey: 'syncedExcludedStreamExpressionUrls', | |
| extract: (v) => v.expression, | |
| }, | |
| { | |
| valuesKey: 'includedStreamExpressions', | |
| syncedKey: 'syncedIncludedStreamExpressionUrls', | |
| extract: (v) => v.expression, | |
| }, | |
| { | |
| valuesKey: 'requiredStreamExpressions', | |
| syncedKey: 'syncedRequiredStreamExpressionUrls', | |
| extract: (v) => v.expression, | |
| }, | |
| { | |
| valuesKey: 'preferredStreamExpressions', | |
| syncedKey: 'syncedPreferredStreamExpressionUrls', | |
| extract: (v) => v.expression, | |
| }, | |
| { | |
| valuesKey: 'rankedStreamExpressions', | |
| syncedKey: 'syncedRankedStreamExpressionUrls', | |
| extract: (v) => v.expression, | |
| }, | |
| ]; | |
| const invalid: string[] = []; | |
| for (const { valuesKey, syncedKey, extract } of checks) { | |
| const values = (config as any)[valuesKey] as any[] | undefined; | |
| if (!values?.length) continue; | |
| const syncedUrls = new Set<string>((config as any)[syncedKey] ?? []); | |
| for (const entry of values) { | |
| const field = extract(entry); | |
| const url = parseSyncedUrl(field); | |
| if (url && !syncedUrls.has(url)) { | |
| invalid.push(url); | |
| } | |
| } | |
| } | |
| if (invalid.length > 0) { | |
| throw new Error( | |
| `Found synced placeholder(s) referencing URL(s) not in the synced URLs list: ${invalid.join(', ')}` | |
| ); | |
| } | |
| } | |
| function ensureDecrypted(config: UserData): UserData { | |
| const decryptedConfig: UserData = structuredClone(config); | |
| // Helper function to decrypt a value if needed | |
| const tryDecrypt = (value: any, context: string) => { | |
| if (!isEncrypted(value)) return value; | |
| const { success, data, error } = decryptString(value); | |
| if (!success) { | |
| throw new Error(`Failed to decrypt ${context}: ${error}`); | |
| } | |
| return data; | |
| }; | |
| // Decrypt service credentials | |
| for (const service of decryptedConfig.services ?? []) { | |
| if (!service.credentials) continue; | |
| for (const [credential, value] of Object.entries(service.credentials)) { | |
| service.credentials[credential] = tryDecrypt( | |
| value, | |
| `credential ${credential}` | |
| ); | |
| } | |
| } | |
| // Decrypt proxy config | |
| if (decryptedConfig.proxy) { | |
| decryptedConfig.proxy.credentials = decryptedConfig.proxy.credentials | |
| ? tryDecrypt(decryptedConfig.proxy.credentials, 'proxy credentials') | |
| : undefined; | |
| decryptedConfig.proxy.url = decryptedConfig.proxy.url | |
| ? tryDecrypt(decryptedConfig.proxy.url, 'proxy URL') | |
| : undefined; | |
| decryptedConfig.proxy.publicUrl = decryptedConfig.proxy.publicUrl | |
| ? tryDecrypt(decryptedConfig.proxy.publicUrl, 'proxy public URL') | |
| : undefined; | |
| } | |
| return decryptedConfig; | |
| } | |
| function validateService( | |
| service: Service, | |
| decryptValues: boolean = false | |
| ): Service { | |
| const serviceMeta = getEnvironmentServiceDetails()[service.id]; | |
| if (!serviceMeta) { | |
| throw new Error(`Service ${service.id} not found`); | |
| } | |
| if (serviceMeta.credentials.every((cred) => cred.forced)) { | |
| service.enabled = true; | |
| } | |
| if (service.enabled) { | |
| for (const credential of serviceMeta.credentials) { | |
| try { | |
| service.credentials[credential.id] = validateOption( | |
| credential, | |
| service.credentials?.[credential.id], | |
| decryptValues | |
| ); | |
| } catch (error) { | |
| throw new Error( | |
| `The value for credential '${credential.name}' in service '${serviceMeta.name}' is invalid: ${error}` | |
| ); | |
| } | |
| } | |
| } | |
| return service; | |
| } | |
| function validatePreset(preset: PresetObject) { | |
| const presetMeta = PresetManager.fromId(preset.type) | |
| .METADATA as PresetMetadata; | |
| const optionMetas = presetMeta.OPTIONS; | |
| if (presetMeta.DISABLED) { | |
| throw new Error( | |
| `${presetMeta.NAME} has been ${presetMeta.DISABLED.removed ? 'removed' : 'disabled'}: ${presetMeta.DISABLED.reason}` | |
| ); | |
| } | |
| for (const optionMeta of optionMetas) { | |
| const optionValue = preset.options[optionMeta.id]; | |
| try { | |
| preset.options[optionMeta.id] = validateOption(optionMeta, optionValue); | |
| } catch (error) { | |
| throw new Error( | |
| `The value for option '${optionMeta.name}' in preset '${presetMeta.NAME}' is invalid: ${error}` | |
| ); | |
| } | |
| } | |
| } | |
| async function validateGroup(group: Group) { | |
| if (!group) { | |
| return; | |
| } | |
| // each group must have at least one addon, and we must be able to parse the condition | |
| if (group.addons.length === 0) { | |
| throw new Error('Every group must have at least one addon'); | |
| } | |
| // we must be able to parse the condition | |
| let result; | |
| try { | |
| result = await GroupConditionEvaluator.testEvaluate(group.condition); | |
| } catch (error: any) { | |
| throw new Error( | |
| `Your group condition - '${group.condition}' - is invalid: ${error.message}` | |
| ); | |
| } | |
| if (typeof result !== 'boolean') { | |
| throw new Error( | |
| `Your group condition - '${group.condition}' - is invalid. Expected evaluation to a boolean, instead got '${typeof result}'` | |
| ); | |
| } | |
| } | |
| function validateOption( | |
| option: Option, | |
| value: any, | |
| decryptValues: boolean = false | |
| ): any { | |
| if (typeof value === 'string' && value === 'undefined') { | |
| value = undefined; | |
| } | |
| const forcedValue = | |
| option.forced !== undefined && option.forced !== null | |
| ? option.forced | |
| : undefined; | |
| if (forcedValue !== undefined) { | |
| value = forcedValue; | |
| } | |
| if (value === undefined) { | |
| if (option.required) { | |
| throw new Error(`Option ${option.id} is required, got ${value}`); | |
| } | |
| return value; | |
| } | |
| if (option.type === 'subsection') { | |
| for (const subOption of option.subOptions ?? []) { | |
| // for subsection, the value must be an object | |
| if (typeof value !== 'object' || Array.isArray(value)) { | |
| throw new Error( | |
| `The value for subsection option '${option.name}' must be an object, got ${Array.isArray(value) ? 'array' : typeof value}` | |
| ); | |
| } | |
| const subValue = value[subOption.id]; | |
| try { | |
| value[subOption.id] = validateOption( | |
| subOption, | |
| subValue, | |
| decryptValues | |
| ); | |
| } catch (error) { | |
| throw new Error( | |
| `The value for sub-option '${subOption.name}' in subsection option '${option.name}' is invalid: ${error}` | |
| ); | |
| } | |
| } | |
| return value; | |
| } | |
| if (option.type === 'multi-select') { | |
| if (!Array.isArray(value)) { | |
| throw new Error( | |
| `Option ${option.id} must be an array, got ${typeof value}` | |
| ); | |
| } | |
| if (option.constraints?.max && value.length > option.constraints.max) { | |
| throw new Error( | |
| `Option ${option.id} must be at most ${option.constraints.max} items, got ${value.length}` | |
| ); | |
| } | |
| if (option.constraints?.min && value.length < option.constraints.min) { | |
| throw new Error( | |
| `Option ${option.id} must be at least ${option.constraints.min} items, got ${value.length}` | |
| ); | |
| } | |
| return value; | |
| } | |
| if (option.type === 'select') { | |
| if (typeof value !== 'string') { | |
| throw new Error( | |
| `Option ${option.id} must be a string, got ${typeof value}` | |
| ); | |
| } | |
| } | |
| if (option.type === 'boolean') { | |
| if (typeof value !== 'boolean') { | |
| throw new Error( | |
| `Option ${option.id} must be a boolean, got ${typeof value}` | |
| ); | |
| } | |
| } | |
| if (option.type === 'number') { | |
| if (typeof value !== 'number') { | |
| throw new Error( | |
| `Option ${option.id} must be a number, got ${typeof value}` | |
| ); | |
| } | |
| if (option.constraints?.min && value < option.constraints.min) { | |
| throw new Error( | |
| `Option ${option.id} must be at least ${option.constraints.min}, got ${value}` | |
| ); | |
| } | |
| if (option.constraints?.max && value > option.constraints.max) { | |
| throw new Error( | |
| `Option ${option.id} must be at most ${option.constraints.max}, got ${value}` | |
| ); | |
| } | |
| } | |
| if (option.type === 'string' || option.type === 'password') { | |
| if (typeof value !== 'string') { | |
| throw new Error( | |
| `Option ${option.id} must be a string, got ${typeof value}: ${value}` | |
| ); | |
| } | |
| if (option.constraints?.min && value.length < option.constraints.min) { | |
| throw new Error( | |
| `Option ${option.id} must be at least ${option.constraints.min} characters, got ${value.length}` | |
| ); | |
| } | |
| if (option.constraints?.max && value.length > option.constraints.max) { | |
| throw new Error( | |
| `Option ${option.id} must be at most ${option.constraints.max} characters, got ${value.length}` | |
| ); | |
| } | |
| } | |
| if (option.type === 'password') { | |
| if (isEncrypted(value) && decryptValues) { | |
| const { success, data, error } = decryptString(value); | |
| if (!success) { | |
| throw new Error( | |
| `Option ${option.id} is encrypted but failed to decrypt: ${error}` | |
| ); | |
| } | |
| value = data; | |
| } | |
| } | |
| if (option.type === 'url') { | |
| if (forcedValue !== undefined) { | |
| value = forcedValue; | |
| } | |
| if (typeof value !== 'string') { | |
| throw new Error( | |
| `Option ${option.id} must be a string, got ${typeof value}` | |
| ); | |
| } | |
| } | |
| return value; | |
| } | |
| async function validateProxy( | |
| config: UserData, | |
| skipProxyErrors: boolean = false, | |
| decryptCredentials: boolean = false | |
| ): Promise<StreamProxyConfig> { | |
| // apply forced values if they exist | |
| const proxy = config.proxy ?? {}; | |
| proxy.enabled = appConfig.proxy.force.enabled ?? proxy.enabled; | |
| proxy.id = (appConfig.proxy.force.id as typeof proxy.id) ?? proxy.id; | |
| proxy.url = appConfig.proxy.force.url | |
| ? (encryptString(appConfig.proxy.force.url).data ?? undefined) | |
| : (proxy.url ?? undefined); | |
| let forcedPublicUrl: string | undefined; | |
| forcedPublicUrl = appConfig.proxy.force.publicUrl ?? forcedPublicUrl; | |
| proxy.publicUrl = forcedPublicUrl | |
| ? (encryptString(forcedPublicUrl).data ?? undefined) | |
| : (proxy.publicUrl ?? undefined); | |
| proxy.credentials = appConfig.proxy.force.credentials | |
| ? (encryptString(appConfig.proxy.force.credentials).data ?? undefined) | |
| : (proxy.credentials ?? undefined); | |
| proxy.publicIp = appConfig.proxy.force.publicIp ?? proxy.publicIp; | |
| proxy.proxiedAddons = appConfig.proxy.force.disableProxiedAddons | |
| ? undefined | |
| : proxy.proxiedAddons; | |
| proxy.proxiedServices = | |
| appConfig.proxy.force.proxiedServices ?? proxy.proxiedServices; | |
| if (proxy.enabled) { | |
| if (!proxy.id) { | |
| throw new Error('Proxy ID is required'); | |
| } | |
| if (proxy.id === constants.BUILTIN_SERVICE) { | |
| proxy.url = appConfig.bootstrap.baseUrl; | |
| } | |
| if (!proxy.url) { | |
| throw new Error('Proxy URL is required'); | |
| } | |
| if (!proxy.credentials) { | |
| throw new Error('Proxy credentials are required'); | |
| } | |
| if (isEncrypted(proxy.credentials) && decryptCredentials) { | |
| const { success, data, error } = decryptString(proxy.credentials); | |
| if (!success) { | |
| throw new Error( | |
| `Proxy credentials for ${proxy.id} are encrypted but failed to decrypt: ${error}` | |
| ); | |
| } | |
| proxy.credentials = data; | |
| } | |
| if (isEncrypted(proxy.url) && decryptCredentials) { | |
| const { success, data, error } = decryptString(proxy.url); | |
| if (!success) { | |
| throw new Error( | |
| `Proxy URL for ${proxy.id} is encrypted but failed to decrypt: ${error}` | |
| ); | |
| } | |
| proxy.url = data; | |
| } | |
| if (proxy.publicUrl && isEncrypted(proxy.publicUrl) && decryptCredentials) { | |
| const { success, data, error } = decryptString(proxy.publicUrl); | |
| if (!success) { | |
| throw new Error( | |
| `Proxy public URL for ${proxy.id} is encrypted but failed to decrypt: ${error}` | |
| ); | |
| } | |
| proxy.publicUrl = data; | |
| } | |
| // use decrypted proxy config for validation. | |
| const ProxyService = createProxy(ensureDecrypted(config).proxy ?? {}); | |
| try { | |
| proxy.publicIp || (await ProxyService.getPublicIp()); | |
| } catch (error) { | |
| if (!skipProxyErrors) { | |
| logger.error( | |
| { | |
| proxyId: proxy.id, | |
| err: error instanceof Error ? error.message : String(error), | |
| }, | |
| 'failed to get proxy public ip' | |
| ); | |
| throw new Error( | |
| `Failed to get the public IP of the proxy service ${proxy.id}: ${error}` | |
| ); | |
| } | |
| } | |
| } | |
| return proxy; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Config inheritance / parent merging | |
| // --------------------------------------------------------------------------- | |
| // prettier-ignore | |
| const FILTER_FIELDS: (keyof UserData)[] = [ | |
| 'excludedResolutions', 'includedResolutions', 'requiredResolutions', 'preferredResolutions', | |
| 'excludedQualities', 'includedQualities', 'requiredQualities', 'preferredQualities', | |
| 'excludedLanguages', 'includedLanguages', 'requiredLanguages', 'preferredLanguages', | |
| 'excludedSubtitles', 'includedSubtitles', 'requiredSubtitles', 'preferredSubtitles', | |
| 'excludedVisualTags', 'includedVisualTags', 'requiredVisualTags', 'preferredVisualTags', | |
| 'excludedAudioTags', 'includedAudioTags', 'requiredAudioTags', 'preferredAudioTags', | |
| 'excludedAudioChannels', 'includedAudioChannels', 'requiredAudioChannels', 'preferredAudioChannels', | |
| 'excludedStreamTypes', 'includedStreamTypes', 'requiredStreamTypes', 'preferredStreamTypes', | |
| 'excludedEncodes', 'includedEncodes', 'requiredEncodes', 'preferredEncodes', | |
| 'excludedRegexPatterns', 'includedRegexPatterns', 'requiredRegexPatterns', | |
| 'preferredRegexPatterns', 'rankedRegexPatterns', 'regexOverrides', 'selOverrides', | |
| 'syncedPreferredRegexUrls', 'syncedExcludedRegexUrls', 'syncedIncludedRegexUrls', | |
| 'syncedRequiredRegexUrls', 'syncedRankedRegexUrls', | |
| 'syncedPreferredStreamExpressionUrls', 'syncedExcludedStreamExpressionUrls', | |
| 'syncedIncludedStreamExpressionUrls', 'syncedRequiredStreamExpressionUrls', | |
| 'syncedRankedStreamExpressionUrls', | |
| 'excludedStreamExpressions', 'requiredStreamExpressions', 'preferredStreamExpressions', | |
| 'includedStreamExpressions', 'rankedStreamExpressions', | |
| 'excludedKeywords', 'includedKeywords', 'requiredKeywords', 'preferredKeywords', | |
| 'excludedReleaseGroups', 'includedReleaseGroups', 'requiredReleaseGroups', 'preferredReleaseGroups', | |
| 'enableSeadex', 'excludeSeasonPacks', | |
| 'excludeCached', 'excludeCachedFromAddons', 'excludeCachedFromServices', | |
| 'excludeCachedFromStreamTypes', 'excludeCachedMode', | |
| 'excludeUncached', 'excludeUncachedFromAddons', 'excludeUncachedFromServices', | |
| 'excludeUncachedFromStreamTypes', 'excludeUncachedMode', | |
| 'excludeSeederRange', 'includeSeederRange', 'requiredSeederRange', 'seederRangeTypes', | |
| 'excludeAgeRange', 'includeAgeRange', 'requiredAgeRange', 'ageRangeTypes', | |
| 'digitalReleaseFilter', 'size', 'bitrate', 'titleMatching', 'yearMatching', 'seasonEpisodeMatching' | |
| ]; | |
| // prettier-ignore | |
| const SORTING_FIELDS: (keyof UserData)[] = [ | |
| 'sortCriteria', 'deduplicator', 'resultLimits', | |
| ]; | |
| // prettier-ignore | |
| const FORMATTER_FIELDS: (keyof UserData)[] = [ | |
| 'formatter', | |
| ]; | |
| // prettier-ignore | |
| const PROXY_FIELDS: (keyof UserData)[] = [ | |
| 'proxy', | |
| ]; | |
| // prettier-ignore | |
| const METADATA_FIELDS: (keyof UserData)[] = [ | |
| 'tmdbApiKey', 'tmdbAccessToken', 'tvdbApiKey', | |
| 'rpdbApiKey', 'topPosterApiKey', 'aioratingsApiKey', 'aioratingsProfileId', | |
| 'openposterdbApiKey', 'openposterdbUrl', 'openposterdbParameters', 'posterService', | |
| 'usePosterRedirectApi', 'usePosterServiceForMeta', | |
| ]; | |
| // prettier-ignore | |
| const MISC_FIELDS: (keyof UserData)[] = [ | |
| 'autoPlay', 'areYouStillThere', 'statistics', 'dynamicAddonFetching', | |
| 'nzbFailover', 'serviceWrap', 'cacheAndPlay', 'preloadStreams', 'precacheSelector', | |
| 'hideErrors', 'hideErrorsForResources', 'addonCategoryColors', 'catalogModifications', 'mergedCatalogs', | |
| 'accessKey', 'externalDownloads', 'autoRemoveDownloads', 'checkOwned', 'showChanges', | |
| ]; | |
| // prettier-ignore | |
| const BRANDING_FIELDS: (keyof UserData)[] = [ | |
| 'addonName', 'addonLogo', 'addonBackground', 'addonDescription', | |
| ]; | |
| // Personal fields are never inherited — always use the child's own values. | |
| // Includes per-user identity and per-instance state that has no meaning across configs. | |
| // prettier-ignore | |
| const PERSONAL_FIELDS: (keyof UserData)[] = [ | |
| 'appliedTemplates', | |
| ]; | |
| /** | |
| * Merges two arrays using "override by identity" semantics: | |
| * - Parent entries are the base. | |
| * - Child entries whose identity matches a parent entry replace it. | |
| * - Child entries with no matching parent entry are appended. | |
| * - For primitive arrays (no identityKey), deduplication by value is used. | |
| */ | |
| function extendList( | |
| parentArr: any[], | |
| childArr: any[], | |
| identityKey?: string | |
| ): any[] { | |
| if (!identityKey) { | |
| const seen = new Set(parentArr); | |
| const result = [...parentArr]; | |
| for (const item of childArr) { | |
| if (!seen.has(item)) { | |
| result.push(item); | |
| seen.add(item); | |
| } | |
| } | |
| return result; | |
| } | |
| const merged = [...parentArr]; | |
| for (const item of childArr) { | |
| const idx = merged.findIndex((p) => p[identityKey] === item[identityKey]); | |
| if (idx >= 0) merged[idx] = item; | |
| else merged.push(item); | |
| } | |
| return merged; | |
| } | |
| function applyBinarySection( | |
| result: UserData, | |
| parent: UserData, | |
| strategy: 'inherit' | 'override', | |
| fields: (keyof UserData)[] | |
| ): void { | |
| if (strategy !== 'inherit') return; | |
| for (const field of fields) { | |
| if (parent[field] !== undefined) { | |
| (result as any)[field] = parent[field]; | |
| } else { | |
| delete (result as any)[field]; | |
| } | |
| } | |
| } | |
| export function mergeConfigs(parent: UserData, child: UserData): UserData { | |
| const strategies = child.parentConfig?.mergeStrategies; | |
| const result: UserData = { ...child }; | |
| // Presets & groups | |
| const presetsMerge = strategies?.presets ?? 'inherit'; | |
| if (presetsMerge === 'inherit') { | |
| result.presets = parent.presets; | |
| result.groups = parent.groups; | |
| } else if (presetsMerge === 'extend') { | |
| const merged = [...(parent.presets ?? [])]; | |
| for (const cp of child.presets ?? []) { | |
| const idx = merged.findIndex((p) => p.instanceId === cp.instanceId); | |
| if (idx >= 0) merged[idx] = cp; | |
| else merged.push(cp); | |
| } | |
| result.presets = merged; | |
| result.groups = child.groups ?? parent.groups; | |
| } | |
| // 'override': keep child's presets already in result | |
| // Services | |
| const servicesMerge = strategies?.services ?? 'inherit'; | |
| if (servicesMerge === 'inherit') { | |
| result.services = parent.services; | |
| } else if (servicesMerge === 'extend') { | |
| const merged = [...(parent.services ?? [])]; | |
| for (const cs of child.services ?? []) { | |
| if (cs.enabled === false) continue; | |
| const idx = merged.findIndex((s) => s.id === cs.id); | |
| if (idx >= 0) { | |
| merged[idx] = cs; | |
| } else { | |
| merged.push(cs); | |
| } | |
| } | |
| result.services = merged; | |
| } | |
| // 'override': keep child's services already in result | |
| applyBinarySection( | |
| result, | |
| parent, | |
| strategies?.filters ?? 'inherit', | |
| FILTER_FIELDS | |
| ); | |
| applyBinarySection( | |
| result, | |
| parent, | |
| strategies?.sorting ?? 'inherit', | |
| SORTING_FIELDS | |
| ); | |
| applyBinarySection( | |
| result, | |
| parent, | |
| strategies?.formatter ?? 'inherit', | |
| FORMATTER_FIELDS | |
| ); | |
| applyBinarySection( | |
| result, | |
| parent, | |
| strategies?.proxy ?? 'inherit', | |
| PROXY_FIELDS | |
| ); | |
| applyBinarySection( | |
| result, | |
| parent, | |
| strategies?.metadata ?? 'inherit', | |
| METADATA_FIELDS | |
| ); | |
| applyBinarySection( | |
| result, | |
| parent, | |
| strategies?.misc ?? 'inherit', | |
| MISC_FIELDS | |
| ); | |
| applyBinarySection( | |
| result, | |
| parent, | |
| strategies?.branding ?? 'inherit', | |
| BRANDING_FIELDS | |
| ); | |
| // Personal fields always come from the child regardless of merge strategies. | |
| for (const field of PERSONAL_FIELDS) { | |
| if (child[field] !== undefined) { | |
| (result as any)[field] = child[field]; | |
| } else { | |
| delete (result as any)[field]; | |
| } | |
| } | |
| // Per-field overrides - applied last so they win over group strategies. | |
| const fieldOverrides = strategies?.fieldOverrides ?? {}; | |
| for (const [fieldKey, override] of Object.entries(fieldOverrides)) { | |
| const field = fieldKey as keyof typeof FIELD_META; | |
| const meta = FIELD_META[field]; | |
| if (!meta || meta.ignoreForParentConfig) continue; | |
| if (override === 'inherit') { | |
| if (parent[field] !== undefined) { | |
| (result as any)[field] = parent[field]; | |
| } else { | |
| delete (result as any)[field]; | |
| } | |
| } else if (override === 'override') { | |
| if (child[field] !== undefined) { | |
| (result as any)[field] = child[field]; | |
| } else { | |
| delete (result as any)[field]; | |
| } | |
| } else if (override === 'extend') { | |
| if (meta.type !== 'list') continue; // extend is only valid for list fields | |
| const parentVal = (parent[field] as any[] | undefined) ?? []; | |
| const childVal = (child[field] as any[] | undefined) ?? []; | |
| if (parentVal.length === 0 && childVal.length === 0) { | |
| delete (result as any)[field]; | |
| } else { | |
| (result as any)[field] = extendList( | |
| parentVal, | |
| childVal, | |
| meta.identityKey | |
| ); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |