import { stremioService, Meta, Manifest } from './stremioService'; import { notificationService } from './notificationService'; import { mmkvStorage } from './mmkvStorage'; import axios from 'axios'; import { TMDBService } from './tmdbService'; import { logger } from '../utils/logger'; import { getCatalogDisplayName } from '../utils/catalogNameUtils'; import { createSafeAxiosConfig } from '../utils/axiosConfig'; // Add a constant for storing the data source preference const DATA_SOURCE_KEY = 'discover_data_source'; // Define data source types export enum DataSource { STREMIO_ADDONS = 'stremio_addons', TMDB = 'tmdb', } interface StreamingCatalogExtra { name: string; isRequired?: boolean; options?: string[]; optionsLimit?: number; } interface StreamingCatalog { type: string; id: string; name: string; extraSupported?: string[]; extra?: StreamingCatalogExtra[]; } export interface StreamingAddon { id: string; name: string; version: string; description: string; types: string[]; catalogs: StreamingCatalog[]; resources: { name: string; types: string[]; idPrefixes?: string[]; }[]; url?: string; // preferred base URL (manifest or original) originalUrl?: string; // original addon URL if provided transportUrl?: string; transportName?: string; } export interface AddonSearchResults { addonId: string; addonName: string; sectionName: string; // Display name — catalog name for named catalogs, addon name otherwise catalogIndex: number; // Position in addon manifest — used for deterministic sort within same addon results: StreamingContent[]; } export interface GroupedSearchResults { byAddon: AddonSearchResults[]; allResults: StreamingContent[]; // Deduplicated flat list for backwards compatibility } export interface StreamingContent { id: string; type: string; name: string; tmdbId?: number; poster: string; posterShape?: 'poster' | 'square' | 'landscape'; banner?: string; logo?: string; imdbRating?: string; year?: number; genres?: string[]; description?: string; runtime?: string; released?: string; trailerStreams?: any[]; videos?: any[]; inLibrary?: boolean; directors?: string[]; creators?: string[]; certification?: string; // Enhanced metadata from addons country?: string; writer?: string[]; links?: Array<{ name: string; category: string; url: string; }>; behaviorHints?: { defaultVideoId?: string; hasScheduledVideos?: boolean; [key: string]: any; }; imdb_id?: string; mal_id?: number; external_ids?: { mal_id?: number; imdb_id?: string; tmdb_id?: number; tvdb_id?: number; }; slug?: string; releaseInfo?: string; traktSource?: 'watchlist' | 'continue-watching' | 'watched'; addonCast?: Array<{ id: number; name: string; character: string; profile_path: string | null; }>; networks?: Array<{ id: number | string; name: string; logo?: string; }>; tvDetails?: { status?: string; firstAirDate?: string; lastAirDate?: string; numberOfSeasons?: number; numberOfEpisodes?: number; episodeRunTime?: number[]; type?: string; originCountry?: string[]; originalLanguage?: string; createdBy?: Array<{ id: number; name: string; profile_path?: string; }>; }; movieDetails?: { status?: string; releaseDate?: string; runtime?: number; budget?: number; revenue?: number; originalLanguage?: string; originCountry?: string[]; tagline?: string; }; collection?: { id: number; name: string; poster_path?: string; backdrop_path?: string; }; addedToLibraryAt?: number; // Timestamp when added to library addonId?: string; // ID of the addon that provided this content } export interface CatalogContent { addon: string; type: string; id: string; name: string; originalName?: string; genre?: string; items: StreamingContent[]; } const CATALOG_SETTINGS_KEY = 'catalog_settings'; class CatalogService { private static instance: CatalogService; private readonly LEGACY_LIBRARY_KEY = 'stremio-library'; private readonly RECENT_CONTENT_KEY = 'stremio-recent-content'; private library: Record = {}; private recentContent: StreamingContent[] = []; private readonly MAX_RECENT_ITEMS = 20; private librarySubscribers: ((items: StreamingContent[]) => void)[] = []; private libraryAddListeners: ((item: StreamingContent) => void)[] = []; private libraryRemoveListeners: ((type: string, id: string) => void)[] = []; private initPromise: Promise; private isInitialized: boolean = false; private constructor() { this.initPromise = this.initialize(); } private async initialize(): Promise { logger.log('[CatalogService] Starting initialization...'); try { logger.log('[CatalogService] Step 1: Initializing scope...'); await this.initializeScope(); logger.log('[CatalogService] Step 2: Loading library...'); await this.loadLibrary(); logger.log('[CatalogService] Step 3: Loading recent content...'); await this.loadRecentContent(); this.isInitialized = true; logger.log(`[CatalogService] Initialization completed successfully. Library contains ${Object.keys(this.library).length} items.`); } catch (error) { logger.error('[CatalogService] Initialization failed:', error); // Still mark as initialized to prevent blocking forever this.isInitialized = true; } } private async ensureInitialized(): Promise { logger.log(`[CatalogService] ensureInitialized() called. isInitialized: ${this.isInitialized}`); try { await this.initPromise; logger.log(`[CatalogService] ensureInitialized() completed. Library ready with ${Object.keys(this.library).length} items.`); } catch (error) { logger.error('[CatalogService] Error waiting for initialization:', error); } } private async initializeScope(): Promise { try { const currentScope = await mmkvStorage.getItem('@user:current'); if (!currentScope) { await mmkvStorage.setItem('@user:current', 'local'); logger.log('[CatalogService] Initialized @user:current scope to "local"'); } else { logger.log(`[CatalogService] Using existing scope: "${currentScope}"`); } } catch (error) { logger.error('[CatalogService] Failed to initialize scope:', error); } } static getInstance(): CatalogService { if (!CatalogService.instance) { CatalogService.instance = new CatalogService(); } return CatalogService.instance; } private async loadLibrary(): Promise { try { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:stremio-library`; let storedLibrary = (await mmkvStorage.getItem(scopedKey)); if (!storedLibrary) { // Fallback: read legacy and migrate into scoped storedLibrary = await mmkvStorage.getItem(this.LEGACY_LIBRARY_KEY); if (storedLibrary) { await mmkvStorage.setItem(scopedKey, storedLibrary); } } 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.`); const libraryObject: Record = {}; for (const item of parsedLibrary) { const key = `${item.type}:${item.id}`; libraryObject[key] = item; } this.library = libraryObject; logger.log(`[CatalogService] Converted ${parsedLibrary.length} items from array to object format`); // Re-save in correct format (don't call ensureInitialized here since we're still initializing) const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:stremio-library`; const libraryData = JSON.stringify(this.library); await mmkvStorage.setItem(scopedKey, libraryData); await mmkvStorage.setItem(this.LEGACY_LIBRARY_KEY, libraryData); logger.log(`[CatalogService] Re-saved library in correct format`); } else { this.library = parsedLibrary; } logger.log(`[CatalogService] Library loaded successfully with ${Object.keys(this.library).length} items from scope: ${scope}`); } else { logger.log(`[CatalogService] No library data found for scope: ${scope}`); this.library = {}; } // Ensure @user:current is set to prevent future scope issues await mmkvStorage.setItem('@user:current', scope); } catch (error: any) { logger.error('Failed to load library:', error); this.library = {}; } } private async saveLibrary(): Promise { // Only wait for initialization if we're not already initializing (avoid circular dependency) if (this.isInitialized) { await this.ensureInitialized(); } try { const itemCount = Object.keys(this.library).length; 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); logger.error(`[CatalogService] Library save failed details - scope: ${(await mmkvStorage.getItem('@user:current')) || 'unknown'}, itemCount: ${Object.keys(this.library).length}`); } } private async loadRecentContent(): Promise { try { const storedRecentContent = await mmkvStorage.getItem(this.RECENT_CONTENT_KEY); if (storedRecentContent) { this.recentContent = JSON.parse(storedRecentContent); } } catch (error: any) { logger.error('Failed to load recent content:', error); } } private async saveRecentContent(): Promise { try { await mmkvStorage.setItem(this.RECENT_CONTENT_KEY, JSON.stringify(this.recentContent)); } catch (error: any) { logger.error('Failed to save recent content:', error); } } async getAllAddons(): Promise { const addons = await stremioService.getInstalledAddonsAsync(); return addons.map(addon => this.convertManifestToStreamingAddon(addon)); } private convertManifestToStreamingAddon(manifest: Manifest): StreamingAddon { return { id: manifest.id, name: manifest.name, version: manifest.version, description: manifest.description, types: manifest.types || [], catalogs: (manifest.catalogs || []).map(catalog => ({ ...catalog, extraSupported: catalog.extraSupported || [], extra: (catalog.extra || []).map(extra => ({ name: extra.name, isRequired: extra.isRequired, options: extra.options, optionsLimit: extra.optionsLimit, })), })), resources: manifest.resources || [], url: (manifest.url || manifest.originalUrl) as any, originalUrl: (manifest.originalUrl || manifest.url) as any, transportUrl: manifest.url, transportName: manifest.name }; } private catalogSupportsExtra(catalog: StreamingCatalog, extraName: string): boolean { return (catalog.extraSupported || []).includes(extraName) || (catalog.extra || []).some(extra => extra.name === extraName); } private getRequiredCatalogExtras(catalog: StreamingCatalog): string[] { return (catalog.extra || []) .filter(extra => extra.isRequired) .map(extra => extra.name); } private canBrowseCatalog(catalog: StreamingCatalog): boolean { // Exclude search-only catalogs from discover browsing if ( (catalog.id && catalog.id.startsWith('search.')) || (catalog.type && catalog.type.startsWith('search')) ) { return false; } const requiredExtras = this.getRequiredCatalogExtras(catalog); return requiredExtras.every(extraName => extraName === 'genre'); } /** * Whether a catalog should appear on the home screen, based purely on the * addon manifest — no user settings / mmkv involved. * * Rules (in order): * 1. Search catalogs (id/type starts with "search") → never on home * 2. Catalogs with any required extra (including required genre) → never on home * 3. Addon uses showInHome flag on at least one catalog: * → only catalogs with showInHome:true appear on home * 4. No showInHome flag on any catalog → all browseable catalogs appear on home */ private isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean { // Rule 1: never show search catalogs if ( (catalog.id && catalog.id.startsWith('search.')) || (catalog.type && catalog.type.startsWith('search')) ) { return false; } // Rule 2: never show catalogs with any required extra (e.g. required genre, calendarVideosIds) const requiredExtras = this.getRequiredCatalogExtras(catalog); if (requiredExtras.length > 0) { return false; } // Rule 3: respect showInHome if the addon uses it on any catalog const addonUsesShowInHome = addonCatalogs.some((c: any) => c.showInHome === true); if (addonUsesShowInHome) { return (catalog as any).showInHome === true; } // Rule 4: no showInHome flag used — show all browseable catalogs return true; } private canSearchCatalog(catalog: StreamingCatalog): boolean { if (!this.catalogSupportsExtra(catalog, 'search')) { return false; } const requiredExtras = this.getRequiredCatalogExtras(catalog); return requiredExtras.every(extraName => extraName === 'search'); } async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> { const addons = await this.getAllAddons(); // Collect catalogs visible on home using manifest-only rules (no mmkv/user settings) const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = []; for (const addon of addons) { if (addon.catalogs) { for (const catalog of addon.catalogs) { if (this.isVisibleOnHome(catalog, addon.catalogs)) { potentialCatalogs.push({ addon, catalog }); } } } } // 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); } return catalogsToFetch; } 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; 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; } } 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 const catalogResults = await Promise.all(catalogPromises); // Filter out null results return catalogResults.filter(catalog => catalog !== null) as CatalogContent[]; } 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 => addon.catalogs && addon.catalogs.some(catalog => catalog.type === type) ); // Create an array of promises for all catalog fetches const catalogPromises: Promise[] = []; for (const addon of typeAddons) { const typeCatalogs = addon.catalogs.filter((catalog: StreamingCatalog) => catalog.type === type && this.isVisibleOnHome(catalog, addon.catalogs) ); for (const catalog of typeCatalogs) { const catalogPromise = (async () => { try { const addonManifest = await stremioService.getInstalledAddonsAsync(); const manifest = addonManifest.find(a => a.id === addon.id); if (!manifest) return null; 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, id: catalog.id, name: displayName, genre: genreFilter, items }; } return null; } catch (error) { logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error); 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[]; } /** * Get catalog content from TMDB by type and genre */ 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 const catalogFetchPromises = [ // Trending catalog (async () => { 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, id: 'trending', name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`, 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, id: 'popular', name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`, 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, id: 'upcoming', name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows', items: upcomingStreamingItems }; })() ]; // Wait for all catalog fetches to complete in parallel return await Promise.all(catalogFetchPromises); } else { // Get content by genre 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, id: 'discover', name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`, genre: genreFilter, items: streamingItems }]; } } catch (error) { logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error); return []; } } /** * Convert TMDB trending/discover result to StreamingContent format */ private async convertTMDBToStreamingContent(item: any, type: 'movie' | 'tv'): Promise { 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() : await tmdbService.getTvGenres(); const genreIds: number[] = item.genre_ids; genres = genreIds .map(genreId => { const genre = genreLists.find(g => g.id === genreId); return genre ? genre.name : null; }) .filter(Boolean) as string[]; } catch (error) { logger.error('Failed to get genres for TMDB content:', error); } } return { id, type: type === 'movie' ? 'movie' : 'series', name: name || 'Unknown', 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' ? (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, genres, inLibrary: this.library[`${type === 'movie' ? 'movie' : 'series'}:${id}`] !== undefined, }; } /** * Get the current data source preference */ async getDataSourcePreference(): Promise { try { const dataSource = await mmkvStorage.getItem(DATA_SOURCE_KEY); return dataSource as DataSource || DataSource.STREMIO_ADDONS; } catch (error) { logger.error('Failed to get data source preference:', error); return DataSource.STREMIO_ADDONS; } } /** * Set the data source preference */ async setDataSourcePreference(dataSource: DataSource): Promise { try { await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource); } catch (error) { logger.error('Failed to set data source preference:', error); } } async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise { console.log(`🔍 [CatalogService] getContentDetails called:`, { type, id, preferredAddonId }); try { // 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, metaName: meta?.name, metaType: meta?.type }); if (meta) break; await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } catch (error) { lastError = error; console.log(`🔍 [CatalogService] Attempt ${i + 1} failed:`, { errorMessage: error instanceof Error ? error.message : String(error), isAxiosError: (error as any)?.isAxiosError, responseStatus: (error as any)?.response?.status, responseData: (error as any)?.response?.data }); logger.error(`Attempt ${i + 1} failed to get content details for ${type}:${id}:`, error); await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } } if (meta) { 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, contentType: content.type, inLibrary: content.inLibrary }); return content; } console.log(`🔍 [CatalogService] No meta found, checking lastError:`, { hasLastError: !!lastError, lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError) }); if (lastError) { console.log(`🔍 [CatalogService] Throwing lastError:`, { errorMessage: lastError instanceof Error ? lastError.message : String(lastError), isAxiosError: (lastError as any)?.isAxiosError, responseStatus: (lastError as any)?.response?.status }); throw lastError; } console.log(`🔍 [CatalogService] No meta and no error, returning null`); return null; } catch (error) { console.log(`🔍 [CatalogService] getContentDetails caught error:`, { errorMessage: error instanceof Error ? error.message : String(error), isAxiosError: (error as any)?.isAxiosError, responseStatus: (error as any)?.response?.status, responseData: (error as any)?.response?.data }); logger.error(`Failed to get content details for ${type}:${id}:`, error); return null; } } // Public method for getting enhanced metadata details (used by MetadataScreen) 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, resultName: result?.name, resultType: result?.type }); return result; } catch (error) { console.log(`🔍 [CatalogService] getEnhancedContentDetails error:`, { errorMessage: error instanceof Error ? error.message : String(error), isAxiosError: (error as any)?.isAxiosError, responseStatus: (error as any)?.response?.status, responseData: (error as any)?.response?.data }); throw error; } } // Public method for getting basic content details without enhanced processing (used by ContinueWatching, etc.) async getBasicContentDetails(type: string, id: string, preferredAddonId?: string): Promise { try { // 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) if (!(await stremioService.isValidContentId(type, id))) { break; } meta = await stremioService.getMetaDetails(type, id, preferredAddonId); if (meta) break; await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } catch (error) { lastError = error; logger.error(`Attempt ${i + 1} failed to get basic content details for ${type}:${id}:`, error); await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } } 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); return null; } } private convertMetaToStreamingContent(meta: Meta): StreamingContent { // Basic conversion for catalog display - no enhanced metadata processing // Use addon's poster if available, otherwise use placeholder let posterUrl = meta.poster; 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, name: meta.name, poster: posterUrl, posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type banner: meta.background, logo: logoUrl, imdbRating: meta.imdbRating, year: meta.year, genres: meta.genres, description: meta.description, runtime: meta.runtime, inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined, certification: meta.certification, releaseInfo: meta.releaseInfo, }; } // Enhanced conversion for detailed metadata (used only when fetching individual content details) private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent { // Enhanced conversion to utilize all available metadata from addons const converted: StreamingContent = { id: meta.id, type: meta.type, name: meta.name, poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', posterShape: meta.posterShape || 'poster', banner: meta.background, // Use addon's logo if available, otherwise undefined logo: (meta as any).logo || undefined, imdbRating: meta.imdbRating, year: meta.year, genres: meta.genres, description: meta.description, runtime: meta.runtime, 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]) : undefined, writer: (meta as any).writer || undefined, country: (meta as any).country || undefined, imdb_id: (meta as any).imdb_id || undefined, slug: (meta as any).slug || undefined, releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined, trailerStreams: (meta as any).trailerStreams || undefined, links: (meta as any).links || undefined, behaviorHints: (meta as any).behaviorHints || undefined, }; // Extract addon cast data if available // Check for both app_extras.cast (structured) and cast (simple array) formats if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) { // Structured format with name, character, photo converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({ id: index + 1, // Use index as numeric ID name: castMember.name || 'Unknown', character: castMember.character || '', profile_path: castMember.photo || null })); } else if (meta.cast && Array.isArray(meta.cast)) { // Simple array format with just names converted.addonCast = meta.cast.map((castName: string, index: number) => ({ id: index + 1, // Use index as numeric ID name: castName || 'Unknown', character: '', // No character info available in simple format profile_path: null // No profile images available in simple format })); } // Log if rich metadata is found if ((meta as any).trailerStreams?.length > 0) { logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`); } if ((meta as any).links?.length > 0) { logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`); } if (converted.addonCast && converted.addonCast.length > 0) { logger.log(`🎭 Enhanced metadata: Found ${converted.addonCast.length} cast members from addon for ${meta.name}`); } // Handle videos/episodes if available if ((meta as any).videos) { converted.videos = (meta as any).videos; } return converted; } private notifyLibrarySubscribers(): void { const items = Object.values(this.library); this.librarySubscribers.forEach(callback => callback(items)); } public onLibraryAdd(listener: (item: StreamingContent) => void): () => void { this.libraryAddListeners.push(listener); return () => { this.libraryAddListeners = this.libraryAddListeners.filter(l => l !== listener); }; } public onLibraryRemove(listener: (type: string, id: string) => void): () => void { this.libraryRemoveListeners.push(listener); return () => { this.libraryRemoveListeners = this.libraryRemoveListeners.filter(l => l !== listener); }; } public async getLibraryItems(): Promise { // Only ensure initialization if not already done to avoid redundant calls if (!this.isInitialized) { await this.ensureInitialized(); } return Object.values(this.library); } public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void { this.librarySubscribers.push(callback); // Defer initial callback to next tick to avoid synchronous state updates during render // This prevents infinite loops when the callback triggers setState in useEffect Promise.resolve().then(() => { this.getLibraryItems().then(items => { // Only call if still subscribed (callback might have been unsubscribed) if (this.librarySubscribers.includes(callback)) { callback(items); } }); }); // Return unsubscribe function return () => { const index = this.librarySubscribers.indexOf(callback); if (index > -1) { this.librarySubscribers.splice(index, 1); } }; } public async addToLibrary(content: StreamingContent): Promise { logger.log(`[CatalogService] addToLibrary() called for: ${content.type}:${content.id} (${content.name})`); await this.ensureInitialized(); const key = `${content.type}:${content.id}`; const itemCountBefore = Object.keys(this.library).length; logger.log(`[CatalogService] Adding to library with key: "${key}". Current library keys: [${Object.keys(this.library).length}] items`); this.library[key] = { ...content, addedToLibraryAt: Date.now() // Add timestamp }; const itemCountAfter = Object.keys(this.library).length; logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items. New library keys: [${Object.keys(this.library).slice(0, 5).join(', ')}${Object.keys(this.library).length > 5 ? '...' : ''}]`); await this.saveLibrary(); logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`); this.notifyLibrarySubscribers(); try { this.libraryAddListeners.forEach(l => l(content)); } catch { } // Auto-setup notifications for series when added to library if (content.type === 'series') { try { await notificationService.updateNotificationsForSeries(content.id); console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); } catch (error) { console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); } } } public async removeFromLibrary(type: string, id: string): Promise { logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`); await this.ensureInitialized(); const key = `${type}:${id}`; const itemCountBefore = Object.keys(this.library).length; const itemExisted = key in this.library; logger.log(`[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(this.library).slice(0, 5).join(', ')}${Object.keys(this.library).length > 5 ? '...' : ''}]`); delete this.library[key]; const itemCountAfter = Object.keys(this.library).length; logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`); await this.saveLibrary(); logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`); this.notifyLibrarySubscribers(); try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch { } // Cancel notifications for series when removed from library if (type === 'series') { try { // Cancel all notifications for this series const scheduledNotifications = notificationService.getScheduledNotifications(); const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); for (const notification of seriesToCancel) { await notificationService.cancelNotification(notification.id); } console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); } catch (error) { console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); } } } private addToRecentContent(content: StreamingContent): void { // Remove if it already exists to prevent duplicates 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(); } getRecentContent(): StreamingContent[] { return this.recentContent; } /** * Get all available discover filters (genres, etc.) from installed addon catalogs * This aggregates genre options from all addons that have catalog extras with options */ async getDiscoverFilters(): Promise<{ genres: string[]; types: string[]; catalogsByType: Record; }> { const addons = await this.getAllAddons(); const allGenres = new Set(); const allTypes = new Set(); const catalogsByType: Record = {}; for (const addon of addons) { if (!addon.catalogs) continue; for (const catalog of addon.catalogs) { if (!this.canBrowseCatalog(catalog)) { continue; } // Track content types if (catalog.type) { allTypes.add(catalog.type); } // Get genres from catalog extras const catalogGenres: string[] = []; if (catalog.extra && Array.isArray(catalog.extra)) { for (const extra of catalog.extra) { if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) { for (const genre of extra.options) { allGenres.add(genre); catalogGenres.push(genre); } } } } // Track catalogs by type for filtering if (catalog.type) { if (!catalogsByType[catalog.type]) { catalogsByType[catalog.type] = []; } catalogsByType[catalog.type].push({ addonId: addon.id, addonName: addon.name, catalogId: catalog.id, catalogName: catalog.name || catalog.id, genres: catalogGenres }); } } } // Sort genres alphabetically const sortedGenres = Array.from(allGenres).sort((a, b) => a.localeCompare(b)); const sortedTypes = Array.from(allTypes); return { genres: sortedGenres, types: sortedTypes, catalogsByType }; } /** * Discover content by type and optional genre filter * Fetches from all installed addons that have catalogs matching the criteria */ async discoverContent( type: string, genre?: string, limit: number = 20 ): Promise<{ addonName: string; items: StreamingContent[] }[]> { const addons = await this.getAllAddons(); const results: { addonName: string; items: StreamingContent[] }[] = []; const manifests = await stremioService.getInstalledAddonsAsync(); // Find catalogs that match the type const catalogPromises: Promise<{ addonName: string; items: StreamingContent[] } | null>[] = []; for (const addon of addons) { if (!addon.catalogs) continue; // Find catalogs matching the type const matchingCatalogs = addon.catalogs.filter(catalog => catalog.type === type && this.canBrowseCatalog(catalog) ); for (const catalog of matchingCatalogs) { // Check if this catalog supports the genre filter const supportsGenre = catalog.extra?.some(e => e.name === 'genre') || catalog.extraSupported?.includes('genre'); // If genre is specified, only use catalogs that support genre OR have no filter restrictions // If genre is specified but catalog doesn't support genre filter, skip it if (genre && !supportsGenre) { continue; } const manifest = manifests.find(m => m.id === addon.id); if (!manifest) continue; const fetchPromise = (async () => { try { const filters = genre ? [{ title: 'genre', value: genre }] : []; const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); if (metas && metas.length > 0) { const items = metas.slice(0, limit).map(meta => { const content = this.convertMetaToStreamingContent(meta); content.addonId = addon.id; return content; }); return { addonName: addon.name, items }; } return null; } catch (error) { logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error); return null; } })(); catalogPromises.push(fetchPromise); } } const catalogResults = await Promise.all(catalogPromises); // Filter out null results and deduplicate by addon const addonMap = new Map(); for (const result of catalogResults) { if (result && result.items.length > 0) { const existing = addonMap.get(result.addonName) || []; // Merge items, avoiding duplicates const existingIds = new Set(existing.map(item => `${item.type}:${item.id}`)); const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`)); addonMap.set(result.addonName, [...existing, ...newItems]); } } // Convert map to array for (const [addonName, items] of addonMap) { results.push({ addonName, items: items.slice(0, limit) }); } return results; } /** * Discover content from a specific catalog with optional genre filter * @param addonId - The addon ID * @param catalogId - The catalog ID * @param type - Content type (movie/series) * @param genre - Optional genre filter * @param page - Page number for pagination (default 1) */ async discoverContentFromCatalog( addonId: string, catalogId: string, type: string, genre?: string, page: number = 1 ): Promise { try { const manifests = await stremioService.getInstalledAddonsAsync(); const manifest = manifests.find(m => m.id === addonId); if (!manifest) { logger.error(`Addon ${addonId} not found`); return []; } const catalog = (manifest.catalogs || []).find(item => item.type === type && item.id === catalogId); if (!catalog || !this.canBrowseCatalog(catalog)) { logger.warn(`Catalog ${catalogId} in addon ${addonId} is not browseable`); return []; } const filters = genre ? [{ title: 'genre', value: genre }] : []; const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters); if (metas && metas.length > 0) { return metas.map(meta => { const content = this.convertMetaToStreamingContent(meta); content.addonId = addonId; return content; }); } return []; } catch (error) { logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error); return []; } } async searchContent(query: string): Promise { if (!query || query.trim().length < 2) { return []; } const addons = await this.getAllAddons(); const results: StreamingContent[] = []; const searchPromises: Promise[] = []; for (const addon of addons) { if (addon.catalogs && addon.catalogs.length > 0) { for (const catalog of addon.catalogs) { if (!this.canSearchCatalog(catalog)) { continue; } const addonManifest = await stremioService.getInstalledAddonsAsync(); const manifest = addonManifest.find(a => a.id === addon.id); if (!manifest) continue; const searchPromise = (async () => { 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 => { const content = this.convertMetaToStreamingContent(meta); content.addonId = addon.id; return content; }); results.push(...items); } } catch (error) { logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error); } })(); searchPromises.push(searchPromise); } } } await Promise.all(searchPromises); // Remove duplicates based on id and type const uniqueResults = Array.from( new Map(results.map(item => [`${item.type}:${item.id}`, item])).values() ); return uniqueResults; } /** * Search across all installed addons that support search functionality. * This dynamically queries any addon with catalogs that have 'search' in their extraSupported or extra fields. * Results are grouped by addon source with headers. * * @param query - The search query string * @returns Promise - Search results grouped by addon with headers */ async searchContentCinemeta(query: string): Promise { if (!query) { return { byAddon: [], allResults: [] }; } const trimmedQuery = query.trim().toLowerCase(); logger.log('Searching across all addons for:', trimmedQuery); const addons = await this.getAllAddons(); const byAddon: AddonSearchResults[] = []; // Get manifests separately to ensure we have correct URLs const manifests = await stremioService.getInstalledAddonsAsync(); const manifestMap = new Map(manifests.map(m => [m.id, m])); // Find all addons that support search const searchableAddons = addons.filter(addon => { if (!addon.catalogs) return false; return addon.catalogs.some(catalog => this.canSearchCatalog(catalog)); }); logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', ')); // Search each addon and keep results grouped for (const addon of searchableAddons) { // Get the manifest to ensure we have the correct URL const manifest = manifestMap.get(addon.id); if (!manifest) { logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); continue; } const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog)); // Search all catalogs for this addon in parallel const catalogPromises = searchableCatalogs.map(catalog => this.searchAddonCatalog(manifest, catalog.type, catalog.id, trimmedQuery) ); const catalogResults = await Promise.allSettled(catalogPromises); // Collect all results for this addon const addonResults: StreamingContent[] = []; catalogResults.forEach((result) => { if (result.status === 'fulfilled' && result.value) { addonResults.push(...result.value); } else if (result.status === 'rejected') { logger.error(`Search failed for ${addon.name}:`, result.reason); } }); // Only add addon section if it has results if (addonResults.length > 0) { // Deduplicate within this addon's results const seen = new Set(); const uniqueAddonResults = addonResults.filter(item => { const key = `${item.type}:${item.id}`; if (seen.has(key)) return false; seen.add(key); return true; }); byAddon.push({ addonId: addon.id, addonName: addon.name, results: uniqueAddonResults, }); } } // 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}`; if (!globalSeen.has(key)) { globalSeen.add(key); allResults.push(item); } }); }); logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`); return { byAddon, allResults }; } /** * Live search that emits results per-addon as they arrive. * Returns a handle with cancel() and a done promise. */ startLiveSearch( query: string, onAddonResults: (section: AddonSearchResults) => void ): { cancel: () => void; done: Promise } { const controller = { cancelled: false } as { cancelled: boolean }; const done = (async () => { if (!query || !query.trim()) return; const trimmedQuery = query.trim().toLowerCase(); logger.log('Live search across addons for:', trimmedQuery); const addons = await this.getAllAddons(); logger.log(`Total addons available: ${addons.length}`); // Get manifests separately to ensure we have correct URLs const manifests = await stremioService.getInstalledAddonsAsync(); const manifestMap = new Map(manifests.map(m => [m.id, m])); // Determine searchable addons const searchableAddons = addons.filter(addon => (addon.catalogs || []).some(catalog => this.canSearchCatalog(catalog)) ); logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => `${a.name} (${a.id})`).join(', ')); if (searchableAddons.length === 0) { logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.'); return; } // Build addon order map for deterministic section sorting const addonOrderRef: Record = {}; searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; }); // Human-readable labels for known content types const CATALOG_TYPE_LABELS: Record = { 'movie': 'Movies', 'series': 'TV Shows', 'anime.series': 'Anime Series', 'anime.movie': 'Anime Movies', 'other': 'Other', 'tv': 'TV', 'channel': 'Channels', }; const GENERIC_CATALOG_NAMES = new Set(['search', 'Search']); // Collect all sections from all addons first, then sort and dedup before emitting. // This avoids race conditions where concurrent addon workers steal each other's IDs // from a shared globalSeen set before they get a chance to emit. type PendingSection = { addonId: string; addonName: string; sectionName: string; catalogIndex: number; results: StreamingContent[]; }; const allPendingSections: PendingSection[] = []; await Promise.all( searchableAddons.map(async (addon) => { if (controller.cancelled) return; try { const manifest = manifestMap.get(addon.id); if (!manifest) { logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); return; } const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog)); logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`); const settled = await Promise.allSettled( searchableCatalogs.map(c => this.searchAddonCatalog(manifest, c.type, c.id, trimmedQuery)) ); if (controller.cancelled) return; const hasMultipleCatalogs = searchableCatalogs.length > 1; const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER; if (hasMultipleCatalogs) { for (let ci = 0; ci < searchableCatalogs.length; ci++) { const s = settled[ci]; const catalog = searchableCatalogs[ci]; if (s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) { if (s.status === 'rejected') logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, s.reason); continue; } const results = (s as PromiseFulfilledResult).value; // Within-catalog dedup: prefer dot-type over generic for same ID const bestById = new Map(); for (const item of results) { const existing = bestById.get(item.id); if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { bestById.set(item.id, item); } } // Stamp catalog type onto results const stamped = Array.from(bestById.values()).map(item => catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item ); // Build section name — use type label if catalog name is generic const typeLabel = CATALOG_TYPE_LABELS[catalog.type] || catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase()); const catalogLabel = (!catalog.name || GENERIC_CATALOG_NAMES.has(catalog.name) || catalog.name === addon.name) ? typeLabel : catalog.name; const sectionName = `${addon.name} - ${catalogLabel}`; const catalogIndex = addonRank * 1000 + ci; allPendingSections.push({ addonId: `${addon.id}||${catalog.type}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: stamped }); } } else { const s = settled[0]; const catalog = searchableCatalogs[0]; if (!s || s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) { if (s?.status === 'rejected') logger.warn(`Search failed for ${addon.name}:`, s.reason); return; } const results = (s as PromiseFulfilledResult).value; const bestById = new Map(); for (const item of results) { const existing = bestById.get(item.id); if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { bestById.set(item.id, item); } } const stamped = Array.from(bestById.values()).map(item => catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item ); allPendingSections.push({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: stamped }); } } catch (e) { logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e); } }) ); if (controller.cancelled) return; // Sort by catalogIndex (addon manifest order + position within addon) then emit. // No cross-section dedup — each section is shown separately so duplicates across // sections are intentional (e.g. same movie in Cinemeta and People Search). allPendingSections.sort((a, b) => a.catalogIndex - b.catalogIndex); for (const section of allPendingSections) { if (controller.cancelled) return; if (section.results.length > 0) { logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`); onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: section.results }); } } })(); return { cancel: () => { controller.cancelled = true; }, done, }; } /** * Search a specific catalog from a specific addon. * Handles URL construction for both Cinemeta (hardcoded) and other addons (dynamic). * * @param manifest - The addon manifest containing id, name, and url * @param type - Content type (movie, series, anime, etc.) * @param catalogId - The catalog ID to search within * @param query - The search query string * @returns Promise - Search results from this specific addon catalog */ private async searchAddonCatalog( manifest: Manifest, type: string, catalogId: string, query: string ): Promise { try { let url: string; // Special handling for Cinemeta (hardcoded URL) if (manifest.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 const chosenUrl: string | undefined = manifest.url || manifest.originalUrl; if (!chosenUrl) { logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`); return []; } // Extract base URL and preserve query params (same logic as stremioService.getAddonBaseURL) 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); // Try path-style URL first (per Stremio protocol) url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`; // Append original query params if they existed if (queryParams) { url += `?${queryParams}`; } } logger.log(`Searching ${manifest.name} (${type}/${catalogId}):`, url); const response = await axios.get<{ metas: any[] }>(url, createSafeAxiosConfig(10000)); const metas = response.data?.metas || []; if (metas.length > 0) { const items = metas.map(meta => { const content = this.convertMetaToStreamingContent(meta); content.addonId = manifest.id; // The meta's own type field may be generic (e.g. "series") even when // the catalog it came from is more specific (e.g. "anime.series"). // Stamp the catalog type so grouping in the UI is correct. if (type && content.type !== type) { content.type = type; } return content; }); logger.log(`Found ${items.length} results from ${manifest.name}`); return items; } return []; } catch (error: any) { // Don't throw, just log and return empty const errorMsg = error?.response?.status ? `HTTP ${error.response.status}` : error?.message || 'Unknown error'; const errorUrl = error?.config?.url || 'unknown URL'; logger.error(`Search failed for ${manifest.name} (${type}/${catalogId}) at ${errorUrl}: ${errorMsg}`); if (error?.response?.data) { logger.error(`Response data:`, error.response.data); } return []; } } async getStremioId(type: string, tmdbId: string): Promise { if (__DEV__) { console.log('=== CatalogService.getStremioId ==='); 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; } else { console.warn('No IMDb ID found for movie:', tmdbId); return null; } } // For TV shows, get the IMDb ID like movies 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; } else { console.warn('No IMDb ID found for TV show, falling back to kitsu format:', tmdbId); const fallbackId = `kitsu:${tmdbId}`; if (__DEV__) console.log('Generated fallback Stremio ID for TV:', fallbackId); return fallbackId; } } else { console.warn('Unknown type provided:', type); return null; } } catch (error: any) { if (__DEV__) { console.error('=== Error in getStremioId ==='); console.error('Type:', type); console.error('TMDB ID:', tmdbId); console.error('Error details:', error); console.error('Error message:', error.message); } logger.error('Error getting Stremio ID:', error); return null; } } } export const catalogService = CatalogService.getInstance(); export default catalogService;