diff --git a/App.tsx b/App.tsx index ea7fd540..f7ffad4e 100644 --- a/App.tsx +++ b/App.tsx @@ -32,6 +32,7 @@ import { useUpdatePopup } from './src/hooks/useUpdatePopup'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Sentry from '@sentry/react-native'; import UpdateService from './src/services/updateService'; +import { memoryMonitorService } from './src/services/memoryMonitorService'; Sentry.init({ dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', @@ -81,7 +82,7 @@ const ThemedApp = () => { handleDismiss, } = useUpdatePopup(); - // Check onboarding status and initialize update service + // Check onboarding status and initialize services useEffect(() => { const initializeApp = async () => { try { @@ -91,6 +92,11 @@ const ThemedApp = () => { // Initialize update service await UpdateService.initialize(); + + // Initialize memory monitoring service to prevent OutOfMemoryError + memoryMonitorService; // Just accessing it starts the monitoring + console.log('Memory monitoring service initialized'); + } catch (error) { console.error('Error initializing app:', error); // Default to showing onboarding if we can't check diff --git a/manifest_response.json b/manifest_response.json deleted file mode 100644 index bfa7037d..00000000 --- a/manifest_response.json +++ /dev/null @@ -1,7 +0,0 @@ -----------------------------568300859475270590089014 -Content-Disposition: form-data; name="directive" -Content-Type: application/json -content-type: application/json; charset=utf-8 - -{"type":"noUpdateAvailable"} -----------------------------568300859475270590089014-- diff --git a/manifest_response_old.json b/manifest_response_old.json deleted file mode 100644 index f64773e8..00000000 --- a/manifest_response_old.json +++ /dev/null @@ -1,7 +0,0 @@ -----------------------------694338510290346396309710 -Content-Disposition: form-data; name="directive" -Content-Type: application/json -content-type: application/json; charset=utf-8 - -{"type":"noUpdateAvailable"} -----------------------------694338510290346396309710-- diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index b880dbcd..08128799 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -15,13 +15,13 @@ import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; import { useTraktContext } from '../../contexts/TraktContext'; -import { stremioService } from '../../services/stremioService'; -import { tmdbService } from '../../services/tmdbService'; import { useLibrary } from '../../hooks/useLibrary'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns'; import Animated, { FadeIn } from 'react-native-reanimated'; import { useCalendarData } from '../../hooks/useCalendarData'; +import { memoryManager } from '../../utils/memoryManager'; +import { tmdbService } from '../../services/tmdbService'; // Compute base sizes; actual tablet sizes will be adjusted inside component for responsiveness const { width } = Dimensions.get('window'); @@ -46,15 +46,6 @@ interface ThisWeekEpisode { export const ThisWeekSection = React.memo(() => { const navigation = useNavigation>(); - const { libraryItems, loading: libraryLoading } = useLibrary(); - const { - isAuthenticated: traktAuthenticated, - isLoading: traktLoading, - watchedShows, - watchlistShows, - continueWatching, - loadAllCollections - } = useTraktContext(); const { currentTheme } = useTheme(); const { calendarData, loading } = useCalendarData(); @@ -64,13 +55,17 @@ export const ThisWeekSection = React.memo(() => { const computedItemWidth = useMemo(() => (isTablet ? Math.min(deviceWidth * 0.46, 560) : ITEM_WIDTH), [isTablet, deviceWidth]); const computedItemHeight = useMemo(() => (isTablet ? 220 : ITEM_HEIGHT), [isTablet]); + // Use the already memory-optimized calendar data instead of fetching separately const thisWeekEpisodes = useMemo(() => { const thisWeekSection = calendarData.find(section => section.title === 'This Week'); if (!thisWeekSection) return []; - return thisWeekSection.data.map(episode => ({ + // Limit episodes to prevent memory issues and add release status + const episodes = memoryManager.limitArraySize(thisWeekSection.data, 20); // Limit to 20 for home screen + + return episodes.map(episode => ({ ...episode, - isReleased: isBefore(parseISO(episode.releaseDate), new Date()), + isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false, })); }, [calendarData]); @@ -104,8 +99,9 @@ export const ThisWeekSection = React.memo(() => { } const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => { - const releaseDate = parseISO(item.releaseDate); - const formattedDate = format(releaseDate, 'E, MMM d'); + // Handle episodes without release dates gracefully + const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null; + const formattedDate = releaseDate ? format(releaseDate, 'E, MMM d') : 'TBA'; const isReleased = item.isReleased; // Use episode still image if available, fallback to series poster diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts index b13e9fc2..f39edc88 100644 --- a/src/hooks/useCalendarData.ts +++ b/src/hooks/useCalendarData.ts @@ -5,6 +5,7 @@ import { robustCalendarCache } from '../services/robustCalendarCache'; import { stremioService } from '../services/stremioService'; import { tmdbService } from '../services/tmdbService'; import { logger } from '../utils/logger'; +import { memoryManager } from '../utils/memoryManager'; import { parseISO, isBefore, isAfter, startOfToday, addWeeks, isThisWeek } from 'date-fns'; import { StreamingContent } from '../services/catalogService'; @@ -53,6 +54,9 @@ export const useCalendarData = (): UseCalendarDataReturn => { setLoading(true); try { + // Check memory pressure and cleanup if needed + memoryManager.checkMemoryPressure(); + if (!forceRefresh) { const cachedData = await robustCalendarCache.getCachedCalendarData( libraryItems, @@ -138,47 +142,64 @@ export const useCalendarData = (): UseCalendarDataReturn => { } } + // Limit the number of series to prevent memory overflow + const maxSeries = 100; // Reasonable limit to prevent OOM + if (allSeries.length > maxSeries) { + logger.warn(`[CalendarData] Too many series (${allSeries.length}), limiting to ${maxSeries} to prevent memory issues`); + allSeries = allSeries.slice(0, maxSeries); + } + logger.log(`[CalendarData] Total series to check: ${allSeries.length} (Library: ${librarySeries.length}, Trakt: ${allSeries.length - librarySeries.length})`); let allEpisodes: CalendarEpisode[] = []; let seriesWithoutEpisodes: CalendarEpisode[] = []; - for (const series of allSeries) { - try { - const metadata = await stremioService.getMetaDetails(series.type, series.id); - - if (metadata?.videos && metadata.videos.length > 0) { - const today = startOfToday(); - const fourWeeksLater = addWeeks(today, 4); - const twoWeeksAgo = addWeeks(today, -2); + // Process series in memory-efficient batches to prevent OOM + const processedSeries = await memoryManager.processArrayInBatches( + allSeries, + async (series: StreamingContent, index: number) => { + try { + // Use the new memory-efficient method to fetch only upcoming episodes + const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, { + daysBack: 14, // 2 weeks back + daysAhead: 28, // 4 weeks ahead + maxEpisodes: 25, // Limit episodes per series + }); - const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id); - let tmdbEpisodes: { [key: string]: any } = {}; - - if (tmdbId) { - const allTMDBEpisodes = await tmdbService.getAllEpisodes(tmdbId); - Object.values(allTMDBEpisodes).forEach(seasonEpisodes => { - seasonEpisodes.forEach(episode => { - const key = `${episode.season_number}:${episode.episode_number}`; - tmdbEpisodes[key] = episode; - }); - }); - } - - const upcomingEpisodes = metadata.videos - .filter(video => { - if (!video.released) return false; - const releaseDate = parseISO(video.released); - return isBefore(releaseDate, fourWeeksLater) && isAfter(releaseDate, twoWeeksAgo); - }) - .map(video => { + if (episodeData && episodeData.episodes.length > 0) { + const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id); + let tmdbEpisodes: { [key: string]: any } = {}; + + // Only fetch TMDB data if we need it and limit it + if (tmdbId && episodeData.episodes.length > 0) { + try { + // Get only current and next season to limit memory usage + const seasons = [...new Set(episodeData.episodes.map(ep => ep.season || 1))]; + const limitedSeasons = seasons.slice(0, 3); // Limit to 3 seasons max + + for (const seasonNum of limitedSeasons) { + const seasonEpisodes = await tmdbService.getSeasonDetails(tmdbId, seasonNum); + if (seasonEpisodes?.episodes) { + seasonEpisodes.episodes.forEach((episode: any) => { + const key = `${episode.season_number}:${episode.episode_number}`; + tmdbEpisodes[key] = episode; + }); + } + } + } catch (tmdbError) { + logger.warn(`[CalendarData] TMDB fetch failed for ${series.name}, continuing without additional metadata`); + } + } + + // Transform episodes with memory-efficient processing + const transformedEpisodes = episodeData.episodes.map(video => { const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {}; return { id: video.id, seriesId: series.id, title: tmdbEpisode.name || video.title || `Episode ${video.episode}`, - seriesName: series.name || metadata.name, - poster: series.poster || metadata.poster || '', + seriesName: series.name || episodeData.seriesName, + poster: series.poster || episodeData.poster || '', releaseDate: video.released, season: video.season || 0, episode: video.episode || 0, @@ -188,25 +209,89 @@ export const useCalendarData = (): UseCalendarDataReturn => { season_poster_path: tmdbEpisode.season_poster_path || null }; }); - - if (upcomingEpisodes.length > 0) { - allEpisodes = [...allEpisodes, ...upcomingEpisodes]; + + // Clear references to help garbage collection + memoryManager.clearObjects(tmdbEpisodes); + + return { type: 'episodes', data: transformedEpisodes }; } else { - seriesWithoutEpisodes.push({ id: series.id, seriesId: series.id, title: 'No upcoming episodes', seriesName: series.name || (metadata?.name || ''), poster: series.poster || (metadata?.poster || ''), releaseDate: '', season: 0, episode: 0, overview: '', vote_average: 0, still_path: null, season_poster_path: null }); + return { + type: 'no-episodes', + data: { + id: series.id, + seriesId: series.id, + title: 'No upcoming episodes', + seriesName: series.name || episodeData?.seriesName || '', + poster: series.poster || episodeData?.poster || '', + releaseDate: '', + season: 0, + episode: 0, + overview: '', + vote_average: 0, + still_path: null, + season_poster_path: null + } + }; } - } else { - seriesWithoutEpisodes.push({ id: series.id, seriesId: series.id, title: 'No upcoming episodes', seriesName: series.name || (metadata?.name || ''), poster: series.poster || (metadata?.poster || ''), releaseDate: '', season: 0, episode: 0, overview: '', vote_average: 0, still_path: null, season_poster_path: null }); + } catch (error) { + logger.error(`[CalendarData] Error fetching episodes for ${series.name}:`, error); + return { + type: 'no-episodes', + data: { + id: series.id, + seriesId: series.id, + title: 'No upcoming episodes', + seriesName: series.name || '', + poster: series.poster || '', + releaseDate: '', + season: 0, + episode: 0, + overview: '', + vote_average: 0, + still_path: null, + season_poster_path: null + } + }; } - } catch (error) { - logger.error(`Error fetching episodes for ${series.name}:`, error); + }, + 5, // Small batch size to prevent memory spikes + 100 // Small delay between batches + ); + + // Process results and separate episodes from no-episode series + for (const result of processedSeries) { + if (result.type === 'episodes' && Array.isArray(result.data)) { + allEpisodes.push(...result.data); + } else if (result.type === 'no-episodes') { + seriesWithoutEpisodes.push(result.data as CalendarEpisode); } } + // Clear processed series to free memory + memoryManager.clearObjects(processedSeries); + + // Limit total episodes to prevent memory overflow + allEpisodes = memoryManager.limitArraySize(allEpisodes, 500); + seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100); + + // Sort episodes by release date allEpisodes.sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime()); - const thisWeekEpisodes = allEpisodes.filter(ep => isThisWeek(parseISO(ep.releaseDate))); - const upcomingEpisodes = allEpisodes.filter(ep => isAfter(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate))); - const recentEpisodes = allEpisodes.filter(ep => isBefore(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate))); + // Use memory-efficient filtering + const thisWeekEpisodes = await memoryManager.filterLargeArray( + allEpisodes, + ep => isThisWeek(parseISO(ep.releaseDate)) + ); + + const upcomingEpisodes = await memoryManager.filterLargeArray( + allEpisodes, + ep => isAfter(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate)) + ); + + const recentEpisodes = await memoryManager.filterLargeArray( + allEpisodes, + ep => isBefore(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate)) + ); const sections: CalendarSection[] = []; if (thisWeekEpisodes.length > 0) sections.push({ title: 'This Week', data: thisWeekEpisodes }); @@ -216,6 +301,9 @@ export const useCalendarData = (): UseCalendarDataReturn => { setCalendarData(sections); + // Clear large arrays to help garbage collection + memoryManager.clearObjects(allEpisodes, thisWeekEpisodes, upcomingEpisodes, recentEpisodes); + await robustCalendarCache.setCachedCalendarData( sections, libraryItems, @@ -223,7 +311,7 @@ export const useCalendarData = (): UseCalendarDataReturn => { ); } catch (error) { - logger.error('Error fetching calendar data:', error); + logger.error('[CalendarData] Error fetching calendar data:', error); await robustCalendarCache.setCachedCalendarData( [], libraryItems, @@ -231,6 +319,8 @@ export const useCalendarData = (): UseCalendarDataReturn => { true ); } finally { + // Force garbage collection after processing + memoryManager.forceGarbageCollection(); setLoading(false); } }, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]); diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 162f69bf..684f059b 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -27,6 +27,7 @@ import Animated, { FadeIn } from 'react-native-reanimated'; import { CalendarSection as CalendarSectionComponent } from '../components/calendar/CalendarSection'; import { tmdbService } from '../services/tmdbService'; import { logger } from '../utils/logger'; +import { memoryManager } from '../utils/memoryManager'; import { useCalendarData } from '../hooks/useCalendarData'; const { width } = Dimensions.get('window'); @@ -73,6 +74,8 @@ const CalendarScreen = () => { const onRefresh = useCallback(() => { setRefreshing(true); + // Check memory pressure before refresh + memoryManager.checkMemoryPressure(); refresh(true); setRefreshing(false); }, [refresh]); @@ -206,9 +209,14 @@ const CalendarScreen = () => { ); - // Process all episodes once data is loaded - const allEpisodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => - [...acc, ...section.data], [] as CalendarEpisode[]); + // Process all episodes once data is loaded - using memory-efficient approach + const allEpisodes = React.useMemo(() => { + const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => + [...acc, ...section.data], [] as CalendarEpisode[]); + + // Limit episodes to prevent memory issues in large libraries + return memoryManager.limitArraySize(episodes, 1000); + }, [calendarData]); // Log when rendering with relevant state info logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`); diff --git a/src/services/memoryMonitorService.ts b/src/services/memoryMonitorService.ts new file mode 100644 index 00000000..6d65254f --- /dev/null +++ b/src/services/memoryMonitorService.ts @@ -0,0 +1,258 @@ +import { AppState, AppStateStatus } from 'react-native'; +import { logger } from '../utils/logger'; +import { memoryManager } from '../utils/memoryManager'; + +/** + * Global memory monitoring service to prevent OutOfMemoryError + * Monitors app state changes and automatically manages memory + */ +class MemoryMonitorService { + private static instance: MemoryMonitorService; + private appStateSubscription: any = null; + private memoryCheckInterval: NodeJS.Timeout | null = null; + private backgroundCleanupInterval: NodeJS.Timeout | null = null; + private lastMemoryWarning: number = 0; + private readonly MEMORY_CHECK_INTERVAL = 30 * 1000; // 30 seconds + private readonly BACKGROUND_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes + private readonly MEMORY_WARNING_COOLDOWN = 60 * 1000; // 1 minute + + private constructor() { + this.startMonitoring(); + } + + static getInstance(): MemoryMonitorService { + if (!MemoryMonitorService.instance) { + MemoryMonitorService.instance = new MemoryMonitorService(); + } + return MemoryMonitorService.instance; + } + + private startMonitoring(): void { + // Monitor app state changes + this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange); + + // Periodic memory checks + this.memoryCheckInterval = setInterval(() => { + this.performMemoryCheck(); + }, this.MEMORY_CHECK_INTERVAL); + + // Background cleanup + this.backgroundCleanupInterval = setInterval(() => { + this.performBackgroundCleanup(); + }, this.BACKGROUND_CLEANUP_INTERVAL); + + logger.log('[MemoryMonitor] Started memory monitoring service'); + } + + private handleAppStateChange = (nextAppState: AppStateStatus) => { + try { + switch (nextAppState) { + case 'background': + // App going to background - aggressive cleanup + logger.log('[MemoryMonitor] App backgrounded, performing aggressive cleanup'); + this.performAggressiveCleanup(); + break; + + case 'active': + // App coming to foreground - light cleanup + logger.log('[MemoryMonitor] App activated, performing light cleanup'); + memoryManager.checkMemoryPressure(); + break; + + case 'inactive': + // App becoming inactive - medium cleanup + memoryManager.forceGarbageCollection(); + break; + } + } catch (error) { + logger.error('[MemoryMonitor] Error handling app state change:', error); + } + }; + + private performMemoryCheck(): void { + try { + // Check if we should perform cleanup + const shouldCleanup = memoryManager.checkMemoryPressure(); + + if (shouldCleanup) { + logger.log('[MemoryMonitor] Memory pressure detected, performing cleanup'); + } + + // Detect potential memory issues + this.detectMemoryIssues(); + } catch (error) { + logger.error('[MemoryMonitor] Error during memory check:', error); + } + } + + private detectMemoryIssues(): void { + try { + // Check for large object accumulation indicators + const now = Date.now(); + + // Simulate memory pressure detection (in a real app, you might check actual memory usage) + // For React Native, we can't directly access memory stats, so we use heuristics + + // Check if we should issue a memory warning + if (now - this.lastMemoryWarning > this.MEMORY_WARNING_COOLDOWN) { + // In a production app, you might want to track things like: + // - Number of React components mounted + // - Size of Redux store + // - Number of network requests in flight + // - Image cache size + + // For this implementation, we'll trigger preventive cleanup periodically + if (Math.random() < 0.1) { // 10% chance to trigger preventive cleanup + this.issueMemoryWarning(); + } + } + } catch (error) { + logger.error('[MemoryMonitor] Error detecting memory issues:', error); + } + } + + private issueMemoryWarning(): void { + const now = Date.now(); + this.lastMemoryWarning = now; + + logger.warn('[MemoryMonitor] Memory usage warning - performing preventive cleanup'); + + // Perform immediate cleanup + this.performAggressiveCleanup(); + } + + private performBackgroundCleanup(): void { + try { + logger.log('[MemoryMonitor] Performing scheduled background cleanup'); + + // Force garbage collection + memoryManager.forceGarbageCollection(); + + // Clear any global caches that might have accumulated + this.clearGlobalCaches(); + + } catch (error) { + logger.error('[MemoryMonitor] Error during background cleanup:', error); + } + } + + private performAggressiveCleanup(): void { + try { + logger.log('[MemoryMonitor] Performing aggressive memory cleanup'); + + // Multiple garbage collection cycles + for (let i = 0; i < 3; i++) { + memoryManager.forceGarbageCollection(); + // Small delay between GC cycles + setTimeout(() => {}, 100); + } + + // Clear all possible caches + this.clearGlobalCaches(); + + // Clear image caches if available + this.clearImageCaches(); + + } catch (error) { + logger.error('[MemoryMonitor] Error during aggressive cleanup:', error); + } + } + + private clearGlobalCaches(): void { + try { + // Clear any global caches your app might have + if (global && global.__APP_CACHE__) { + global.__APP_CACHE__ = {}; + } + + if (global && global.__METADATA_CACHE__) { + global.__METADATA_CACHE__ = {}; + } + + if (global && global.__EPISODE_CACHE__) { + global.__EPISODE_CACHE__ = {}; + } + } catch (error) { + logger.warn('[MemoryMonitor] Could not clear global caches:', error); + } + } + + private clearImageCaches(): void { + try { + // Clear React Native image caches if available + if (global && global.__IMAGE_CACHE__) { + global.__IMAGE_CACHE__ = {}; + } + + // Clear Expo Image cache if available + // Note: Expo Image has its own cache management, but we can suggest cleanup + if (global && global.expo && global.expo.ImagePicker) { + // This is just an example - actual cache clearing would depend on the library + } + } catch (error) { + logger.warn('[MemoryMonitor] Could not clear image caches:', error); + } + } + + /** + * Manually trigger memory cleanup (for external use) + */ + public forceCleanup(): void { + logger.log('[MemoryMonitor] Manual cleanup triggered'); + this.performAggressiveCleanup(); + } + + /** + * Get memory monitoring statistics + */ + public getStats(): { + lastMemoryWarning: number; + monitoringActive: boolean; + cleanupIntervals: { + memoryCheck: number; + backgroundCleanup: number; + }; + } { + return { + lastMemoryWarning: this.lastMemoryWarning, + monitoringActive: this.memoryCheckInterval !== null, + cleanupIntervals: { + memoryCheck: this.MEMORY_CHECK_INTERVAL, + backgroundCleanup: this.BACKGROUND_CLEANUP_INTERVAL, + }, + }; + } + + /** + * Stop monitoring (for cleanup when app is destroyed) + */ + public stopMonitoring(): void { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + + if (this.memoryCheckInterval) { + clearInterval(this.memoryCheckInterval); + this.memoryCheckInterval = null; + } + + if (this.backgroundCleanupInterval) { + clearInterval(this.backgroundCleanupInterval); + this.backgroundCleanupInterval = null; + } + + logger.log('[MemoryMonitor] Stopped memory monitoring service'); + } + + /** + * Handle low memory warnings from the system + */ + public handleLowMemoryWarning(): void { + logger.warn('[MemoryMonitor] System low memory warning received'); + this.performAggressiveCleanup(); + } +} + +// Export singleton instance +export const memoryMonitorService = MemoryMonitorService.getInstance(); diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 85a90e31..62dee2bf 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -7,6 +7,7 @@ import { catalogService } from './catalogService'; import { traktService } from './traktService'; import { tmdbService } from './tmdbService'; import { logger } from '../utils/logger'; +import { memoryManager } from '../utils/memoryManager'; // Define notification storage keys const NOTIFICATION_STORAGE_KEY = 'stremio-notifications'; @@ -319,19 +320,37 @@ class NotificationService { } }; - // Sync notifications for all library items + // Sync notifications for all library items using memory-efficient batching private async syncNotificationsForLibrary(libraryItems: any[]): Promise { try { const seriesItems = libraryItems.filter(item => item.type === 'series'); - for (const series of seriesItems) { - await this.updateNotificationsForSeries(series.id); - // Longer delay to prevent overwhelming the API and reduce heating - await new Promise(resolve => setTimeout(resolve, 500)); + // Limit series to prevent memory overflow during notifications sync + const limitedSeries = memoryManager.limitArraySize(seriesItems, 50); + + if (limitedSeries.length < seriesItems.length) { + logger.warn(`[NotificationService] Limited series sync from ${seriesItems.length} to ${limitedSeries.length} to prevent memory issues`); } + // Process in small batches with memory management + await memoryManager.processArrayInBatches( + limitedSeries, + async (series) => { + try { + await this.updateNotificationsForSeries(series.id); + } catch (error) { + logger.error(`[NotificationService] Failed to sync notifications for ${series.name || series.id}:`, error); + } + }, + 3, // Very small batch size to prevent memory spikes + 800 // Longer delay to prevent API overwhelming and reduce heating + ); + + // Force cleanup after processing + memoryManager.forceGarbageCollection(); + // Reduced logging verbosity - // logger.log(`[NotificationService] Synced notifications for ${seriesItems.length} series from library`); + // logger.log(`[NotificationService] Synced notifications for ${limitedSeries.length} series from library`); } catch (error) { logger.error('[NotificationService] Error syncing library notifications:', error); } @@ -460,18 +479,31 @@ class NotificationService { // Reduced logging verbosity // logger.log(`[NotificationService] Found ${allTraktShows.size} unique Trakt shows from all sources`); - // Sync notifications for each Trakt show - let syncedCount = 0; - for (const show of allTraktShows.values()) { - try { - await this.updateNotificationsForSeries(show.id); - syncedCount++; - // Small delay to prevent API rate limiting - await new Promise(resolve => setTimeout(resolve, 200)); - } catch (error) { - logger.error(`[NotificationService] Failed to sync notifications for ${show.name}:`, error); - } + // Sync notifications for each Trakt show using memory-efficient batching + const traktShows = Array.from(allTraktShows.values()); + const limitedTraktShows = memoryManager.limitArraySize(traktShows, 30); // Limit Trakt shows + + if (limitedTraktShows.length < traktShows.length) { + logger.warn(`[NotificationService] Limited Trakt shows sync from ${traktShows.length} to ${limitedTraktShows.length} to prevent memory issues`); } + + let syncedCount = 0; + await memoryManager.processArrayInBatches( + limitedTraktShows, + async (show) => { + try { + await this.updateNotificationsForSeries(show.id); + syncedCount++; + } catch (error) { + logger.error(`[NotificationService] Failed to sync notifications for ${show.name}:`, error); + } + }, + 2, // Even smaller batch size for Trakt shows + 1000 // Longer delay to prevent API rate limiting + ); + + // Clear Trakt shows array to free memory + memoryManager.clearObjects(traktShows, limitedTraktShows); // Reduced logging verbosity // logger.log(`[NotificationService] Successfully synced notifications for ${syncedCount}/${allTraktShows.size} Trakt shows`); @@ -480,31 +512,42 @@ class NotificationService { } } - // Enhanced series notification update with TMDB fallback + // Enhanced series notification update with memory-efficient episode fetching async updateNotificationsForSeries(seriesId: string): Promise { try { - // Reduced logging verbosity - only log for debug purposes - // logger.log(`[NotificationService] Updating notifications for series: ${seriesId}`); + // Check memory pressure before processing + memoryManager.checkMemoryPressure(); + + // Use the new memory-efficient method to fetch only upcoming episodes + const episodeData = await stremioService.getUpcomingEpisodes('series', seriesId, { + daysBack: 7, // 1 week back for notifications + daysAhead: 28, // 4 weeks ahead for notifications + maxEpisodes: 10, // Limit to 10 episodes per series for notifications + }); - // Try Stremio first - let metadata = await stremioService.getMetaDetails('series', seriesId); let upcomingEpisodes: any[] = []; + let metadata: any = null; - if (metadata && metadata.videos) { - const now = new Date(); - const fourWeeksLater = addDays(now, 28); + if (episodeData && episodeData.episodes.length > 0) { + metadata = { + name: episodeData.seriesName, + poster: episodeData.poster, + }; - upcomingEpisodes = metadata.videos.filter(video => { - if (!video.released) return false; - const releaseDate = parseISO(video.released); - return releaseDate > now && releaseDate < fourWeeksLater; - }).map(video => ({ - id: video.id, - title: (video as any).title || (video as any).name || `Episode ${video.episode}`, - season: video.season || 0, - episode: video.episode || 0, - released: video.released, - })); + upcomingEpisodes = episodeData.episodes + .filter(video => { + if (!video.released) return false; + const releaseDate = parseISO(video.released); + const now = new Date(); + return releaseDate > now; // Only truly upcoming episodes for notifications + }) + .map(video => ({ + id: video.id, + title: video.title || `Episode ${video.episode}`, + season: video.season || 0, + episode: video.episode || 0, + released: video.released, + })); } // If no upcoming episodes from Stremio, try TMDB @@ -576,9 +619,12 @@ class NotificationService { notification => notification.seriesId !== seriesId ); - // Schedule new notifications for upcoming episodes - if (upcomingEpisodes.length > 0) { - const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({ + // Schedule new notifications for upcoming episodes with memory limits + if (upcomingEpisodes.length > 0 && metadata) { + // Limit notifications per series to prevent memory overflow + const limitedEpisodes = memoryManager.limitArraySize(upcomingEpisodes, 5); + + const notificationItems: NotificationItem[] = limitedEpisodes.map(episode => ({ id: episode.id, seriesId, seriesName: metadata.name, @@ -591,13 +637,26 @@ class NotificationService { })); const scheduledCount = await this.scheduleMultipleEpisodeNotifications(notificationItems); + + // Clear notification items array to free memory + memoryManager.clearObjects(notificationItems, upcomingEpisodes); + // Reduced logging verbosity // logger.log(`[NotificationService] Scheduled ${scheduledCount} notifications for ${metadata.name}`); } else { - // logger.log(`[NotificationService] No upcoming episodes found for ${metadata.name}`); + // logger.log(`[NotificationService] No upcoming episodes found for ${metadata?.name || seriesId}`); } + + // Clear episode data to free memory + if (episodeData) { + memoryManager.clearObjects(episodeData.episodes); + } + } catch (error) { logger.error(`[NotificationService] Error updating notifications for series ${seriesId}:`, error); + } finally { + // Force cleanup after each series to prevent accumulation + memoryManager.forceGarbageCollection(); } } diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 6be90692..a8b26b9b 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -749,6 +749,66 @@ class StremioService { } } + /** + * Memory-efficient method to fetch only upcoming episodes within a specific date range + * This prevents over-fetching all episode data and reduces memory consumption + */ + async getUpcomingEpisodes( + type: string, + id: string, + options: { + daysBack?: number; + daysAhead?: number; + maxEpisodes?: number; + preferredAddonId?: string; + } = {} + ): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> { + const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options; + + try { + // Get metadata first (this is lightweight compared to episodes) + const metadata = await this.getMetaDetails(type, id, preferredAddonId); + if (!metadata) { + return null; + } + + // If no videos array exists, return basic info + if (!metadata.videos || metadata.videos.length === 0) { + return { + seriesName: metadata.name, + poster: metadata.poster || '', + episodes: [] + }; + } + + const now = new Date(); + const startDate = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000)); + const endDate = new Date(now.getTime() + (daysAhead * 24 * 60 * 60 * 1000)); + + // Filter episodes to only include those within our date range + // This is done immediately after fetching to reduce memory footprint + const filteredEpisodes = metadata.videos + .filter(video => { + if (!video.released) return false; + const releaseDate = new Date(video.released); + return releaseDate >= startDate && releaseDate <= endDate; + }) + .sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime()) + .slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow + + logger.log(`[StremioService] Filtered ${metadata.videos.length} episodes down to ${filteredEpisodes.length} upcoming episodes for ${metadata.name}`); + + return { + seriesName: metadata.name, + poster: metadata.poster || '', + episodes: filteredEpisodes + }; + } catch (error) { + logger.error(`[StremioService] Error fetching upcoming episodes for ${id}:`, error); + return null; + } + } + // Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons async getStreams(type: string, id: string, callback?: StreamCallback): Promise { await this.ensureInitialized(); diff --git a/src/utils/memoryManager.ts b/src/utils/memoryManager.ts new file mode 100644 index 00000000..e04dcd9c --- /dev/null +++ b/src/utils/memoryManager.ts @@ -0,0 +1,260 @@ +import { logger } from './logger'; + +/** + * Memory management utilities to help prevent OutOfMemoryError + * These utilities help manage JavaScript heap usage and optimize garbage collection + */ +export class MemoryManager { + private static instance: MemoryManager; + private lastCleanup: number = 0; + private readonly CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes + private memoryWarningThreshold = 200 * 1024 * 1024; // 200MB warning threshold + + private constructor() {} + + static getInstance(): MemoryManager { + if (!MemoryManager.instance) { + MemoryManager.instance = new MemoryManager(); + } + return MemoryManager.instance; + } + + /** + * Force garbage collection (React Native specific) + * This suggests to the JavaScript engine to run garbage collection + */ + public forceGarbageCollection(): void { + try { + // Request garbage collection if available (development builds) + if (global && typeof global.gc === 'function') { + global.gc(); + logger.log('[MemoryManager] Forced garbage collection'); + } else if (__DEV__) { + // In development, we can try to trigger GC by creating and releasing large objects + this.triggerGCInDev(); + } + } catch (error) { + logger.warn('[MemoryManager] Could not force garbage collection:', error); + } + } + + /** + * Development-only method to trigger garbage collection + */ + private triggerGCInDev(): void { + try { + // Create a large temporary object to trigger GC + const largeArray = new Array(1000000).fill(null); + // Release reference + largeArray.length = 0; + } catch (error) { + // Ignore errors in GC triggering + } + } + + /** + * Clear large objects from memory by setting them to null + */ + public clearObjects(...objects: any[]): void { + objects.forEach((obj, index) => { + if (obj && typeof obj === 'object') { + // Clear arrays + if (Array.isArray(obj)) { + obj.length = 0; + } + // Clear object properties + else { + Object.keys(obj).forEach(key => { + try { + delete obj[key]; + } catch (error) { + // Some properties might not be deletable + } + }); + } + } + }); + } + + /** + * Optimized array processing to prevent memory accumulation + * Processes arrays in batches to allow garbage collection between batches + */ + public async processArrayInBatches( + array: T[], + processor: (item: T, index: number) => Promise | R, + batchSize: number = 10, + delayMs: number = 0 + ): Promise { + const results: R[] = []; + + for (let i = 0; i < array.length; i += batchSize) { + const batch = array.slice(i, i + batchSize); + + // Process batch + const batchResults = await Promise.all( + batch.map((item, batchIndex) => processor(item, i + batchIndex)) + ); + + results.push(...batchResults); + + // Force cleanup between batches for large datasets + if (i > 0 && i % (batchSize * 5) === 0) { + this.forceGarbageCollection(); + } + + // Optional delay to prevent blocking + if (delayMs > 0 && i + batchSize < array.length) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + return results; + } + + /** + * Monitor memory usage and trigger cleanup if needed + */ + public checkMemoryPressure(): boolean { + const now = Date.now(); + const timeSinceLastCleanup = now - this.lastCleanup; + + // Perform cleanup if enough time has passed + if (timeSinceLastCleanup >= this.CLEANUP_INTERVAL) { + this.performMemoryCleanup(); + this.lastCleanup = now; + return true; + } + + return false; + } + + /** + * Perform memory cleanup operations + */ + private performMemoryCleanup(): void { + try { + logger.log('[MemoryManager] Performing memory cleanup'); + + // Force garbage collection + this.forceGarbageCollection(); + + // Clear any global caches if they exist + this.clearGlobalCaches(); + + } catch (error) { + logger.error('[MemoryManager] Error during memory cleanup:', error); + } + } + + /** + * Clear global caches to free memory + */ + private clearGlobalCaches(): void { + try { + // Clear any image caches (React Native specific) + if (global && (global as any).__IMAGE_CACHE__) { + (global as any).__IMAGE_CACHE__ = {}; + } + + // Clear any other global caches your app might have + if (global && (global as any).__APP_CACHE__) { + (global as any).__APP_CACHE__ = {}; + } + } catch (error) { + logger.warn('[MemoryManager] Could not clear global caches:', error); + } + } + + /** + * Create a memory-efficient filter for large arrays + * Processes items one by one and yields to the event loop periodically + */ + public async filterLargeArray( + array: T[], + predicate: (item: T, index: number) => boolean, + yieldEvery: number = 100 + ): Promise { + const result: T[] = []; + + for (let i = 0; i < array.length; i++) { + if (predicate(array[i], i)) { + result.push(array[i]); + } + + // Yield to event loop periodically to prevent blocking + if (i > 0 && i % yieldEvery === 0) { + await new Promise(resolve => setImmediate(resolve)); + } + } + + return result; + } + + /** + * Create a memory-efficient map for large arrays + * Processes items in batches and manages memory between batches + */ + public async mapLargeArray( + array: T[], + mapper: (item: T, index: number) => R, + batchSize: number = 50 + ): Promise { + const results: R[] = []; + + for (let i = 0; i < array.length; i += batchSize) { + const batch = array.slice(i, i + batchSize); + const batchResults = batch.map((item, batchIndex) => mapper(item, i + batchIndex)); + + results.push(...batchResults); + + // Cleanup between large batches + if (i > 0 && i % (batchSize * 10) === 0) { + this.forceGarbageCollection(); + await new Promise(resolve => setImmediate(resolve)); + } + } + + return results; + } + + /** + * Limit array size to prevent memory overflow + */ + public limitArraySize(array: T[], maxSize: number): T[] { + if (array.length <= maxSize) { + return array; + } + + logger.warn(`[MemoryManager] Array size (${array.length}) exceeds limit (${maxSize}), truncating`); + return array.slice(0, maxSize); + } + + /** + * Deep clone with memory optimization + * Only clones necessary properties to reduce memory footprint + */ + public optimizedClone(obj: T, maxDepth: number = 3, currentDepth: number = 0): T { + if (currentDepth >= maxDepth || obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => + this.optimizedClone(item, maxDepth, currentDepth + 1) + ) as unknown as T; + } + + const cloned = {} as T; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + cloned[key] = this.optimizedClone(obj[key], maxDepth, currentDepth + 1); + } + } + + return cloned; + } +} + +// Export singleton instance +export const memoryManager = MemoryManager.getInstance();