mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
improved catalogfetching logic
This commit is contained in:
parent
4ac45a041a
commit
80d75a528f
2 changed files with 234 additions and 213 deletions
|
|
@ -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<string>();
|
||||
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<StreamingContent[]> => {
|
||||
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<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
|
||||
}
|
||||
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),
|
||||
};
|
||||
|
||||
// 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<string, string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
// 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<CatalogContent | null> {
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<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
|
||||
|
|
|
|||
Loading…
Reference in a new issue