diff --git a/local-scrapers-repo b/local-scrapers-repo index db674925..46fce12a 160000 --- a/local-scrapers-repo +++ b/local-scrapers-repo @@ -1 +1 @@ -Subproject commit db674925bbf74e1240cc0625d531853505ca941f +Subproject commit 46fce12a69ce684962a76893520e89fec18e0989 diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 4b928ded..82ccfa45 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -23,6 +23,7 @@ import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import * as Haptics from 'expo-haptics'; import { TraktService } from '../../services/traktService'; +import { stremioService } from '../../services/stremioService'; // Define interface for continue watching items interface ContinueWatchingItem extends StreamingContent { @@ -86,6 +87,38 @@ const ContinueWatchingSection = React.forwardRef((props, re // Use a state to track if a background refresh is in progress const [isRefreshing, setIsRefreshing] = useState(false); + // Cache for metadata to avoid redundant API calls + const metadataCache = useRef>({}); + const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + + // Helper function to get cached or fetch metadata + const getCachedMetadata = useCallback(async (type: string, id: string) => { + const cacheKey = `${type}:${id}`; + const cached = metadataCache.current[cacheKey]; + const now = Date.now(); + + if (cached && (now - cached.timestamp) < CACHE_DURATION) { + return cached; + } + + try { + const [metadata, basicContent] = await Promise.all([ + stremioService.getMetaDetails(type, id), + catalogService.getBasicContentDetails(type, id) + ]); + + if (basicContent) { + const result = { metadata, basicContent, timestamp: now }; + metadataCache.current[cacheKey] = result; + return result; + } + return null; + } catch (error) { + logger.error(`Failed to fetch metadata for ${type}:${id}:`, error); + return null; + } + }, []); + // Modified loadContinueWatching to be more efficient const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { // Prevent multiple concurrent refreshes @@ -106,153 +139,163 @@ const ContinueWatchingSection = React.forwardRef((props, re const progressItems: ContinueWatchingItem[] = []; const latestEpisodes: Record = {}; - const contentPromises: Promise[] = []; - // Process each saved progress + // Group progress items by content ID to batch API calls + const contentGroups: Record }> = {}; + + // First pass: group by content ID for (const key in allProgress) { - // Parse the key to get type and id const keyParts = key.split(':'); const [type, id, ...episodeIdParts] = keyParts; const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; const progress = allProgress[key]; - - // For series, skip episodes that are essentially finished (≥85%) - // For movies we still include them so users can "Watch Again" const progressPercent = (progress.currentTime / progress.duration) * 100; // Skip fully watched movies if (type === 'movie' && progressPercent >= 85) { continue; } - - if (type === 'series' && progressPercent >= 85) { - // Determine next episode ID by incrementing episode number - let nextSeason: number | undefined; - let nextEpisode: number | undefined; - let nextEpisodeId: string | undefined; - - if (episodeId) { - // Pattern 1: s1e1 - const match = episodeId.match(/s(\d+)e(\d+)/i); - if (match) { - const currentSeason = parseInt(match[1], 10); - const currentEpisode = parseInt(match[2], 10); - nextSeason = currentSeason; - nextEpisode = currentEpisode + 1; - nextEpisodeId = `s${nextSeason}e${nextEpisode}`; - } else { - // Pattern 2: id:season:episode - const parts = episodeId.split(':'); - if (parts.length >= 2) { - const seasonNum = parseInt(parts[parts.length - 2], 10); - const episodeNum = parseInt(parts[parts.length - 1], 10); - if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - nextSeason = seasonNum; - nextEpisode = episodeNum + 1; - nextEpisodeId = `${id}:${nextSeason}:${nextEpisode}`; - } - } - } - } - - // Push placeholder for next episode with 0% progress - if (nextEpisodeId !== undefined) { - const basicContent = await catalogService.getBasicContentDetails(type, id); - const nextEpisodeItem = { - ...basicContent, - id, - type, - progress: 0, - lastUpdated: progress.lastUpdated, - season: nextSeason, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, - } as ContinueWatchingItem; - - // Store in latestEpisodes to ensure single entry per show - const existingLatest = latestEpisodes[id]; - if (!existingLatest || existingLatest.lastUpdated < nextEpisodeItem.lastUpdated) { - latestEpisodes[id] = nextEpisodeItem; - } - } - - // Skip adding the finished episode itself - continue; + + const contentKey = `${type}:${id}`; + if (!contentGroups[contentKey]) { + contentGroups[contentKey] = { type, id, episodes: [] }; } - const contentPromise = (async () => { - try { - // Validate IMDB ID format before attempting to fetch - if (!isValidImdbId(id)) { - return; - } + contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent }); + } + + // Second pass: process each content group with batched API calls + const contentPromises = Object.values(contentGroups).map(async (group) => { + try { + // Validate IMDB ID format before attempting to fetch + if (!isValidImdbId(group.id)) { + return; + } + + // Get metadata once per content + const cachedData = await getCachedMetadata(group.type, group.id); + if (!cachedData?.basicContent) { + return; + } + + const { metadata, basicContent } = cachedData; + + // Process all episodes for this content + for (const episode of group.episodes) { + const { key, episodeId, progress, progressPercent } = episode; - let content: StreamingContent | null = null; - - // Get basic content details using catalogService (no enhanced metadata needed for continue watching) - content = await catalogService.getBasicContentDetails(type, id); - - if (content) { - // Extract season and episode info from episodeId if available - let season: number | undefined; - let episode: number | undefined; - let episodeTitle: string | undefined; - - if (episodeId && type === 'series') { - // Try different episode ID formats - let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1 + if (group.type === 'series' && progressPercent >= 85) { + // Handle next episode logic for completed episodes + let nextSeason: number | undefined; + let nextEpisode: number | undefined; + + if (episodeId) { + // Pattern 1: s1e1 + const match = episodeId.match(/s(\d+)e(\d+)/i); if (match) { - season = parseInt(match[1], 10); - episode = parseInt(match[2], 10); - episodeTitle = `Episode ${episode}`; + const currentSeason = parseInt(match[1], 10); + const currentEpisode = parseInt(match[2], 10); + nextSeason = currentSeason; + nextEpisode = currentEpisode + 1; } else { - // Try format: seriesId:season:episode (e.g., tt0108778:4:6) + // Pattern 2: id:season:episode const parts = episodeId.split(':'); - if (parts.length >= 3) { - const seasonPart = parts[parts.length - 2]; // Second to last part - const episodePart = parts[parts.length - 1]; // Last part - - const seasonNum = parseInt(seasonPart, 10); - const episodeNum = parseInt(episodePart, 10); - + if (parts.length >= 2) { + const seasonNum = parseInt(parts[parts.length - 2], 10); + const episodeNum = parseInt(parts[parts.length - 1], 10); if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - season = seasonNum; - episode = episodeNum; - episodeTitle = `Episode ${episode}`; + nextSeason = seasonNum; + nextEpisode = episodeNum + 1; } } } } - - const continueWatchingItem: ContinueWatchingItem = { - ...content, - progress: progressPercent, - lastUpdated: progress.lastUpdated, - season, - episode, - episodeTitle - }; - - if (type === 'series') { - // For series, keep only the latest watched episode for each show - if (!latestEpisodes[id] || latestEpisodes[id].lastUpdated < progress.lastUpdated) { - latestEpisodes[id] = continueWatchingItem; + + // Check if next episode exists using cached metadata + if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { + const nextEpisodeExists = metadata.videos.some((video: any) => + video.season === nextSeason && video.episode === nextEpisode + ); + + if (nextEpisodeExists) { + const nextEpisodeItem = { + ...basicContent, + id: group.id, + type: group.type, + progress: 0, + lastUpdated: progress.lastUpdated, + season: nextSeason, + episode: nextEpisode, + episodeTitle: `Episode ${nextEpisode}`, + } as ContinueWatchingItem; + + // Store in latestEpisodes to ensure single entry per show + const existingLatest = latestEpisodes[group.id]; + if (!existingLatest || existingLatest.lastUpdated < nextEpisodeItem.lastUpdated) { + latestEpisodes[group.id] = nextEpisodeItem; + } } + } + continue; + } + + // Handle in-progress episodes + let season: number | undefined; + let episodeNumber: number | undefined; + let episodeTitle: string | undefined; + + if (episodeId && group.type === 'series') { + // Try different episode ID formats + let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1 + if (match) { + season = parseInt(match[1], 10); + episodeNumber = parseInt(match[2], 10); + episodeTitle = `Episode ${episodeNumber}`; } else { - // For movies, add to the list directly - progressItems.push(continueWatchingItem); + // Try format: seriesId:season:episode (e.g., tt0108778:4:6) + const parts = episodeId.split(':'); + if (parts.length >= 3) { + const seasonPart = parts[parts.length - 2]; // Second to last part + const episodePart = parts[parts.length - 1]; // Last part + + const seasonNum = parseInt(seasonPart, 10); + const episodeNum = parseInt(episodePart, 10); + + if (!isNaN(seasonNum) && !isNaN(episodeNum)) { + season = seasonNum; + episodeNumber = episodeNum; + episodeTitle = `Episode ${episodeNumber}`; + } + } } } - } catch (error) { - logger.error(`Failed to get content details for ${type}:${id}`, error); + + const continueWatchingItem: ContinueWatchingItem = { + ...basicContent, + progress: progressPercent, + lastUpdated: progress.lastUpdated, + season, + episode: episodeNumber, + episodeTitle + }; + + if (group.type === 'series') { + // For series, keep only the latest watched episode for each show + if (!latestEpisodes[group.id] || latestEpisodes[group.id].lastUpdated < progress.lastUpdated) { + latestEpisodes[group.id] = continueWatchingItem; + } + } else { + // For movies, add to the list directly + progressItems.push(continueWatchingItem); + } } - })(); - - contentPromises.push(contentPromise); - } + } catch (error) { + logger.error(`Failed to process content group ${group.type}:${group.id}:`, error); + } + }); // Wait for all content to be processed - await Promise.all(contentPromises); + await Promise.all(contentPromises); // -------------------- TRAKT HISTORY INTEGRATION -------------------- try { @@ -278,29 +321,40 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // Create placeholders (or update) for each show based on Trakt history - for (const [showId, info] of Object.entries(latestWatchedByShow)) { - const nextEpisode = info.episode + 1; - const nextEpisodeId = `${showId}:${info.season}:${nextEpisode}`; - + // Process Trakt shows in batches using cached metadata + const traktPromises = Object.entries(latestWatchedByShow).map(async ([showId, info]) => { try { - const basicContent = await catalogService.getBasicContentDetails('series', showId); - if (!basicContent) continue; + const nextEpisode = info.episode + 1; + + // Use cached metadata to validate next episode exists + const cachedData = await getCachedMetadata('series', showId); + if (!cachedData?.basicContent) return; + + const { metadata, basicContent } = cachedData; + let nextEpisodeExists = false; + + if (metadata?.videos && Array.isArray(metadata.videos)) { + nextEpisodeExists = metadata.videos.some((video: any) => + video.season === info.season && video.episode === nextEpisode + ); + } + + if (nextEpisodeExists) { + const placeholder: ContinueWatchingItem = { + ...basicContent, + id: showId, + type: 'series', + progress: 0, + lastUpdated: info.watchedAt, + season: info.season, + episode: nextEpisode, + episodeTitle: `Episode ${nextEpisode}`, + } as ContinueWatchingItem; - const placeholder: ContinueWatchingItem = { - ...basicContent, - id: showId, - type: 'series', - progress: 0, - lastUpdated: info.watchedAt, - season: info.season, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, - } as ContinueWatchingItem; - - const existing = latestEpisodes[showId]; - if (!existing || existing.lastUpdated < info.watchedAt) { - latestEpisodes[showId] = placeholder; + const existing = latestEpisodes[showId]; + if (!existing || existing.lastUpdated < info.watchedAt) { + latestEpisodes[showId] = placeholder; + } } // Persist "watched" progress for the episode that Trakt reported @@ -325,7 +379,9 @@ const ContinueWatchingSection = React.forwardRef((props, re } catch (err) { logger.error('Failed to build placeholder from history:', err); } - } + }); + + await Promise.all(traktPromises); } } catch (err) { logger.error('Error merging Trakt history:', err); @@ -345,7 +401,14 @@ const ContinueWatchingSection = React.forwardRef((props, re setLoading(false); setIsRefreshing(false); } - }, [isRefreshing]); + }, [isRefreshing, getCachedMetadata]); + + // Clear cache when component unmounts or when needed + useEffect(() => { + return () => { + metadataCache.current = {}; + }; + }, []); // Function to handle app state changes const handleAppStateChange = useCallback((nextAppState: AppStateStatus) => { @@ -816,4 +879,4 @@ const styles = StyleSheet.create({ }, }); -export default React.memo(ContinueWatchingSection); \ No newline at end of file +export default React.memo(ContinueWatchingSection); \ No newline at end of file diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 122e134e..85cc74e8 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -67,6 +67,7 @@ interface HeroSectionProps { getPlayButtonText: () => string; setBannerImage: (bannerImage: string | null) => void; setLogoLoadError: (error: boolean) => void; + groupedEpisodes?: { [seasonNumber: number]: any[] }; } // Ultra-optimized ActionButtons Component - minimal re-renders @@ -80,7 +81,8 @@ const ActionButtons = React.memo(({ playButtonText, animatedStyle, isWatched, - watchProgress + watchProgress, + groupedEpisodes }: { handleShowStreams: () => void; toggleLibrary: () => void; @@ -92,6 +94,7 @@ const ActionButtons = React.memo(({ animatedStyle: any; isWatched: boolean; watchProgress: any; + groupedEpisodes?: { [seasonNumber: number]: any[] }; }) => { const { currentTheme } = useTheme(); @@ -147,17 +150,13 @@ const ActionButtons = React.memo(({ }, [isWatched, type]); const finalPlayButtonText = useMemo(() => { - if (!isWatched) { - return playButtonText; - } - - // If content is a movie, keep existing "Watch Again" label + // For movies, handle watched state if (type === 'movie') { - return 'Watch Again'; + return isWatched ? 'Watch Again' : playButtonText; } - // For series, attempt to show the next episode label (e.g., "Play S02E05") - if (type === 'series' && watchProgress?.episodeId) { + // For series, validate next episode existence for both watched and resume cases + if (type === 'series' && watchProgress?.episodeId && groupedEpisodes) { let seasonNum: number | null = null; let episodeNum: number | null = null; @@ -181,20 +180,47 @@ const ActionButtons = React.memo(({ } if (seasonNum !== null && episodeNum !== null && !isNaN(seasonNum) && !isNaN(episodeNum)) { - // For watched episodes, show the NEXT episode number - const nextEpisode = episodeNum + 1; - const seasonStr = seasonNum.toString().padStart(2, '0'); - const episodeStr = nextEpisode.toString().padStart(2, '0'); - return `Play S${seasonStr}E${episodeStr}`; + if (isWatched) { + // For watched episodes, check if next episode exists + const nextEpisode = episodeNum + 1; + const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; + const nextEpisodeExists = currentSeasonEpisodes.some(ep => + ep.episode_number === nextEpisode + ); + + if (nextEpisodeExists) { + // Show the NEXT episode number only if it exists + const seasonStr = seasonNum.toString().padStart(2, '0'); + const episodeStr = nextEpisode.toString().padStart(2, '0'); + return `Play S${seasonStr}E${episodeStr}`; + } else { + // If next episode doesn't exist, show generic text + return 'Completed'; + } + } else { + // For non-watched episodes, check if current episode exists + const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; + const currentEpisodeExists = currentSeasonEpisodes.some(ep => + ep.episode_number === episodeNum + ); + + if (currentEpisodeExists) { + // Current episode exists, use original button text + return playButtonText; + } else { + // Current episode doesn't exist, fallback to generic play + return 'Play'; + } + } } // Fallback label if parsing fails - return 'Play Next Episode'; + return isWatched ? 'Play Next Episode' : playButtonText; } - // Default fallback - return 'Play'; - }, [isWatched, playButtonText, type, watchProgress]); + // Default fallback for non-series or missing data + return isWatched ? 'Play' : playButtonText; + }, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]); return ( @@ -620,6 +646,7 @@ const HeroSection: React.FC = ({ getPlayButtonText, setBannerImage, setLogoLoadError, + groupedEpisodes, }) => { const { currentTheme } = useTheme(); const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); @@ -875,6 +902,7 @@ const HeroSection: React.FC = ({ animatedStyle={buttonsAnimatedStyle} isWatched={isWatched} watchProgress={watchProgress} + groupedEpisodes={groupedEpisodes} /> @@ -1277,4 +1305,4 @@ const styles = StyleSheet.create({ }, }); -export default React.memo(HeroSection); \ No newline at end of file +export default React.memo(HeroSection); \ No newline at end of file diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 85ade268..cd2933d2 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -432,6 +432,7 @@ const MetadataScreen: React.FC = () => { getPlayButtonText={watchProgressData.getPlayButtonText} setBannerImage={assetData.setBannerImage} setLogoLoadError={assetData.setLogoLoadError} + groupedEpisodes={groupedEpisodes} /> {/* Main Content - Optimized */}