diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index 80a3bdc2..1a2987d3 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -60,7 +60,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { const { currentTheme } = useTheme(); const handleContentPress = (id: string, type: string) => { - navigation.navigate('Metadata', { id, type }); + navigation.navigate('Metadata', { id, type, addonId: catalog.addon }); }; const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => { diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 13c420dc..ba9a8ce4 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -116,8 +116,8 @@ const ContinueWatchingSection = React.forwardRef((props, re let content: StreamingContent | null = null; - // Get content details using catalogService - content = await catalogService.getContentDetails(type, id); + // Get basic content details using catalogService (no enhanced metadata needed for continue watching) + content = await catalogService.getBasicContentDetails(type, id); if (content) { // Extract season and episode info from episodeId if available diff --git a/src/components/metadata/MovieContent.tsx b/src/components/metadata/MovieContent.tsx index 95836e13..eceabe31 100644 --- a/src/components/metadata/MovieContent.tsx +++ b/src/components/metadata/MovieContent.tsx @@ -10,7 +10,7 @@ interface MovieContentProps { export const MovieContent: React.FC = ({ metadata }) => { const { currentTheme } = useTheme(); const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0; - const castDisplay = hasCast ? (metadata.cast as string[]).slice(0, 5).join(', ') : ''; + const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : ''; return ( @@ -23,10 +23,10 @@ export const MovieContent: React.FC = ({ metadata }) => { )} - {metadata.writer && ( + {metadata.writer && metadata.writer.length > 0 && ( Writer: - {metadata.writer} + {Array.isArray(metadata.writer) ? metadata.writer.join(', ') : metadata.writer} )} diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 5a44301a..4e48ddef 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -60,6 +60,7 @@ const withRetry = async ( interface UseMetadataProps { id: string; type: string; + addonId?: string; } interface UseMetadataReturn { @@ -94,7 +95,7 @@ interface UseMetadataReturn { imdbId: string | null; } -export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => { +export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => { const [metadata, setMetadata] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -411,7 +412,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = if (writers.length > 0) { (formattedMovie as any).creators = writers; - (formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', '); + (formattedMovie as any).writer = writers; } } } catch (error) { @@ -513,10 +514,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const [content, castData] = await Promise.allSettled([ // Load content with timeout and retry withRetry(async () => { - const result = await withTimeout( - catalogService.getContentDetails(type, actualId), - API_TIMEOUT - ); + const result = await withTimeout( + catalogService.getEnhancedContentDetails(type, actualId, addonId), + API_TIMEOUT + ); // Store the actual ID used (could be IMDB) if (actualId.startsWith('tt')) { setImdbId(actualId); @@ -540,8 +541,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = cacheService.setMetadata(id, type, content.value); if (type === 'series') { - // Load series data in parallel with other data - loadSeriesData().catch(console.error); + // Load series data after the enhanced metadata is processed + setTimeout(() => { + loadSeriesData().catch(console.error); + }, 100); } } else { throw new Error('Content not found'); @@ -564,6 +567,67 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const loadSeriesData = async () => { setLoadingSeasons(true); try { + // First check if we have episode data from the addon + const addonVideos = metadata?.videos; + if (addonVideos && Array.isArray(addonVideos) && addonVideos.length > 0) { + logger.log(`🎬 Found ${addonVideos.length} episodes from addon metadata for ${metadata?.name || id}`); + + // Group addon episodes by season + const groupedAddonEpisodes: GroupedEpisodes = {}; + + addonVideos.forEach((video: any) => { + const seasonNumber = video.season || 1; + const episodeNumber = video.episode || video.number || 1; + + if (!groupedAddonEpisodes[seasonNumber]) { + groupedAddonEpisodes[seasonNumber] = []; + } + + // Convert addon episode format to our Episode interface + const episode: Episode = { + id: video.id, + name: video.name || video.title || `Episode ${episodeNumber}`, + overview: video.overview || video.description || '', + season_number: seasonNumber, + episode_number: episodeNumber, + air_date: video.released ? video.released.split('T')[0] : video.firstAired ? video.firstAired.split('T')[0] : '', + still_path: video.thumbnail ? video.thumbnail.replace('https://image.tmdb.org/t/p/w500', '') : null, + vote_average: parseFloat(video.rating) || 0, + runtime: undefined, + episodeString: `S${seasonNumber.toString().padStart(2, '0')}E${episodeNumber.toString().padStart(2, '0')}`, + stremioId: video.id, + season_poster_path: null + }; + + groupedAddonEpisodes[seasonNumber].push(episode); + }); + + // Sort episodes within each season + Object.keys(groupedAddonEpisodes).forEach(season => { + groupedAddonEpisodes[parseInt(season)].sort((a, b) => a.episode_number - b.episode_number); + }); + + logger.log(`📺 Processed addon episodes into ${Object.keys(groupedAddonEpisodes).length} seasons`); + setGroupedEpisodes(groupedAddonEpisodes); + + // Set the first available season + const seasons = Object.keys(groupedAddonEpisodes).map(Number); + const firstSeason = Math.min(...seasons); + logger.log(`📺 Setting season ${firstSeason} as selected (${groupedAddonEpisodes[firstSeason]?.length || 0} episodes)`); + setSelectedSeason(firstSeason); + setEpisodes(groupedAddonEpisodes[firstSeason] || []); + + // Try to get TMDB ID for additional metadata (cast, etc.) but don't override episodes + const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id); + if (tmdbIdResult) { + setTmdbId(tmdbIdResult); + } + + return; // Use addon episodes, skip TMDB loading + } + + // Fallback to TMDB if no addon episodes + logger.log('📺 No addon episodes found, falling back to TMDB'); const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id); if (tmdbIdResult) { setTmdbId(tmdbIdResult); @@ -866,6 +930,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = loadMetadata(); }, [id, type]); + // Re-run series data loading when metadata updates with videos + useEffect(() => { + if (metadata && type === 'series' && metadata.videos && metadata.videos.length > 0) { + logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`); + loadSeriesData().catch(console.error); + } + }, [metadata?.videos, type]); + const loadRecommendations = useCallback(async () => { if (!tmdbId) return; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ee73cb26..1c1cec2c 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -54,6 +54,7 @@ export type RootStackParamList = { id: string; type: string; episodeId?: string; + addonId?: string; }; Streams: { id: string; diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 8401e791..e80afcc2 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -503,7 +503,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH } ]} - onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} + onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })} activeOpacity={0.7} > { // Initialize catalogs array with proper length setCatalogs(new Array(catalogIndex).fill(null)); - // Wait for all catalogs to finish loading (success or failure) - await Promise.allSettled(catalogPromises); - console.log('[HomeScreen] All catalogs processed'); - - // Filter out null values to get only successfully loaded catalogs - setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); + // Start all catalog loading promises but don't wait for them + // They will update the state progressively as they complete + Promise.allSettled(catalogPromises).then(() => { + console.log('[HomeScreen] All catalogs processed'); + + // Final cleanup: Filter out null values to get only successfully loaded catalogs + setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); + }); } catch (error) { console.error('[HomeScreen] Error in progressive catalog loading:', error); diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 73b5d5b3..325f0036 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -45,9 +45,9 @@ import { TraktService, TraktPlaybackItem } from '../services/traktService'; const { height } = Dimensions.get('window'); const MetadataScreen: React.FC = () => { - const route = useRoute, string>>(); + const route = useRoute, string>>(); const navigation = useNavigation>(); - const { id, type, episodeId } = route.params; + const { id, type, episodeId, addonId } = route.params; // Consolidated hooks for better performance const { settings } = useSettings(); @@ -78,7 +78,7 @@ const MetadataScreen: React.FC = () => { loadingRecommendations, setMetadata, imdbId, - } = useMetadata({ id, type }); + } = useMetadata({ id, type, addonId }); // Optimized hooks with memoization const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes); diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 6c078463..d8ede4b2 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -54,6 +54,22 @@ export interface StreamingContent { 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 { @@ -442,7 +458,7 @@ class CatalogService { } } - async getContentDetails(type: string, id: string): Promise { + async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise { try { // Try up to 3 times with increasing delays let meta = null; @@ -450,7 +466,7 @@ class CatalogService { for (let i = 0; i < 3; i++) { try { - meta = await stremioService.getMetaDetails(type, id); + meta = await stremioService.getMetaDetails(type, id, preferredAddonId); if (meta) break; await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); } catch (error) { @@ -461,8 +477,8 @@ class CatalogService { } if (meta) { - // Add to recent content - const content = this.convertMetaToStreamingContent(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 @@ -482,7 +498,54 @@ class CatalogService { } } + // Public method for getting enhanced metadata details (used by MetadataScreen) + async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise { + 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 { + 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, @@ -490,17 +553,70 @@ class CatalogService { 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`, + 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 + 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)); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index c1163862..7aef7993 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -26,9 +26,35 @@ export interface Meta { genres?: string[]; runtime?: string; cast?: string[]; - director?: string; - writer?: string; + director?: string | string[]; + writer?: string | string[]; certification?: string; + // Extended fields available from some addons + country?: string; + imdb_id?: string; + slug?: string; + released?: string; + trailerStreams?: Array<{ + title: string; + ytId: string; + }>; + links?: Array<{ + name: string; + category: string; + url: string; + }>; + behaviorHints?: { + defaultVideoId?: string; + hasScheduledVideos?: boolean; + [key: string]: any; + }; + app_extras?: { + cast?: Array<{ + name: string; + character?: string; + photo?: string; + }>; + }; } export interface Subtitle { @@ -464,8 +490,71 @@ class StremioService { } } - async getMetaDetails(type: string, id: string): Promise { + async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise { try { + const addons = this.getInstalledAddons(); + + // If a preferred addon is specified, try it first + if (preferredAddonId) { + logger.log(`🎯 Trying preferred addon first: ${preferredAddonId}`); + const preferredAddon = addons.find(addon => addon.id === preferredAddonId); + + if (preferredAddon && preferredAddon.resources) { + // Log what URL would be used for debugging + const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || ''); + const wouldBeUrl = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; + logger.log(`🔍 Would check URL: ${wouldBeUrl} (addon: ${preferredAddon.name})`); + + // Log addon resources for debugging + logger.log(`🔍 Addon resources:`, JSON.stringify(preferredAddon.resources, null, 2)); + + // Check if addon supports meta resource for this type + let hasMetaSupport = false; + + for (const resource of preferredAddon.resources) { + // Check if the current element is a ResourceObject + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'meta' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + // Check if the element is the simple string "meta" AND the addon has a top-level types array + else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) { + if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + } + + logger.log(`🔍 Meta support check: ${hasMetaSupport} (addon types: ${JSON.stringify(preferredAddon.types)})`); + + if (hasMetaSupport) { + try { + logger.log(`HTTP GET: ${wouldBeUrl} (preferred addon: ${preferredAddon.name})`); + const response = await this.retryRequest(async () => { + return await axios.get(wouldBeUrl, { timeout: 10000 }); + }); + + if (response.data && response.data.meta) { + logger.log(`✅ Metadata fetched successfully from preferred addon: ${wouldBeUrl}`); + return response.data.meta; + } + } catch (error) { + logger.warn(`❌ Failed to fetch meta from preferred addon ${preferredAddon.name}:`, error); + } + } else { + logger.warn(`⚠️ Preferred addon ${preferredAddonId} does not support meta for type ${type}`); + } + } else { + logger.warn(`⚠️ Preferred addon ${preferredAddonId} not found or has no resources`); + } + } + // Try Cinemeta with different base URLs const cinemetaUrls = [ 'https://v3-cinemeta.strem.io', @@ -475,44 +564,66 @@ class StremioService { for (const baseUrl of cinemetaUrls) { try { const url = `${baseUrl}/meta/${type}/${id}.json`; + logger.log(`HTTP GET: ${url}`); const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); if (response.data && response.data.meta) { + logger.log(`✅ Metadata fetched successfully from: ${url}`); return response.data.meta; } } catch (error) { - logger.warn(`Failed to fetch meta from ${baseUrl}:`, error); + logger.warn(`❌ Failed to fetch meta from ${baseUrl}:`, error); continue; // Try next URL } } - // If Cinemeta fails, try other addons - const addons = this.getInstalledAddons(); + // If Cinemeta fails, try other addons (excluding the preferred one already tried) for (const addon of addons) { - if (!addon.resources || addon.id === 'com.linvo.cinemeta') continue; + if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue; - const metaResource = addon.resources.find( - resource => resource.name === 'meta' && resource.types.includes(type) - ); + // Check if addon supports meta resource for this type (handles both string and object formats) + let hasMetaSupport = false; - if (!metaResource) continue; + for (const resource of addon.resources) { + // Check if the current element is a ResourceObject + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'meta' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + // Check if the element is the simple string "meta" AND the addon has a top-level types array + else if (typeof resource === 'string' && resource === 'meta' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(type)) { + hasMetaSupport = true; + break; + } + } + } + + if (!hasMetaSupport) continue; try { const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; + logger.log(`HTTP GET: ${url}`); const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); if (response.data && response.data.meta) { + logger.log(`✅ Metadata fetched successfully from: ${url}`); return response.data.meta; } } catch (error) { - logger.warn(`Failed to fetch meta from ${addon.name}:`, error); + logger.warn(`❌ Failed to fetch meta from ${addon.name} (${addon.id}):`, error); continue; // Try next addon } } diff --git a/src/types/metadata.ts b/src/types/metadata.ts index 84d3ebfe..aff9d856 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -81,6 +81,7 @@ export interface StreamingContent { name: string; description?: string; poster?: string; + posterShape?: string; banner?: string; logo?: string; year?: string | number; @@ -88,12 +89,30 @@ export interface StreamingContent { imdbRating?: string; genres?: string[]; director?: string; - writer?: string; + writer?: string[]; cast?: string[]; releaseInfo?: string; directors?: string[]; creators?: string[]; certification?: string; + released?: string; + trailerStreams?: any[]; + videos?: any[]; + inLibrary?: boolean; + // Enhanced metadata from addons + country?: string; + links?: Array<{ + name: string; + category: string; + url: string; + }>; + behaviorHints?: { + defaultVideoId?: string; + hasScheduledVideos?: boolean; + [key: string]: any; + }; + imdb_id?: string; + slug?: string; } // Navigation types diff --git a/src/types/navigation.d.ts b/src/types/navigation.d.ts index c01523fb..a69bd543 100644 --- a/src/types/navigation.d.ts +++ b/src/types/navigation.d.ts @@ -6,6 +6,7 @@ export type RootStackParamList = { Metadata: { id: string; type: string; + addonId?: string; }; Streams: { id: string;