NuvioStreaming/src/services/catalogService.ts
tapframe d88962ae01 Enhance metadata handling and navigation with addon support
This update introduces support for addon IDs in various components, including CatalogSection, ContinueWatchingSection, and MetadataScreen, allowing for enhanced metadata fetching. The CatalogService now includes methods for retrieving both basic and enhanced content details based on the specified addon. Additionally, improvements to the loading process in HomeScreen ensure a more efficient catalog loading experience. These changes enhance user experience by providing richer content details and smoother navigation.
2025-06-20 13:00:24 +05:30

796 lines
No EOL
27 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;
// 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;
}
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, 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 {
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 content details for ${type}:${id}:`, error);
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
if (meta) {
// 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;
return content;
}
if (lastError) {
throw lastError;
}
return null;
} catch (error) {
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> {
logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`);
return this.getContentDetails(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<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, 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
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`, // Use metahub for catalog display
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
certification: meta.certification,
releaseInfo: meta.releaseInfo,
};
}
// Enhanced conversion for detailed metadata (used only when fetching individual content details)
private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
// Enhanced conversion to utilize all available metadata from addons
const converted: StreamingContent = {
id: meta.id,
type: meta.type,
name: meta.name,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: 'poster',
banner: meta.background,
// Use addon's logo if available, fallback to metahub
logo: (meta as any).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,
// 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,
};
// Cast is handled separately by the dedicated CastSection component via TMDB
// 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}`);
}
// 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 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;