mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
2296 lines
No EOL
96 KiB
TypeScript
2296 lines
No EOL
96 KiB
TypeScript
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/pluginService';
|
||
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
||
import { TMDBService } from '../services/tmdbService';
|
||
import { logger } from '../utils/logger';
|
||
import { usePersistentSeasons } from './usePersistentSeasons';
|
||
import { mmkvStorage } from '../services/mmkvStorage';
|
||
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[];
|
||
collectionMovies: StreamingContent[];
|
||
loadingCollection: boolean;
|
||
}
|
||
|
||
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 [collectionMovies, setCollectionMovies] = useState<StreamingContent[]>([]);
|
||
const [loadingCollection, setLoadingCollection] = 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;
|
||
|
||
console.log('🔍 [processStremioSource] Callback received:', {
|
||
addonId,
|
||
addonName,
|
||
streamCount: streams?.length || 0,
|
||
error: error?.message || null,
|
||
processTime
|
||
});
|
||
|
||
// ALWAYS remove from active fetching list when callback is received
|
||
// This ensures that even failed scrapers are removed from the "Fetching from:" chip
|
||
if (addonName) {
|
||
setActiveFetchingScrapers(prev => {
|
||
const updated = prev.filter(name => name !== addonName);
|
||
console.log('🔍 [processStremioSource] Removing from activeFetchingScrapers:', {
|
||
addonName,
|
||
before: prev,
|
||
after: updated
|
||
});
|
||
return updated;
|
||
});
|
||
}
|
||
|
||
// 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];
|
||
}
|
||
});
|
||
}
|
||
|
||
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 {
|
||
// Even providers with no streams should be added to the streams object
|
||
// This ensures streamsEmpty becomes false and UI shows available streams progressively
|
||
if (__DEV__) logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
|
||
|
||
debouncedStreamUpdate(() => {
|
||
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
||
if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Adding empty provider ${addonName} (${addonId}) to state`);
|
||
return {
|
||
...prevState,
|
||
[addonId]: {
|
||
addonName: addonName,
|
||
streams: [] // Empty array for providers with no 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 {
|
||
// 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);
|
||
|
||
// Remove all addons and scrapers from active fetching since the entire request failed
|
||
setActiveFetchingScrapers(prev => {
|
||
// Get both Stremio addon names and local scraper names
|
||
const stremioAddons = stremioService.getInstalledAddons();
|
||
const stremioNames = stremioAddons.map(addon => addon.name);
|
||
|
||
// Get local scraper names
|
||
localScraperService.getInstalledScrapers().then(localScrapers => {
|
||
const localScraperNames = localScrapers.filter(s => s.enabled).map(s => s.name);
|
||
const allNames = [...stremioNames, ...localScraperNames];
|
||
|
||
// Remove all from active fetching
|
||
setActiveFetchingScrapers(current =>
|
||
current.filter(name => !allNames.includes(name))
|
||
);
|
||
}).catch(() => {
|
||
// If we can't get local scrapers, just remove Stremio addons
|
||
setActiveFetchingScrapers(current =>
|
||
current.filter(name => !stremioNames.includes(name))
|
||
);
|
||
});
|
||
|
||
// Immediately remove Stremio addons (local scrapers will be removed async above)
|
||
return prev.filter(name => !stremioNames.includes(name));
|
||
});
|
||
|
||
// Update scraper statuses to mark all scrapers as failed
|
||
setScraperStatuses(prevStatuses => {
|
||
const stremioAddons = stremioService.getInstalledAddons();
|
||
|
||
return prevStatuses.map(status => {
|
||
const isStremioAddon = stremioAddons.some(addon => addon.id === status.id || addon.name === status.name);
|
||
|
||
// Mark both Stremio addons and local scrapers as failed
|
||
if (isStremioAddon || !status.hasCompleted) {
|
||
return {
|
||
...status,
|
||
isLoading: false,
|
||
hasCompleted: true,
|
||
error: error instanceof Error ? error.message : 'Initial request failed',
|
||
endTime: Date.now()
|
||
};
|
||
}
|
||
return status;
|
||
});
|
||
});
|
||
}
|
||
// 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');
|
||
|
||
// Check if we have addon cast data available
|
||
if (metadata?.addonCast && metadata.addonCast.length > 0) {
|
||
if (__DEV__) logger.log(`[loadCast] Using addon cast data: ${metadata.addonCast.length} cast members`);
|
||
setCast(metadata.addonCast);
|
||
setLoadingCast(false);
|
||
return;
|
||
}
|
||
|
||
if (__DEV__) logger.log('[loadCast] No addon cast data available');
|
||
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 (only if enrichment is enabled)
|
||
let tmdbId;
|
||
if (id.startsWith('tt') && settings.enrichMetadataWithTMDB) {
|
||
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 {
|
||
console.log('🔍 [useMetadata] loadMetadata started:', {
|
||
id,
|
||
type,
|
||
addonId,
|
||
loadAttempts,
|
||
maxRetries: MAX_RETRIES,
|
||
settingsLoaded: settingsLoaded
|
||
});
|
||
|
||
if (loadAttempts >= MAX_RETRIES) {
|
||
console.log('🔍 [useMetadata] Max retries exceeded:', { loadAttempts, maxRetries: 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) {
|
||
console.log('🔍 [useMetadata] Using cached metadata:', {
|
||
id,
|
||
type,
|
||
hasMetadata: !!cachedScreen.metadata,
|
||
hasCast: !!cachedScreen.cast,
|
||
hasEpisodes: !!cachedScreen.episodes,
|
||
tmdbId: cachedScreen.tmdbId
|
||
});
|
||
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
|
||
(async () => {
|
||
const items = await catalogService.getLibraryItems();
|
||
const isInLib = items.some(item => item.id === id);
|
||
setInLibrary(isInLib);
|
||
})();
|
||
setLoading(false);
|
||
return;
|
||
} else {
|
||
console.log('🔍 [useMetadata] No cached metadata found, proceeding with fresh fetch');
|
||
}
|
||
|
||
// Handle TMDB-specific IDs
|
||
let actualId = id;
|
||
if (id.startsWith('tmdb:')) {
|
||
// Always try the original TMDB ID first - let addons decide if they support it
|
||
console.log('🔍 [useMetadata] TMDB ID detected, trying original ID first:', { originalId: id });
|
||
|
||
// If enrichment disabled, try original ID first, then fallback to conversion if needed
|
||
if (!settings.enrichMetadataWithTMDB) {
|
||
// Keep the original TMDB ID - let the addon system handle it dynamically
|
||
actualId = id;
|
||
console.log('🔍 [useMetadata] TMDB enrichment disabled, using original TMDB ID:', { actualId });
|
||
} 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,
|
||
};
|
||
|
||
// OPTIMIZATION: Fetch credits and logo in parallel instead of sequentially
|
||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||
const [creditsResult, logoResult] = await Promise.allSettled([
|
||
tmdbService.getCredits(parseInt(tmdbId), 'movie'),
|
||
tmdbService.getContentLogo('movie', tmdbId, preferredLanguage)
|
||
]);
|
||
|
||
// Process credits result
|
||
if (creditsResult.status === 'fulfilled' && creditsResult.value?.crew) {
|
||
const credits = creditsResult.value;
|
||
// 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;
|
||
}
|
||
} else if (creditsResult.status === 'rejected') {
|
||
logger.error('Failed to fetch credits for movie:', creditsResult.reason);
|
||
}
|
||
|
||
// Process logo result
|
||
if (logoResult.status === 'fulfilled') {
|
||
formattedMovie.logo = logoResult.value || undefined;
|
||
if (__DEV__) logger.log(`Successfully fetched logo for movie ${tmdbId} from TMDB`);
|
||
} else {
|
||
logger.error('Failed to fetch logo from TMDB:', logoResult.reason);
|
||
formattedMovie.logo = undefined;
|
||
}
|
||
|
||
setMetadata(formattedMovie);
|
||
cacheService.setMetadata(id, type, formattedMovie);
|
||
(async () => {
|
||
const items = await catalogService.getLibraryItems();
|
||
const isInLib = items.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) {
|
||
// OPTIMIZATION: Fetch external IDs, credits, and logo in parallel
|
||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||
const [externalIdsResult, creditsResult, logoResult] = await Promise.allSettled([
|
||
tmdbService.getShowExternalIds(parseInt(tmdbId)),
|
||
tmdbService.getCredits(parseInt(tmdbId), 'series'),
|
||
tmdbService.getContentLogo('tv', tmdbId, preferredLanguage)
|
||
]);
|
||
|
||
const externalIds = externalIdsResult.status === 'fulfilled' ? externalIdsResult.value : null;
|
||
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,
|
||
};
|
||
|
||
// Process credits result (already fetched in parallel)
|
||
if (creditsResult.status === 'fulfilled' && creditsResult.value?.crew) {
|
||
const credits = creditsResult.value;
|
||
// 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);
|
||
}
|
||
} else if (creditsResult.status === 'rejected') {
|
||
logger.error('Failed to fetch credits for TV show:', creditsResult.reason);
|
||
}
|
||
|
||
// Process logo result (already fetched in parallel)
|
||
if (logoResult.status === 'fulfilled') {
|
||
formattedShow.logo = logoResult.value || undefined;
|
||
if (__DEV__) logger.log(`Successfully fetched logo for TV show ${tmdbId} from TMDB`);
|
||
} else {
|
||
logger.error('Failed to fetch logo from TMDB:', (logoResult as PromiseRejectedResult).reason);
|
||
formattedShow.logo = undefined;
|
||
}
|
||
|
||
setMetadata(formattedShow);
|
||
cacheService.setMetadata(id, type, formattedShow);
|
||
|
||
// Load series data (episodes)
|
||
setTmdbId(parseInt(tmdbId));
|
||
loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
|
||
|
||
(async () => {
|
||
const items = await catalogService.getLibraryItems();
|
||
const isInLib = items.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
|
||
console.log('🔍 [useMetadata] Starting parallel data fetch:', { type, actualId, addonId, apiTimeout: API_TIMEOUT });
|
||
if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId });
|
||
|
||
let contentResult = null;
|
||
let lastError = null;
|
||
|
||
// Try with original ID first
|
||
try {
|
||
console.log('🔍 [useMetadata] Attempting metadata fetch with original ID:', { type, actualId, addonId });
|
||
const [content, castData] = await Promise.allSettled([
|
||
// Load content with timeout and retry
|
||
withRetry(async () => {
|
||
console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId });
|
||
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);
|
||
}
|
||
console.log('🔍 [useMetadata] catalogService.getEnhancedContentDetails result:', {
|
||
hasResult: Boolean(result),
|
||
resultId: result?.id,
|
||
resultName: result?.name,
|
||
resultType: result?.type
|
||
});
|
||
if (__DEV__) logger.log('[loadMetadata] addon metadata fetched', { hasResult: Boolean(result) });
|
||
return result;
|
||
}),
|
||
// Start loading cast immediately in parallel
|
||
loadCast()
|
||
]);
|
||
|
||
contentResult = content;
|
||
if (content.status === 'fulfilled' && content.value) {
|
||
console.log('🔍 [useMetadata] Successfully got metadata with original ID');
|
||
} else {
|
||
console.log('🔍 [useMetadata] Original ID failed, will try fallback conversion');
|
||
lastError = (content as any)?.reason;
|
||
}
|
||
} catch (error) {
|
||
console.log('🔍 [useMetadata] Original ID attempt failed:', { error: error instanceof Error ? error.message : String(error) });
|
||
lastError = error;
|
||
}
|
||
|
||
// If original TMDB ID failed and enrichment is disabled, try ID conversion as fallback
|
||
if (!contentResult || (contentResult.status === 'fulfilled' && !contentResult.value) || contentResult.status === 'rejected') {
|
||
if (id.startsWith('tmdb:') && !settings.enrichMetadataWithTMDB) {
|
||
console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback');
|
||
const tmdbRaw = id.split(':')[1];
|
||
try {
|
||
const stremioId = await catalogService.getStremioId(type === 'series' ? 'tv' : 'movie', tmdbRaw);
|
||
if (stremioId && stremioId !== id) {
|
||
console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId });
|
||
const [content, castData] = await Promise.allSettled([
|
||
withRetry(async () => {
|
||
const result = await withTimeout(
|
||
catalogService.getEnhancedContentDetails(type, stremioId, addonId),
|
||
API_TIMEOUT
|
||
);
|
||
if (stremioId.startsWith('tt')) {
|
||
setImdbId(stremioId);
|
||
}
|
||
return result;
|
||
}),
|
||
loadCast()
|
||
]);
|
||
contentResult = content;
|
||
}
|
||
} catch (e) {
|
||
console.log('🔍 [useMetadata] ID conversion fallback also failed:', { error: e instanceof Error ? e.message : String(e) });
|
||
}
|
||
}
|
||
}
|
||
|
||
const content = contentResult || { status: 'rejected' as const, reason: lastError || new Error('No content result') };
|
||
const castData = { status: 'fulfilled' as const, value: undefined };
|
||
|
||
console.log('🔍 [useMetadata] Promise.allSettled results:', {
|
||
contentStatus: content.status,
|
||
contentFulfilled: content.status === 'fulfilled',
|
||
hasContentValue: content.status === 'fulfilled' ? !!content.value : false,
|
||
castStatus: castData.status,
|
||
castFulfilled: castData.status === 'fulfilled'
|
||
});
|
||
|
||
if (content.status === 'fulfilled' && content.value) {
|
||
console.log('🔍 [useMetadata] Content fetch successful:', {
|
||
id: content.value?.id,
|
||
type: content.value?.type,
|
||
name: content.value?.name,
|
||
hasDescription: !!content.value?.description,
|
||
hasPoster: !!content.value?.poster
|
||
});
|
||
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;
|
||
|
||
// Store addon logo before TMDB enrichment overwrites it
|
||
const addonLogo = (finalMetadata as any).logo;
|
||
|
||
// If localization is enabled, merge TMDB localized text (name/overview) before first render
|
||
try {
|
||
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
|
||
const tmdbSvc = TMDBService.getInstance();
|
||
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) {
|
||
const movieDetailsObj = {
|
||
status: localized.status,
|
||
releaseDate: localized.release_date,
|
||
runtime: localized.runtime,
|
||
budget: localized.budget,
|
||
revenue: localized.revenue,
|
||
originalLanguage: localized.original_language,
|
||
originCountry: localized.production_countries?.map((c: any) => c.iso_3166_1),
|
||
tagline: localized.tagline,
|
||
};
|
||
const productionInfo = Array.isArray(localized.production_companies)
|
||
? localized.production_companies
|
||
.map((c: any) => ({ id: c?.id, name: c?.name, logo: tmdbSvc.getImageUrl(c?.logo_path, 'w185') }))
|
||
.filter((c: any) => c && (c.logo || c.name))
|
||
: [];
|
||
|
||
finalMetadata = {
|
||
...finalMetadata,
|
||
name: localized.title || finalMetadata.name,
|
||
description: localized.overview || finalMetadata.description,
|
||
movieDetails: movieDetailsObj,
|
||
...(productionInfo.length > 0 && { networks: productionInfo }),
|
||
};
|
||
}
|
||
} else { // 'series'
|
||
const localized = await tmdbSvc.getTVShowDetails(Number(finalTmdbId), lang);
|
||
if (localized) {
|
||
const tvDetails = {
|
||
status: localized.status,
|
||
firstAirDate: localized.first_air_date,
|
||
lastAirDate: localized.last_air_date,
|
||
numberOfSeasons: localized.number_of_seasons,
|
||
numberOfEpisodes: localized.number_of_episodes,
|
||
episodeRunTime: localized.episode_run_time,
|
||
type: localized.type,
|
||
originCountry: localized.origin_country,
|
||
originalLanguage: localized.original_language,
|
||
createdBy: localized.created_by?.map(creator => ({
|
||
id: creator.id,
|
||
name: creator.name,
|
||
profile_path: creator.profile_path || undefined
|
||
})),
|
||
};
|
||
const productionInfo = Array.isArray(localized.networks)
|
||
? localized.networks
|
||
.map((n: any) => ({
|
||
id: n?.id,
|
||
name: n?.name,
|
||
logo: tmdbSvc.getImageUrl(n?.logo_path, 'w185') || undefined
|
||
}))
|
||
.filter((n: any) => n && (n.logo || n.name))
|
||
: [];
|
||
|
||
finalMetadata = {
|
||
...finalMetadata,
|
||
name: localized.name || finalMetadata.name,
|
||
description: localized.overview || finalMetadata.description,
|
||
tvDetails,
|
||
...(productionInfo.length > 0 && { networks: productionInfo }),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (__DEV__) console.log('[useMetadata] failed to merge localized TMDB text', e);
|
||
}
|
||
|
||
// Centralized logo fetching logic
|
||
try {
|
||
if (settings.enrichMetadataWithTMDB) {
|
||
// Only use TMDB logos when enrichment is ON
|
||
const tmdbService = TMDBService.getInstance();
|
||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||
const contentType = type === 'series' ? 'tv' : 'movie';
|
||
|
||
// Get TMDB ID
|
||
let tmdbIdForLogo = null;
|
||
if (tmdbId) {
|
||
tmdbIdForLogo = String(tmdbId);
|
||
} else if (finalMetadata.imdb_id) {
|
||
const foundId = await tmdbService.findTMDBIdByIMDB(finalMetadata.imdb_id);
|
||
tmdbIdForLogo = foundId ? String(foundId) : null;
|
||
}
|
||
|
||
if (tmdbIdForLogo) {
|
||
const logoUrl = await tmdbService.getContentLogo(contentType, tmdbIdForLogo, preferredLanguage);
|
||
finalMetadata.logo = logoUrl || undefined; // TMDB logo or undefined (no addon fallback)
|
||
if (__DEV__) {
|
||
console.log('[useMetadata] Logo fetch result:', {
|
||
contentType,
|
||
tmdbIdForLogo,
|
||
preferredLanguage,
|
||
logoUrl: !!logoUrl,
|
||
enrichmentEnabled: true
|
||
});
|
||
}
|
||
} else {
|
||
finalMetadata.logo = undefined; // No TMDB ID means no logo
|
||
if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, will show text title');
|
||
}
|
||
} else {
|
||
// When enrichment is OFF, keep addon logo or undefined
|
||
finalMetadata.logo = finalMetadata.logo || undefined;
|
||
if (__DEV__) {
|
||
console.log('[useMetadata] TMDB enrichment disabled, using addon logo:', {
|
||
hasAddonLogo: !!finalMetadata.logo,
|
||
enrichmentEnabled: false
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// Handle error silently, keep existing logo behavior
|
||
if (__DEV__) console.error('[useMetadata] Unexpected error in logo fetch:', error);
|
||
finalMetadata.logo = undefined;
|
||
}
|
||
|
||
// Commit final metadata once and cache it
|
||
// Store addon logo as fallback if TMDB enrichment is enabled
|
||
if (settings.enrichMetadataWithTMDB && addonLogo) {
|
||
(finalMetadata as any).addonLogo = addonLogo;
|
||
}
|
||
|
||
// Clear banner field if TMDB enrichment is enabled to prevent flash
|
||
if (settings.enrichMetadataWithTMDB) {
|
||
finalMetadata = {
|
||
...finalMetadata,
|
||
banner: undefined, // Let useMetadataAssets handle banner via TMDB
|
||
};
|
||
}
|
||
|
||
// Preserve existing collection if it was set by fetchProductionInfo
|
||
setMetadata((prev) => {
|
||
const updated = { ...finalMetadata };
|
||
if (prev?.collection) {
|
||
updated.collection = prev.collection;
|
||
}
|
||
return updated;
|
||
});
|
||
cacheService.setMetadata(id, type, finalMetadata);
|
||
(async () => {
|
||
const items = await catalogService.getLibraryItems();
|
||
const isInLib = items.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);
|
||
|
||
console.log('🔍 [useMetadata] Content fetch failed:', {
|
||
status: content.status,
|
||
reason: reasonMessage,
|
||
fullReason: reason,
|
||
isAxiosError: reason?.isAxiosError,
|
||
responseStatus: reason?.response?.status,
|
||
responseData: reason?.response?.data
|
||
});
|
||
|
||
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')
|
||
)) {
|
||
console.log('🔍 [useMetadata] Detected server/network error, preserving original error');
|
||
// This was a server/network error, preserve the original error message
|
||
throw reason instanceof Error ? reason : new Error(reasonMessage);
|
||
} else {
|
||
console.log('🔍 [useMetadata] Detected content not found error, throwing generic error');
|
||
// This was likely a content not found error
|
||
throw new Error('Content not found');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log('🔍 [useMetadata] loadMetadata caught error:', {
|
||
errorMessage: error instanceof Error ? error.message : String(error),
|
||
errorType: typeof error,
|
||
isAxiosError: (error as any)?.isAxiosError,
|
||
responseStatus: (error as any)?.response?.status,
|
||
responseData: (error as any)?.response?.data,
|
||
stack: error instanceof Error ? error.stack : undefined
|
||
});
|
||
|
||
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 {
|
||
console.log('🔍 [useMetadata] loadMetadata completed, setting loading to false');
|
||
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) => {
|
||
// Use season 0 for videos without season numbers (PPV-style content, specials, etc.)
|
||
const seasonNumber = video.season || 0;
|
||
const episodeNumber = video.episode || video.number || 1;
|
||
|
||
if (!groupedAddonEpisodes[seasonNumber]) {
|
||
groupedAddonEpisodes[seasonNumber] = [];
|
||
}
|
||
|
||
// Resolve image and description dynamically from arbitrary addons
|
||
const imageCandidate = (
|
||
video.thumbnail ||
|
||
video.image ||
|
||
video.thumb ||
|
||
(video.images && video.images.still) ||
|
||
null
|
||
);
|
||
const descriptionCandidate = (
|
||
video.overview ||
|
||
video.description ||
|
||
video.plot ||
|
||
video.synopsis ||
|
||
''
|
||
);
|
||
|
||
// Convert addon episode format to our Episode interface
|
||
const episode: Episode = {
|
||
id: video.id,
|
||
name: video.name || video.title || `Episode ${episodeNumber}`,
|
||
overview: descriptionCandidate,
|
||
season_number: seasonNumber,
|
||
episode_number: episodeNumber,
|
||
air_date: video.released ? video.released.split('T')[0] : video.firstAired ? video.firstAired.split('T')[0] : '',
|
||
still_path: imageCandidate,
|
||
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);
|
||
};
|
||
|
||
// Extract embedded streams from metadata videos (used by PPV-style addons)
|
||
const extractEmbeddedStreams = useCallback(() => {
|
||
if (!metadata?.videos) return;
|
||
|
||
// Check if any video has embedded streams
|
||
const videosWithStreams = (metadata.videos as any[]).filter(
|
||
(video: any) => video.streams && Array.isArray(video.streams) && video.streams.length > 0
|
||
);
|
||
|
||
if (videosWithStreams.length === 0) return;
|
||
|
||
// Get the addon info from metadata if available
|
||
const addonId = (metadata as any).addonId || 'embedded';
|
||
const addonName = (metadata as any).addonName || metadata.name || 'Embedded Streams';
|
||
|
||
// Extract all streams from videos
|
||
const embeddedStreams: Stream[] = [];
|
||
for (const video of videosWithStreams) {
|
||
for (const stream of video.streams) {
|
||
embeddedStreams.push({
|
||
...stream,
|
||
name: stream.name || stream.title || video.title,
|
||
title: stream.title || video.title,
|
||
addonId,
|
||
addonName,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (embeddedStreams.length > 0) {
|
||
if (__DEV__) console.log(`✅ [extractEmbeddedStreams] Found ${embeddedStreams.length} embedded streams from ${addonName}`);
|
||
|
||
// Add to grouped streams
|
||
setGroupedStreams(prevStreams => ({
|
||
...prevStreams,
|
||
[addonId]: {
|
||
addonName,
|
||
streams: embeddedStreams,
|
||
},
|
||
}));
|
||
|
||
// Track addon response order
|
||
setAddonResponseOrder(prevOrder => {
|
||
if (!prevOrder.includes(addonId)) {
|
||
return [...prevOrder, addonId];
|
||
}
|
||
return prevOrder;
|
||
});
|
||
|
||
// Mark loading as complete since we have streams
|
||
setLoadingStreams(false);
|
||
}
|
||
}, [metadata]);
|
||
|
||
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 (settings.enrichMetadataWithTMDB) {
|
||
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 {
|
||
if (__DEV__) console.log('📝 [loadStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion');
|
||
}
|
||
} 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);
|
||
console.log('🔍 [loadStreams] Initialized activeFetchingScrapers:', 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);
|
||
|
||
// Also extract any embedded streams from metadata (PPV-style addons)
|
||
extractEmbeddedStreams();
|
||
|
||
// 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([]);
|
||
// Mark all incomplete scrapers as failed
|
||
setScraperStatuses(prevStatuses =>
|
||
prevStatuses.map(status =>
|
||
!status.hasCompleted && !status.error
|
||
? { ...status, isLoading: false, hasCompleted: true, error: 'Request timed out', endTime: Date.now() }
|
||
: status
|
||
)
|
||
);
|
||
}, 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);
|
||
console.log('🔍 [loadEpisodeStreams] Initialized activeFetchingScrapers:', 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
|
||
if (settings.enrichMetadataWithTMDB) {
|
||
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 (settings.enrichMetadataWithTMDB) {
|
||
if (__DEV__) console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
|
||
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
||
} else {
|
||
if (__DEV__) console.log('📝 [loadEpisodeStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion');
|
||
}
|
||
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([]);
|
||
// Mark all incomplete scrapers as failed
|
||
setScraperStatuses(prevStatuses =>
|
||
prevStatuses.map(status =>
|
||
!status.hasCompleted && !status.error
|
||
? { ...status, isLoading: false, hasCompleted: true, error: 'Request timed out', endTime: Date.now() }
|
||
: status
|
||
)
|
||
);
|
||
}, 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); });
|
||
// Also extract embedded streams from metadata videos (PPV-style addons)
|
||
extractEmbeddedStreams();
|
||
}
|
||
}, [metadata?.videos, type, extractEmbeddedStreams]);
|
||
|
||
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,
|
||
tmdbId: fetchedTmdbId,
|
||
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]);
|
||
|
||
// Load addon cast data when metadata is available and TMDB enrichment is disabled
|
||
useEffect(() => {
|
||
if (!settings.enrichMetadataWithTMDB && metadata?.addonCast && metadata.addonCast.length > 0) {
|
||
if (__DEV__) logger.log('[useMetadata] Loading addon cast data after metadata loaded');
|
||
loadCast();
|
||
}
|
||
}, [metadata, 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, tmdbId, 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]);
|
||
|
||
// Fetch TMDB networks/production companies when TMDB ID is available and enrichment is enabled
|
||
const productionInfoFetchedRef = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
if (!tmdbId || !settings.enrichMetadataWithTMDB || !metadata) {
|
||
return;
|
||
}
|
||
|
||
const contentKey = `${type}-${tmdbId}`;
|
||
if (productionInfoFetchedRef.current === contentKey) {
|
||
return;
|
||
}
|
||
|
||
// Only skip if networks are set AND collection is already set (for movies)
|
||
const hasNetworks = !!(metadata as any).networks;
|
||
const hasCollection = !!(metadata as any).collection;
|
||
if (hasNetworks && (type !== 'movie' || hasCollection)) {
|
||
return;
|
||
}
|
||
|
||
const fetchProductionInfo = async () => {
|
||
try {
|
||
productionInfoFetchedRef.current = contentKey;
|
||
const tmdbService = TMDBService.getInstance();
|
||
let productionInfo: any[] = [];
|
||
|
||
if (__DEV__) console.log('[useMetadata] fetchProductionInfo starting', {
|
||
contentKey,
|
||
type,
|
||
tmdbId,
|
||
useLocalized: settings.useTmdbLocalizedMetadata,
|
||
lang: settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en',
|
||
hasExistingNetworks: !!(metadata as any).networks
|
||
});
|
||
|
||
if (type === 'series') {
|
||
// Fetch networks and additional details for TV shows
|
||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||
const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang);
|
||
if (showDetails) {
|
||
if (__DEV__) console.log('[useMetadata] fetchProductionInfo got showDetails', {
|
||
hasNetworks: !!showDetails.networks,
|
||
networksCount: showDetails.networks?.length || 0
|
||
});
|
||
// Fetch networks
|
||
if (showDetails.networks) {
|
||
productionInfo = Array.isArray(showDetails.networks)
|
||
? showDetails.networks
|
||
.map((n: any) => ({
|
||
id: n?.id,
|
||
name: n?.name,
|
||
logo: tmdbService.getImageUrl(n?.logo_path, 'w185') || undefined,
|
||
}))
|
||
.filter((n: any) => n && (n.logo || n.name))
|
||
: [];
|
||
}
|
||
|
||
// Fetch additional TV details
|
||
const tvDetails = {
|
||
status: showDetails.status,
|
||
firstAirDate: showDetails.first_air_date,
|
||
lastAirDate: showDetails.last_air_date,
|
||
numberOfSeasons: showDetails.number_of_seasons,
|
||
numberOfEpisodes: showDetails.number_of_episodes,
|
||
episodeRunTime: showDetails.episode_run_time,
|
||
type: showDetails.type,
|
||
originCountry: showDetails.origin_country,
|
||
originalLanguage: showDetails.original_language,
|
||
createdBy: showDetails.created_by?.map(creator => ({
|
||
id: creator.id,
|
||
name: creator.name,
|
||
profile_path: creator.profile_path || undefined
|
||
})),
|
||
};
|
||
|
||
// Update metadata with TV details
|
||
setMetadata((prev: any) => ({
|
||
...prev,
|
||
tmdbId,
|
||
tvDetails
|
||
}));
|
||
}
|
||
} else if (type === 'movie') {
|
||
// Fetch production companies and additional details for movies
|
||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);
|
||
if (movieDetails) {
|
||
if (__DEV__) console.log('[useMetadata] fetchProductionInfo got movieDetails', {
|
||
hasProductionCompanies: !!movieDetails.production_companies,
|
||
productionCompaniesCount: movieDetails.production_companies?.length || 0
|
||
});
|
||
// Fetch production companies
|
||
if (movieDetails.production_companies) {
|
||
productionInfo = Array.isArray(movieDetails.production_companies)
|
||
? movieDetails.production_companies
|
||
.map((c: any) => ({
|
||
id: c?.id,
|
||
name: c?.name,
|
||
logo: tmdbService.getImageUrl(c?.logo_path, 'w185'),
|
||
}))
|
||
.filter((c: any) => c && (c.logo || c.name))
|
||
: [];
|
||
}
|
||
|
||
// Fetch additional movie details
|
||
const movieDetailsObj = {
|
||
status: movieDetails.status,
|
||
releaseDate: movieDetails.release_date,
|
||
runtime: movieDetails.runtime,
|
||
budget: movieDetails.budget,
|
||
revenue: movieDetails.revenue,
|
||
originalLanguage: movieDetails.original_language,
|
||
originCountry: movieDetails.production_countries?.map((c: any) => c.iso_3166_1),
|
||
tagline: movieDetails.tagline,
|
||
};
|
||
|
||
// Update metadata with movie details
|
||
setMetadata((prev: any) => ({
|
||
...prev,
|
||
tmdbId,
|
||
movieDetails: movieDetailsObj
|
||
}));
|
||
|
||
// Fetch collection data if movie belongs to a collection
|
||
if (movieDetails.belongs_to_collection) {
|
||
setLoadingCollection(true);
|
||
try {
|
||
const collectionDetails = await tmdbService.getCollectionDetails(
|
||
movieDetails.belongs_to_collection.id,
|
||
lang
|
||
);
|
||
|
||
if (collectionDetails && collectionDetails.parts) {
|
||
// Fetch individual movie images to get backdrops with embedded titles/logos
|
||
const collectionMoviesData = await Promise.all(
|
||
collectionDetails.parts.map(async (part: any, index: number) => {
|
||
let movieBackdropUrl = undefined;
|
||
|
||
// Try to fetch movie images with language parameter
|
||
try {
|
||
const movieImages = await tmdbService.getMovieImagesFull(part.id, lang);
|
||
if (movieImages && movieImages.backdrops && movieImages.backdrops.length > 0) {
|
||
// Filter and sort backdrops by language and quality
|
||
const languageBackdrops = movieImages.backdrops
|
||
.filter((backdrop: any) => backdrop.aspect_ratio > 1.0) // Landscape orientation
|
||
.sort((a: any, b: any) => {
|
||
// Prioritize backdrops with the requested language
|
||
const aHasLang = a.iso_639_1 === lang;
|
||
const bHasLang = b.iso_639_1 === lang;
|
||
if (aHasLang && !bHasLang) return -1;
|
||
if (!aHasLang && bHasLang) return 1;
|
||
|
||
// Then prioritize English if requested language not available
|
||
const aIsEn = a.iso_639_1 === 'en';
|
||
const bIsEn = b.iso_639_1 === 'en';
|
||
if (aIsEn && !bIsEn) return -1;
|
||
if (!aIsEn && bIsEn) return 1;
|
||
|
||
// Then sort by vote average (quality), then by resolution
|
||
if (a.vote_average !== b.vote_average) {
|
||
return b.vote_average - a.vote_average;
|
||
}
|
||
return (b.width * b.height) - (a.width * a.height);
|
||
});
|
||
|
||
if (languageBackdrops.length > 0) {
|
||
movieBackdropUrl = tmdbService.getImageUrl(languageBackdrops[0].file_path, 'original');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (__DEV__) console.warn('[useMetadata] Failed to fetch movie images for:', part.id, error);
|
||
}
|
||
|
||
return {
|
||
id: `tmdb:${part.id}`,
|
||
type: 'movie',
|
||
name: part.title,
|
||
poster: part.poster_path ? tmdbService.getImageUrl(part.poster_path, 'w500') : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
||
banner: movieBackdropUrl || (part.backdrop_path ? tmdbService.getImageUrl(part.backdrop_path, 'original') : undefined),
|
||
year: part.release_date ? new Date(part.release_date).getFullYear() : undefined,
|
||
description: part.overview,
|
||
collection: {
|
||
id: collectionDetails.id,
|
||
name: collectionDetails.name,
|
||
poster_path: collectionDetails.poster_path,
|
||
backdrop_path: collectionDetails.backdrop_path
|
||
}
|
||
};
|
||
})
|
||
) as StreamingContent[];
|
||
|
||
setCollectionMovies(collectionMoviesData);
|
||
|
||
// Update metadata with collection info
|
||
setMetadata((prev: any) => ({
|
||
...prev,
|
||
collection: {
|
||
id: collectionDetails.id,
|
||
name: collectionDetails.name,
|
||
poster_path: collectionDetails.poster_path,
|
||
backdrop_path: collectionDetails.backdrop_path
|
||
}
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
if (__DEV__) console.error('[useMetadata] Error fetching collection:', error);
|
||
} finally {
|
||
setLoadingCollection(false);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (productionInfo.length > 0) {
|
||
setMetadata((prev: any) => ({ ...prev, networks: productionInfo }));
|
||
}
|
||
} catch (error) {
|
||
if (__DEV__) console.error('[useMetadata] Failed to fetch production info:', error);
|
||
}
|
||
};
|
||
|
||
fetchProductionInfo();
|
||
}, [tmdbId, settings.enrichMetadataWithTMDB, metadata, type]);
|
||
|
||
// 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);
|
||
// Only update state if the value actually changed to prevent unnecessary re-renders
|
||
setInLibrary(prev => prev !== isInLib ? isInLib : prev);
|
||
});
|
||
|
||
return () => unsubscribe();
|
||
}, [id]);
|
||
|
||
// Memory optimization: Cleanup on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
// Clear cleanup timeout
|
||
if (cleanupTimeoutRef.current) {
|
||
clearTimeout(cleanupTimeoutRef.current);
|
||
}
|
||
|
||
// Force cleanup
|
||
cleanupStreams();
|
||
|
||
// Reset production info fetch tracking
|
||
productionInfoFetchedRef.current = null;
|
||
|
||
if (__DEV__) console.log('[useMetadata] Component unmounted, memory cleaned up');
|
||
};
|
||
}, [cleanupStreams]);
|
||
|
||
|
||
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,
|
||
collectionMovies,
|
||
loadingCollection,
|
||
};
|
||
}; |