From 80d75a528f3321f81dc15b61305cd84963c40edd Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 15 Dec 2025 02:10:23 +0530 Subject: [PATCH] improved catalogfetching logic --- src/hooks/useFeaturedContent.ts | 346 ++++++++++++++++---------------- src/services/catalogService.ts | 101 +++++----- 2 files changed, 234 insertions(+), 213 deletions(-) diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 9326af1..cd4c27c 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -83,215 +83,225 @@ export function useFeaturedContent() { const signal = abortControllerRef.current.signal; try { - let formattedContent: StreamingContent[] = []; + // Load list of catalogs to fetch + const configs = await catalogService.resolveHomeCatalogsToFetch(selectedCatalogs); - { - // Load from installed catalogs with optimization - const tCats = Date.now(); - // Pass selected catalogs to service for optimized fetching - const catalogs = await catalogService.getHomeCatalogs(selectedCatalogs); + if (signal.aborted) return; - if (signal.aborted) return; + // Prepare for incremental loading + const seenIds = new Set(); + let accumulatedContent: StreamingContent[] = []; + const TARGET_COUNT = 10; + let hasSetInitialContent = false; - // If no catalogs are installed, stop loading and return. - if (catalogs.length === 0) { - formattedContent = []; - } else { - // Use catalogs directly (filtering is now done in service) - const filteredCatalogs = catalogs; + // Helper function to enrich items + const enrichItems = async (items: any[]): Promise => { + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - // Flatten all catalog items into a single array, filter out items without posters - const tFlat = Date.now(); - const allItems = filteredCatalogs.flatMap(catalog => catalog.items) - .filter(item => item.poster) - .filter((item, index, self) => - // Remove duplicates based on ID - index === self.findIndex(t => t.id === item.id) - ); - - // Sort by popular, newest, etc. (possibly enhanced later) and take first 10 - const topItems = allItems.sort(() => Math.random() - 0.5).slice(0, 10); - - // Optionally enrich with logos (TMDB only) for tmdb/imdb sourced IDs - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - - const enrichLogo = async (item: any): Promise => { - const base: StreamingContent = { - id: item.id, - type: item.type, - name: item.name, - poster: item.poster, - banner: (item as any).banner, - logo: (item as any).logo, - description: (item as any).description, - year: (item as any).year, - genres: (item as any).genres, - inLibrary: Boolean((item as any).inLibrary), - }; - - try { - if (!settings.enrichMetadataWithTMDB) { - // When enrichment is OFF, keep addon logo or undefined - return { ...base, logo: base.logo || undefined }; - } - - // When enrichment is ON, fetch from TMDB with language preference - const rawId = String(item.id); - const isTmdb = rawId.startsWith('tmdb:'); - const isImdb = rawId.startsWith('tt'); - let tmdbId: string | null = null; - let imdbId: string | null = null; - - if (isTmdb) tmdbId = rawId.split(':')[1]; - if (isImdb) imdbId = rawId.split(':')[0]; - if (!tmdbId && imdbId) { - 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 - } + const enrichLogo = async (item: any): Promise => { + const base: StreamingContent = { + id: item.id, + type: item.type, + name: item.name, + poster: item.poster, + banner: (item as any).banner, + logo: (item as any).logo, + description: (item as any).description, + year: (item as any).year, + genres: (item as any).genres, + inLibrary: Boolean((item as any).inLibrary), }; - // Only enrich with logos if enrichment is enabled - if (settings.enrichMetadataWithTMDB) { - formattedContent = await Promise.all(topItems.map(enrichLogo)); - try { - const details = formattedContent.slice(0, 20).map((c) => ({ - id: c.id, - name: c.name, - hasLogo: Boolean(c.logo), - logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none', - logo: c.logo || undefined, - })); - } 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) => { - const base: StreamingContent = { - id: item.id, - type: item.type, - name: item.name, - poster: item.poster, - banner: (item as any).banner, - logo: (item as any).logo || undefined, - description: (item as any).description, - year: (item as any).year, - genres: (item as any).genres, - inLibrary: Boolean((item as any).inLibrary), - }; - return base; - }); + try { + if (!settings.enrichMetadataWithTMDB) { + return { ...base, logo: base.logo || undefined }; + } - // Attempt to fill missing logos from addon meta details for a limited subset - const candidates = baseItems.filter(i => !i.logo).slice(0, 10); + const rawId = String(item.id); + const isTmdb = rawId.startsWith('tmdb:'); + const isImdb = rawId.startsWith('tt'); + let tmdbId: string | null = null; + let imdbId: string | null = null; + if (isTmdb) tmdbId = rawId.split(':')[1]; + if (isImdb) imdbId = rawId.split(':')[0]; + if (!tmdbId && imdbId) { + 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 }; + } + + return { ...base, logo: undefined }; + } catch (error) { + return { ...base, logo: undefined }; + } + }; + + if (settings.enrichMetadataWithTMDB) { + return Promise.all(items.map(enrichLogo)); + } else { + // Fallback logic for when enrichment is disabled + const baseItems = items.map((item: any) => ({ + id: item.id, + type: item.type, + name: item.name, + poster: item.poster, + banner: (item as any).banner, + logo: (item as any).logo || undefined, + description: (item as any).description, + year: (item as any).year, + genres: (item as any).genres, + inLibrary: Boolean((item as any).inLibrary), + })); + + // Try to get logos for items missing them + const missingLogoCandidates = baseItems.filter((i: any) => !i.logo); + if (missingLogoCandidates.length > 0) { try { - const filled = await Promise.allSettled(candidates.map(async (item) => { + const filled = await Promise.allSettled(missingLogoCandidates.map(async (item: any) => { try { const meta = await catalogService.getBasicContentDetails(item.type, item.id); - if (meta?.logo) { - return { id: item.id, logo: meta.logo } as { id: string; logo: string }; - } - } catch (e) { - } - return { id: item.id, logo: undefined as any }; + if (meta?.logo) return { id: item.id, logo: meta.logo }; + } catch { } + return { id: item.id, logo: undefined }; })); - const idToLogo = new Map(); - filled.forEach(res => { - if (res.status === 'fulfilled' && res.value && res.value.logo) { + const idToLogo = new Map(); + filled.forEach((res: any) => { + if (res.status === 'fulfilled' && res.value?.logo) { idToLogo.set(res.value.id, res.value.logo); } }); - formattedContent = baseItems.map(i => ( - idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id)! } : i - )); + return baseItems.map((i: any) => idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id) } : i); } catch { - formattedContent = baseItems; + return baseItems; } - - try { - const details = formattedContent.slice(0, 20).map((c) => ({ - id: c.id, - name: c.name, - hasLogo: Boolean(c.logo), - logoSource: c.logo ? (isTmdbUrl(String(c.logo)) ? 'tmdb' : 'addon') : 'none', - logo: c.logo || undefined, - })); - } catch { } } + return baseItems; } + }; + + // Process each catalog independently + const processCatalog = async (config: { addon: any, catalog: any }) => { + if (signal.aborted) return; + // Optimization: Stop fetching if we have enough items + // Note: We check length here but parallel requests might race. This is acceptable. + if (accumulatedContent.length >= TARGET_COUNT) return; + + try { + const cat = await catalogService.fetchHomeCatalog(config.addon, config.catalog); + if (signal.aborted) return; + if (!cat || !cat.items || cat.items.length === 0) return; + + // Deduplicate + const newItems = cat.items.filter(item => { + if (!item.poster) return false; + if (seenIds.has(item.id)) return false; + return true; + }); + + if (newItems.length === 0) return; + + // Take only what we need (or a small batch) + const needed = TARGET_COUNT - accumulatedContent.length; + // Shuffle this batch locally just to mix it up a bit if the catalog returns strict order + const shuffledBatch = newItems.sort(() => Math.random() - 0.5).slice(0, needed); + + if (shuffledBatch.length === 0) return; + + shuffledBatch.forEach(item => seenIds.add(item.id)); + + // Enrich this batch + const enrichedBatch = await enrichItems(shuffledBatch); + if (signal.aborted) return; + + // Update accumulated content + accumulatedContent = [...accumulatedContent, ...enrichedBatch]; + + // Update State + // Always update allFeaturedContent to show progress + setAllFeaturedContent([...accumulatedContent]); + + // If this is the first batch, set initial state and UNBLOCK LOADING + if (!hasSetInitialContent && accumulatedContent.length > 0) { + hasSetInitialContent = true; + setFeaturedContent(accumulatedContent[0]); + persistentStore.featuredContent = accumulatedContent[0]; + persistentStore.allFeaturedContent = accumulatedContent; + currentIndexRef.current = 0; + setLoading(false); // <--- Key improvement: Display content immediately + } else { + // Just update store for subsequent batches + persistentStore.allFeaturedContent = accumulatedContent; + } + + } catch (e) { + logger.error('Error processing catalog in parallel', e); + } + }; + + // If no catalogs to fetch, fallback immediately + if (configs.length === 0) { + // Fallback logic + } else { + // Run fetches in parallel + await Promise.all(configs.map(processCatalog)); } if (signal.aborted) return; - // Safety guard: if nothing came back within a reasonable time, stop loading - if (!formattedContent || formattedContent.length === 0) { + // Handle case where we finished all fetches but found NOTHING + if (accumulatedContent.length === 0) { // Fall back to any cached featured item so UI can render something const cachedJson = await mmkvStorage.getItem(STORAGE_KEY).catch(() => null); if (cachedJson) { try { const parsed = JSON.parse(cachedJson); if (parsed?.featuredContent) { - formattedContent = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0 + const fallback = Array.isArray(parsed.allFeaturedContent) && parsed.allFeaturedContent.length > 0 ? parsed.allFeaturedContent : [parsed.featuredContent]; + + setAllFeaturedContent(fallback); + setFeaturedContent(fallback[0]); + setLoading(false); + return; // Done } } catch { } } - } - // Update persistent store with the new data (no lastFetchTime when cache disabled) - persistentStore.allFeaturedContent = formattedContent; - if (!DISABLE_CACHE) { - persistentStore.lastFetchTime = now; - } - persistentStore.isFirstLoad = false; - - setAllFeaturedContent(formattedContent); - - if (formattedContent.length > 0) { - persistentStore.featuredContent = formattedContent[0]; - setFeaturedContent(formattedContent[0]); - currentIndexRef.current = 0; - // Persist cache for fast startup (skipped when cache disabled) - if (!DISABLE_CACHE) { - try { - await mmkvStorage.setItem( - STORAGE_KEY, - JSON.stringify({ - ts: now, - featuredContent: formattedContent[0], - allFeaturedContent: formattedContent, - }) - ); - } catch { } - } - } else { - persistentStore.featuredContent = null; + // If still nothing setFeaturedContent(null); - // Clear persisted cache on empty (skipped when cache disabled) - if (!DISABLE_CACHE) { - try { await mmkvStorage.removeItem(STORAGE_KEY); } catch { } - } + setAllFeaturedContent([]); + setLoading(false); // Ensure we don't hang in loading state } + + // Final persistence + persistentStore.allFeaturedContent = accumulatedContent; + if (!DISABLE_CACHE && accumulatedContent.length > 0) { + persistentStore.lastFetchTime = now; + try { + await mmkvStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + ts: now, + featuredContent: accumulatedContent[0], + allFeaturedContent: accumulatedContent, + }) + ); + } catch { } + } + } catch (error) { - if (signal.aborted) { - } else { - } - setFeaturedContent(null); - setAllFeaturedContent([]); - } finally { if (!signal.aborted) { + // Even on error, ensure we stop loading + setFeaturedContent(null); + setAllFeaturedContent([]); setLoading(false); } } diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index d85a951..589a9d1 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -323,7 +323,7 @@ class CatalogService { }; } - async getHomeCatalogs(limitIds?: string[]): Promise { + async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> { const addons = await this.getAllAddons(); // Load enabled/disabled settings @@ -360,59 +360,70 @@ class CatalogService { 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; + return catalogsToFetch; + } - 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)); + async fetchHomeCatalog(addon: StreamingAddon, catalog: any): Promise { + 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; - // 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; + 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)); - 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(' '); + // 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; - // Add content type if not present - const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; - if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { - displayName = `${displayName} ${contentType}`; + 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(' '); - return { - addon: addon.id, - type: catalog.type, - id: catalog.id, - name: displayName, - items - }; + // Add content type if not present + const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { + displayName = `${displayName} ${contentType}`; + } } - return null; - } catch (error) { - logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); - return null; + + 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; + } + } + + async getHomeCatalogs(limitIds?: string[]): Promise { + // Determine which catalogs to actually fetch + const catalogsToFetch = await this.resolveHomeCatalogsToFetch(limitIds); + + // Create promises for the selected catalogs + const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => { + return this.fetchHomeCatalog(addon, catalog); }); // Wait for all selected catalog fetch promises to resolve in parallel