diff --git a/src/services/catalog/catalog-utils.ts b/src/services/catalog/catalog-utils.ts new file mode 100644 index 00000000..de273e57 --- /dev/null +++ b/src/services/catalog/catalog-utils.ts @@ -0,0 +1,84 @@ +import type { Manifest } from '../stremioService'; + +import type { StreamingAddon, StreamingCatalog } from './types'; + +export function 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, + }; +} + +export async function getAllAddons(getInstalledAddons: () => Promise): Promise { + const addons = await getInstalledAddons(); + return addons.map(convertManifestToStreamingAddon); +} + +export function catalogSupportsExtra(catalog: StreamingCatalog, extraName: string): boolean { + return (catalog.extraSupported || []).includes(extraName) || + (catalog.extra || []).some(extra => extra.name === extraName); +} + +export function getRequiredCatalogExtras(catalog: StreamingCatalog): string[] { + return (catalog.extra || []).filter(extra => extra.isRequired).map(extra => extra.name); +} + +export function canBrowseCatalog(catalog: StreamingCatalog): boolean { + if ( + (catalog.id && catalog.id.startsWith('search.')) || + (catalog.type && catalog.type.startsWith('search')) + ) { + return false; + } + + const requiredExtras = getRequiredCatalogExtras(catalog); + return requiredExtras.every(extraName => extraName === 'genre'); +} + +export function isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean { + if ( + (catalog.id && catalog.id.startsWith('search.')) || + (catalog.type && catalog.type.startsWith('search')) + ) { + return false; + } + + const requiredExtras = getRequiredCatalogExtras(catalog); + if (requiredExtras.length > 0) { + return false; + } + + const addonUsesShowInHome = addonCatalogs.some(addonCatalog => addonCatalog.showInHome === true); + if (addonUsesShowInHome) { + return catalog.showInHome === true; + } + + return true; +} + +export function canSearchCatalog(catalog: StreamingCatalog): boolean { + if (!catalogSupportsExtra(catalog, 'search')) { + return false; + } + + const requiredExtras = getRequiredCatalogExtras(catalog); + return requiredExtras.every(extraName => extraName === 'search'); +} diff --git a/src/services/catalog/content-details.ts b/src/services/catalog/content-details.ts new file mode 100644 index 00000000..bfec521a --- /dev/null +++ b/src/services/catalog/content-details.ts @@ -0,0 +1,282 @@ +import { stremioService } from '../stremioService'; +import { mmkvStorage } from '../mmkvStorage'; +import { TMDBService } from '../tmdbService'; +import { logger } from '../../utils/logger'; + +import { convertMetaToStreamingContent, convertMetaToStreamingContentEnhanced } from './content-mappers'; +import { addToRecentContent, createLibraryKey, type CatalogLibraryState } from './library'; +import { DATA_SOURCE_KEY, DataSource, type StreamingContent } from './types'; + +export async function 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; + } +} + +export async function setDataSourcePreference(dataSource: DataSource): Promise { + try { + await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource); + } catch (error) { + logger.error('Failed to set data source preference:', error); + } +} + +export async function getContentDetails( + state: CatalogLibraryState, + type: string, + id: string, + preferredAddonId?: string +): Promise { + console.log('🔍 [CatalogService] getContentDetails called:', { type, id, preferredAddonId }); + + try { + let meta = null; + let lastError = null; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + console.log(`🔍 [CatalogService] Attempt ${attempt + 1}/2 for getContentDetails:`, { type, id, preferredAddonId }); + + 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, attempt))); + } catch (error) { + lastError = error; + console.log(`🔍 [CatalogService] Attempt ${attempt + 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 ${attempt + 1} failed to get content details for ${type}:${id}:`, error); + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } + } + + if (meta) { + console.log('🔍 [CatalogService] Meta found, converting to StreamingContent:', { + metaId: meta.id, + metaName: meta.name, + metaType: meta.type, + }); + + const content = convertMetaToStreamingContentEnhanced(meta, state.library); + addToRecentContent(state, content); + content.inLibrary = state.library[createLibraryKey(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; + } +} + +export async function getEnhancedContentDetails( + state: CatalogLibraryState, + 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 getContentDetails(state, 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; + } +} + +export async function getBasicContentDetails( + state: CatalogLibraryState, + type: string, + id: string, + preferredAddonId?: string +): Promise { + try { + let meta = null; + let lastError = null; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + 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, attempt))); + } catch (error) { + lastError = error; + logger.error(`Attempt ${attempt + 1} failed to get basic content details for ${type}:${id}:`, error); + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } + } + + if (meta) { + const content = convertMetaToStreamingContent(meta, state.library); + content.inLibrary = state.library[createLibraryKey(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; + } +} + +export async function getStremioId(type: string, tmdbId: string): Promise { + if (__DEV__) { + console.log('=== CatalogService.getStremioId ==='); + console.log('Input type:', type); + console.log('Input tmdbId:', tmdbId); + } + + try { + if (type === 'movie') { + if (__DEV__) { + console.log('Processing movie - fetching TMDB details...'); + } + + const movieDetails = await TMDBService.getInstance().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; + } + + console.warn('No IMDb ID found for movie:', tmdbId); + return null; + } + + if (type === 'tv' || type === 'series') { + if (__DEV__) { + console.log('Processing TV show - fetching TMDB details for IMDb ID...'); + } + + const externalIds = await TMDBService.getInstance().getShowExternalIds(parseInt(tmdbId, 10)); + + if (__DEV__) { + console.log('TV show external IDs result:', { + 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; + } + + 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; + } + + 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; + } +} diff --git a/src/services/catalog/content-mappers.ts b/src/services/catalog/content-mappers.ts new file mode 100644 index 00000000..e61dc8d9 --- /dev/null +++ b/src/services/catalog/content-mappers.ts @@ -0,0 +1,157 @@ +import { TMDBService } from '../tmdbService'; +import { logger } from '../../utils/logger'; + +import type { Meta } from '../stremioService'; + +import { createLibraryKey } from './library'; +import type { StreamingContent } from './types'; + +const FALLBACK_POSTER_URL = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image'; + +export function convertMetaToStreamingContent( + meta: Meta, + library: Record +): StreamingContent { + let posterUrl = meta.poster; + if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') { + posterUrl = FALLBACK_POSTER_URL; + } + + 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', + banner: meta.background, + logo: logoUrl, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined, + certification: meta.certification, + releaseInfo: meta.releaseInfo, + }; +} + +export function convertMetaToStreamingContentEnhanced( + meta: Meta, + library: Record +): StreamingContent { + const converted: StreamingContent = { + id: meta.id, + type: meta.type, + name: meta.name, + poster: meta.poster || FALLBACK_POSTER_URL, + posterShape: meta.posterShape || 'poster', + banner: meta.background, + logo: (meta as any).logo || undefined, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined, + certification: meta.certification, + 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, + }; + + if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) { + converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({ + id: index + 1, + name: castMember.name || 'Unknown', + character: castMember.character || '', + profile_path: castMember.photo || null, + })); + } else if (meta.cast && Array.isArray(meta.cast)) { + converted.addonCast = meta.cast.map((castName: string, index: number) => ({ + id: index + 1, + name: castName || 'Unknown', + character: '', + profile_path: null, + })); + } + + 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}`); + } + + if ((meta as any).videos) { + converted.videos = (meta as any).videos; + } + + return converted; +} + +export async function convertTMDBToStreamingContent( + item: any, + type: 'movie' | 'tv', + library: Record +): Promise { + const id = item.external_ids?.imdb_id || `tmdb:${item.id}`; + const name = type === 'movie' ? item.title : item.name; + const posterPath = item.poster_path; + + 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(); + + genres = item.genre_ids + .map((genreId: number) => { + const genre = genreLists.find(currentGenre => currentGenre.id === genreId); + return genre ? genre.name : null; + }) + .filter(Boolean) as string[]; + } catch (error) { + logger.error('Failed to get genres for TMDB content:', error); + } + } + + const contentType = type === 'movie' ? 'movie' : 'series'; + + return { + id, + type: contentType, + name: name || 'Unknown', + poster: posterPath + ? `https://image.tmdb.org/t/p/w500${posterPath}` + : FALLBACK_POSTER_URL, + 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: library[createLibraryKey(contentType, id)] !== undefined, + }; +} diff --git a/src/services/catalog/discovery.ts b/src/services/catalog/discovery.ts new file mode 100644 index 00000000..8189449f --- /dev/null +++ b/src/services/catalog/discovery.ts @@ -0,0 +1,398 @@ +import { stremioService } from '../stremioService'; +import { TMDBService } from '../tmdbService'; +import { logger } from '../../utils/logger'; +import { getCatalogDisplayName } from '../../utils/catalogNameUtils'; + +import { + canBrowseCatalog, + convertManifestToStreamingAddon, + getAllAddons, + isVisibleOnHome, +} from './catalog-utils'; +import { convertMetaToStreamingContent, convertTMDBToStreamingContent } from './content-mappers'; +import type { CatalogContent, DataSource, StreamingAddon, StreamingCatalog, StreamingContent } from './types'; + +export async function getAllStreamingAddons(): Promise { + return getAllAddons(() => stremioService.getInstalledAddonsAsync()); +} + +export async function resolveHomeCatalogsToFetch( + limitIds?: string[] +): Promise> { + const addons = await getAllStreamingAddons(); + const potentialCatalogs: Array<{ addon: StreamingAddon; catalog: StreamingCatalog }> = []; + + for (const addon of addons) { + for (const catalog of addon.catalogs || []) { + if (isVisibleOnHome(catalog, addon.catalogs)) { + potentialCatalogs.push({ addon, catalog }); + } + } + } + + if (limitIds && limitIds.length > 0) { + return potentialCatalogs.filter(item => { + const catalogId = `${item.addon.id}:${item.catalog.type}:${item.catalog.id}`; + return limitIds.includes(catalogId); + }); + } + + return potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5); +} + +export async function fetchHomeCatalog( + library: Record, + addon: StreamingAddon, + catalog: StreamingCatalog +): Promise { + try { + const addonManifests = await stremioService.getInstalledAddonsAsync(); + const manifest = addonManifests.find(currentAddon => currentAddon.id === addon.id); + if (!manifest) { + return null; + } + + const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); + if (!metas || metas.length === 0) { + return null; + } + + const items = metas.slice(0, 12).map(meta => convertMetaToStreamingContent(meta, library)); + const originalName = catalog.name || catalog.id; + let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName); + const isCustom = displayName !== originalName; + + if (!isCustom) { + const uniqueWords: string[] = []; + const seenWords = new Set(); + + for (const word of displayName.split(' ')) { + const normalizedWord = word.toLowerCase(); + if (!seenWords.has(normalizedWord)) { + uniqueWords.push(word); + seenWords.add(normalizedWord); + } + } + + displayName = uniqueWords.join(' '); + + 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, + }; + } catch (error) { + logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); + return null; + } +} + +export async function getHomeCatalogs( + library: Record, + limitIds?: string[] +): Promise { + const catalogsToFetch = await resolveHomeCatalogsToFetch(limitIds); + const catalogResults = await Promise.all( + catalogsToFetch.map(({ addon, catalog }) => fetchHomeCatalog(library, addon, catalog)) + ); + + return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null); +} + +export async function getCatalogByType( + library: Record, + dataSourcePreference: DataSource, + type: string, + genreFilter?: string +): Promise { + if (dataSourcePreference === 'tmdb') { + return getCatalogByTypeFromTMDB(library, type, genreFilter); + } + + const addons = await getAllStreamingAddons(); + const typeAddons = addons.filter(addon => addon.catalogs.some(catalog => catalog.type === type)); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const catalogPromises: Array> = []; + + for (const addon of typeAddons) { + const typeCatalogs = addon.catalogs.filter( + catalog => catalog.type === type && isVisibleOnHome(catalog, addon.catalogs) + ); + + for (const catalog of typeCatalogs) { + catalogPromises.push( + (async () => { + try { + const manifest = manifestMap.get(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) { + return null; + } + + return { + addon: addon.id, + type, + id: catalog.id, + name: await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name), + genre: genreFilter, + items: metas.map(meta => convertMetaToStreamingContent(meta, library)), + }; + } catch (error) { + logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error); + return null; + } + })() + ); + } + } + + const catalogResults = await Promise.all(catalogPromises); + return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null); +} + +async function getCatalogByTypeFromTMDB( + library: Record, + type: string, + genreFilter?: string +): Promise { + const tmdbService = TMDBService.getInstance(); + const tmdbType = type === 'movie' ? 'movie' : 'tv'; + + try { + if (!genreFilter || genreFilter === 'All') { + return Promise.all([ + (async () => ({ + addon: 'tmdb', + type, + id: 'trending', + name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + items: await Promise.all( + (await tmdbService.getTrending(tmdbType, 'week')).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }))(), + (async () => ({ + addon: 'tmdb', + type, + id: 'popular', + name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + items: await Promise.all( + (await tmdbService.getPopular(tmdbType, 1)).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }))(), + (async () => ({ + addon: 'tmdb', + type, + id: 'upcoming', + name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows', + items: await Promise.all( + (await tmdbService.getUpcoming(tmdbType, 1)).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }))(), + ]); + } + + return [{ + addon: 'tmdb', + type, + id: 'discover', + name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + genre: genreFilter, + items: await Promise.all( + (await tmdbService.discoverByGenre(tmdbType, genreFilter)).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }]; + } catch (error) { + logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error); + return []; + } +} + +export async function getDiscoverFilters(): Promise<{ + genres: string[]; + types: string[]; + catalogsByType: Record< + string, + Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }> + >; +}> { + const addons = await getAllStreamingAddons(); + const allGenres = new Set(); + const allTypes = new Set(); + const catalogsByType: Record< + string, + Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }> + > = {}; + + for (const addon of addons) { + for (const catalog of addon.catalogs || []) { + if (!canBrowseCatalog(catalog)) { + continue; + } + + if (catalog.type) { + allTypes.add(catalog.type); + } + + const catalogGenres: string[] = []; + for (const extra of catalog.extra || []) { + if (extra.name === 'genre' && Array.isArray(extra.options)) { + for (const genre of extra.options) { + allGenres.add(genre); + catalogGenres.push(genre); + } + } + } + + if (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, + }); + } + } + } + + return { + genres: Array.from(allGenres).sort((left, right) => left.localeCompare(right)), + types: Array.from(allTypes), + catalogsByType, + }; +} + +export async function discoverContent( + library: Record, + type: string, + genre?: string, + limit = 20 +): Promise> { + const addons = await getAllStreamingAddons(); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const catalogPromises: Array> = []; + + for (const addon of addons) { + const matchingCatalogs = addon.catalogs.filter( + catalog => catalog.type === type && canBrowseCatalog(catalog) + ); + + for (const catalog of matchingCatalogs) { + const supportsGenre = catalog.extra?.some(extra => extra.name === 'genre') || + catalog.extraSupported?.includes('genre'); + + if (genre && !supportsGenre) { + continue; + } + + const manifest = manifestMap.get(addon.id); + if (!manifest) { + continue; + } + + catalogPromises.push( + (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) { + return null; + } + + return { + addonName: addon.name, + items: metas.slice(0, limit).map(meta => ({ + ...convertMetaToStreamingContent(meta, library), + addonId: addon.id, + })), + }; + } catch (error) { + logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error); + return null; + } + })() + ); + } + } + + const addonMap = new Map(); + for (const result of await Promise.all(catalogPromises)) { + if (!result || result.items.length === 0) { + continue; + } + + const existingItems = addonMap.get(result.addonName) || []; + const existingIds = new Set(existingItems.map(item => `${item.type}:${item.id}`)); + const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`)); + addonMap.set(result.addonName, [...existingItems, ...newItems]); + } + + return Array.from(addonMap.entries()).map(([addonName, items]) => ({ + addonName, + items: items.slice(0, limit), + })); +} + +export async function discoverContentFromCatalog( + library: Record, + addonId: string, + catalogId: string, + type: string, + genre?: string, + page = 1 +): Promise { + try { + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifest = manifests.find(currentManifest => currentManifest.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 || !canBrowseCatalog(convertManifestToStreamingAddon(manifest).catalogs.find( + item => item.type === type && item.id === catalogId + ) || { ...catalog, extraSupported: catalog.extraSupported || [], extra: catalog.extra || [] })) { + 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); + + return (metas || []).map(meta => ({ + ...convertMetaToStreamingContent(meta, library), + addonId, + })); + } catch (error) { + logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error); + return []; + } +} diff --git a/src/services/catalog/library.ts b/src/services/catalog/library.ts new file mode 100644 index 00000000..2751b396 --- /dev/null +++ b/src/services/catalog/library.ts @@ -0,0 +1,329 @@ +import { notificationService } from '../notificationService'; +import { mmkvStorage } from '../mmkvStorage'; +import { logger } from '../../utils/logger'; + +import type { StreamingContent } from './types'; + +export interface CatalogLibraryState { + LEGACY_LIBRARY_KEY: string; + RECENT_CONTENT_KEY: string; + MAX_RECENT_ITEMS: number; + library: Record; + recentContent: StreamingContent[]; + librarySubscribers: Array<(items: StreamingContent[]) => void>; + libraryAddListeners: Array<(item: StreamingContent) => void>; + libraryRemoveListeners: Array<(type: string, id: string) => void>; + initPromise: Promise; + isInitialized: boolean; +} + +export function createLibraryKey(type: string, id: string): string { + return `${type}:${id}`; +} + +export async function initializeCatalogState(state: CatalogLibraryState): Promise { + logger.log('[CatalogService] Starting initialization...'); + + try { + logger.log('[CatalogService] Step 1: Initializing scope...'); + await initializeScope(); + + logger.log('[CatalogService] Step 2: Loading library...'); + await loadLibrary(state); + + logger.log('[CatalogService] Step 3: Loading recent content...'); + await loadRecentContent(state); + + state.isInitialized = true; + logger.log( + `[CatalogService] Initialization completed successfully. Library contains ${Object.keys(state.library).length} items.` + ); + } catch (error) { + logger.error('[CatalogService] Initialization failed:', error); + state.isInitialized = true; + } +} + +export async function ensureCatalogInitialized(state: CatalogLibraryState): Promise { + logger.log(`[CatalogService] ensureInitialized() called. isInitialized: ${state.isInitialized}`); + + try { + await state.initPromise; + logger.log( + `[CatalogService] ensureInitialized() completed. Library ready with ${Object.keys(state.library).length} items.` + ); + } catch (error) { + logger.error('[CatalogService] Error waiting for initialization:', error); + } +} + +async function 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"'); + return; + } + + logger.log(`[CatalogService] Using existing scope: "${currentScope}"`); + } catch (error) { + logger.error('[CatalogService] Failed to initialize scope:', error); + } +} + +async function loadLibrary(state: CatalogLibraryState): Promise { + try { + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + let storedLibrary = await mmkvStorage.getItem(scopedKey); + + if (!storedLibrary) { + storedLibrary = await mmkvStorage.getItem(state.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))}` + ); + + 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) { + libraryObject[createLibraryKey(item.type, item.id)] = item; + } + + state.library = libraryObject; + logger.log(`[CatalogService] Converted ${parsedLibrary.length} items from array to object format`); + + const normalizedLibrary = JSON.stringify(state.library); + await mmkvStorage.setItem(scopedKey, normalizedLibrary); + await mmkvStorage.setItem(state.LEGACY_LIBRARY_KEY, normalizedLibrary); + logger.log('[CatalogService] Re-saved library in correct format'); + } else { + state.library = parsedLibrary; + } + + logger.log( + `[CatalogService] Library loaded successfully with ${Object.keys(state.library).length} items from scope: ${scope}` + ); + } else { + logger.log(`[CatalogService] No library data found for scope: ${scope}`); + state.library = {}; + } + + await mmkvStorage.setItem('@user:current', scope); + } catch (error: any) { + logger.error('Failed to load library:', error); + state.library = {}; + } +} + +async function saveLibrary(state: CatalogLibraryState): Promise { + if (state.isInitialized) { + await ensureCatalogInitialized(state); + } + + try { + const itemCount = Object.keys(state.library).length; + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + const libraryData = JSON.stringify(state.library); + + logger.log(`[CatalogService] Saving library with ${itemCount} items to scope: "${scope}" (key: ${scopedKey})`); + + await mmkvStorage.setItem(scopedKey, libraryData); + await mmkvStorage.setItem(state.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(state.library).length}` + ); + } +} + +async function loadRecentContent(state: CatalogLibraryState): Promise { + try { + const storedRecentContent = await mmkvStorage.getItem(state.RECENT_CONTENT_KEY); + if (storedRecentContent) { + state.recentContent = JSON.parse(storedRecentContent); + } + } catch (error: any) { + logger.error('Failed to load recent content:', error); + } +} + +async function saveRecentContent(state: CatalogLibraryState): Promise { + try { + await mmkvStorage.setItem(state.RECENT_CONTENT_KEY, JSON.stringify(state.recentContent)); + } catch (error: any) { + logger.error('Failed to save recent content:', error); + } +} + +function notifyLibrarySubscribers(state: CatalogLibraryState): void { + const items = Object.values(state.library); + state.librarySubscribers.forEach(callback => callback(items)); +} + +export async function getLibraryItems(state: CatalogLibraryState): Promise { + if (!state.isInitialized) { + await ensureCatalogInitialized(state); + } + + return Object.values(state.library); +} + +export function subscribeToLibraryUpdates( + state: CatalogLibraryState, + callback: (items: StreamingContent[]) => void +): () => void { + state.librarySubscribers.push(callback); + + Promise.resolve().then(() => { + getLibraryItems(state).then(items => { + if (state.librarySubscribers.includes(callback)) { + callback(items); + } + }); + }); + + return () => { + const index = state.librarySubscribers.indexOf(callback); + if (index > -1) { + state.librarySubscribers.splice(index, 1); + } + }; +} + +export function onLibraryAdd( + state: CatalogLibraryState, + listener: (item: StreamingContent) => void +): () => void { + state.libraryAddListeners.push(listener); + + return () => { + state.libraryAddListeners = state.libraryAddListeners.filter(currentListener => currentListener !== listener); + }; +} + +export function onLibraryRemove( + state: CatalogLibraryState, + listener: (type: string, id: string) => void +): () => void { + state.libraryRemoveListeners.push(listener); + + return () => { + state.libraryRemoveListeners = state.libraryRemoveListeners.filter( + currentListener => currentListener !== listener + ); + }; +} + +export async function addToLibrary(state: CatalogLibraryState, content: StreamingContent): Promise { + logger.log(`[CatalogService] addToLibrary() called for: ${content.type}:${content.id} (${content.name})`); + + await ensureCatalogInitialized(state); + + const key = createLibraryKey(content.type, content.id); + const itemCountBefore = Object.keys(state.library).length; + logger.log(`[CatalogService] Adding to library with key: "${key}". Current library keys: [${Object.keys(state.library).length}] items`); + + state.library[key] = { + ...content, + addedToLibraryAt: Date.now(), + }; + + const itemCountAfter = Object.keys(state.library).length; + logger.log( + `[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items. New library keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]` + ); + + await saveLibrary(state); + logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`); + + notifyLibrarySubscribers(state); + + try { + state.libraryAddListeners.forEach(listener => listener(content)); + } catch {} + + 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); + } + } +} + +export async function removeFromLibrary( + state: CatalogLibraryState, + type: string, + id: string +): Promise { + logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`); + + await ensureCatalogInitialized(state); + + const key = createLibraryKey(type, id); + const itemCountBefore = Object.keys(state.library).length; + const itemExisted = key in state.library; + logger.log( + `[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]` + ); + + delete state.library[key]; + + const itemCountAfter = Object.keys(state.library).length; + logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`); + + await saveLibrary(state); + logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`); + + notifyLibrarySubscribers(state); + + try { + state.libraryRemoveListeners.forEach(listener => listener(type, id)); + } catch {} + + if (type === 'series') { + try { + 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); + } + } +} + +export function addToRecentContent(state: CatalogLibraryState, content: StreamingContent): void { + state.recentContent = state.recentContent.filter(item => !(item.id === content.id && item.type === content.type)); + state.recentContent.unshift(content); + + if (state.recentContent.length > state.MAX_RECENT_ITEMS) { + state.recentContent = state.recentContent.slice(0, state.MAX_RECENT_ITEMS); + } + + void saveRecentContent(state); +} + +export function getRecentContent(state: CatalogLibraryState): StreamingContent[] { + return state.recentContent; +} diff --git a/src/services/catalog/search.ts b/src/services/catalog/search.ts new file mode 100644 index 00000000..b20a3af0 --- /dev/null +++ b/src/services/catalog/search.ts @@ -0,0 +1,401 @@ +import axios from 'axios'; + +import { stremioService, type Manifest } from '../stremioService'; +import { logger } from '../../utils/logger'; +import { createSafeAxiosConfig } from '../../utils/axiosConfig'; + +import { canSearchCatalog, getAllAddons } from './catalog-utils'; +import { convertMetaToStreamingContent } from './content-mappers'; +import type { AddonSearchResults, GroupedSearchResults, StreamingContent } from './types'; + +type PendingSection = { + addonId: string; + addonName: string; + sectionName: string; + catalogIndex: number; + results: StreamingContent[]; +}; + +export async function searchContent( + library: Record, + query: string +): Promise { + if (!query || query.trim().length < 2) { + return []; + } + + const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync()); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const results: StreamingContent[] = []; + + await Promise.all( + addons.flatMap(addon => + (addon.catalogs || []) + .filter(catalog => canSearchCatalog(catalog)) + .map(async catalog => { + const manifest = manifestMap.get(addon.id); + if (!manifest) { + return; + } + + try { + const metas = await stremioService.getCatalog( + manifest, + catalog.type, + catalog.id, + 1, + [{ title: 'search', value: query }] + ); + + if (metas?.length) { + results.push( + ...metas.map(meta => ({ + ...convertMetaToStreamingContent(meta, library), + addonId: addon.id, + })) + ); + } + } catch (error) { + logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error); + } + }) + ) + ); + + return Array.from(new Map(results.map(item => [`${item.type}:${item.id}`, item])).values()); +} + +export async function searchContentCinemeta( + library: Record, + query: string +): Promise { + if (!query) { + return { byAddon: [], allResults: [] }; + } + + const trimmedQuery = query.trim().toLowerCase(); + logger.log('Searching across all addons for:', trimmedQuery); + + const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync()); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const searchableAddons = addons.filter(addon => addon.catalogs.some(catalog => canSearchCatalog(catalog))); + const byAddon: AddonSearchResults[] = []; + + logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(addon => addon.name).join(', ')); + + for (const [addonIndex, addon] of searchableAddons.entries()) { + const manifest = manifestMap.get(addon.id); + if (!manifest) { + logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); + continue; + } + + const catalogResults = await Promise.allSettled( + addon.catalogs + .filter(catalog => canSearchCatalog(catalog)) + .map(catalog => searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery)) + ); + + const addonResults: StreamingContent[] = []; + for (const result of catalogResults) { + if (result.status === 'fulfilled' && result.value) { + addonResults.push(...result.value); + } else if (result.status === 'rejected') { + logger.error(`Search failed for ${addon.name}:`, result.reason); + } + } + + if (addonResults.length > 0) { + const seen = new Set(); + byAddon.push({ + addonId: addon.id, + addonName: addon.name, + sectionName: addon.name, + catalogIndex: addonIndex, + results: addonResults.filter(item => { + const key = `${item.type}:${item.id}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }), + }); + } + } + + const allResults: StreamingContent[] = []; + const globalSeen = new Set(); + + for (const addonGroup of byAddon) { + for (const item of addonGroup.results) { + 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 }; +} + +export function startLiveSearch( + library: Record, + query: string, + onAddonResults: (section: AddonSearchResults) => void +): { cancel: () => void; done: Promise } { + const controller = { cancelled: false }; + + const done = (async () => { + if (!query || !query.trim()) { + return; + } + + const trimmedQuery = query.trim().toLowerCase(); + logger.log('Live search across addons for:', trimmedQuery); + + const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync()); + logger.log(`Total addons available: ${addons.length}`); + + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const searchableAddons = addons.filter(addon => + (addon.catalogs || []).some(catalog => canSearchCatalog(catalog)) + ); + + logger.log( + `Found ${searchableAddons.length} searchable addons:`, + searchableAddons.map(addon => `${addon.name} (${addon.id})`).join(', ') + ); + + if (searchableAddons.length === 0) { + logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.'); + return; + } + + const addonOrderRef: Record = {}; + searchableAddons.forEach((addon, index) => { + addonOrderRef[addon.id] = index; + }); + + const catalogTypeLabels: Record = { + movie: 'Movies', + series: 'TV Shows', + 'anime.series': 'Anime Series', + 'anime.movie': 'Anime Movies', + other: 'Other', + tv: 'TV', + channel: 'Channels', + }; + const genericCatalogNames = new Set(['search', 'Search']); + 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 => canSearchCatalog(catalog)); + logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`); + + const settled = await Promise.allSettled( + searchableCatalogs.map(catalog => + searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery) + ) + ); + + if (controller.cancelled) { + return; + } + + const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER; + if (searchableCatalogs.length > 1) { + searchableCatalogs.forEach((catalog, index) => { + const result = settled[index]; + if (result.status === 'rejected' || !result.value?.length) { + if (result.status === 'rejected') { + logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, result.reason); + } + return; + } + + const sectionName = buildSectionName( + addon.name, + catalog.name, + catalog.type, + genericCatalogNames, + catalogTypeLabels + ); + + allPendingSections.push({ + addonId: `${addon.id}||${catalog.type}||${catalog.id}`, + addonName: addon.name, + sectionName, + catalogIndex: addonRank * 1000 + index, + results: dedupeAndStampResults(result.value, catalog.type), + }); + }); + return; + } + + const result = settled[0]; + const catalog = searchableCatalogs[0]; + if (!result || result.status === 'rejected' || !result.value?.length) { + if (result?.status === 'rejected') { + logger.warn(`Search failed for ${addon.name}:`, result.reason); + } + return; + } + + allPendingSections.push({ + addonId: addon.id, + addonName: addon.name, + sectionName: addon.name, + catalogIndex: addonRank * 1000, + results: dedupeAndStampResults(result.value, catalog.type), + }); + } catch (error) { + logger.error(`Error searching addon ${addon.name} (${addon.id}):`, error); + } + }) + ); + + if (controller.cancelled) { + return; + } + + allPendingSections.sort((left, right) => left.catalogIndex - right.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(section); + } + } + })(); + + return { + cancel: () => { + controller.cancelled = true; + }, + done, + }; +} + +async function searchAddonCatalog( + library: Record, + manifest: Manifest, + type: string, + catalogId: string, + query: string +): Promise { + try { + const url = buildSearchUrl(manifest, type, catalogId, query); + if (!url) { + return []; + } + + 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) { + return []; + } + + const items = metas.map(meta => { + const content = convertMetaToStreamingContent(meta, library); + content.addonId = manifest.id; + if (type && content.type !== type) { + content.type = type; + } + return content; + }); + + logger.log(`Found ${items.length} results from ${manifest.name}`); + return items; + } catch (error: any) { + const errorMessage = 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}: ${errorMessage}`); + if (error?.response?.data) { + logger.error('Response data:', error.response.data); + } + return []; + } +} + +function buildSearchUrl(manifest: Manifest, type: string, catalogId: string, query: string): string | null { + if (manifest.id === 'com.linvo.cinemeta') { + return `https://v3-cinemeta.strem.io/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`; + } + + const chosenUrl = manifest.url || manifest.originalUrl; + if (!chosenUrl) { + logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`); + return null; + } + + const [baseUrlPart, queryParams] = chosenUrl.split('?'); + let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, ''); + if (!cleanBaseUrl.startsWith('http')) { + cleanBaseUrl = `https://${cleanBaseUrl}`; + } + + let url = `${cleanBaseUrl}/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`; + if (queryParams) { + url += `?${queryParams}`; + } + + return url; +} + +function dedupeAndStampResults(results: StreamingContent[], catalogType: string): StreamingContent[] { + 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); + } + } + + return Array.from(bestById.values()).map(item => + catalogType && item.type !== catalogType ? { ...item, type: catalogType } : item + ); +} + +function buildSectionName( + addonName: string, + catalogName: string | undefined, + catalogType: string, + genericCatalogNames: Set, + catalogTypeLabels: Record +): string { + const typeLabel = catalogTypeLabels[catalogType] || + catalogType.replace(/[._]/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); + + const catalogLabel = (!catalogName || genericCatalogNames.has(catalogName) || catalogName === addonName) + ? typeLabel + : catalogName; + + return `${addonName} - ${catalogLabel}`; +} diff --git a/src/services/catalog/types.ts b/src/services/catalog/types.ts new file mode 100644 index 00000000..42a2b21a --- /dev/null +++ b/src/services/catalog/types.ts @@ -0,0 +1,154 @@ +export const DATA_SOURCE_KEY = 'discover_data_source'; + +export enum DataSource { + STREMIO_ADDONS = 'stremio_addons', + TMDB = 'tmdb', +} + +export interface StreamingCatalogExtra { + name: string; + isRequired?: boolean; + options?: string[]; + optionsLimit?: number; +} + +export interface StreamingCatalog { + type: string; + id: string; + name: string; + extraSupported?: string[]; + extra?: StreamingCatalogExtra[]; + showInHome?: boolean; +} + +export interface StreamingAddon { + id: string; + name: string; + version: string; + description: string; + types: string[]; + catalogs: StreamingCatalog[]; + resources: { + name: string; + types: string[]; + idPrefixes?: string[]; + }[]; + url?: string; + originalUrl?: string; + transportUrl?: string; + transportName?: string; +} + +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; + 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; + addonId?: string; +} + +export interface AddonSearchResults { + addonId: string; + addonName: string; + sectionName: string; + catalogIndex: number; + results: StreamingContent[]; +} + +export interface GroupedSearchResults { + byAddon: AddonSearchResults[]; + allResults: StreamingContent[]; +} + +export interface CatalogContent { + addon: string; + type: string; + id: string; + name: string; + originalName?: string; + genre?: string; + items: StreamingContent[]; +} diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index e27b26be..2cfce314 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1,1836 +1,202 @@ -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'; +import { + getBasicContentDetails, + getContentDetails, + getDataSourcePreference, + getEnhancedContentDetails, + getStremioId, + setDataSourcePreference, +} from './catalog/content-details'; +import { + discoverContent, + discoverContentFromCatalog, + fetchHomeCatalog, + getAllStreamingAddons, + getCatalogByType, + getDiscoverFilters, + getHomeCatalogs, + resolveHomeCatalogsToFetch, +} from './catalog/discovery'; +import { + addToLibrary, + ensureCatalogInitialized, + getLibraryItems, + getRecentContent, + initializeCatalogState, + onLibraryAdd, + onLibraryRemove, + removeFromLibrary, + subscribeToLibraryUpdates, +} from './catalog/library'; +import { + searchContent, + searchContentCinemeta, + startLiveSearch, +} from './catalog/search'; +import type { CatalogLibraryState } from './catalog/library'; +import type { + AddonSearchResults, + CatalogContent, + DataSource, + GroupedSearchResults, + StreamingAddon, + StreamingCatalog, + StreamingContent, +} from './catalog/types'; -// Add a constant for storing the data source preference -const DATA_SOURCE_KEY = 'discover_data_source'; +export { DataSource } from './catalog/types'; +export type { + AddonSearchResults, + CatalogContent, + GroupedSearchResults, + StreamingAddon, + StreamingContent, +} from './catalog/types'; -// 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 { +class CatalogService implements CatalogLibraryState { 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; + + readonly LEGACY_LIBRARY_KEY = 'stremio-library'; + readonly RECENT_CONTENT_KEY = 'stremio-recent-content'; + readonly MAX_RECENT_ITEMS = 20; + + library: Record = {}; + recentContent: StreamingContent[] = []; + librarySubscribers: Array<(items: StreamingContent[]) => void> = []; + libraryAddListeners: Array<(item: StreamingContent) => void> = []; + libraryRemoveListeners: Array<(type: string, id: string) => void> = []; + initPromise: Promise; + isInitialized = 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); - } + this.initPromise = initializeCatalogState(this); } 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); - } + private async ensureInitialized(): Promise { + await ensureCatalogInitialized(this); } async getAllAddons(): Promise { - const addons = await stremioService.getInstalledAddonsAsync(); - return addons.map(addon => this.convertManifestToStreamingAddon(addon)); + return getAllStreamingAddons(); } - 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 - }; + async resolveHomeCatalogsToFetch(limitIds?: string[]) { + return resolveHomeCatalogsToFetch(limitIds); } - 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 fetchHomeCatalog(addon: StreamingAddon, catalog: StreamingCatalog): Promise { + return fetchHomeCatalog(this.library, addon, catalog); } 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[]; + return getHomeCatalogs(this.library, limitIds); } 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[]; + const dataSourcePreference = await getDataSourcePreference(); + return getCatalogByType(this.library, dataSourcePreference, type, genreFilter); } - /** - * 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 []; - } + async getDataSourcePreference() { + return getDataSourcePreference(); } - /** - * 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); - } + await setDataSourcePreference(dataSource); } 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; - } + return getContentDetails(this, type, id, preferredAddonId); } - // 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; - } + async getEnhancedContentDetails( + type: string, + id: string, + preferredAddonId?: string + ): Promise { + return getEnhancedContentDetails(this, type, id, preferredAddonId); } - // 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; - } + async getBasicContentDetails( + type: string, + id: string, + preferredAddonId?: string + ): Promise { + return getBasicContentDetails(this, type, id, preferredAddonId); } - 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, - }; + onLibraryAdd(listener: (item: StreamingContent) => void): () => void { + return onLibraryAdd(this, listener); } - // 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; + onLibraryRemove(listener: (type: string, id: string) => void): () => void { + return onLibraryRemove(this, listener); } - private notifyLibrarySubscribers(): void { - const items = Object.values(this.library); - this.librarySubscribers.forEach(callback => callback(items)); + async getLibraryItems(): Promise { + return getLibraryItems(this); } - public onLibraryAdd(listener: (item: StreamingContent) => void): () => void { - this.libraryAddListeners.push(listener); - return () => { - this.libraryAddListeners = this.libraryAddListeners.filter(l => l !== listener); - }; + subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void { + return subscribeToLibraryUpdates(this, callback); } - public onLibraryRemove(listener: (type: string, id: string) => void): () => void { - this.libraryRemoveListeners.push(listener); - return () => { - this.libraryRemoveListeners = this.libraryRemoveListeners.filter(l => l !== listener); - }; + async addToLibrary(content: StreamingContent): Promise { + await addToLibrary(this, content); } - 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(); + async removeFromLibrary(type: string, id: string): Promise { + await removeFromLibrary(this, type, id); } getRecentContent(): StreamingContent[] { - return this.recentContent; + return getRecentContent(this); } - /** - * 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 - }; + async getDiscoverFilters() { + return getDiscoverFilters(); } - /** - * 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; + async discoverContent(type: string, genre?: string, limit = 20) { + return discoverContent(this.library, type, genre, limit); } - /** - * 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 + page = 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 []; - } + return discoverContentFromCatalog(this.library, addonId, catalogId, type, genre, page); } 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; + return searchContent(this.library, query); } - /** - * 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 [addonIndex, addon] of searchableAddons.entries()) { - // 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, - sectionName: addon.name, - catalogIndex: addonIndex, - 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 }; + return searchContentCinemeta(this.library, query); } - /** - * 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 []; - } + return startLiveSearch(this.library, query, onAddonResults); } 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; - } + return getStremioId(type, tmdbId); } } export const catalogService = CatalogService.getInstance(); -export default catalogService; +export default catalogService;