mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-25 11:59:11 +00:00
680 lines
No EOL
22 KiB
TypeScript
680 lines
No EOL
22 KiB
TypeScript
import { stremioService, Meta, Manifest } from './stremioService';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
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;
|
|
}[];
|
|
resources: {
|
|
name: string;
|
|
types: string[];
|
|
idPrefixes?: string[];
|
|
}[];
|
|
transportUrl?: string;
|
|
transportName?: string;
|
|
}
|
|
|
|
export interface StreamingContent {
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
poster: string;
|
|
posterShape?: string;
|
|
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;
|
|
}
|
|
|
|
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 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 constructor() {
|
|
this.loadLibrary();
|
|
this.loadRecentContent();
|
|
}
|
|
|
|
static getInstance(): CatalogService {
|
|
if (!CatalogService.instance) {
|
|
CatalogService.instance = new CatalogService();
|
|
}
|
|
return CatalogService.instance;
|
|
}
|
|
|
|
private async loadLibrary(): Promise<void> {
|
|
try {
|
|
const storedLibrary = await AsyncStorage.getItem(this.LIBRARY_KEY);
|
|
if (storedLibrary) {
|
|
this.library = JSON.parse(storedLibrary);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load library:', error);
|
|
}
|
|
}
|
|
|
|
private async saveLibrary(): Promise<void> {
|
|
try {
|
|
await AsyncStorage.setItem(this.LIBRARY_KEY, JSON.stringify(this.library));
|
|
} catch (error) {
|
|
logger.error('Failed to save library:', error);
|
|
}
|
|
}
|
|
|
|
private async loadRecentContent(): Promise<void> {
|
|
try {
|
|
const storedRecentContent = await AsyncStorage.getItem(this.RECENT_CONTENT_KEY);
|
|
if (storedRecentContent) {
|
|
this.recentContent = JSON.parse(storedRecentContent);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load recent content:', error);
|
|
}
|
|
}
|
|
|
|
private async saveRecentContent(): Promise<void> {
|
|
try {
|
|
await AsyncStorage.setItem(this.RECENT_CONTENT_KEY, JSON.stringify(this.recentContent));
|
|
} catch (error) {
|
|
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 || [],
|
|
transportUrl: manifest.url,
|
|
transportName: manifest.name
|
|
};
|
|
}
|
|
|
|
async getHomeCatalogs(): Promise<CatalogContent[]> {
|
|
const addons = await this.getAllAddons();
|
|
|
|
// Load enabled/disabled settings
|
|
const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
|
|
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
|
|
|
|
// Create an array of promises for all catalog fetches
|
|
const catalogPromises: Promise<CatalogContent | null>[] = [];
|
|
|
|
// Process addons in order (they're already returned in order from getAllAddons)
|
|
for (const addon of addons) {
|
|
if (addon.catalogs) {
|
|
for (const catalog of addon.catalogs) {
|
|
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
|
|
const isEnabled = catalogSettings[settingKey] ?? true;
|
|
|
|
if (isEnabled) {
|
|
// Create a promise for each catalog fetch
|
|
const catalogPromise = (async () => {
|
|
try {
|
|
const addonManifest = await stremioService.getInstalledAddonsAsync();
|
|
const manifest = addonManifest.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) {
|
|
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
|
|
|
|
// Get potentially custom display name
|
|
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
|
|
|
|
// Remove duplicate words and clean up the name (case-insensitive)
|
|
const words = displayName.split(' ');
|
|
const uniqueWords = [];
|
|
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;
|
|
}
|
|
})();
|
|
|
|
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[];
|
|
}
|
|
|
|
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 AsyncStorage.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 AsyncStorage.setItem(DATA_SOURCE_KEY, dataSource);
|
|
} catch (error) {
|
|
logger.error('Failed to set data source preference:', error);
|
|
}
|
|
}
|
|
|
|
async getContentDetails(type: string, id: 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 {
|
|
meta = await stremioService.getMetaDetails(type, id);
|
|
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 content details for ${type}:${id}:`, error);
|
|
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
|
|
}
|
|
}
|
|
|
|
if (meta) {
|
|
// Add to recent content
|
|
const content = this.convertMetaToStreamingContent(meta);
|
|
this.addToRecentContent(content);
|
|
|
|
// 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 content details for ${type}:${id}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private convertMetaToStreamingContent(meta: Meta): StreamingContent {
|
|
return {
|
|
id: meta.id,
|
|
type: meta.type,
|
|
name: meta.name,
|
|
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
|
posterShape: 'poster',
|
|
banner: meta.background,
|
|
logo: `https://images.metahub.space/logo/medium/${meta.id}/img`,
|
|
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
|
|
};
|
|
}
|
|
|
|
private notifyLibrarySubscribers(): void {
|
|
const items = Object.values(this.library);
|
|
this.librarySubscribers.forEach(callback => callback(items));
|
|
}
|
|
|
|
public getLibraryItems(): StreamingContent[] {
|
|
return Object.values(this.library);
|
|
}
|
|
|
|
public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void {
|
|
this.librarySubscribers.push(callback);
|
|
// Initial callback with current items
|
|
callback(this.getLibraryItems());
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
const index = this.librarySubscribers.indexOf(callback);
|
|
if (index > -1) {
|
|
this.librarySubscribers.splice(index, 1);
|
|
}
|
|
};
|
|
}
|
|
|
|
public addToLibrary(content: StreamingContent): void {
|
|
const key = `${content.type}:${content.id}`;
|
|
this.library[key] = content;
|
|
this.saveLibrary();
|
|
this.notifyLibrarySubscribers();
|
|
}
|
|
|
|
public removeFromLibrary(type: string, id: string): void {
|
|
const key = `${type}:${id}`;
|
|
delete this.library[key];
|
|
this.saveLibrary();
|
|
this.notifyLibrarySubscribers();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async searchContentCinemeta(query: string): Promise<StreamingContent[]> {
|
|
if (!query) {
|
|
return [];
|
|
}
|
|
|
|
const trimmedQuery = query.trim().toLowerCase();
|
|
logger.log('Searching Cinemeta for:', trimmedQuery);
|
|
|
|
const addons = await this.getAllAddons();
|
|
const results: StreamingContent[] = [];
|
|
|
|
// Find Cinemeta addon by its ID
|
|
const cinemeta = addons.find(addon => addon.id === 'com.linvo.cinemeta');
|
|
|
|
if (!cinemeta || !cinemeta.catalogs) {
|
|
logger.error('Cinemeta addon not found');
|
|
return [];
|
|
}
|
|
|
|
// Search in both movie and series catalogs simultaneously
|
|
const searchPromises = ['movie', 'series'].map(async (type) => {
|
|
try {
|
|
// Direct API call to Cinemeta
|
|
const url = `https://v3-cinemeta.strem.io/catalog/${type}/top/search=${encodeURIComponent(trimmedQuery)}.json`;
|
|
logger.log('Request URL:', url);
|
|
|
|
const response = await axios.get<{ metas: any[] }>(url);
|
|
const metas = response.data.metas || [];
|
|
|
|
if (metas && metas.length > 0) {
|
|
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
|
|
results.push(...items);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Cinemeta search failed for ${type}:`, error);
|
|
}
|
|
});
|
|
|
|
await Promise.all(searchPromises);
|
|
|
|
// Remove duplicates while preserving order
|
|
const seen = new Set();
|
|
return results.filter(item => {
|
|
const key = `${item.type}:${item.id}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async getStremioId(type: string, tmdbId: string): Promise<string | null> {
|
|
try {
|
|
// For movies, use the tt prefix with IMDb ID
|
|
if (type === 'movie') {
|
|
const tmdbService = TMDBService.getInstance();
|
|
const movieDetails = await tmdbService.getMovieDetails(tmdbId);
|
|
if (movieDetails?.imdb_id) {
|
|
return movieDetails.imdb_id;
|
|
}
|
|
}
|
|
// For TV shows, use the kitsu prefix
|
|
else if (type === 'series') {
|
|
return `kitsu:${tmdbId}`;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Error getting Stremio ID:', error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const catalogService = CatalogService.getInstance();
|
|
export default catalogService;
|