featured content fix

This commit is contained in:
tapframe 2025-12-09 12:57:14 +05:30
parent 22d8fe311a
commit d457db5053
4 changed files with 235 additions and 203 deletions

View file

@ -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"
}
}
}

View file

@ -569,6 +569,33 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
);
}, [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();

View file

@ -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]);

View file

@ -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<CatalogContent[]> {
async getHomeCatalogs(limitIds?: string[]): Promise<CatalogContent[]> {
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<CatalogContent | null>[] = [];
// 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<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
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<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
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<CatalogContent[]> {
// 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<CatalogContent[]> {
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<StreamingContent | null> {
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<string>();
byAddon.forEach(addonGroup => {
addonGroup.results.forEach(item => {
const key = `${item.type}:${item.id}`;
@ -1262,20 +1275,20 @@ class CatalogService {
* @returns Promise<StreamingContent[]> - 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<StreamingContent[]> {
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;