NuvioStreaming/src/services/catalogService.ts
2025-12-30 15:02:01 +05:30

1629 lines
59 KiB
TypeScript

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';
// Add a constant for storing the data source preference
const DATA_SOURCE_KEY = 'discover_data_source';
// Define data source types
export enum DataSource {
STREMIO_ADDONS = 'stremio_addons',
TMDB = 'tmdb',
}
export interface StreamingAddon {
id: string;
name: string;
version: string;
description: string;
types: string[];
catalogs: {
type: string;
id: string;
name: string;
extraSupported?: string[];
extra?: Array<{ name: string; options?: string[] }>;
}[];
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;
results: StreamingContent[];
}
export interface GroupedSearchResults {
byAddon: AddonSearchResults[];
allResults: StreamingContent[]; // Deduplicated flat list for backwards compatibility
}
export interface StreamingContent {
id: string;
type: string;
name: string;
addonId?: 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;
slug?: string;
releaseInfo?: string;
addonId?: 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
}
export interface CatalogContent {
addon: string;
type: string;
id: string;
name: string;
genre?: string;
items: StreamingContent[];
}
const CATALOG_SETTINGS_KEY = 'catalog_settings';
class CatalogService {
private static instance: CatalogService;
private readonly LEGACY_LIBRARY_KEY = 'stremio-library';
private readonly RECENT_CONTENT_KEY = 'stremio-recent-content';
private library: Record<string, StreamingContent> = {};
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<void>;
private isInitialized: boolean = false;
private constructor() {
this.initPromise = this.initialize();
}
private async initialize(): Promise<void> {
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<void> {
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<void> {
try {
const currentScope = await mmkvStorage.getItem('@user:current');
if (!currentScope) {
await mmkvStorage.setItem('@user:current', 'local');
logger.log('[CatalogService] Initialized @user:current scope to "local"');
} else {
logger.log(`[CatalogService] Using existing scope: "${currentScope}"`);
}
} catch (error) {
logger.error('[CatalogService] Failed to initialize scope:', error);
}
}
static getInstance(): CatalogService {
if (!CatalogService.instance) {
CatalogService.instance = new CatalogService();
}
return CatalogService.instance;
}
private async loadLibrary(): Promise<void> {
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<string, StreamingContent> = {};
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<void> {
// 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<void> {
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<void> {
try {
await mmkvStorage.setItem(this.RECENT_CONTENT_KEY, JSON.stringify(this.recentContent));
} catch (error: any) {
logger.error('Failed to save recent content:', error);
}
}
async getAllAddons(): Promise<StreamingAddon[]> {
const addons = await stremioService.getInstalledAddonsAsync();
return addons.map(addon => this.convertManifestToStreamingAddon(addon));
}
private convertManifestToStreamingAddon(manifest: Manifest): StreamingAddon {
return {
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
types: manifest.types || [],
catalogs: manifest.catalogs || [],
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[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
const addons = await this.getAllAddons();
// Load enabled/disabled settings
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Collect all potential catalogs first
const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = [];
for (const addon of addons) {
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
const isEnabled = catalogSettings[settingKey] ?? true;
if (isEnabled) {
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<CatalogContent | null> {
try {
// Hoist manifest list retrieval and find once
const addonManifests = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifests.find(a => a.id === addon.id);
if (!manifest) return null;
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (metas && metas.length > 0) {
// Cap items per catalog to reduce memory and rendering load
const limited = metas.slice(0, 12);
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name; if customized, respect it as-is
const originalName = catalog.name || catalog.id;
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
const isCustom = displayName !== originalName;
if (!isCustom) {
// Remove duplicate words and clean up the name (case-insensitive)
const words = displayName.split(' ');
const uniqueWords: string[] = [];
const seenWords = new Set<string>();
for (const word of words) {
const lowerWord = word.toLowerCase();
if (!seenWords.has(lowerWord)) {
uniqueWords.push(word);
seenWords.add(lowerWord);
}
}
displayName = uniqueWords.join(' ');
// Add content type if not present
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
}
return {
addon: addon.id,
type: catalog.type,
id: catalog.id,
name: displayName,
items
};
}
return null;
} catch (error) {
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
return null;
}
}
async getHomeCatalogs(limitIds?: string[]): Promise<CatalogContent[]> {
// Determine which catalogs to actually fetch
const catalogsToFetch = await this.resolveHomeCatalogsToFetch(limitIds);
// Create promises for the selected catalogs
const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => {
return this.fetchHomeCatalog(addon, catalog);
});
// Wait for all selected catalog fetch promises to resolve in parallel
const catalogResults = await Promise.all(catalogPromises);
// Filter out null results
return catalogResults.filter(catalog => catalog !== null) as CatalogContent[];
}
async getCatalogByType(type: string, genreFilter?: string): Promise<CatalogContent[]> {
// Get the data source preference (default to Stremio addons)
const dataSourcePreference = await this.getDataSourcePreference();
// If TMDB is selected as the data source, use TMDB API
if (dataSourcePreference === DataSource.TMDB) {
return this.getCatalogByTypeFromTMDB(type, genreFilter);
}
// Otherwise use the original Stremio addons method
const addons = await this.getAllAddons();
const typeAddons = addons.filter(addon =>
addon.catalogs && addon.catalogs.some(catalog => catalog.type === type)
);
// Create an array of promises for all catalog fetches
const catalogPromises: Promise<CatalogContent | null>[] = [];
for (const addon of typeAddons) {
const typeCatalogs = addon.catalogs.filter(catalog => catalog.type === type);
for (const catalog of typeCatalogs) {
const catalogPromise = (async () => {
try {
const addonManifest = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifest.find(a => a.id === addon.id);
if (!manifest) return null;
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
return {
addon: addon.id,
type,
id: catalog.id,
name: displayName,
genre: genreFilter,
items
};
}
return null;
} catch (error) {
logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error);
return null;
}
})();
catalogPromises.push(catalogPromise);
}
}
// Wait for all catalog fetch promises to resolve in parallel
const catalogResults = await Promise.all(catalogPromises);
// Filter out null results
return catalogResults.filter(catalog => catalog !== null) as CatalogContent[];
}
/**
* Get catalog content from TMDB by type and genre
*/
private async getCatalogByTypeFromTMDB(type: string, genreFilter?: string): Promise<CatalogContent[]> {
const tmdbService = TMDBService.getInstance();
const catalogs: CatalogContent[] = [];
try {
// Map Stremio content type to TMDB content type
const tmdbType = type === 'movie' ? 'movie' : 'tv';
// If no genre filter or All is selected, get multiple catalogs
if (!genreFilter || genreFilter === 'All') {
// Create an array of promises for all catalog fetches
const catalogFetchPromises = [
// Trending catalog
(async () => {
const trendingItems = await tmdbService.getTrending(tmdbType, 'week');
const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const trendingStreamingItems = await Promise.all(trendingItemsPromises);
return {
addon: 'tmdb',
type,
id: 'trending',
name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: trendingStreamingItems
};
})(),
// Popular catalog
(async () => {
const popularItems = await tmdbService.getPopular(tmdbType, 1);
const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const popularStreamingItems = await Promise.all(popularItemsPromises);
return {
addon: 'tmdb',
type,
id: 'popular',
name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: popularStreamingItems
};
})(),
// Upcoming/on air catalog
(async () => {
const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1);
const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const upcomingStreamingItems = await Promise.all(upcomingItemsPromises);
return {
addon: 'tmdb',
type,
id: 'upcoming',
name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows',
items: upcomingStreamingItems
};
})()
];
// Wait for all catalog fetches to complete in parallel
return await Promise.all(catalogFetchPromises);
} else {
// Get content by genre
const genreItems = await tmdbService.discoverByGenre(tmdbType, genreFilter);
const streamingItemsPromises = genreItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
const streamingItems = await Promise.all(streamingItemsPromises);
return [{
addon: 'tmdb',
type,
id: 'discover',
name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
genre: genreFilter,
items: streamingItems
}];
}
} catch (error) {
logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error);
return [];
}
}
/**
* Convert TMDB trending/discover result to StreamingContent format
*/
private async convertTMDBToStreamingContent(item: any, type: 'movie' | 'tv'): Promise<StreamingContent> {
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<DataSource> {
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<void> {
try {
await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource);
} catch (error) {
logger.error('Failed to set data source preference:', error);
}
}
async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
console.log(`🔍 [CatalogService] getContentDetails called:`, { type, id, preferredAddonId });
try {
// Try up to 2 times with increasing delays to reduce CPU load
let meta = null;
let lastError = null;
for (let i = 0; i < 2; i++) {
try {
console.log(`🔍 [CatalogService] Attempt ${i + 1}/2 for getContentDetails:`, { type, id, preferredAddonId });
// Skip meta requests for non-content ids (e.g., provider slugs)
const isValidId = await stremioService.isValidContentId(type, id);
console.log(`🔍 [CatalogService] Content ID validation:`, { type, id, isValidId });
if (!isValidId) {
console.log(`🔍 [CatalogService] Invalid content ID, breaking retry loop`);
break;
}
console.log(`🔍 [CatalogService] Calling stremioService.getMetaDetails:`, { type, id, preferredAddonId });
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
console.log(`🔍 [CatalogService] stremioService.getMetaDetails result:`, {
hasMeta: !!meta,
metaId: meta?.id,
metaName: meta?.name,
metaType: meta?.type
});
if (meta) break;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
} catch (error) {
lastError = error;
console.log(`🔍 [CatalogService] Attempt ${i + 1} failed:`, {
errorMessage: error instanceof Error ? error.message : String(error),
isAxiosError: (error as any)?.isAxiosError,
responseStatus: (error as any)?.response?.status,
responseData: (error as any)?.response?.data
});
logger.error(`Attempt ${i + 1} failed to get content details for ${type}:${id}:`, error);
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
if (meta) {
console.log(`🔍 [CatalogService] Meta found, converting to StreamingContent:`, {
metaId: meta.id,
metaName: meta.name,
metaType: meta.type
});
// Add to recent content using enhanced conversion for full metadata
const content = this.convertMetaToStreamingContentEnhanced(meta);
this.addToRecentContent(content);
// Check if it's in the library
content.inLibrary = this.library[`${type}:${id}`] !== undefined;
console.log(`🔍 [CatalogService] Successfully converted meta to StreamingContent:`, {
contentId: content.id,
contentName: content.name,
contentType: content.type,
inLibrary: content.inLibrary
});
return content;
}
console.log(`🔍 [CatalogService] No meta found, checking lastError:`, {
hasLastError: !!lastError,
lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError)
});
if (lastError) {
console.log(`🔍 [CatalogService] Throwing lastError:`, {
errorMessage: lastError instanceof Error ? lastError.message : String(lastError),
isAxiosError: (lastError as any)?.isAxiosError,
responseStatus: (lastError as any)?.response?.status
});
throw lastError;
}
console.log(`🔍 [CatalogService] No meta and no error, returning null`);
return null;
} catch (error) {
console.log(`🔍 [CatalogService] getContentDetails caught error:`, {
errorMessage: error instanceof Error ? error.message : String(error),
isAxiosError: (error as any)?.isAxiosError,
responseStatus: (error as any)?.response?.status,
responseData: (error as any)?.response?.data
});
logger.error(`Failed to get content details for ${type}:${id}:`, error);
return null;
}
}
// Public method for getting enhanced metadata details (used by MetadataScreen)
async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
console.log(`🔍 [CatalogService] getEnhancedContentDetails called:`, { type, id, preferredAddonId });
logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`);
try {
const result = await this.getContentDetails(type, id, preferredAddonId);
console.log(`🔍 [CatalogService] getEnhancedContentDetails result:`, {
hasResult: !!result,
resultId: result?.id,
resultName: result?.name,
resultType: result?.type
});
return result;
} catch (error) {
console.log(`🔍 [CatalogService] getEnhancedContentDetails error:`, {
errorMessage: error instanceof Error ? error.message : String(error),
isAxiosError: (error as any)?.isAxiosError,
responseStatus: (error as any)?.response?.status,
responseData: (error as any)?.response?.data
});
throw error;
}
}
// Public method for getting basic content details without enhanced processing (used by ContinueWatching, etc.)
async getBasicContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
try {
// Try up to 3 times with increasing delays
let meta = null;
let lastError = null;
for (let i = 0; i < 3; i++) {
try {
// Skip meta requests for non-content ids (e.g., provider slugs)
if (!(await stremioService.isValidContentId(type, id))) {
break;
}
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
if (meta) break;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
} catch (error) {
lastError = error;
logger.error(`Attempt ${i + 1} failed to get basic content details for ${type}:${id}:`, error);
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
if (meta) {
// Use basic conversion without enhanced metadata processing
const content = this.convertMetaToStreamingContent(meta);
// Check if it's in the library
content.inLibrary = this.library[`${type}:${id}`] !== undefined;
return content;
}
if (lastError) {
throw lastError;
}
return null;
} catch (error) {
logger.error(`Failed to get basic content details for ${type}:${id}:`, error);
return null;
}
}
private convertMetaToStreamingContent(meta: Meta): StreamingContent {
// Basic conversion for catalog display - no enhanced metadata processing
// Use addon's poster if available, otherwise use placeholder
let posterUrl = meta.poster;
if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') {
posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
}
// Use addon's logo if available, otherwise undefined
let logoUrl = (meta as any).logo;
if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') {
logoUrl = undefined;
}
return {
id: meta.id,
type: meta.type,
name: meta.name,
addonId,
poster: posterUrl,
posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
banner: meta.background,
logo: logoUrl,
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
certification: meta.certification,
releaseInfo: meta.releaseInfo,
};
}
// Enhanced conversion for detailed metadata (used only when fetching individual content details)
private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
// Enhanced conversion to utilize all available metadata from addons
const converted: StreamingContent = {
id: meta.id,
type: meta.type,
name: meta.name,
addonId,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: meta.posterShape || 'poster',
banner: meta.background,
// Use addon's logo if available, otherwise undefined
logo: (meta as any).logo || undefined,
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
certification: meta.certification,
// Enhanced fields from addon metadata
directors: (meta as any).director ?
(Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director])
: undefined,
writer: (meta as any).writer || undefined,
country: (meta as any).country || undefined,
imdb_id: (meta as any).imdb_id || undefined,
slug: (meta as any).slug || undefined,
releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
trailerStreams: (meta as any).trailerStreams || undefined,
links: (meta as any).links || undefined,
behaviorHints: (meta as any).behaviorHints || undefined,
};
// Extract addon cast data if available
// Check for both app_extras.cast (structured) and cast (simple array) formats
if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) {
// Structured format with name, character, photo
converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({
id: index + 1, // Use index as numeric ID
name: castMember.name || 'Unknown',
character: castMember.character || '',
profile_path: castMember.photo || null
}));
} else if (meta.cast && Array.isArray(meta.cast)) {
// Simple array format with just names
converted.addonCast = meta.cast.map((castName: string, index: number) => ({
id: index + 1, // Use index as numeric ID
name: castName || 'Unknown',
character: '', // No character info available in simple format
profile_path: null // No profile images available in simple format
}));
}
// Log if rich metadata is found
if ((meta as any).trailerStreams?.length > 0) {
logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`);
}
if ((meta as any).links?.length > 0) {
logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`);
}
if (converted.addonCast && converted.addonCast.length > 0) {
logger.log(`🎭 Enhanced metadata: Found ${converted.addonCast.length} cast members from addon for ${meta.name}`);
}
// Handle videos/episodes if available
if ((meta as any).videos) {
converted.videos = (meta as any).videos;
}
return converted;
}
private notifyLibrarySubscribers(): void {
const items = Object.values(this.library);
this.librarySubscribers.forEach(callback => callback(items));
}
public onLibraryAdd(listener: (item: StreamingContent) => void): () => void {
this.libraryAddListeners.push(listener);
return () => {
this.libraryAddListeners = this.libraryAddListeners.filter(l => l !== listener);
};
}
public onLibraryRemove(listener: (type: string, id: string) => void): () => void {
this.libraryRemoveListeners.push(listener);
return () => {
this.libraryRemoveListeners = this.libraryRemoveListeners.filter(l => l !== listener);
};
}
public async getLibraryItems(): Promise<StreamingContent[]> {
// 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<void> {
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<void> {
logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`);
await this.ensureInitialized();
const key = `${type}:${id}`;
const itemCountBefore = Object.keys(this.library).length;
const itemExisted = key in this.library;
logger.log(`[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(this.library).slice(0, 5).join(', ')}${Object.keys(this.library).length > 5 ? '...' : ''}]`);
delete this.library[key];
const itemCountAfter = Object.keys(this.library).length;
logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`);
await this.saveLibrary();
logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`);
this.notifyLibrarySubscribers();
try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch { }
// Cancel notifications for series when removed from library
if (type === 'series') {
try {
// Cancel all notifications for this series
const scheduledNotifications = notificationService.getScheduledNotifications();
const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id);
for (const notification of seriesToCancel) {
await notificationService.cancelNotification(notification.id);
}
console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`);
} catch (error) {
console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error);
}
}
}
private addToRecentContent(content: StreamingContent): void {
// Remove if it already exists to prevent duplicates
this.recentContent = this.recentContent.filter(item =>
!(item.id === content.id && item.type === content.type)
);
// Add to the beginning of the array
this.recentContent.unshift(content);
// Trim the array if it exceeds the maximum
if (this.recentContent.length > this.MAX_RECENT_ITEMS) {
this.recentContent = this.recentContent.slice(0, this.MAX_RECENT_ITEMS);
}
this.saveRecentContent();
}
getRecentContent(): StreamingContent[] {
return this.recentContent;
}
/**
* Get all available discover filters (genres, etc.) from installed addon catalogs
* This aggregates genre options from all addons that have catalog extras with options
*/
async getDiscoverFilters(): Promise<{
genres: string[];
types: string[];
catalogsByType: Record<string, { addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }[]>;
}> {
const addons = await this.getAllAddons();
const allGenres = new Set<string>();
const allTypes = new Set<string>();
const catalogsByType: Record<string, { addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }[]> = {};
for (const addon of addons) {
if (!addon.catalogs) continue;
for (const catalog of addon.catalogs) {
// Track content types
if (catalog.type) {
allTypes.add(catalog.type);
}
// Get genres from catalog extras
const catalogGenres: string[] = [];
if (catalog.extra && Array.isArray(catalog.extra)) {
for (const extra of catalog.extra) {
if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) {
for (const genre of extra.options) {
allGenres.add(genre);
catalogGenres.push(genre);
}
}
}
}
// Track catalogs by type for filtering
if (catalog.type) {
if (!catalogsByType[catalog.type]) {
catalogsByType[catalog.type] = [];
}
catalogsByType[catalog.type].push({
addonId: addon.id,
addonName: addon.name,
catalogId: catalog.id,
catalogName: catalog.name || catalog.id,
genres: catalogGenres
});
}
}
}
// Sort genres alphabetically
const sortedGenres = Array.from(allGenres).sort((a, b) => a.localeCompare(b));
const sortedTypes = Array.from(allTypes);
return {
genres: sortedGenres,
types: sortedTypes,
catalogsByType
};
}
/**
* Discover content by type and optional genre filter
* Fetches from all installed addons that have catalogs matching the criteria
*/
async discoverContent(
type: string,
genre?: string,
limit: number = 20
): Promise<{ addonName: string; items: StreamingContent[] }[]> {
const addons = await this.getAllAddons();
const results: { addonName: string; items: StreamingContent[] }[] = [];
const manifests = await stremioService.getInstalledAddonsAsync();
// Find catalogs that match the type
const catalogPromises: Promise<{ addonName: string; items: StreamingContent[] } | null>[] = [];
for (const addon of addons) {
if (!addon.catalogs) continue;
// Find catalogs matching the type
const matchingCatalogs = addon.catalogs.filter(catalog => catalog.type === type);
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 but not supported, we still fetch but without the filter
// This ensures we don't skip addons that don't support the filter
const manifest = manifests.find(m => m.id === addon.id);
if (!manifest) continue;
const fetchPromise = (async () => {
try {
// Only apply genre filter if supported
const filters = (genre && supportsGenre) ? [{ 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 => ({
...this.convertMetaToStreamingContent(meta),
addonId: addon.id // Attach addon ID to each result
}));
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<string, StreamingContent[]>();
for (const result of catalogResults) {
if (result && result.items.length > 0) {
const existing = addonMap.get(result.addonName) || [];
// Merge items, avoiding duplicates
const existingIds = new Set(existing.map(item => `${item.type}:${item.id}`));
const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`));
addonMap.set(result.addonName, [...existing, ...newItems]);
}
}
// Convert map to array
for (const [addonName, items] of addonMap) {
results.push({ addonName, items: items.slice(0, limit) });
}
return results;
}
/**
* Discover content from a specific catalog with optional genre filter
* @param addonId - The addon ID
* @param catalogId - The catalog ID
* @param type - Content type (movie/series)
* @param genre - Optional genre filter
* @param limit - Maximum items to return
*/
async discoverContentFromCatalog(
addonId: string,
catalogId: string,
type: string,
genre?: string,
page: number = 1
): Promise<StreamingContent[]> {
try {
const manifests = await stremioService.getInstalledAddonsAsync();
const manifest = manifests.find(m => m.id === addonId);
if (!manifest) {
logger.error(`Addon ${addonId} not found`);
return [];
}
// Find the catalog to check if it supports genre filter
const addon = (await this.getAllAddons()).find(a => a.id === addonId);
const catalog = addon?.catalogs?.find(c => c.id === catalogId);
// Check if catalog supports genre filter
const supportsGenre = catalog?.extra?.some((e: any) => e.name === 'genre') ||
catalog?.extraSupported?.includes('genre');
// Only apply genre filter if the catalog supports it
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
if (metas && metas.length > 0) {
return metas.map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addonId
}));
}
return [];
} catch (error) {
logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error);
return [];
}
}
async searchContent(query: string): Promise<StreamingContent[]> {
if (!query || query.trim().length < 2) {
return [];
}
const addons = await this.getAllAddons();
const results: StreamingContent[] = [];
const searchPromises: Promise<void>[] = [];
for (const addon of addons) {
if (addon.catalogs && addon.catalogs.length > 0) {
for (const catalog of addon.catalogs) {
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 => this.convertMetaToStreamingContent(meta));
results.push(...items);
}
} catch (error) {
logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error);
}
})();
searchPromises.push(searchPromise);
}
}
}
await Promise.all(searchPromises);
// Remove duplicates based on id and type
const uniqueResults = Array.from(
new Map(results.map(item => [`${item.type}:${item.id}`, item])).values()
);
return uniqueResults;
}
/**
* Search across all installed addons that support search functionality.
* This dynamically queries any addon with catalogs that have 'search' in their extraSupported or extra fields.
* Results are grouped by addon source with headers.
*
* @param query - The search query string
* @returns Promise<GroupedSearchResults> - Search results grouped by addon with headers
*/
async searchContentCinemeta(query: string): Promise<GroupedSearchResults> {
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[] = [];
// Find all addons that support search
const searchableAddons = addons.filter(addon => {
if (!addon.catalogs) return false;
// Check if any catalog supports search
return addon.catalogs.some(catalog => {
const extraSupported = catalog.extraSupported || [];
const extra = catalog.extra || [];
// Check if 'search' is in extraSupported or extra
return extraSupported.includes('search') ||
extra.some((e: any) => e.name === 'search');
});
});
logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', '));
// Search each addon and keep results grouped
for (const addon of searchableAddons) {
const searchableCatalogs = (addon.catalogs || []).filter(catalog => {
const extraSupported = catalog.extraSupported || [];
const extra = catalog.extra || [];
return extraSupported.includes('search') ||
extra.some((e: any) => e.name === 'search');
});
// Search all catalogs for this addon in parallel
const catalogPromises = searchableCatalogs.map(catalog =>
this.searchAddonCatalog(addon, catalog.type, catalog.id, trimmedQuery)
);
const catalogResults = await Promise.allSettled(catalogPromises);
// Collect all results for this addon
const addonResults: StreamingContent[] = [];
catalogResults.forEach((result) => {
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<string>();
const uniqueAddonResults = addonResults.filter(item => {
const key = `${item.type}:${item.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
byAddon.push({
addonId: addon.id,
addonName: addon.name,
results: uniqueAddonResults,
});
}
}
// Create deduplicated flat list for backwards compatibility
const allResults: StreamingContent[] = [];
const globalSeen = new Set<string>();
byAddon.forEach(addonGroup => {
addonGroup.results.forEach(item => {
const key = `${item.type}:${item.id}`;
if (!globalSeen.has(key)) {
globalSeen.add(key);
allResults.push(item);
}
});
});
logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`);
return { byAddon, allResults };
}
/**
* Live search that emits results per-addon as they arrive.
* Returns a handle with cancel() and a done promise.
*/
startLiveSearch(
query: string,
onAddonResults: (section: AddonSearchResults) => void
): { cancel: () => void; done: Promise<void> } {
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();
// Determine searchable addons
const searchableAddons = addons.filter(addon =>
(addon.catalogs || []).some(c =>
(c.extraSupported && c.extraSupported.includes('search')) ||
(c.extra && c.extra.some(e => e.name === 'search'))
)
);
// Global dedupe across emitted results
const globalSeen = new Set<string>();
await Promise.all(
searchableAddons.map(async (addon) => {
if (controller.cancelled) return;
try {
const searchableCatalogs = (addon.catalogs || []).filter(c =>
(c.extraSupported && c.extraSupported.includes('search')) ||
(c.extra && c.extra.some(e => e.name === 'search'))
);
// Fetch all catalogs for this addon in parallel
const settled = await Promise.allSettled(
searchableCatalogs.map(c => this.searchAddonCatalog(addon, c.type, c.id, trimmedQuery))
);
if (controller.cancelled) return;
const addonResults: StreamingContent[] = [];
for (const s of settled) {
if (s.status === 'fulfilled' && Array.isArray(s.value)) {
addonResults.push(...s.value);
}
}
if (addonResults.length === 0) return;
// Dedupe within addon and against global
const localSeen = new Set<string>();
const unique = addonResults.filter(item => {
const key = `${item.type}:${item.id}`;
if (localSeen.has(key) || globalSeen.has(key)) return false;
localSeen.add(key);
globalSeen.add(key);
return true;
});
if (unique.length > 0 && !controller.cancelled) {
onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique });
}
} catch (e) {
// ignore individual addon errors
}
})
);
})();
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 addon - 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<StreamingContent[]> - Search results from this specific addon catalog
*/
private async searchAddonCatalog(
addon: any,
type: string,
catalogId: string,
query: string
): Promise<StreamingContent[]> {
try {
let url: string;
// Special handling for Cinemeta (hardcoded URL)
if (addon.id === 'com.linvo.cinemeta') {
const encodedCatalogId = encodeURIComponent(catalogId);
const encodedQuery = encodeURIComponent(query);
url = `https://v3-cinemeta.strem.io/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
}
// Handle other addons
else {
// Choose best available URL
const chosenUrl: string | undefined = addon.url || addon.originalUrl || addon.transportUrl;
if (!chosenUrl) {
logger.warn(`Addon ${addon.name} has no URL, skipping search`);
return [];
}
// Extract base URL and preserve query params
const [baseUrlPart, queryParams] = chosenUrl.split('?');
let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, '');
// Ensure URL has protocol
if (!cleanBaseUrl.startsWith('http')) {
cleanBaseUrl = `https://${cleanBaseUrl}`;
}
const encodedCatalogId = encodeURIComponent(catalogId);
const encodedQuery = encodeURIComponent(query);
url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
// Append original query params if they existed
if (queryParams) {
url += `?${queryParams}`;
}
}
logger.log(`Searching ${addon.name} (${type}/${catalogId}):`, url);
const response = await axios.get<{ metas: any[] }>(url, {
timeout: 10000, // 10 second timeout per addon
});
const metas = response.data?.metas || [];
if (metas.length > 0) {
const items = metas.map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addon.id
}));
logger.log(`Found ${items.length} results from ${addon.name}`);
return items;
}
return [];
} catch (error: any) {
// Don't throw, just log and return empty
const errorMsg = error?.response?.status
? `HTTP ${error.response.status}`
: error?.message || 'Unknown error';
logger.error(`Search failed for ${addon.name} (${type}/${catalogId}): ${errorMsg}`);
return [];
}
}
async getStremioId(type: string, tmdbId: string): Promise<string | null> {
if (__DEV__) {
console.log('=== CatalogService.getStremioId ===');
console.log('Input type:', type);
console.log('Input tmdbId:', tmdbId);
}
try {
// For movies, use the tt prefix with IMDb ID
if (type === 'movie') {
if (__DEV__) console.log('Processing movie - fetching TMDB details...');
const tmdbService = TMDBService.getInstance();
const movieDetails = await tmdbService.getMovieDetails(tmdbId);
if (__DEV__) console.log('Movie details result:', {
id: movieDetails?.id,
title: movieDetails?.title,
imdb_id: movieDetails?.imdb_id,
hasImdbId: !!movieDetails?.imdb_id
});
if (movieDetails?.imdb_id) {
if (__DEV__) console.log('Successfully found IMDb ID:', movieDetails.imdb_id);
return movieDetails.imdb_id;
} else {
console.warn('No IMDb ID found for movie:', tmdbId);
return null;
}
}
// For TV shows, get the IMDb ID like movies
else if (type === 'tv' || type === 'series') {
if (__DEV__) console.log('Processing TV show - fetching TMDB details for IMDb ID...');
const tmdbService = TMDBService.getInstance();
// Get TV show external IDs to find IMDb ID
const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId));
if (__DEV__) console.log('TV show external IDs result:', {
tmdbId: tmdbId,
imdb_id: externalIds?.imdb_id,
hasImdbId: !!externalIds?.imdb_id
});
if (externalIds?.imdb_id) {
if (__DEV__) console.log('Successfully found IMDb ID for TV show:', externalIds.imdb_id);
return externalIds.imdb_id;
} else {
console.warn('No IMDb ID found for TV show, falling back to kitsu format:', tmdbId);
const fallbackId = `kitsu:${tmdbId}`;
if (__DEV__) console.log('Generated fallback Stremio ID for TV:', fallbackId);
return fallbackId;
}
}
else {
console.warn('Unknown type provided:', type);
return null;
}
} catch (error: any) {
if (__DEV__) {
console.error('=== Error in getStremioId ===');
console.error('Type:', type);
console.error('TMDB ID:', tmdbId);
console.error('Error details:', error);
console.error('Error message:', error.message);
}
logger.error('Error getting Stremio ID:', error);
return null;
}
}
}
export const catalogService = CatalogService.getInstance();
export default catalogService;