import React from 'react'; import { UserData } from '@aiostreams/core'; import { QUALITIES, RESOLUTIONS, SERVICE_DETAILS, DEFAULT_PRECACHE_SELECTOR, DEFAULT_SMART_DETECT_ATTRIBUTES, } from '../../../core/src/utils/constants'; import { useStatus } from './status'; const USER_DATA_KEY = 'aiostreams-user-data'; 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: 0, 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') { config[type + 'VisualTags'] = [ ...(config[type + 'VisualTags'] ?? []), encode, ]; 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; 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')" ); 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), })); } 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) : []' : DEFAULT_PRECACHE_SELECTOR; } } delete config.alwaysPrecache; delete config.precacheCondition; // migrate p2pWrap to serviceWrap if (config.p2pWrap !== undefined && config.serviceWrap === undefined) { config.serviceWrap = config.p2pWrap; delete config.p2pWrap; } // 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].some((expr: unknown) => typeof expr === 'string') ) { config[key] = config[key].map((expr: unknown) => typeof expr === 'string' ? { expression: expr, enabled: true } : expr ); } } // 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; } export 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 const DefaultUserData: UserData = { services: Object.values(SERVICE_DETAILS).map((service) => ({ id: service.id, enabled: false, credentials: {}, })), presets: [], formatter: { id: 'gdrive', }, preferredQualities: Object.values(QUALITIES), preferredResolutions: Object.values(RESOLUTIONS), excludedQualities: ['CAM', 'SCR', 'TS', 'TC'], excludedVisualTags: ['3D'], sortCriteria: { global: [ { key: 'cached', direction: 'desc', }, { key: 'library', direction: 'desc', }, { key: 'resolution', direction: 'desc', }, { key: 'quality', direction: 'desc', }, { key: 'streamExpressionScore', direction: 'desc', }, { key: 'regexPatterns', direction: 'desc', }, { key: 'streamType', direction: 'desc', }, { key: 'visualTag', direction: 'desc', }, { key: 'audioTag', direction: 'desc', }, { key: 'audioChannel', direction: 'desc', }, { key: 'encode', direction: 'desc', }, { key: 'language', direction: 'desc', }, { key: 'subtitle', direction: 'desc', }, { key: 'size', direction: 'desc', }, ], }, posterService: 'rpdb', deduplicator: { enabled: true, keys: ['filename', 'infoHash'], multiGroupBehaviour: 'aggressive', cached: 'single_result', uncached: 'per_service', p2p: 'single_result', http: 'disabled', live: 'disabled', youtube: 'disabled', external: 'disabled', smartDetectAttributes: DEFAULT_SMART_DETECT_ATTRIBUTES, smartDetectRounding: 10, libraryBehaviour: 'ignore', }, autoPlay: { enabled: true, method: 'matchingFile', attributes: ['resolution', 'quality', 'releaseGroup'], }, cacheAndPlay: { enabled: false, streamTypes: ['usenet'], }, statistics: { enabled: false, position: 'bottom', statsToShow: ['addon', 'filter', 'timing'], showFilterStatsOnNoStreams: true, }, digitalReleaseFilter: { enabled: false, tolerance: 0, requestTypes: [], addons: [], showInfoOnFilter: true, }, ageRangeTypes: ['usenet'], seasonEpisodeMatching: { addons: [], requestTypes: [], }, yearMatching: { addons: [], requestTypes: [], }, titleMatching: { addons: [], requestTypes: [], }, precacheNextEpisode: false, precacheSingleStream: true, precacheSelector: DEFAULT_PRECACHE_SELECTOR, enableSeadex: true, regexOverrides: [], checkOwned: true, }; interface UserDataContextType { userData: UserData; setUserData: (data: ((prev: UserData) => UserData | null) | null) => void; uuid: string | null; setUuid: (uuid: string | null) => void; password: string | null; setPassword: (password: string | null) => void; encryptedPassword: string | null; setEncryptedPassword: (encryptedPassword: string | null) => void; } const UserDataContext = React.createContext( undefined ); export function UserDataProvider({ children }: { children: React.ReactNode }) { const { status } = useStatus(); // Initialize userData from local storage or apply default const [userData, setUserData] = React.useState(() => { try { const stored = localStorage.getItem(USER_DATA_KEY); const data = stored ? JSON.parse(stored) : DefaultUserData; return applyMigrations(data); } catch { return DefaultUserData; } }); const [uuid, setUuid] = React.useState(null); const [password, setPassword] = React.useState(null); const [encryptedPassword, setEncryptedPassword] = React.useState< string | null >(null); // Effect to persist userData to local storage React.useEffect(() => { localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData)); }, [userData]); const statusApplied = React.useRef(false); React.useEffect(() => { if (!status || statusApplied.current) return; statusApplied.current = true; const forced = status.settings.forced; const defaults = status.settings.defaults; const services = status.settings.services; setUserData((prev) => { const newData = { ...prev }; newData.proxy = { ...newData.proxy, enabled: forced.proxy.enabled ?? defaults.proxy?.enabled ?? undefined, id: (forced.proxy.id ?? defaults.proxy?.id ?? 'builtin') as | 'builtin' | 'mediaflow' | 'stremthru' | undefined, url: forced.proxy.url ?? defaults.proxy?.url ?? undefined, publicUrl: forced.proxy.publicUrl ?? defaults.proxy?.publicUrl ?? undefined, publicIp: forced.proxy.publicIp ?? defaults.proxy?.publicIp ?? undefined, credentials: forced.proxy.credentials ?? defaults.proxy?.credentials ?? undefined, proxiedServices: forced.proxy.proxiedServices ?? defaults.proxy?.proxiedServices ?? [], }; newData.services = (newData.services ?? []).map((service) => { const serviceMeta = services[service.id]; if (!serviceMeta) return service; serviceMeta.credentials.forEach((credential) => { if (credential.forced) { service.credentials[credential.id] = credential.forced; } else if (credential.default) { service.credentials[credential.id] = credential.default; } }); // enable if every credential is set service.enabled = serviceMeta.credentials.every( (credential) => credential.forced || credential.default || service.credentials[credential.id] !== undefined ); return service; }); return newData; }); }, [status]); const safeSetUserData = ( data: ((prev: UserData) => UserData | null) | null ) => { if (data === null) { setUserData(DefaultUserData); } else { setUserData((prev) => { const result = data(prev); return result === null ? DefaultUserData : result; }); } }; return ( {children} ); } export function useUserData() { const context = React.useContext(UserDataContext); if (context === undefined) { throw new Error('useUserData must be used within a UserDataProvider'); } return context; } export function useParentInheritance() { const { userData } = useUserData(); const parentConfig = userData?.parentConfig; const strategies = parentConfig?.mergeStrategies; function isInherited( section: | 'presets' | 'services' | 'filters' | 'sorting' | 'formatter' | 'proxy' | 'metadata' | 'misc' ): boolean { if (!parentConfig) return false; const strategy = strategies?.[section] ?? 'inherit'; return strategy === 'inherit'; } return { hasParent: !!parentConfig, parentUuid: parentConfig?.uuid, isInherited, }; }