Calender screen optimziation which caused OOM.

This commit is contained in:
tapframe 2025-09-11 11:43:28 +05:30
parent bc2a15f81f
commit 2a118a17d4
10 changed files with 838 additions and 115 deletions

View file

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

View file

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

View file

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

View file

@ -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<NavigationProp<RootStackParamList>>();
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

View file

@ -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]);

View file

@ -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 = () => {
</View>
);
// 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}`);

View file

@ -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();

View file

@ -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<void> {
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<void> {
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();
}
}

View file

@ -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<void> {
await this.ensureInitialized();

260
src/utils/memoryManager.ts Normal file
View file

@ -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<T, R>(
array: T[],
processor: (item: T, index: number) => Promise<R> | R,
batchSize: number = 10,
delayMs: number = 0
): Promise<R[]> {
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<T>(
array: T[],
predicate: (item: T, index: number) => boolean,
yieldEvery: number = 100
): Promise<T[]> {
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<T, R>(
array: T[],
mapper: (item: T, index: number) => R,
batchSize: number = 50
): Promise<R[]> {
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<T>(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<T>(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();