improved catalogfetching logic

This commit is contained in:
tapframe 2025-12-15 02:10:23 +05:30
parent 4ac45a041a
commit 80d75a528f
2 changed files with 234 additions and 213 deletions

View file

@ -83,215 +83,225 @@ export function useFeaturedContent() {
const signal = abortControllerRef.current.signal; const signal = abortControllerRef.current.signal;
try { try {
let formattedContent: StreamingContent[] = []; // Load list of catalogs to fetch
const configs = await catalogService.resolveHomeCatalogsToFetch(selectedCatalogs);
{ if (signal.aborted) return;
// 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; // Prepare for incremental loading
const seenIds = new Set<string>();
let accumulatedContent: StreamingContent[] = [];
const TARGET_COUNT = 10;
let hasSetInitialContent = false;
// If no catalogs are installed, stop loading and return. // Helper function to enrich items
if (catalogs.length === 0) { const enrichItems = async (items: any[]): Promise<StreamingContent[]> => {
formattedContent = []; const preferredLanguage = settings.tmdbLanguagePreference || 'en';
} else {
// 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 enrichLogo = async (item: any): Promise<StreamingContent> => {
const tFlat = Date.now(); const base: StreamingContent = {
const allItems = filteredCatalogs.flatMap(catalog => catalog.items) id: item.id,
.filter(item => item.poster) type: item.type,
.filter((item, index, self) => name: item.name,
// Remove duplicates based on ID poster: item.poster,
index === self.findIndex(t => t.id === item.id) banner: (item as any).banner,
); logo: (item as any).logo,
description: (item as any).description,
// Sort by popular, newest, etc. (possibly enhanced later) and take first 10 year: (item as any).year,
const topItems = allItems.sort(() => Math.random() - 0.5).slice(0, 10); genres: (item as any).genres,
inLibrary: Boolean((item as any).inLibrary),
// Optionally enrich with logos (TMDB only) for tmdb/imdb sourced IDs
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
const enrichLogo = async (item: any): Promise<StreamingContent> => {
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
}
}; };
// Only enrich with logos if enrichment is enabled try {
if (settings.enrichMetadataWithTMDB) { if (!settings.enrichMetadataWithTMDB) {
formattedContent = await Promise.all(topItems.map(enrichLogo)); return { ...base, logo: base.logo || undefined };
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;
});
// Attempt to fill missing logos from addon meta details for a limited subset const rawId = String(item.id);
const candidates = baseItems.filter(i => !i.logo).slice(0, 10); 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 { try {
const filled = await Promise.allSettled(candidates.map(async (item) => { const filled = await Promise.allSettled(missingLogoCandidates.map(async (item: any) => {
try { try {
const meta = await catalogService.getBasicContentDetails(item.type, item.id); const meta = await catalogService.getBasicContentDetails(item.type, item.id);
if (meta?.logo) { if (meta?.logo) return { id: item.id, logo: meta.logo };
return { id: item.id, logo: meta.logo } as { id: string; logo: string }; } catch { }
} return { id: item.id, logo: undefined };
} catch (e) {
}
return { id: item.id, logo: undefined as any };
})); }));
const idToLogo = new Map<string, string>(); const idToLogo = new Map();
filled.forEach(res => { filled.forEach((res: any) => {
if (res.status === 'fulfilled' && res.value && res.value.logo) { if (res.status === 'fulfilled' && res.value?.logo) {
idToLogo.set(res.value.id, res.value.logo); idToLogo.set(res.value.id, res.value.logo);
} }
}); });
formattedContent = baseItems.map(i => ( return baseItems.map((i: any) => idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id) } : i);
idToLogo.has(i.id) ? { ...i, logo: idToLogo.get(i.id)! } : i
));
} catch { } 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; if (signal.aborted) return;
// Safety guard: if nothing came back within a reasonable time, stop loading // Handle case where we finished all fetches but found NOTHING
if (!formattedContent || formattedContent.length === 0) { if (accumulatedContent.length === 0) {
// Fall back to any cached featured item so UI can render something // Fall back to any cached featured item so UI can render something
const cachedJson = await mmkvStorage.getItem(STORAGE_KEY).catch(() => null); const cachedJson = await mmkvStorage.getItem(STORAGE_KEY).catch(() => null);
if (cachedJson) { if (cachedJson) {
try { try {
const parsed = JSON.parse(cachedJson); const parsed = JSON.parse(cachedJson);
if (parsed?.featuredContent) { 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.allFeaturedContent
: [parsed.featuredContent]; : [parsed.featuredContent];
setAllFeaturedContent(fallback);
setFeaturedContent(fallback[0]);
setLoading(false);
return; // Done
} }
} catch { } } catch { }
} }
}
// Update persistent store with the new data (no lastFetchTime when cache disabled) // If still nothing
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;
setFeaturedContent(null); setFeaturedContent(null);
// Clear persisted cache on empty (skipped when cache disabled) setAllFeaturedContent([]);
if (!DISABLE_CACHE) { setLoading(false); // Ensure we don't hang in loading state
try { await mmkvStorage.removeItem(STORAGE_KEY); } catch { }
}
} }
// 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) { } catch (error) {
if (signal.aborted) {
} else {
}
setFeaturedContent(null);
setAllFeaturedContent([]);
} finally {
if (!signal.aborted) { if (!signal.aborted) {
// Even on error, ensure we stop loading
setFeaturedContent(null);
setAllFeaturedContent([]);
setLoading(false); setLoading(false);
} }
} }

