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.
This commit is contained in:
tapframe 2025-06-20 13:00:24 +05:30
parent 805d7e1fa6
commit d88962ae01
12 changed files with 365 additions and 43 deletions

View file

@ -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 }) => {

View file

@ -116,8 +116,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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

View file

@ -10,7 +10,7 @@ interface MovieContentProps {
export const MovieContent: React.FC<MovieContentProps> = ({ 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 (
<View style={styles.container}>
@ -23,10 +23,10 @@ export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
</View>
)}
{metadata.writer && (
{metadata.writer && metadata.writer.length > 0 && (
<View style={styles.metadataRow}>
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Writer:</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.writer}</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{Array.isArray(metadata.writer) ? metadata.writer.join(', ') : metadata.writer}</Text>
</View>
)}

View file

@ -60,6 +60,7 @@ const withRetry = async <T>(
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<StreamingContent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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;

View file

@ -54,6 +54,7 @@ export type RootStackParamList = {
id: string;
type: string;
episodeId?: string;
addonId?: string;
};
Streams: {
id: string;

View file

@ -503,7 +503,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ 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}
>
<Image

View file

@ -497,12 +497,14 @@ const HomeScreen = () => {
// 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);

View file

@ -45,9 +45,9 @@ import { TraktService, TraktPlaybackItem } from '../services/traktService';
const { height } = Dimensions.get('window');
const MetadataScreen: React.FC = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
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);

View file

@ -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<StreamingContent | null> {
async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
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<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,
@ -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));

View file

@ -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<MetaDetails | null> {
async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> {
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
}
}

View file

@ -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

View file

@ -6,6 +6,7 @@ export type RootStackParamList = {
Metadata: {
id: string;
type: string;
addonId?: string;
};
Streams: {
id: string;