import React, { createContext, useContext, useState, useEffect, useRef } from "react"; import { AppConfig, CatalogConfig, SearchConfig } from "./config"; import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; import { allCatalogDefinitions, allSearchProviders } from "@/data/catalogs"; import { LoadingScreen } from "@/components/LoadingScreen"; interface AuthState { authenticated: boolean; userUUID: string | null; password: string | null; // ephemeral, in-memory only } interface ConfigContextType { config: AppConfig; setConfig: React.Dispatch>; addonVersion: string; resetConfig: () => Promise; auth: AuthState; setAuth: React.Dispatch>; hasBuiltInTvdb: boolean; hasBuiltInTmdb: boolean; traktSearchEnabled: boolean; catalogTTL: number; isLoading: boolean; sessionId: string; setSessionId: (sessionId: string) => void; manifestFingerprint: React.MutableRefObject; snapshotManifestFingerprint: () => boolean; } const ConfigContext = createContext(undefined); const CONFIG_STORAGE_KEY = 'stremio-addon-config'; let initialConfigFromSources: AppConfig | null = null; let hasInitialized = false; const DEFAULT_SEARCH_ORDER = [ 'movie', 'series', 'tvdb.collections.search', 'gemini.search', 'anime_series', 'anime_movie', 'people_search_movie', 'people_search_series', ]; function initializeConfigFromSources(): AppConfig | null { if (hasInitialized) { return initialConfigFromSources; } hasInitialized = true; let loadedConfig: any = null; try { const pathParts = window.location.pathname.split('/'); const configStringIndex = pathParts.findIndex(p => p.toLowerCase() === 'configure'); // Only load config from URL if it's NOT a Stremio UUID-based URL // Stremio UUID URLs should require authentication const isStremioUUIDUrl = pathParts.includes('stremio') && configStringIndex > 1 && pathParts[configStringIndex - 2] && pathParts[configStringIndex - 2].match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); if (configStringIndex > 0 && pathParts[configStringIndex - 1] && !isStremioUUIDUrl) { const decompressed = decompressFromEncodedURIComponent(pathParts[configStringIndex - 1]); if (decompressed) { console.log('[Config] Initializing from URL.'); loadedConfig = JSON.parse(decompressed); window.history.replaceState({}, '', '/configure'); } } } catch (e) { /* Fall through */ } // Note: localStorage initialization removed - configurations now stored in database if (loadedConfig) { const providers = loadedConfig.search?.providers; if (providers && providers.anime) { console.log("[Config Migration] Old 'anime' provider found. Upgrading configuration..."); providers.anime_movie = providers.anime_movie || 'mal.search.movie'; providers.anime_series = providers.anime_series || 'mal.search.series'; delete providers.anime; // Migration completed - config will be saved to database when user saves } } initialConfigFromSources = loadedConfig; return initialConfigFromSources; } // --- Define the initial, default state for a new user --- const initialConfig: AppConfig = { language: "en-US", addonName: "", includeAdult: false, blurThumbs: false, showPrefix: false, showMetaProviderAttribution: false, castCount: 10, displayAgeRating: false, showDisabledCatalogs: false, sfw: false, hideUnreleasedDigital: false, hideUnreleasedDigitalSearch: false, hideWatchedTrakt: false, hideWatchedAnilist: false, hideWatchedMdblist: false, providers: { movie: 'tmdb', series: 'tvdb', anime: 'mal', anime_id_provider: 'imdb', forceAnimeForDetectedImdb: false }, artProviders: { movie: { poster: 'meta', background: 'meta', logo: 'meta' }, series: { poster: 'meta', background: 'meta', logo: 'meta' }, anime: { poster: 'meta', background: 'imdb', logo: 'imdb' }, englishArtOnly: false, originalLangFallback: false }, tvdbSeasonType: 'default', mal: { skipFiller: false, skipRecap: false, allowEpisodeMarking: false, useImdbIdForCatalogAndSearch: false, }, tmdb: { scrapeImdb: false, forceLatinCastNames: false, }, apiKeys: { gemini: "", tmdb: "", tvdb: "", fanart: "", rpdb: "", topPoster: "", mdblist: "", openrouter: "", publicmetadb: "", }, posterRatingProvider: 'rpdb' as 'rpdb' | 'top', usePosterProxy: true, mdblistWatchTracking: true, anilistWatchTracking: true, simklWatchTracking: true, traktWatchTracking: true, publicmetadbWatchTracking: false, enableRatingPostersForLibrary: true, // Default to enabled - keep Rating Posters for library items showRateMeButton: false, // Default to disabled - user must enable to show rate button ageRating: 'None', searchEnabled: true, sessionId: "", catalogSetupComplete: false, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', catalogs: allCatalogDefinitions .map(c => ({ id: c.id, name: c.name, type: c.type, source: c.source, enabled: c.isEnabledByDefault || false, showInHome: c.showOnHomeByDefault || false, enableRatingPosters: true, // Default to enabled for new catalogs randomizePerPage: false, })), search: { enabled: true, ai_enabled: false, ai_provider: 'gemini', ai_model: 'gemini-2.5-flash-lite', providers: { movie: 'tmdb.search', series: 'tvdb.search', anime_movie: 'mal.search.movie', anime_series: 'mal.search.series', people_search_movie: 'tmdb.people.search', people_search_series: 'tmdb.people.search', }, engineEnabled: { 'tmdb.search': true, 'tvdb.search': true, 'tvdb.collections.search': false, 'tvmaze.search': true, 'trakt.search': true, 'mdblist.search': true, 'people_search_movie': false, 'people_search_series': false, 'mal.search.movie': true, 'mal.search.series': true, }, searchNames: {}, searchOrder: DEFAULT_SEARCH_ORDER, }, streaming: [], // Added to satisfy AppConfig interface customPosterUrlPattern: '', customBackgroundUrlPattern: '', customLogoUrlPattern: '', }; const defaultCatalogs = allCatalogDefinitions.map(c => ({ id: c.id, name: c.name, type: c.type, source: c.source, enabled: c.isEnabledByDefault || false, showInHome: c.showOnHomeByDefault || false, enableRatingPosters: true, // Default to enabled for new catalogs randomizePerPage: false, })); function getManifestFingerprint(config: AppConfig): string { const catalogFingerprint = (config.catalogs || []).map(c => ({ id: c.id, type: c.type, enabled: c.enabled, name: c.name, displayType: c.displayType, showInHome: c.showInHome, })); return JSON.stringify({ catalogs: catalogFingerprint, addonName: config.addonName, catalogModeOnly: config.catalogModeOnly, showRateMeButton: config.showRateMeButton, showPrefix: config.showPrefix, language: config.language, search: { enabled: config.search?.enabled, engineEnabled: config.search?.engineEnabled, searchNames: config.search?.searchNames, searchDisplayTypes: config.search?.searchDisplayTypes, searchOrder: config.search?.searchOrder, ai_enabled: config.search?.ai_enabled, }, }); } export function ConfigProvider({ children }: { children: React.ReactNode }) { const [addonVersion, setAddonVersion] = useState(' '); const [preloadedConfig] = useState(initializeConfigFromSources); const [auth, setAuth] = useState({ authenticated: false, userUUID: null, password: null }); const [config, setConfig] = useState(() => { if (preloadedConfig) { let hydratedCatalogs: CatalogConfig[] = [...defaultCatalogs] as CatalogConfig[]; if (preloadedConfig.catalogs && preloadedConfig.catalogs.length > 0) { const userCatalogSettings = new Map( preloadedConfig.catalogs.map(c => { const settings: CatalogConfig = { id: c.id, name: c.name, type: c.type as any, source: c.source as any, enabled: c.enabled, showInHome: c.showInHome, }; if (c.enableRatingPosters !== undefined) settings.enableRatingPosters = c.enableRatingPosters; if (c.randomizePerPage !== undefined) settings.randomizePerPage = c.randomizePerPage; if (c.displayType !== undefined) settings.displayType = c.displayType; if (c.cacheTTL !== undefined) settings.cacheTTL = c.cacheTTL; if (c.genreSelection !== undefined) settings.genreSelection = c.genreSelection; if (c.sort !== undefined) settings.sort = c.sort; if (c.order !== undefined) settings.order = c.order; if (c.pageSize !== undefined) settings.pageSize = c.pageSize; if (c.metadata !== undefined) settings.metadata = c.metadata; return [`${c.id}-${c.type}`, settings]; }) ); // Always merge in new catalogs from allCatalogDefinitions // MIGRATION: Ensure all catalogs from allCatalogDefinitions are present in user configs const userCatalogKeys = new Set(preloadedConfig.catalogs.map(c => `${c.id}-${c.type}`)); const missingCatalogs = defaultCatalogs.filter(def => !userCatalogKeys.has(`${def.id}-${def.type}`)); const mergedCatalogs = [ ...missingCatalogs, ...preloadedConfig.catalogs ]; hydratedCatalogs = mergedCatalogs.map(defaultCatalog => { const key = `${defaultCatalog.id}-${defaultCatalog.type}`; if (userCatalogSettings.has(key)) { const catalog = { ...defaultCatalog, ...userCatalogSettings.get(key) } as CatalogConfig; // MIGRATION: Fix invalid sort values for streaming catalogs if (catalog.source === 'streaming') { const validStreamingSorts = ['popularity', 'release_date', 'vote_average', 'revenue']; if (!catalog.sort || !validStreamingSorts.includes(catalog.sort as string)) { catalog.sort = 'popularity'; } if (!catalog.sortDirection) { catalog.sortDirection = 'desc'; } } return catalog; } return defaultCatalog as CatalogConfig; }); // Remove the old forEach that pushed missing userCatalogs (now handled above) } // Hydrate search.engineEnabled const hydratedEngineEnabled = { ...initialConfig.search.engineEnabled, ...(preloadedConfig.search?.engineEnabled || {}) }; return { ...initialConfig, ...preloadedConfig, catalogSetupComplete: true, apiKeys: { ...initialConfig.apiKeys, ...preloadedConfig.apiKeys }, providers: { ...initialConfig.providers, ...preloadedConfig.providers }, artProviders: (() => { const defaultArtProviders = initialConfig.artProviders; const userArtProviders = preloadedConfig.artProviders; if (!userArtProviders) return defaultArtProviders; // Migrate legacy string format to new nested format const migratedArtProviders = { ...defaultArtProviders }; ['movie', 'series', 'anime'].forEach(contentType => { const userValue = userArtProviders[contentType]; if (typeof userValue === 'string') { // Legacy format: convert single string to nested object migratedArtProviders[contentType] = { poster: userValue, background: userValue, logo: userValue }; } else if (userValue && typeof userValue === 'object') { // New format: merge with defaults migratedArtProviders[contentType] = { ...defaultArtProviders[contentType], ...userValue }; } }); if (userArtProviders.englishArtOnly !== undefined) { migratedArtProviders.englishArtOnly = userArtProviders.englishArtOnly; } if (userArtProviders.originalLangFallback !== undefined) { migratedArtProviders.originalLangFallback = userArtProviders.originalLangFallback; } return migratedArtProviders; })(), search: { ...initialConfig.search, ...preloadedConfig.search, engineEnabled: hydratedEngineEnabled, }, mal: { ...initialConfig.mal, ...preloadedConfig.mal }, tmdb: { ...initialConfig.tmdb, ...preloadedConfig.tmdb }, catalogs: hydratedCatalogs, }; } return initialConfig; }); const [isLoading, setIsLoading] = useState(true); const [hasBuiltInTvdb, setHasBuiltInTvdb] = useState(false); const [hasBuiltInTmdb, setHasBuiltInTmdb] = useState(false); const [traktSearchEnabled, setTraktSearchEnabled] = useState(true); const [catalogTTL, setCatalogTTL] = useState(86400); // Default to 24 hours const manifestFingerprint = useRef(null); // --- THIS IS THE CORRECTED EFFECT --- useEffect(() => { let isMounted = true; const finalizeConfig = async () => { try { const envResponse = await fetch('/api/config'); if (!isMounted) return; const envApiKeys = await envResponse.json(); setAddonVersion(envApiKeys.addonVersion || ' '); setHasBuiltInTvdb(!!envApiKeys.hasBuiltInTvdb); setHasBuiltInTmdb(!!envApiKeys.hasBuiltInTmdb); setTraktSearchEnabled(envApiKeys.traktSearchEnabled ?? true); setCatalogTTL(envApiKeys.catalogTTL || 86400); // Layer in the server keys with the correct priority. // We use `preloadedConfig` because it holds the user's saved data. setConfig(currentConfig => ({ ...currentConfig, apiKeys: { ...initialConfig.apiKeys, // Priority 3: Default empty strings ...envApiKeys, // Priority 2: Server-provided keys ...preloadedConfig?.apiKeys, // Priority 1: User's saved keys (from URL or localStorage) // ALWAYS override customDescriptionBlurb from server - it's instance-specific, not user-specific customDescriptionBlurb: envApiKeys.customDescriptionBlurb, } })); } catch (e) { console.error("Could not fetch server-side keys.", e); } finally { if (isMounted) setIsLoading(false); } }; finalizeConfig(); return () => { isMounted = false; }; }, []); // The empty dependency array is correct. // Snapshot the manifest fingerprint when config is first loaded or when auth changes (user logs in) useEffect(() => { if (!isLoading) { manifestFingerprint.current = getManifestFingerprint(config); } }, [isLoading, auth.authenticated, auth.userUUID]); // eslint-disable-line react-hooks/exhaustive-deps // Note: localStorage usage has been removed in favor of database storage // Configurations are now saved via the ConfigurationManager component const resetConfig = async () => { try { const envResponse = await fetch('/api/config'); const envApiKeys = await envResponse.json(); setConfig({ ...initialConfig, catalogSetupComplete: true, apiKeys: { ...initialConfig.apiKeys, ...envApiKeys }, }); } catch (e) { // Fallback to pure defaults if env fetch fails setConfig(initialConfig); } }; if (isLoading) { return ; } // Helper functions for sessionId const sessionId = config.sessionId || ""; const setSessionId = (newSessionId: string) => { setConfig(prev => ({ ...prev, sessionId: newSessionId })); }; // Compares current config against the stored baseline. // Returns true if the manifest changed. Updates the baseline to current. const snapshotManifestFingerprint = (): boolean => { const current = getManifestFingerprint(config); const changed = manifestFingerprint.current !== null && current !== manifestFingerprint.current; manifestFingerprint.current = current; return changed; }; return ( {children} ); } export const useConfig = () => { const context = useContext(ConfigContext); if (context === undefined) { throw new Error('useConfig must be used within a ConfigProvider'); } return context; }; export type { AppConfig }; export type { CatalogConfig };