NuvioStreaming/src/hooks/useMetadata.ts
2025-10-08 17:37:46 +05:30

1648 lines
No EOL
68 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react';
import { StreamingContent } from '../services/catalogService';
import { catalogService } from '../services/catalogService';
import { stremioService } from '../services/stremioService';
import { tmdbService } from '../services/tmdbService';
import { cacheService } from '../services/cacheService';
import { localScraperService, ScraperInfo } from '../services/localScraperService';
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
import { TMDBService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import { usePersistentSeasons } from './usePersistentSeasons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Stream } from '../types/metadata';
import { storageService } from '../services/storageService';
import { useSettings } from './useSettings';
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
const MAX_RETRIES = 1; // Reduced since stremioService already retries
const RETRY_DELAY = 1000; // 1 second
// Utility function to add timeout to promises
const withTimeout = <T>(promise: Promise<T>, timeout: number, fallback?: T): Promise<T> => {
return Promise.race([
promise,
new Promise<T>((resolve, reject) =>
setTimeout(() => fallback ? resolve(fallback) : reject(new Error('Request timed out')), timeout)
)
]);
};
// Utility function for parallel loading with fallback
const loadWithFallback = async <T>(
loadFn: () => Promise<T>,
fallback: T,
timeout: number = API_TIMEOUT
): Promise<T> => {
try {
return await withTimeout(loadFn(), timeout, fallback);
} catch (error) {
logger.error('Loading failed, using fallback:', error);
return fallback;
}
};
// Utility function to retry failed requests
const withRetry = async <T>(
fn: () => Promise<T>,
retries = MAX_RETRIES,
delay = RETRY_DELAY
): Promise<T> => {
try {
return await fn();
} catch (error) {
if (retries === 0) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return withRetry(fn, retries - 1, delay);
}
};
interface UseMetadataProps {
id: string;
type: string;
addonId?: string;
}
interface ScraperStatus {
id: string;
name: string;
isLoading: boolean;
hasCompleted: boolean;
error: string | null;
startTime: number;
endTime: number | null;
}
interface UseMetadataReturn {
metadata: StreamingContent | null;
loading: boolean;
error: string | null;
cast: Cast[];
loadingCast: boolean;
episodes: Episode[];
groupedEpisodes: GroupedEpisodes;
selectedSeason: number;
tmdbId: number | null;
loadingSeasons: boolean;
groupedStreams: GroupedStreams;
loadingStreams: boolean;
episodeStreams: GroupedStreams;
loadingEpisodeStreams: boolean;
addonResponseOrder: string[];
preloadedStreams: GroupedStreams;
preloadedEpisodeStreams: { [episodeId: string]: GroupedStreams };
selectedEpisode: string | null;
inLibrary: boolean;
loadMetadata: () => Promise<void>;
loadStreams: () => Promise<void>;
loadEpisodeStreams: (episodeId: string) => Promise<void>;
handleSeasonChange: (seasonNumber: number) => void;
toggleLibrary: () => void;
setSelectedEpisode: (episodeId: string | null) => void;
setEpisodeStreams: (streams: GroupedStreams) => void;
recommendations: StreamingContent[];
loadingRecommendations: boolean;
setMetadata: React.Dispatch<React.SetStateAction<StreamingContent | null>>;
imdbId: string | null;
scraperStatuses: ScraperStatus[];
activeFetchingScrapers: string[];
clearScraperCache: () => Promise<void>;
invalidateScraperCache: (scraperId: string) => Promise<void>;
invalidateContentCache: (type: string, tmdbId: string, season?: number, episode?: number) => Promise<void>;
getScraperCacheStats: () => Promise<{
local: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
};
global: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
hitRate: number;
};
combined: {
totalEntries: number;
hitRate: number;
};
}>;
}
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
const { settings, isLoaded: settingsLoaded } = useSettings();
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cast, setCast] = useState<Cast[]>([]);
const [loadingCast, setLoadingCast] = useState(false);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [groupedEpisodes, setGroupedEpisodes] = useState<GroupedEpisodes>({});
const [selectedSeason, setSelectedSeason] = useState<number>(1);
const [tmdbId, setTmdbId] = useState<number | null>(null);
const [loadingSeasons, setLoadingSeasons] = useState(false);
const [groupedStreams, setGroupedStreams] = useState<GroupedStreams>({});
const [loadingStreams, setLoadingStreams] = useState(false);
const [episodeStreams, setEpisodeStreams] = useState<GroupedStreams>({});
const [loadingEpisodeStreams, setLoadingEpisodeStreams] = useState(false);
const [preloadedStreams, setPreloadedStreams] = useState<GroupedStreams>({});
const [preloadedEpisodeStreams, setPreloadedEpisodeStreams] = useState<{ [episodeId: string]: GroupedStreams }>({});
const [selectedEpisode, setSelectedEpisode] = useState<string | null>(null);
const [inLibrary, setInLibrary] = useState(false);
const [loadAttempts, setLoadAttempts] = useState(0);
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const [imdbId, setImdbId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
const [scraperStatuses, setScraperStatuses] = useState<ScraperStatus[]>([]);
const [activeFetchingScrapers, setActiveFetchingScrapers] = useState<string[]>([]);
// Track response order for addons to preserve actual response order
const [addonResponseOrder, setAddonResponseOrder] = useState<string[]>([]);
// Prevent re-initializing season selection repeatedly for the same series
const initializedSeasonRef = useRef(false);
// Memory optimization: Track stream counts and implement cleanup (limits removed)
const streamCountRef = useRef(0);
const cleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Add hook for persistent seasons
const { getSeason, saveSeason } = usePersistentSeasons();
// Memory optimization: Stream cleanup and garbage collection
const cleanupStreams = useCallback(() => {
if (__DEV__) console.log('[useMetadata] Running stream cleanup to free memory');
// Clear preloaded streams cache
setPreloadedStreams({});
setPreloadedEpisodeStreams({});
// Reset stream count
streamCountRef.current = 0;
// Force garbage collection if available (development only)
if (__DEV__ && global.gc) {
global.gc();
}
}, []);
// Memory optimization: Debounced stream state updates
const debouncedStreamUpdate = useCallback((updateFn: () => void) => {
// Clear existing timeout
if (cleanupTimeoutRef.current) {
clearTimeout(cleanupTimeoutRef.current);
}
// Set new timeout for cleanup
cleanupTimeoutRef.current = setTimeout(() => {
cleanupStreams();
}, 30000); // Cleanup after 30 seconds of inactivity
// Execute the update
updateFn();
}, [cleanupStreams]);
// Memory optimization: Lightly optimize stream data (no sorting or limiting)
const optimizeStreams = useCallback((streams: Stream[]): Stream[] => {
if (!streams || streams.length === 0) return streams;
return streams.map(stream => ({
...stream,
description: stream.description && stream.description.length > 200
? stream.description.substring(0, 200) + '...'
: stream.description,
behaviorHints: stream.behaviorHints ? {
cached: stream.behaviorHints.cached,
notWebReady: stream.behaviorHints.notWebReady,
bingeGroup: stream.behaviorHints.bingeGroup,
} : undefined,
}));
}, []);
const processStremioSource = async (type: string, id: string, isEpisode = false) => {
const sourceStartTime = Date.now();
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
const sourceName = 'stremio';
if (__DEV__) logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`);
try {
await stremioService.getStreams(type, id,
(streams, addonId, addonName, error) => {
const processTime = Date.now() - sourceStartTime;
// Update scraper status when we get a callback
if (addonId && addonName) {
setScraperStatuses(prevStatuses => {
const existingIndex = prevStatuses.findIndex(s => s.id === addonId);
const newStatus: ScraperStatus = {
id: addonId,
name: addonName,
isLoading: false,
hasCompleted: true,
error: error ? error.message : null,
startTime: sourceStartTime,
endTime: Date.now()
};
if (existingIndex >= 0) {
const updated = [...prevStatuses];
updated[existingIndex] = newStatus;
return updated;
} else {
return [...prevStatuses, newStatus];
}
});
// Remove from active fetching list
setActiveFetchingScrapers(prev => prev.filter(name => name !== addonName));
}
if (error) {
logger.error(`❌ [${logPrefix}:${sourceName}] Error for addon ${addonName} (${addonId}):`, error);
} else if (streams && addonId && addonName) {
if (__DEV__) logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams from ${addonName} (${addonId}) after ${processTime}ms`);
if (streams.length > 0) {
// Optimize streams before storing
const optimizedStreams = optimizeStreams(streams);
streamCountRef.current += optimizedStreams.length;
if (__DEV__) logger.log(`📊 [${logPrefix}:${sourceName}] Optimized ${streams.length}${optimizedStreams.length} streams, total: ${streamCountRef.current}`);
// Use debounced update to prevent rapid state changes
debouncedStreamUpdate(() => {
const updateState = (prevState: GroupedStreams): GroupedStreams => {
if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Updating state for addon ${addonName} (${addonId})`);
return {
...prevState,
[addonId]: {
addonName: addonName,
streams: optimizedStreams // Use optimized streams
}
};
};
// Track response order for addons
setAddonResponseOrder(prevOrder => {
if (!prevOrder.includes(addonId)) {
return [...prevOrder, addonId];
}
return prevOrder;
});
if (isEpisode) {
setEpisodeStreams(updateState);
setLoadingEpisodeStreams(false);
} else {
setGroupedStreams(updateState);
setLoadingStreams(false);
}
});
} else {
if (__DEV__) logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
}
} else {
// Handle case where callback provides null streams without error (e.g., empty results)
if (__DEV__) logger.log(`🏁 [${logPrefix}:${sourceName}] Finished fetching for addon ${addonName} (${addonId}) with no streams after ${processTime}ms`);
}
}
);
// The function now returns void, just await to let callbacks fire
if (__DEV__) logger.log(`🏁 [${logPrefix}:${sourceName}] Stremio fetching process initiated`);
} catch (error) {
// Catch errors from the initial call to getStreams (e.g., initialization errors)
logger.error(`❌ [${logPrefix}:${sourceName}] Initial call failed:`, error);
// Maybe update state to show a general Stremio error?
}
// Note: This function completes when getStreams returns, not when all callbacks have fired.
// Loading indicators should probably be managed based on callbacks completing.
};
const loadCast = async () => {
if (__DEV__) logger.log('[loadCast] Starting cast fetch for:', id);
setLoadingCast(true);
try {
if (!settings.enrichMetadataWithTMDB) {
if (__DEV__) logger.log('[loadCast] TMDB enrichment disabled by settings');
setLoadingCast(false);
return;
}
// Check cache first
const cachedCast = cacheService.getCast(id, type);
if (cachedCast) {
if (__DEV__) logger.log('[loadCast] Using cached cast data');
setCast(cachedCast);
setLoadingCast(false);
return;
}
// Handle TMDB IDs
if (id.startsWith('tmdb:')) {
const tmdbId = id.split(':')[1];
if (__DEV__) logger.log('[loadCast] Using TMDB ID directly:', tmdbId);
const castData = await tmdbService.getCredits(parseInt(tmdbId), type);
if (castData && castData.cast) {
const formattedCast = castData.cast.map((actor: any) => ({
id: actor.id,
name: actor.name,
character: actor.character,
profile_path: actor.profile_path
}));
if (__DEV__) logger.log(`[loadCast] Found ${formattedCast.length} cast members from TMDB`);
setCast(formattedCast);
cacheService.setCast(id, type, formattedCast);
setLoadingCast(false);
return;
}
}
// Handle IMDb IDs or convert to TMDB ID
let tmdbId;
if (id.startsWith('tt')) {
if (__DEV__) logger.log('[loadCast] Converting IMDb ID to TMDB ID');
tmdbId = await tmdbService.findTMDBIdByIMDB(id);
}
if (tmdbId) {
if (__DEV__) logger.log('[loadCast] Fetching cast using TMDB ID:', tmdbId);
const castData = await tmdbService.getCredits(tmdbId, type);
if (castData && castData.cast) {
const formattedCast = castData.cast.map((actor: any) => ({
id: actor.id,
name: actor.name,
character: actor.character,
profile_path: actor.profile_path
}));
if (__DEV__) logger.log(`[loadCast] Found ${formattedCast.length} cast members`);
setCast(formattedCast);
cacheService.setCast(id, type, formattedCast);
}
} else {
if (__DEV__) logger.warn('[loadCast] Could not find TMDB ID for cast fetch');
}
} catch (error) {
logger.error('[loadCast] Failed to load cast:', error);
// Don't clear existing cast data on error
} finally {
setLoadingCast(false);
}
};
const loadMetadata = async () => {
try {
if (loadAttempts >= MAX_RETRIES) {
setError(`Failed to load content after ${MAX_RETRIES + 1} attempts. Please check your connection and try again.`);
setLoading(false);
return;
}
setLoading(true);
setError(null);
setLoadAttempts(prev => prev + 1);
// Check metadata screen cache
const cachedScreen = cacheService.getMetadataScreen(id, type);
if (cachedScreen) {
setMetadata(cachedScreen.metadata);
setCast(cachedScreen.cast);
if (type === 'series' && cachedScreen.episodes) {
setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes);
setEpisodes(cachedScreen.episodes.currentEpisodes);
setSelectedSeason(cachedScreen.episodes.selectedSeason);
setTmdbId(cachedScreen.tmdbId);
}
// Check if item is in library
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
setLoading(false);
return;
}
// Handle TMDB-specific IDs
let actualId = id;
if (id.startsWith('tmdb:')) {
// If enrichment disabled, resolve to an addon-friendly ID (IMDb) before calling addons
if (!settings.enrichMetadataWithTMDB) {
const tmdbRaw = id.split(':')[1];
try {
if (__DEV__) logger.log('[loadMetadata] enrichment=OFF; resolving TMDB→Stremio ID', { type, tmdbId: tmdbRaw });
const stremioId = await catalogService.getStremioId(type === 'series' ? 'tv' : 'movie', tmdbRaw);
if (stremioId) {
actualId = stremioId;
if (__DEV__) logger.log('[loadMetadata] resolved TMDB→Stremio ID', { actualId });
} else {
if (__DEV__) logger.warn('[loadMetadata] failed to resolve TMDB→Stremio ID; addon fetch may fail', { type, tmdbId: tmdbRaw });
}
} catch (e) {
if (__DEV__) logger.error('[loadMetadata] error resolving TMDB→Stremio ID', e);
}
} else {
const tmdbId = id.split(':')[1];
// For TMDB IDs, we need to handle metadata differently
if (type === 'movie') {
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
const movieDetails = await tmdbService.getMovieDetails(
tmdbId,
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
);
if (movieDetails) {
const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id;
if (imdbId) {
// Use the imdbId for compatibility with the rest of the app
actualId = imdbId;
setImdbId(imdbId);
// Also store the TMDB ID for later use
setTmdbId(parseInt(tmdbId));
} else {
// If no IMDb ID, directly call loadTMDBMovie (create this function if needed)
const formattedMovie: StreamingContent = {
id: `tmdb:${tmdbId}`,
type: 'movie',
name: movieDetails.title,
poster: tmdbService.getImageUrl(movieDetails.poster_path) || '',
banner: tmdbService.getImageUrl(movieDetails.backdrop_path) || '',
description: movieDetails.overview || '',
year: movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4)) : undefined,
genres: movieDetails.genres?.map((g: { name: string }) => g.name) || [],
inLibrary: false,
};
// Fetch credits to get director and crew information
try {
const credits = await tmdbService.getCredits(parseInt(tmdbId), 'movie');
if (credits && credits.crew) {
// Extract directors
const directors = credits.crew
.filter((person: any) => person.job === 'Director')
.map((person: any) => person.name);
// Extract creators/writers
const writers = credits.crew
.filter((person: any) => ['Writer', 'Screenplay'].includes(person.job))
.map((person: any) => person.name);
// Add to formatted movie
if (directors.length > 0) {
(formattedMovie as any).directors = directors;
(formattedMovie as StreamingContent & { director: string }).director = directors.join(', ');
}
if (writers.length > 0) {
(formattedMovie as any).creators = writers;
(formattedMovie as any).writer = writers;
}
}
} catch (error) {
logger.error('Failed to fetch credits for movie:', error);
}
setMetadata(formattedMovie);
cacheService.setMetadata(id, type, formattedMovie);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
setLoading(false);
return;
}
}
} else if (type === 'series') {
// Handle TV shows with TMDB IDs
if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId);
try {
const showDetails = await tmdbService.getTVShowDetails(
parseInt(tmdbId),
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
);
if (showDetails) {
// Get external IDs to check for IMDb ID
const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId));
const imdbId = externalIds?.imdb_id;
if (imdbId) {
// Use the imdbId for compatibility with the rest of the app
actualId = imdbId;
setImdbId(imdbId);
// Also store the TMDB ID for later use
setTmdbId(parseInt(tmdbId));
} else {
// If no IMDb ID, create formatted show from TMDB data
const formattedShow: StreamingContent = {
id: `tmdb:${tmdbId}`,
type: 'series',
name: showDetails.name,
poster: tmdbService.getImageUrl(showDetails.poster_path) || '',
banner: tmdbService.getImageUrl(showDetails.backdrop_path) || '',
description: showDetails.overview || '',
year: showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4)) : undefined,
genres: showDetails.genres?.map((g: { name: string }) => g.name) || [],
inLibrary: false,
};
// Fetch credits to get creators
try {
const credits = await tmdbService.getCredits(parseInt(tmdbId), 'series');
if (credits && credits.crew) {
// Extract creators
const creators = credits.crew
.filter((person: any) =>
person.job === 'Creator' ||
person.job === 'Series Creator' ||
person.department === 'Production' ||
person.job === 'Executive Producer'
)
.map((person: any) => person.name);
if (creators.length > 0) {
(formattedShow as any).creators = creators.slice(0, 3);
}
}
} catch (error) {
logger.error('Failed to fetch credits for TV show:', error);
}
// Fetch TV show logo from TMDB
try {
const logoUrl = await tmdbService.getTvShowImages(tmdbId);
if (logoUrl) {
formattedShow.logo = logoUrl;
if (__DEV__) logger.log(`Successfully fetched logo for TV show ${tmdbId} from TMDB`);
}
} catch (error) {
logger.error('Failed to fetch logo from TMDB:', error);
// Continue with execution, logo is optional
}
setMetadata(formattedShow);
cacheService.setMetadata(id, type, formattedShow);
// Load series data (episodes)
setTmdbId(parseInt(tmdbId));
loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
setLoading(false);
return;
}
}
} catch (error) {
logger.error('Failed to fetch TV show details from TMDB:', error);
}
}
}
}
// Load all data in parallel
if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId });
const [content, castData] = await Promise.allSettled([
// Load content with timeout and retry
withRetry(async () => {
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);
}
if (__DEV__) logger.log('[loadMetadata] addon metadata fetched', { hasResult: Boolean(result) });
return result;
}),
// Start loading cast immediately in parallel
loadCast()
]);
if (content.status === 'fulfilled' && content.value) {
if (__DEV__) logger.log('[loadMetadata] addon metadata:success', { id: content.value?.id, type: content.value?.type, name: content.value?.name });
// Start with addon metadata
let finalMetadata = content.value as StreamingContent;
// If localization is enabled, merge TMDB localized text (name/overview) before first render
try {
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
const tmdbSvc = TMDBService.getInstance();
// Ensure we have a TMDB ID
let finalTmdbId: number | null = tmdbId;
if (!finalTmdbId) {
finalTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(actualId);
if (finalTmdbId) setTmdbId(finalTmdbId);
}
if (finalTmdbId) {
const lang = settings.tmdbLanguagePreference || 'en';
if (type === 'movie') {
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
if (localized) {
finalMetadata = {
...finalMetadata,
name: localized.title || finalMetadata.name,
description: localized.overview || finalMetadata.description,
};
}
} else {
const localized = await tmdbSvc.getTVShowDetails(Number(finalTmdbId), lang);
if (localized) {
finalMetadata = {
...finalMetadata,
name: localized.name || finalMetadata.name,
description: localized.overview || finalMetadata.description,
};
}
}
}
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized TMDB text', e);
}
// Commit final metadata once and cache it
setMetadata(finalMetadata);
cacheService.setMetadata(id, type, finalMetadata);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
} else {
// Extract the error from the rejected promise
const reason = (content as any)?.reason;
const reasonMessage = reason?.message || String(reason);
if (__DEV__) {
console.log('[loadMetadata] addon metadata:not found or failed', {
status: content.status,
reason: reasonMessage,
fullReason: reason
});
}
// Check if this was a network/server error rather than content not found
if (reasonMessage && (
reasonMessage.includes('500') ||
reasonMessage.includes('502') ||
reasonMessage.includes('503') ||
reasonMessage.includes('Network Error') ||
reasonMessage.includes('Request failed')
)) {
// This was a server/network error, preserve the original error message
throw reason instanceof Error ? reason : new Error(reasonMessage);
} else {
// This was likely a content not found error
throw new Error('Content not found');
}
}
} catch (error) {
if (__DEV__) {
console.error('Failed to load metadata:', error);
console.log('Error message being set:', error instanceof Error ? error.message : String(error));
}
// Preserve the original error details for better error parsing
const errorMessage = error instanceof Error ? error.message : 'Failed to load content';
setError(errorMessage);
// Clear any stale data
setMetadata(null);
setCast([]);
setGroupedEpisodes({});
setEpisodes([]);
} finally {
setLoading(false);
}
};
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) {
if (__DEV__) 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;
if (!seasonNumber || seasonNumber < 1) {
return; // Skip season 0, which often contains extras
}
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,
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);
});
if (__DEV__) logger.log(`📺 Processed addon episodes into ${Object.keys(groupedAddonEpisodes).length} seasons`);
// Fetch season posters from TMDB only if enrichment is enabled; otherwise skip quietly
if (settings.enrichMetadataWithTMDB) {
try {
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
if (!tmdbId) setTmdbId(tmdbIdToUse);
const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse);
if (showDetails?.seasons) {
Object.keys(groupedAddonEpisodes).forEach(seasonStr => {
const seasonNum = parseInt(seasonStr, 10);
const seasonInfo = showDetails.seasons.find(s => s.season_number === seasonNum);
const seasonPosterPath = seasonInfo?.poster_path;
if (seasonPosterPath) {
groupedAddonEpisodes[seasonNum] = groupedAddonEpisodes[seasonNum].map(ep => ({
...ep,
season_poster_path: seasonPosterPath,
}));
}
});
if (__DEV__) logger.log('🖼️ Successfully fetched and attached TMDB season posters to addon episodes.');
}
}
} catch (error) {
logger.error('Failed to fetch TMDB season posters for addon episodes:', error);
}
} else {
if (__DEV__) logger.log('[loadSeriesData] TMDB enrichment disabled; skipping season poster fetch');
}
// If localized TMDB text is enabled, merge episode names/overviews per language
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
try {
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
const lang = `${settings.tmdbLanguagePreference || 'en'}-US`;
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
for (const seasonNum of seasons) {
const seasonEps = groupedAddonEpisodes[seasonNum];
// Parallel fetch a reasonable batch (limit concurrency implicitly by season)
const localized = await Promise.all(
seasonEps.map(async ep => {
try {
const data = await tmdbService.getEpisodeDetails(Number(tmdbIdToUse), seasonNum, ep.episode_number, lang);
if (data) {
return {
...ep,
name: data.name || ep.name,
overview: data.overview || ep.overview,
};
}
} catch {}
return ep;
})
);
groupedAddonEpisodes[seasonNum] = localized;
}
if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB');
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized episode text', e);
}
}
setGroupedEpisodes(groupedAddonEpisodes);
// Determine initial season only once per series
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
const firstSeason = Math.min(...seasons);
if (!initializedSeasonRef.current) {
const nextSeason = firstSeason;
if (selectedSeason !== nextSeason) {
logger.log(`📺 Setting season ${nextSeason} as selected (${groupedAddonEpisodes[nextSeason]?.length || 0} episodes)`);
setSelectedSeason(nextSeason);
}
setEpisodes(groupedAddonEpisodes[nextSeason] || []);
initializedSeasonRef.current = true;
} else {
// Keep current selection; refresh episode list for selected season
setEpisodes(groupedAddonEpisodes[selectedSeason] || []);
}
// Try to get TMDB ID for additional metadata (cast, etc.) but don't override episodes
if (!settings.enrichMetadataWithTMDB) {
if (__DEV__) logger.log('[loadSeriesData] TMDB enrichment disabled; skipping TMDB episode fallback (preserving current episodes)');
return;
}
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);
const [allEpisodes, showDetails] = await Promise.all([
tmdbService.getAllEpisodes(tmdbIdResult),
tmdbService.getTVShowDetails(tmdbIdResult)
]);
const transformedEpisodes: GroupedEpisodes = {};
Object.entries(allEpisodes).forEach(([seasonStr, episodes]) => {
const seasonNum = parseInt(seasonStr, 10);
if (seasonNum < 1) {
return; // Skip season 0, which often contains extras
}
const seasonInfo = showDetails?.seasons?.find(s => s.season_number === seasonNum);
const seasonPosterPath = seasonInfo?.poster_path;
transformedEpisodes[seasonNum] = episodes.map(episode => ({
...episode,
episodeString: `S${episode.season_number.toString().padStart(2, '0')}E${episode.episode_number.toString().padStart(2, '0')}`,
season_poster_path: seasonPosterPath || null
}));
});
setGroupedEpisodes(transformedEpisodes);
// Get the first available season as fallback
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
if (!initializedSeasonRef.current) {
// Check for watch progress to auto-select season
let selectedSeasonNumber = firstSeason;
try {
const allProgress = await storageService.getAllWatchProgress();
let mostRecentEpisodeId = '';
let mostRecentTimestamp = 0;
Object.entries(allProgress).forEach(([key, progress]) => {
if (key.includes(`series:${id}:`)) {
const episodeId = key.split(`series:${id}:`)[1];
if (progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
mostRecentTimestamp = progress.lastUpdated;
mostRecentEpisodeId = episodeId;
}
}
});
if (mostRecentEpisodeId) {
const parts = mostRecentEpisodeId.split(':');
if (parts.length === 3) {
const watchProgressSeason = parseInt(parts[1], 10);
if (transformedEpisodes[watchProgressSeason]) {
selectedSeasonNumber = watchProgressSeason;
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for ${mostRecentEpisodeId}`);
}
} else {
const allEpisodesList = Object.values(transformedEpisodes).flat();
const episode = allEpisodesList.find(ep => ep.stremioId === mostRecentEpisodeId);
if (episode) {
selectedSeasonNumber = episode.season_number;
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for episode with stremioId ${mostRecentEpisodeId}`);
}
}
} else {
selectedSeasonNumber = getSeason(id, firstSeason);
logger.log(`[useMetadata] No watch progress found, using persistent season ${selectedSeasonNumber}`);
}
} catch (error) {
logger.error('[useMetadata] Error checking watch progress for season selection:', error);
selectedSeasonNumber = getSeason(id, firstSeason);
}
if (selectedSeason !== selectedSeasonNumber) {
setSelectedSeason(selectedSeasonNumber);
}
setEpisodes(transformedEpisodes[selectedSeasonNumber] || []);
initializedSeasonRef.current = true;
} else {
// Keep existing selection stable and only refresh episode list for it
setEpisodes(transformedEpisodes[selectedSeason] || []);
}
}
} catch (error) {
if (__DEV__) console.error('Failed to load episodes:', error);
} finally {
setLoadingSeasons(false);
}
};
// Function to indicate that streams are loading without blocking UI
const updateLoadingState = () => {
// We set this to true initially, but we'll show results as they come in
setLoadingStreams(true);
// Also clear previous streams
setGroupedStreams({});
setError(null);
};
// Function to indicate that episode streams are loading without blocking UI
const updateEpisodeLoadingState = () => {
// We set this to true initially, but we'll show results as they come in
setLoadingEpisodeStreams(true);
// Also clear previous streams
setEpisodeStreams({});
setError(null);
};
const loadStreams = async () => {
const startTime = Date.now();
try {
if (__DEV__) console.log('🚀 [loadStreams] START - Loading streams for:', id);
updateLoadingState();
// Reset scraper tracking
setScraperStatuses([]);
setActiveFetchingScrapers([]);
setAddonResponseOrder([]); // Reset response order
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
if (__DEV__) console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
let tmdbId;
let stremioId = id; // Default to original ID
if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
if (__DEV__) console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId);
// Try to get IMDb ID from metadata first, then convert if needed
if (metadata?.imdb_id) {
stremioId = metadata.imdb_id;
if (__DEV__) console.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId);
} else if (imdbId) {
stremioId = imdbId;
if (__DEV__) console.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId);
} else {
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
try {
let externalIds = null;
if (type === 'movie') {
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
externalIds = movieDetails?.external_ids;
} else if (type === 'series') {
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
}
if (externalIds?.imdb_id) {
stremioId = externalIds.imdb_id;
if (__DEV__) console.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId);
} else {
if (__DEV__) console.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId);
}
} catch (error) {
if (__DEV__) console.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error);
}
}
} else if (id.startsWith('tt')) {
// This is already an IMDB ID, perfect for Stremio
stremioId = id;
if (__DEV__) console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
if (__DEV__) console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
} else {
tmdbId = id;
stremioId = id;
if (__DEV__) console.log(' [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
}
// Initialize scraper tracking
try {
const allStremioAddons = await stremioService.getInstalledAddons();
const localScrapers = await localScraperService.getInstalledScrapers();
// Filter Stremio addons to only include those that provide streams for this content type
const streamAddons = allStremioAddons.filter(addon => {
if (!addon.resources || !Array.isArray(addon.resources)) {
return false;
}
let hasStreamResource = false;
let supportsIdPrefix = false;
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 any;
if (typedResource.name === 'stream' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasStreamResource = true;
// Check if this addon supports the ID prefix generically: any prefix must match start of id
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
supportsIdPrefix = typedResource.idPrefixes.some((p: string) => id.startsWith(p));
} else {
// If no idPrefixes specified, assume it supports all prefixes
supportsIdPrefix = true;
}
break;
}
}
// Check if the element is the simple string "stream" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
if (Array.isArray(addon.types) && addon.types.includes(type)) {
hasStreamResource = true;
// For simple string resources, check addon-level idPrefixes generically
if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) {
supportsIdPrefix = addon.idPrefixes.some((p: string) => id.startsWith(p));
} else {
// If no idPrefixes specified, assume it supports all prefixes
supportsIdPrefix = true;
}
break;
}
}
}
return hasStreamResource && supportsIdPrefix;
});
if (__DEV__) console.log('[useMetadata.loadStreams] Eligible stream addons:', streamAddons.map(a => a.id));
// Initialize scraper statuses for tracking
const initialStatuses: ScraperStatus[] = [];
const initialActiveFetching: string[] = [];
// Add stream-capable Stremio addons only
streamAddons.forEach(addon => {
initialStatuses.push({
id: addon.id,
name: addon.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
});
initialActiveFetching.push(addon.name);
});
// Add local scrapers if enabled
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
initialStatuses.push({
id: scraper.id,
name: scraper.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
});
initialActiveFetching.push(scraper.name);
});
setScraperStatuses(initialStatuses);
setActiveFetchingScrapers(initialActiveFetching);
} catch (error) {
if (__DEV__) console.error('Failed to initialize scraper tracking:', error);
}
// Start Stremio request using the converted ID format
if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
processStremioSource(type, stremioId, false);
// Monitor scraper completion status instead of using fixed timeout
const checkScrapersCompletion = () => {
setScraperStatuses(currentStatuses => {
const allCompleted = currentStatuses.every(status => status.hasCompleted || status.error !== null);
if (allCompleted && currentStatuses.length > 0) {
setLoadingStreams(false);
setActiveFetchingScrapers([]);
}
return currentStatuses;
});
};
// Check completion less frequently to reduce CPU load
const completionInterval = setInterval(checkScrapersCompletion, 2000);
// Fallback timeout after 1 minute
const fallbackTimeout = setTimeout(() => {
clearInterval(completionInterval);
setLoadingStreams(false);
setActiveFetchingScrapers([]);
}, 60000);
} catch (error) {
if (__DEV__) console.error('❌ [loadStreams] Failed to load streams:', error);
// Preserve the original error details for better error parsing
const errorMessage = error instanceof Error ? error.message : 'Failed to load streams';
setError(errorMessage);
setLoadingStreams(false);
}
};
const loadEpisodeStreams = async (episodeId: string) => {
const startTime = Date.now();
try {
if (__DEV__) console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
updateEpisodeLoadingState();
// Reset scraper tracking for episodes
setScraperStatuses([]);
setActiveFetchingScrapers([]);
setAddonResponseOrder([]); // Reset response order
// Initialize scraper tracking for episodes
try {
const allStremioAddons = await stremioService.getInstalledAddons();
const localScrapers = await localScraperService.getInstalledScrapers();
// Filter Stremio addons to only include those that provide streams for series content
const streamAddons = allStremioAddons.filter(addon => {
if (!addon.resources || !Array.isArray(addon.resources)) {
return false;
}
let hasStreamResource = false;
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 any;
if (typedResource.name === 'stream' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes('series')) {
hasStreamResource = true;
break;
}
}
// Check if the element is the simple string "stream" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
if (Array.isArray(addon.types) && addon.types.includes('series')) {
hasStreamResource = true;
break;
}
}
}
return hasStreamResource;
});
// Initialize scraper statuses for tracking
const initialStatuses: ScraperStatus[] = [];
const initialActiveFetching: string[] = [];
// Add stream-capable Stremio addons only
streamAddons.forEach(addon => {
initialStatuses.push({
id: addon.id,
name: addon.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
});
initialActiveFetching.push(addon.name);
});
// Add local scrapers if enabled
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
initialStatuses.push({
id: scraper.id,
name: scraper.name,
isLoading: true,
hasCompleted: false,
error: null,
startTime: Date.now(),
endTime: null
});
initialActiveFetching.push(scraper.name);
});
setScraperStatuses(initialStatuses);
setActiveFetchingScrapers(initialActiveFetching);
} catch (error) {
if (__DEV__) console.error('Failed to initialize episode scraper tracking:', error);
}
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
if (__DEV__) console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
let tmdbId;
let stremioEpisodeId = episodeId; // Default to original episode ID
let isCollection = false;
// Dynamically detect if this is a collection by checking addon capabilities
const { isCollection: detectedCollection, addon: collectionAddon } = stremioService.isCollectionContent(id);
isCollection = detectedCollection;
if (isCollection && collectionAddon) {
if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Detected collection from addon: ${collectionAddon.name}, treating episodes as individual movies`);
// For collections, extract the individual movie ID from the episodeId
// episodeId format for collections: "tt7888964" (IMDb ID of individual movie)
if (episodeId.startsWith('tt')) {
// This is an IMDb ID of an individual movie in the collection
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(episodeId), API_TIMEOUT);
stremioEpisodeId = episodeId; // Use the IMDb ID directly for Stremio addons
if (__DEV__) console.log('✅ [loadEpisodeStreams] Collection movie - using IMDb ID:', episodeId, 'TMDB ID:', tmdbId);
} else {
// Fallback: try to parse as TMDB ID
tmdbId = episodeId;
stremioEpisodeId = episodeId;
if (__DEV__) console.log('⚠️ [loadEpisodeStreams] Collection movie - using episodeId as-is:', episodeId);
}
} else if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
if (__DEV__) console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
// Try to get IMDb ID from metadata first, then convert if needed
if (metadata?.imdb_id) {
// Replace the series ID in episodeId with the IMDb ID
const [, season, episode] = episodeId.split(':');
stremioEpisodeId = `${metadata.imdb_id}:${season}:${episode}`;
if (__DEV__) console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId);
} else if (imdbId) {
const [, season, episode] = episodeId.split(':');
stremioEpisodeId = `${imdbId}:${season}:${episode}`;
if (__DEV__) console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId);
} else {
// Convert TMDB ID to IMDb ID for Stremio addons
try {
const externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
if (externalIds?.imdb_id) {
const [, season, episode] = episodeId.split(':');
stremioEpisodeId = `${externalIds.imdb_id}:${season}:${episode}`;
if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId);
} else {
if (__DEV__) console.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using original episode ID:', stremioEpisodeId);
}
} catch (error) {
if (__DEV__) console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using original episode ID:', error);
}
}
} else if (id.startsWith('tt')) {
// This is already an IMDB ID, perfect for Stremio
if (__DEV__) console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
// Normalize episode id to 'tt:season:episode' format for addons that expect tt prefix
const parts = episodeId.split(':');
if (parts.length === 3 && parts[0] === 'series') {
stremioEpisodeId = `${id}:${parts[1]}:${parts[2]}`;
if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId);
}
} else {
tmdbId = id;
if (__DEV__) console.log(' [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
}
// Extract episode info from the episodeId for logging
const [, season, episode] = episodeId.split(':');
const episodeQuery = `?s=${season}&e=${episode}`;
if (__DEV__) console.log(` [loadEpisodeStreams] Episode query: ${episodeQuery}`);
if (__DEV__) console.log('🔄 [loadEpisodeStreams] Starting stream requests');
// Start Stremio request using the converted episode ID format
if (__DEV__) console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
// For collections, treat episodes as individual movies, not series
const contentType = isCollection ? 'movie' : 'series';
if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : 'series'}`);
processStremioSource(contentType, stremioEpisodeId, true);
// Monitor scraper completion status instead of using fixed timeout
const checkEpisodeScrapersCompletion = () => {
setScraperStatuses(currentStatuses => {
const allCompleted = currentStatuses.every(status => status.hasCompleted || status.error !== null);
if (allCompleted && currentStatuses.length > 0) {
setLoadingEpisodeStreams(false);
setActiveFetchingScrapers([]);
}
return currentStatuses;
});
};
// Check completion less frequently to reduce CPU load
const episodeCompletionInterval = setInterval(checkEpisodeScrapersCompletion, 3000);
// Fallback timeout after 1 minute
const episodeFallbackTimeout = setTimeout(() => {
clearInterval(episodeCompletionInterval);
setLoadingEpisodeStreams(false);
setActiveFetchingScrapers([]);
}, 60000);
} catch (error) {
if (__DEV__) console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
// Preserve the original error details for better error parsing
const errorMessage = error instanceof Error ? error.message : 'Failed to load episode streams';
setError(errorMessage);
setLoadingEpisodeStreams(false);
}
};
const handleSeasonChange = useCallback((seasonNumber: number) => {
if (selectedSeason === seasonNumber) return;
// Update local state
setSelectedSeason(seasonNumber);
setEpisodes(groupedEpisodes[seasonNumber] || []);
// Persist the selection
saveSeason(id, seasonNumber);
}, [selectedSeason, groupedEpisodes, saveSeason, id]);
const toggleLibrary = useCallback(() => {
if (!metadata) return;
if (inLibrary) {
catalogService.removeFromLibrary(type, id);
} else {
catalogService.addToLibrary(metadata);
}
setInLibrary(!inLibrary);
}, [metadata, inLibrary, type, id]);
// Reset load attempts when id or type changes
useEffect(() => {
setLoadAttempts(0);
initializedSeasonRef.current = false;
// Memory optimization: Clean up streams when content changes
cleanupStreams();
// Clear any pending cleanup timeouts
if (cleanupTimeoutRef.current) {
clearTimeout(cleanupTimeoutRef.current);
cleanupTimeoutRef.current = null;
}
}, [id, type, cleanupStreams]);
// Auto-retry on error with delay
useEffect(() => {
if (error && loadAttempts < MAX_RETRIES) {
const timer = setTimeout(() => {
loadMetadata();
}, RETRY_DELAY * (loadAttempts + 1));
return () => clearTimeout(timer);
}
}, [error, loadAttempts]);
useEffect(() => {
if (!settingsLoaded) return;
// Check for cached streams immediately on mount
const checkAndLoadCachedStreams = async () => {
try {
// This will be handled by the StreamsScreen component
// The useMetadata hook focuses on metadata and episodes
} catch (error) {
if (__DEV__) console.log('[useMetadata] Error checking cached streams on mount:', error);
}
};
loadMetadata();
}, [id, type, settingsLoaded]);
// Re-fetch when localization settings change to guarantee selected language at open
useEffect(() => {
if (!settingsLoaded) return;
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
loadMetadata();
}
}, [settingsLoaded, settings.enrichMetadataWithTMDB, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]);
// Re-run series data loading when metadata updates with videos
useEffect(() => {
if (metadata && metadata.videos && metadata.videos.length > 0) {
logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
}
}, [metadata?.videos, type]);
const loadRecommendations = useCallback(async () => {
if (!settings.enrichMetadataWithTMDB) {
if (__DEV__) console.log('[useMetadata] enrichment disabled; skip recommendations');
return;
}
if (!tmdbId) return;
setLoadingRecommendations(true);
try {
const tmdbService = TMDBService.getInstance();
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId));
// Convert TMDB results to StreamingContent format (simplified)
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
id: `tmdb:${item.id}`,
type: type === 'movie' ? 'movie' : 'series',
name: item.title || item.name || 'Untitled',
poster: tmdbService.getImageUrl(item.poster_path) || 'https://via.placeholder.com/300x450', // Provide fallback
year: (item.release_date || item.first_air_date)?.substring(0, 4) || 'N/A', // Ensure string and provide fallback
}));
setRecommendations(formattedRecommendations);
} catch (error) {
if (__DEV__) console.error('Failed to load recommendations:', error);
setRecommendations([]);
} finally {
setLoadingRecommendations(false);
}
}, [tmdbId, type]);
// Fetch TMDB ID if needed and then recommendations
useEffect(() => {
const fetchTmdbIdAndRecommendations = async () => {
if (!settings.enrichMetadataWithTMDB) {
if (__DEV__) console.log('[useMetadata] enrichment disabled; skip TMDB id extraction and certification (extract path)');
return;
}
if (metadata && !tmdbId) {
try {
const tmdbService = TMDBService.getInstance();
const fetchedTmdbId = await tmdbService.extractTMDBIdFromStremioId(id);
if (fetchedTmdbId) {
if (__DEV__) console.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId });
setTmdbId(fetchedTmdbId);
// Fetch certification
const certification = await tmdbService.getCertification(type, fetchedTmdbId);
if (certification) {
if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
setMetadata(prev => prev ? {
...prev,
certification
} : null);
} else {
if (__DEV__) console.warn('[useMetadata] certification not returned from TMDB (extract path)', { type, fetchedTmdbId });
}
} else {
if (__DEV__) console.warn('[useMetadata] Could not determine TMDB ID for recommendations / certification', { id });
}
} catch (error) {
if (__DEV__) console.error('[useMetadata] Error fetching TMDB ID (extract path):', error);
}
}
};
fetchTmdbIdAndRecommendations();
}, [metadata, id, settings.enrichMetadataWithTMDB]);
useEffect(() => {
if (tmdbId) {
if (settings.enrichMetadataWithTMDB) {
if (__DEV__) console.log('[useMetadata] tmdbId available; loading recommendations and enabling certification checks', { tmdbId });
loadRecommendations();
}
// Reset recommendations when tmdbId changes
return () => {
setRecommendations([]);
setLoadingRecommendations(true);
};
}
}, [tmdbId, loadRecommendations, settings.enrichMetadataWithTMDB]);
// Ensure certification is attached whenever a TMDB id is known and metadata lacks it
useEffect(() => {
const maybeAttachCertification = async () => {
if (!settings.enrichMetadataWithTMDB) {
if (__DEV__) console.log('[useMetadata] enrichment disabled; skip certification (attach path)');
return;
}
try {
if (!metadata) {
if (__DEV__) console.warn('[useMetadata] skip certification attach: metadata not ready');
return;
}
if (!tmdbId) {
if (__DEV__) console.warn('[useMetadata] skip certification attach: tmdbId not available yet');
return;
}
if ((metadata as any).certification) {
if (__DEV__) console.log('[useMetadata] certification already present on metadata; skipping fetch');
return;
}
const tmdbSvc = TMDBService.getInstance();
const cert = await tmdbSvc.getCertification(type, tmdbId);
if (cert) {
if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert });
setMetadata(prev => prev ? { ...prev, certification: cert } : prev);
} else {
if (__DEV__) console.warn('[useMetadata] TMDB returned no certification (attach path)', { type, tmdbId });
}
} catch (err) {
if (__DEV__) console.error('[useMetadata] error attaching certification', err);
}
};
maybeAttachCertification();
}, [tmdbId, metadata, type, settings.enrichMetadataWithTMDB]);
// Reset tmdbId when id changes
useEffect(() => {
setTmdbId(null);
}, [id]);
// Subscribe to library updates
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
const isInLib = libraryItems.some(item => item.id === id);
setInLibrary(isInLib);
});
return () => unsubscribe();
}, [id]);
// Memory optimization: Cleanup on unmount
useEffect(() => {
return () => {
// Clear cleanup timeout
if (cleanupTimeoutRef.current) {
clearTimeout(cleanupTimeoutRef.current);
}
// Force cleanup
cleanupStreams();
if (__DEV__) console.log('[useMetadata] Component unmounted, memory cleaned up');
};
}, [cleanupStreams]);
// Cache management methods
const clearScraperCache = useCallback(async () => {
await localScraperService.clearScraperCache();
}, []);
const invalidateScraperCache = useCallback(async (scraperId: string) => {
await localScraperService.invalidateScraperCache(scraperId);
}, []);
const invalidateContentCache = useCallback(async (type: string, tmdbId: string, season?: number, episode?: number) => {
await localScraperService.invalidateContentCache(type, tmdbId, season, episode);
}, []);
const getScraperCacheStats = useCallback(async () => {
const localStats = await localScraperService.getCacheStats();
return {
local: localStats.local,
global: {
totalEntries: 0,
totalSize: 0,
oldestEntry: null,
newestEntry: null,
hitRate: 0
},
combined: {
totalEntries: localStats.local.totalEntries,
hitRate: 0
}
};
}, []);
return {
metadata,
loading,
error,
cast,
loadingCast,
episodes,
groupedEpisodes,
selectedSeason,
tmdbId,
loadingSeasons,
groupedStreams,
loadingStreams,
episodeStreams,
loadingEpisodeStreams,
addonResponseOrder,
preloadedStreams,
preloadedEpisodeStreams,
selectedEpisode,
inLibrary,
loadMetadata,
loadStreams,
loadEpisodeStreams,
handleSeasonChange,
toggleLibrary,
setSelectedEpisode,
setEpisodeStreams,
recommendations,
loadingRecommendations,
setMetadata,
imdbId,
scraperStatuses,
activeFetchingScrapers,
clearScraperCache,
invalidateScraperCache,
invalidateContentCache,
getScraperCacheStats,
};
};