View file

@ -323,7 +323,7 @@ class CatalogService {
}; };
} }
async getHomeCatalogs(limitIds?: string[]): Promise<CatalogContent[]> { async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
const addons = await this.getAllAddons(); const addons = await this.getAllAddons();
// Load enabled/disabled settings // Load enabled/disabled settings
@ -360,59 +360,70 @@ class CatalogService {
catalogsToFetch = potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5); catalogsToFetch = potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5);
} }
// Create promises for the selected catalogs return catalogsToFetch;
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); async fetchHomeCatalog(addon: StreamingAddon, catalog: any): Promise<CatalogContent | null> {
if (metas && metas.length > 0) { try {
// Cap items per catalog to reduce memory and rendering load // Hoist manifest list retrieval and find once
const limited = metas.slice(0, 12); const addonManifests = await stremioService.getInstalledAddonsAsync();
const items = limited.map(meta => this.convertMetaToStreamingContent(meta)); 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 metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
const originalName = catalog.name || catalog.id; if (metas && metas.length > 0) {
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName); // Cap items per catalog to reduce memory and rendering load
const isCustom = displayName !== originalName; const limited = metas.slice(0, 12);
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
if (!isCustom) { // Get potentially custom display name; if customized, respect it as-is
// Remove duplicate words and clean up the name (case-insensitive) const originalName = catalog.name || catalog.id;
const words = displayName.split(' '); let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
const uniqueWords: string[] = []; const isCustom = displayName !== originalName;
const seenWords = new Set<string>();
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 if (!isCustom) {
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; // Remove duplicate words and clean up the name (case-insensitive)
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { const words = displayName.split(' ');
displayName = `${displayName} ${contentType}`; const uniqueWords: string[] = [];
const seenWords = new Set<string>();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word);
seenWords.add(lowerWord);
} }
} }
displayName = uniqueWords.join(' ');
return { // Add content type if not present
addon: addon.id, const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
type: catalog.type, if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
id: catalog.id, displayName = `${displayName} ${contentType}`;
name: displayName, }
items
};
} }
return null;
} catch (error) { return {
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); addon: addon.id,
return null; 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<CatalogContent[]> {
// 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 // Wait for all selected catalog fetch promises to resolve in parallel