diff --git a/app.json b/app.json index dfa4d30..a6843c6 100644 --- a/app.json +++ b/app.json @@ -49,7 +49,7 @@ "permissions": [ "INTERNET", "WAKE_LOCK", - "WRITE_SETTINGS" + "android.permission.WRITE_SETTINGS" ], "package": "com.nuvio.app", "versionCode": 25, @@ -107,4 +107,4 @@ }, "runtimeVersion": "1.2.10" } -} +} \ No newline at end of file diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 585c7d6..9f25880 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -569,6 +569,33 @@ const AppleTVHero: React.FC = ({ ); }, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]); + // Preload next and previous images for instant swiping + useEffect(() => { + if (items.length <= 1) return; + + const prevIdx = (currentIndex - 1 + items.length) % items.length; + const nextIdx = (currentIndex + 1) % items.length; + + const prevItem = items[prevIdx]; + const nextItem = items[nextIdx]; + + const urlsToPreload: { uri: string }[] = []; + + if (prevItem) { + const url = prevItem.banner || prevItem.poster; + if (url) urlsToPreload.push({ uri: url }); + } + + if (nextItem) { + const url = nextItem.banner || nextItem.poster; + if (url) urlsToPreload.push({ uri: url }); + } + + if (urlsToPreload.length > 0) { + FastImage.preload(urlsToPreload); + } + }, [currentIndex, items]); + // Callback for updating interaction time const updateInteractionTime = useCallback(() => { lastInteractionRef.current = Date.now(); diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index efef66a..9326af1 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -59,15 +59,15 @@ export function useFeaturedContent() { const loadFeaturedContent = useCallback(async (forceRefresh = false) => { const t0 = Date.now(); - + // Check if we should use cached data (disabled if DISABLE_CACHE) const now = Date.now(); const cacheAge = now - persistentStore.lastFetchTime; if (!DISABLE_CACHE) { - if (!forceRefresh && - persistentStore.featuredContent && - persistentStore.allFeaturedContent.length > 0 && - cacheAge < CACHE_TIMEOUT) { + if (!forceRefresh && + persistentStore.featuredContent && + persistentStore.allFeaturedContent.length > 0 && + cacheAge < CACHE_TIMEOUT) { // Use cached data setFeaturedContent(persistentStore.featuredContent); setAllFeaturedContent(persistentStore.allFeaturedContent); @@ -86,23 +86,19 @@ export function useFeaturedContent() { let formattedContent: StreamingContent[] = []; { - // Load from installed catalogs + // Load from installed catalogs with optimization const tCats = Date.now(); - const catalogs = await catalogService.getHomeCatalogs(); - + // Pass selected catalogs to service for optimized fetching + const catalogs = await catalogService.getHomeCatalogs(selectedCatalogs); + if (signal.aborted) return; // If no catalogs are installed, stop loading and return. if (catalogs.length === 0) { formattedContent = []; } else { - // Filter catalogs based on user selection if any catalogs are selected - const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0 - ? catalogs.filter(catalog => { - const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`; - return selectedCatalogs.includes(catalogId); - }) - : catalogs; // Use all catalogs if none specifically selected + // Use catalogs directly (filtering is now done in service) + const filteredCatalogs = catalogs; // Flatten all catalog items into a single array, filter out items without posters const tFlat = Date.now(); @@ -132,7 +128,7 @@ export function useFeaturedContent() { genres: (item as any).genres, inLibrary: Boolean((item as any).inLibrary), }; - + try { if (!settings.enrichMetadataWithTMDB) { // When enrichment is OFF, keep addon logo or undefined @@ -152,12 +148,12 @@ export function useFeaturedContent() { const found = await tmdbService.findTMDBIdByIMDB(imdbId); tmdbId = found ? String(found) : null; } - + if (tmdbId) { const logoUrl = await tmdbService.getContentLogo(item.type === 'series' ? 'tv' : 'movie', tmdbId as string, preferredLanguage); return { ...base, logo: logoUrl || undefined }; // TMDB logo or undefined (no addon fallback) } - + return { ...base, logo: undefined }; // No TMDB ID means no logo } catch (error) { return { ...base, logo: undefined }; // Error means no logo @@ -175,7 +171,7 @@ export function useFeaturedContent() { logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none', logo: c.logo || undefined, })); - } catch {} + } catch { } } else { // When enrichment is disabled, prefer addon-provided logos; if missing, fetch basic meta to pull logo (like HeroSection) const baseItems = topItems.map((item: any) => { @@ -231,7 +227,7 @@ export function useFeaturedContent() { logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none', logo: c.logo || undefined, })); - } catch {} + } catch { } } } } @@ -250,7 +246,7 @@ export function useFeaturedContent() { ? parsed.allFeaturedContent : [parsed.featuredContent]; } - } catch {} + } catch { } } } @@ -260,9 +256,9 @@ export function useFeaturedContent() { persistentStore.lastFetchTime = now; } persistentStore.isFirstLoad = false; - + setAllFeaturedContent(formattedContent); - + if (formattedContent.length > 0) { persistentStore.featuredContent = formattedContent[0]; setFeaturedContent(formattedContent[0]); @@ -278,14 +274,14 @@ export function useFeaturedContent() { allFeaturedContent: formattedContent, }) ); - } catch {} + } catch { } } } else { persistentStore.featuredContent = null; setFeaturedContent(null); // Clear persisted cache on empty (skipped when cache disabled) if (!DISABLE_CACHE) { - try { await mmkvStorage.removeItem(STORAGE_KEY); } catch {} + try { await mmkvStorage.removeItem(STORAGE_KEY); } catch { } } } } catch (error) { @@ -326,7 +322,7 @@ export function useFeaturedContent() { setLoading(false); } } - } catch {} + } catch { } })(); return () => { cancelled = true; }; }, []); @@ -334,12 +330,12 @@ export function useFeaturedContent() { // Check for settings changes, including during app restart useEffect(() => { // Check if settings changed while app was closed - const settingsChanged = + const settingsChanged = persistentStore.lastSettings.showHeroSection !== settings.showHeroSection || JSON.stringify(persistentStore.lastSettings.selectedHeroCatalogs) !== JSON.stringify(settings.selectedHeroCatalogs) || persistentStore.lastSettings.logoSourcePreference !== settings.logoSourcePreference || persistentStore.lastSettings.tmdbLanguagePreference !== settings.tmdbLanguagePreference; - + // Update our tracking of last used settings persistentStore.lastSettings = { showHeroSection: settings.showHeroSection, @@ -348,7 +344,7 @@ export function useFeaturedContent() { logoSourcePreference: settings.logoSourcePreference, tmdbLanguagePreference: settings.tmdbLanguagePreference }; - + // Force refresh if settings changed during app restart, but only if we have content if (settingsChanged && persistentStore.featuredContent) { loadFeaturedContent(true); @@ -387,20 +383,16 @@ export function useFeaturedContent() { loadFeaturedContent(true); } }; - + // Subscribe to settings changes const unsubscribe = settingsEmitter.addListener(handleSettingsChange); - + return unsubscribe; }, [loadFeaturedContent, settings, contentSource, selectedCatalogs]); - // Load featured content initially and when catalogs selection changes useEffect(() => { // Always use catalogs - setAllFeaturedContent([]); - setFeaturedContent(null); - persistentStore.allFeaturedContent = []; - persistentStore.featuredContent = null; + // Don't clear content here to prevent flashing loadFeaturedContent(true); }, [loadFeaturedContent, selectedCatalogs]); diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index a174e94..d85a951 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -226,7 +226,7 @@ class CatalogService { if (storedLibrary) { const parsedLibrary = JSON.parse(storedLibrary); logger.log(`[CatalogService] Raw library data type: ${Array.isArray(parsedLibrary) ? 'ARRAY' : 'OBJECT'}, keys: ${JSON.stringify(Object.keys(parsedLibrary).slice(0, 5))}`); - + // Convert array format to object format if needed if (Array.isArray(parsedLibrary)) { logger.log(`[CatalogService] WARNING: Library is stored as ARRAY format. Converting to OBJECT format.`); @@ -270,12 +270,12 @@ class CatalogService { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:stremio-library`; const libraryData = JSON.stringify(this.library); - + logger.log(`[CatalogService] Saving library with ${itemCount} items to scope: "${scope}" (key: ${scopedKey})`); - + await mmkvStorage.setItem(scopedKey, libraryData); await mmkvStorage.setItem(this.LEGACY_LIBRARY_KEY, libraryData); - + logger.log(`[CatalogService] Library saved successfully with ${itemCount} items`); } catch (error: any) { logger.error('Failed to save library:', error); @@ -323,17 +323,16 @@ class CatalogService { }; } - async getHomeCatalogs(): Promise { + async getHomeCatalogs(limitIds?: string[]): Promise { const addons = await this.getAllAddons(); - + // Load enabled/disabled settings const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY); const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {}; - // Create an array of promises for all catalog fetches - const catalogPromises: Promise[] = []; + // Collect all potential catalogs first + const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = []; - // Process addons in order (they're already returned in order from getAllAddons) for (const addon of addons) { if (addon.catalogs) { for (const catalog of addon.catalogs) { @@ -341,70 +340,84 @@ class CatalogService { const isEnabled = catalogSettings[settingKey] ?? true; if (isEnabled) { - // Create a promise for each catalog fetch - const catalogPromise = (async () => { - try { - // Hoist manifest list retrieval and find once - const addonManifests = await stremioService.getInstalledAddonsAsync(); - const manifest = addonManifests.find(a => a.id === addon.id); - if (!manifest) return null; - - const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); - if (metas && metas.length > 0) { - // Cap items per catalog to reduce memory and rendering load - const limited = metas.slice(0, 12); - const items = limited.map(meta => this.convertMetaToStreamingContent(meta)); - - // Get potentially custom display name; if customized, respect it as-is - const originalName = catalog.name || catalog.id; - let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName); - const isCustom = displayName !== originalName; - - if (!isCustom) { - // Remove duplicate words and clean up the name (case-insensitive) - const words = displayName.split(' '); - const uniqueWords: string[] = []; - const seenWords = new Set(); - for (const word of words) { - const lowerWord = word.toLowerCase(); - if (!seenWords.has(lowerWord)) { - uniqueWords.push(word); - seenWords.add(lowerWord); - } - } - displayName = uniqueWords.join(' '); - - // Add content type if not present - const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; - if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { - displayName = `${displayName} ${contentType}`; - } - } - - return { - addon: addon.id, - type: catalog.type, - id: catalog.id, - name: displayName, - items - }; - } - return null; - } catch (error) { - logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); - return null; - } - })(); - - catalogPromises.push(catalogPromise); + potentialCatalogs.push({ addon, catalog }); } } } } - // Wait for all catalog fetch promises to resolve in parallel + // Determine which catalogs to actually fetch + let catalogsToFetch: { addon: StreamingAddon; catalog: any }[] = []; + + if (limitIds && limitIds.length > 0) { + // User selected specific catalogs - strict filtering + catalogsToFetch = potentialCatalogs.filter(item => { + const catalogId = `${item.addon.id}:${item.catalog.type}:${item.catalog.id}`; + return limitIds.includes(catalogId); + }); + } else { + // "All" mode - Smart Sample: Pick 5 random catalogs to avoid waterfall + catalogsToFetch = potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5); + } + + // Create promises for the selected catalogs + const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => { + try { + // Hoist manifest list retrieval and find once + const addonManifests = await stremioService.getInstalledAddonsAsync(); + const manifest = addonManifests.find(a => a.id === addon.id); + if (!manifest) return null; + + const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); + if (metas && metas.length > 0) { + // Cap items per catalog to reduce memory and rendering load + const limited = metas.slice(0, 12); + const items = limited.map(meta => this.convertMetaToStreamingContent(meta)); + + // Get potentially custom display name; if customized, respect it as-is + const originalName = catalog.name || catalog.id; + let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName); + const isCustom = displayName !== originalName; + + if (!isCustom) { + // Remove duplicate words and clean up the name (case-insensitive) + const words = displayName.split(' '); + const uniqueWords: string[] = []; + const seenWords = new Set(); + for (const word of words) { + const lowerWord = word.toLowerCase(); + if (!seenWords.has(lowerWord)) { + uniqueWords.push(word); + seenWords.add(lowerWord); + } + } + displayName = uniqueWords.join(' '); + + // Add content type if not present + const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { + displayName = `${displayName} ${contentType}`; + } + } + + return { + addon: addon.id, + type: catalog.type, + id: catalog.id, + name: displayName, + items + }; + } + return null; + } catch (error) { + logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); + return null; + } + }); + + // Wait for all selected catalog fetch promises to resolve in parallel const catalogResults = await Promise.all(catalogPromises); - + // Filter out null results return catalogResults.filter(catalog => catalog !== null) as CatalogContent[]; } @@ -412,16 +425,16 @@ class CatalogService { async getCatalogByType(type: string, genreFilter?: string): Promise { // Get the data source preference (default to Stremio addons) const dataSourcePreference = await this.getDataSourcePreference(); - + // If TMDB is selected as the data source, use TMDB API if (dataSourcePreference === DataSource.TMDB) { return this.getCatalogByTypeFromTMDB(type, genreFilter); } - + // Otherwise use the original Stremio addons method const addons = await this.getAllAddons(); - - const typeAddons = addons.filter(addon => + + const typeAddons = addons.filter(addon => addon.catalogs && addon.catalogs.some(catalog => catalog.type === type) ); @@ -440,13 +453,13 @@ class CatalogService { const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : []; const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); - + if (metas && metas.length > 0) { const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); - + // Get potentially custom display name const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name); - + return { addon: addon.id, type, @@ -462,14 +475,14 @@ class CatalogService { return null; } })(); - + catalogPromises.push(catalogPromise); } } // Wait for all catalog fetch promises to resolve in parallel const catalogResults = await Promise.all(catalogPromises); - + // Filter out null results return catalogResults.filter(catalog => catalog !== null) as CatalogContent[]; } @@ -480,11 +493,11 @@ class CatalogService { private async getCatalogByTypeFromTMDB(type: string, genreFilter?: string): Promise { const tmdbService = TMDBService.getInstance(); const catalogs: CatalogContent[] = []; - + try { // Map Stremio content type to TMDB content type const tmdbType = type === 'movie' ? 'movie' : 'tv'; - + // If no genre filter or All is selected, get multiple catalogs if (!genreFilter || genreFilter === 'All') { // Create an array of promises for all catalog fetches @@ -494,7 +507,7 @@ class CatalogService { const trendingItems = await tmdbService.getTrending(tmdbType, 'week'); const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); const trendingStreamingItems = await Promise.all(trendingItemsPromises); - + return { addon: 'tmdb', type, @@ -503,13 +516,13 @@ class CatalogService { items: trendingStreamingItems }; })(), - + // Popular catalog (async () => { const popularItems = await tmdbService.getPopular(tmdbType, 1); const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); const popularStreamingItems = await Promise.all(popularItemsPromises); - + return { addon: 'tmdb', type, @@ -518,13 +531,13 @@ class CatalogService { items: popularStreamingItems }; })(), - + // Upcoming/on air catalog (async () => { const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1); const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); const upcomingStreamingItems = await Promise.all(upcomingItemsPromises); - + return { addon: 'tmdb', type, @@ -534,7 +547,7 @@ class CatalogService { }; })() ]; - + // Wait for all catalog fetches to complete in parallel return await Promise.all(catalogFetchPromises); } else { @@ -542,7 +555,7 @@ class CatalogService { const genreItems = await tmdbService.discoverByGenre(tmdbType, genreFilter); const streamingItemsPromises = genreItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); const streamingItems = await Promise.all(streamingItemsPromises); - + return [{ addon: 'tmdb', type, @@ -565,16 +578,16 @@ class CatalogService { const id = item.external_ids?.imdb_id || `tmdb:${item.id}`; const name = type === 'movie' ? item.title : item.name; const posterPath = item.poster_path; - + // Get genres from genre_ids let genres: string[] = []; if (item.genre_ids && item.genre_ids.length > 0) { try { const tmdbService = TMDBService.getInstance(); - const genreLists = type === 'movie' - ? await tmdbService.getMovieGenres() + const genreLists = type === 'movie' + ? await tmdbService.getMovieGenres() : await tmdbService.getTvGenres(); - + const genreIds: number[] = item.genre_ids; genres = genreIds .map(genreId => { @@ -586,7 +599,7 @@ class CatalogService { logger.error('Failed to get genres for TMDB content:', error); } } - + return { id, type: type === 'movie' ? 'movie' : 'series', @@ -594,7 +607,7 @@ class CatalogService { poster: posterPath ? `https://image.tmdb.org/t/p/w500${posterPath}` : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', posterShape: 'poster', banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : undefined, - year: type === 'movie' + year: type === 'movie' ? (item.release_date ? new Date(item.release_date).getFullYear() : undefined) : (item.first_air_date ? new Date(item.first_air_date).getFullYear() : undefined), description: item.overview, @@ -633,29 +646,29 @@ class CatalogService { // Try up to 2 times with increasing delays to reduce CPU load let meta = null; let lastError = null; - + for (let i = 0; i < 2; i++) { try { console.log(`🔍 [CatalogService] Attempt ${i + 1}/2 for getContentDetails:`, { type, id, preferredAddonId }); - + // Skip meta requests for non-content ids (e.g., provider slugs) const isValidId = await stremioService.isValidContentId(type, id); console.log(`🔍 [CatalogService] Content ID validation:`, { type, id, isValidId }); - + if (!isValidId) { console.log(`🔍 [CatalogService] Invalid content ID, breaking retry loop`); break; } - + console.log(`🔍 [CatalogService] Calling stremioService.getMetaDetails:`, { type, id, preferredAddonId }); meta = await stremioService.getMetaDetails(type, id, preferredAddonId); - console.log(`🔍 [CatalogService] stremioService.getMetaDetails result:`, { - hasMeta: !!meta, - metaId: meta?.id, + console.log(`🔍 [CatalogService] stremioService.getMetaDetails result:`, { + hasMeta: !!meta, + metaId: meta?.id, metaName: meta?.name, - metaType: meta?.type + metaType: meta?.type }); - + if (meta) break; await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } catch (error) { @@ -672,30 +685,30 @@ class CatalogService { } if (meta) { - console.log(`🔍 [CatalogService] Meta found, converting to StreamingContent:`, { - metaId: meta.id, - metaName: meta.name, - metaType: meta.type + console.log(`🔍 [CatalogService] Meta found, converting to StreamingContent:`, { + metaId: meta.id, + metaName: meta.name, + metaType: meta.type }); - + // Add to recent content using enhanced conversion for full metadata const content = this.convertMetaToStreamingContentEnhanced(meta); this.addToRecentContent(content); - + // Check if it's in the library content.inLibrary = this.library[`${type}:${id}`] !== undefined; - - console.log(`🔍 [CatalogService] Successfully converted meta to StreamingContent:`, { - contentId: content.id, - contentName: content.name, + + console.log(`🔍 [CatalogService] Successfully converted meta to StreamingContent:`, { + contentId: content.id, + contentName: content.name, contentType: content.type, inLibrary: content.inLibrary }); - + return content; } - console.log(`🔍 [CatalogService] No meta found, checking lastError:`, { + console.log(`🔍 [CatalogService] No meta found, checking lastError:`, { hasLastError: !!lastError, lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError) }); @@ -708,7 +721,7 @@ class CatalogService { }); throw lastError; } - + console.log(`🔍 [CatalogService] No meta and no error, returning null`); return null; } catch (error) { @@ -727,14 +740,14 @@ class CatalogService { async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise { console.log(`🔍 [CatalogService] getEnhancedContentDetails called:`, { type, id, preferredAddonId }); logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`); - + try { const result = await this.getContentDetails(type, id, preferredAddonId); - console.log(`🔍 [CatalogService] getEnhancedContentDetails result:`, { - hasResult: !!result, - resultId: result?.id, + console.log(`🔍 [CatalogService] getEnhancedContentDetails result:`, { + hasResult: !!result, + resultId: result?.id, resultName: result?.name, - resultType: result?.type + resultType: result?.type }); return result; } catch (error) { @@ -754,7 +767,7 @@ class CatalogService { // Try up to 3 times with increasing delays let meta = null; let lastError = null; - + for (let i = 0; i < 3; i++) { try { // Skip meta requests for non-content ids (e.g., provider slugs) @@ -774,17 +787,17 @@ class CatalogService { if (meta) { // Use basic conversion without enhanced metadata processing const content = this.convertMetaToStreamingContent(meta); - + // Check if it's in the library content.inLibrary = this.library[`${type}:${id}`] !== undefined; - + return content; } if (lastError) { throw lastError; } - + return null; } catch (error) { logger.error(`Failed to get basic content details for ${type}:${id}:`, error); @@ -799,13 +812,13 @@ class CatalogService { if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') { posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image'; } - + // Use addon's logo if available, otherwise undefined let logoUrl = (meta as any).logo; if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') { logoUrl = undefined; } - + return { id: meta.id, type: meta.type, @@ -845,8 +858,8 @@ class CatalogService { inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined, certification: meta.certification, // Enhanced fields from addon metadata - directors: (meta as any).director ? - (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director]) + directors: (meta as any).director ? + (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director]) : undefined, writer: (meta as any).writer || undefined, country: (meta as any).country || undefined, @@ -938,7 +951,7 @@ class CatalogService { } }); }); - + // Return unsubscribe function return () => { const index = this.librarySubscribers.indexOf(callback); @@ -963,8 +976,8 @@ class CatalogService { await this.saveLibrary(); logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`); this.notifyLibrarySubscribers(); - try { this.libraryAddListeners.forEach(l => l(content)); } catch {} - + try { this.libraryAddListeners.forEach(l => l(content)); } catch { } + // Auto-setup notifications for series when added to library if (content.type === 'series') { try { @@ -989,7 +1002,7 @@ class CatalogService { await this.saveLibrary(); logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`); this.notifyLibrarySubscribers(); - try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch {} + try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch { } // Cancel notifications for series when removed from library if (type === 'series') { @@ -1009,18 +1022,18 @@ class CatalogService { private addToRecentContent(content: StreamingContent): void { // Remove if it already exists to prevent duplicates - this.recentContent = this.recentContent.filter(item => + this.recentContent = this.recentContent.filter(item => !(item.id === content.id && item.type === content.type) ); - + // Add to the beginning of the array this.recentContent.unshift(content); - + // Trim the array if it exceeds the maximum if (this.recentContent.length > this.MAX_RECENT_ITEMS) { this.recentContent = this.recentContent.slice(0, this.MAX_RECENT_ITEMS); } - + this.saveRecentContent(); } @@ -1048,7 +1061,7 @@ class CatalogService { try { const filters = [{ title: 'search', value: query }]; const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1, filters); - + if (metas && metas.length > 0) { const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); results.push(...items); @@ -1057,7 +1070,7 @@ class CatalogService { logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error); } })(); - + searchPromises.push(searchPromise); } } @@ -1095,15 +1108,15 @@ class CatalogService { // Find all addons that support search const searchableAddons = addons.filter(addon => { if (!addon.catalogs) return false; - + // Check if any catalog supports search return addon.catalogs.some(catalog => { const extraSupported = catalog.extraSupported || []; const extra = catalog.extra || []; - + // Check if 'search' is in extraSupported or extra - return extraSupported.includes('search') || - extra.some((e: any) => e.name === 'search'); + return extraSupported.includes('search') || + extra.some((e: any) => e.name === 'search'); }); }); @@ -1114,17 +1127,17 @@ class CatalogService { const searchableCatalogs = (addon.catalogs || []).filter(catalog => { const extraSupported = catalog.extraSupported || []; const extra = catalog.extra || []; - return extraSupported.includes('search') || - extra.some((e: any) => e.name === 'search'); + return extraSupported.includes('search') || + extra.some((e: any) => e.name === 'search'); }); // Search all catalogs for this addon in parallel - const catalogPromises = searchableCatalogs.map(catalog => + const catalogPromises = searchableCatalogs.map(catalog => this.searchAddonCatalog(addon, catalog.type, catalog.id, trimmedQuery) ); const catalogResults = await Promise.allSettled(catalogPromises); - + // Collect all results for this addon const addonResults: StreamingContent[] = []; catalogResults.forEach((result) => { @@ -1157,7 +1170,7 @@ class CatalogService { // Create deduplicated flat list for backwards compatibility const allResults: StreamingContent[] = []; const globalSeen = new Set(); - + byAddon.forEach(addonGroup => { addonGroup.results.forEach(item => { const key = `${item.type}:${item.id}`; @@ -1262,20 +1275,20 @@ class CatalogService { * @returns Promise - Search results from this specific addon catalog */ private async searchAddonCatalog( - addon: any, - type: string, - catalogId: string, + addon: any, + type: string, + catalogId: string, query: string ): Promise { try { let url: string; - + // Special handling for Cinemeta (hardcoded URL) if (addon.id === 'com.linvo.cinemeta') { const encodedCatalogId = encodeURIComponent(catalogId); const encodedQuery = encodeURIComponent(query); url = `https://v3-cinemeta.strem.io/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`; - } + } // Handle other addons else { // Choose best available URL @@ -1287,16 +1300,16 @@ class CatalogService { // Extract base URL and preserve query params const [baseUrlPart, queryParams] = chosenUrl.split('?'); let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, ''); - + // Ensure URL has protocol if (!cleanBaseUrl.startsWith('http')) { cleanBaseUrl = `https://${cleanBaseUrl}`; } - + const encodedCatalogId = encodeURIComponent(catalogId); const encodedQuery = encodeURIComponent(query); url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`; - + // Append original query params if they existed if (queryParams) { url += `?${queryParams}`; @@ -1304,24 +1317,24 @@ class CatalogService { } logger.log(`Searching ${addon.name} (${type}/${catalogId}):`, url); - + const response = await axios.get<{ metas: any[] }>(url, { timeout: 10000, // 10 second timeout per addon }); - + const metas = response.data?.metas || []; - + if (metas.length > 0) { const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); logger.log(`Found ${items.length} results from ${addon.name}`); return items; } - + return []; } catch (error: any) { // Don't throw, just log and return empty - const errorMsg = error?.response?.status - ? `HTTP ${error.response.status}` + const errorMsg = error?.response?.status + ? `HTTP ${error.response.status}` : error?.message || 'Unknown error'; logger.error(`Search failed for ${addon.name} (${type}/${catalogId}): ${errorMsg}`); return []; @@ -1334,21 +1347,21 @@ class CatalogService { console.log('Input type:', type); console.log('Input tmdbId:', tmdbId); } - + try { // For movies, use the tt prefix with IMDb ID if (type === 'movie') { if (__DEV__) console.log('Processing movie - fetching TMDB details...'); const tmdbService = TMDBService.getInstance(); const movieDetails = await tmdbService.getMovieDetails(tmdbId); - + if (__DEV__) console.log('Movie details result:', { id: movieDetails?.id, title: movieDetails?.title, imdb_id: movieDetails?.imdb_id, hasImdbId: !!movieDetails?.imdb_id }); - + if (movieDetails?.imdb_id) { if (__DEV__) console.log('Successfully found IMDb ID:', movieDetails.imdb_id); return movieDetails.imdb_id; @@ -1361,16 +1374,16 @@ class CatalogService { else if (type === 'tv' || type === 'series') { if (__DEV__) console.log('Processing TV show - fetching TMDB details for IMDb ID...'); const tmdbService = TMDBService.getInstance(); - + // Get TV show external IDs to find IMDb ID const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId)); - + if (__DEV__) console.log('TV show external IDs result:', { tmdbId: tmdbId, imdb_id: externalIds?.imdb_id, hasImdbId: !!externalIds?.imdb_id }); - + if (externalIds?.imdb_id) { if (__DEV__) console.log('Successfully found IMDb ID for TV show:', externalIds.imdb_id); return externalIds.imdb_id;