import { useState, useEffect, useCallback, useRef } from 'react'; import { AppState, AppStateStatus } from 'react-native'; import { SimklService, SimklContentData, SimklPlaybackData, SimklUserSettings, SimklStats, SimklActivities, SimklWatchlistItem, SimklRatingItem, SimklStatus } from '../services/simklService'; import { storageService } from '../services/storageService'; import { mmkvStorage } from '../services/mmkvStorage'; import { logger } from '../utils/logger'; const simklService = SimklService.getInstance(); // Cache keys const SIMKL_ACTIVITIES_CACHE = '@simkl:activities'; const SIMKL_COLLECTIONS_CACHE = '@simkl:collections'; const SIMKL_CACHE_TIMESTAMP = '@simkl:cache_timestamp'; let hasLoadedProfileOnce = false; let cachedUserSettings: SimklUserSettings | null = null; let cachedUserStats: SimklStats | null = null; interface CollectionsCache { timestamp: number; watchingShows: SimklWatchlistItem[]; watchingMovies: SimklWatchlistItem[]; watchingAnime: SimklWatchlistItem[]; planToWatchShows: SimklWatchlistItem[]; planToWatchMovies: SimklWatchlistItem[]; planToWatchAnime: SimklWatchlistItem[]; completedShows: SimklWatchlistItem[]; completedMovies: SimklWatchlistItem[]; completedAnime: SimklWatchlistItem[]; onHoldShows: SimklWatchlistItem[]; onHoldMovies: SimklWatchlistItem[]; onHoldAnime: SimklWatchlistItem[]; droppedShows: SimklWatchlistItem[]; droppedMovies: SimklWatchlistItem[]; droppedAnime: SimklWatchlistItem[]; continueWatching: SimklPlaybackData[]; ratedContent: SimklRatingItem[]; } export function useSimklIntegration() { // Authentication state const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); const [userSettings, setUserSettings] = useState(() => cachedUserSettings); const [userStats, setUserStats] = useState(() => cachedUserStats); // Collection state - Shows const [watchingShows, setWatchingShows] = useState([]); const [planToWatchShows, setPlanToWatchShows] = useState([]); const [completedShows, setCompletedShows] = useState([]); const [onHoldShows, setOnHoldShows] = useState([]); const [droppedShows, setDroppedShows] = useState([]); // Collection state - Movies const [watchingMovies, setWatchingMovies] = useState([]); const [planToWatchMovies, setPlanToWatchMovies] = useState([]); const [completedMovies, setCompletedMovies] = useState([]); const [onHoldMovies, setOnHoldMovies] = useState([]); const [droppedMovies, setDroppedMovies] = useState([]); // Collection state - Anime const [watchingAnime, setWatchingAnime] = useState([]); const [planToWatchAnime, setPlanToWatchAnime] = useState([]); const [completedAnime, setCompletedAnime] = useState([]); const [onHoldAnime, setOnHoldAnime] = useState([]); const [droppedAnime, setDroppedAnime] = useState([]); // Special collections const [continueWatching, setContinueWatching] = useState([]); const [ratedContent, setRatedContent] = useState([]); // Lookup Sets for O(1) status checks (combined across types) const [watchingSet, setWatchingSet] = useState>(new Set()); const [planToWatchSet, setPlanToWatchSet] = useState>(new Set()); const [completedSet, setCompletedSet] = useState>(new Set()); const [onHoldSet, setOnHoldSet] = useState>(new Set()); const [droppedSet, setDroppedSet] = useState>(new Set()); // Activity tracking for caching const [lastActivityCheck, setLastActivityCheck] = useState(null); const lastPlaybackFetchAt = useRef(0); const lastActivitiesCheckAt = useRef(0); const lastPlaybackActivityAt = useRef(null); // Helper: Normalize IMDB ID const normalizeImdbId = (imdbId: string): string => { return imdbId.replace('tt', ''); }; // Helper: Parse activity date const parseActivityDate = (value?: string): number | null => { if (!value) return null; const parsed = Date.parse(value); return Number.isNaN(parsed) ? null : parsed; }; // Helper: Get latest playback activity timestamp const getLatestPlaybackActivity = (activities: SimklActivities | null): number | null => { if (!activities) return null; const candidates: Array = [ parseActivityDate(activities.playback?.all), parseActivityDate(activities.playback?.movies), parseActivityDate(activities.playback?.episodes), parseActivityDate(activities.playback?.tv), parseActivityDate(activities.playback?.anime), parseActivityDate(activities.all), parseActivityDate((activities as any).last_update), parseActivityDate((activities as any).updated_at) ]; const timestamps = candidates.filter((value): value is number => typeof value === 'number'); if (timestamps.length === 0) return null; return Math.max(...timestamps); }; // Helper: Build lookup Sets const buildLookupSets = useCallback(( watchingItems: SimklWatchlistItem[], planItems: SimklWatchlistItem[], completedItems: SimklWatchlistItem[], holdItems: SimklWatchlistItem[], droppedItems: SimklWatchlistItem[] ) => { const buildSet = (items: SimklWatchlistItem[]): Set => { const set = new Set(); items.forEach(item => { const content = item.show || item.movie || item.anime; if (content?.ids?.imdb) { const type = item.show ? 'show' : item.movie ? 'movie' : 'anime'; const key = `${type}:${normalizeImdbId(content.ids.imdb)}`; set.add(key); } }); return set; }; setWatchingSet(buildSet(watchingItems)); setPlanToWatchSet(buildSet(planItems)); setCompletedSet(buildSet(completedItems)); setOnHoldSet(buildSet(holdItems)); setDroppedSet(buildSet(droppedItems)); }, []); // Load collections from cache const loadFromCache = useCallback(async (): Promise => { try { const cachedData = await mmkvStorage.getItem(SIMKL_COLLECTIONS_CACHE); if (!cachedData) return false; const cache: CollectionsCache = JSON.parse(cachedData); // Check cache age (5 minutes) const age = Date.now() - cache.timestamp; if (age > 5 * 60 * 1000) { logger.log('[useSimklIntegration] Cache expired'); return false; } // Debug: Log cache sample to check poster data if (cache.watchingShows && cache.watchingShows.length > 0) { logger.log('[useSimklIntegration] Cache sample - first watching show:', JSON.stringify(cache.watchingShows[0], null, 2)); } if (cache.watchingMovies && cache.watchingMovies.length > 0) { logger.log('[useSimklIntegration] Cache sample - first watching movie:', JSON.stringify(cache.watchingMovies[0], null, 2)); } // Load into state setWatchingShows(cache.watchingShows || []); setWatchingMovies(cache.watchingMovies || []); setWatchingAnime(cache.watchingAnime || []); setPlanToWatchShows(cache.planToWatchShows || []); setPlanToWatchMovies(cache.planToWatchMovies || []); setPlanToWatchAnime(cache.planToWatchAnime || []); setCompletedShows(cache.completedShows || []); setCompletedMovies(cache.completedMovies || []); setCompletedAnime(cache.completedAnime || []); setOnHoldShows(cache.onHoldShows || []); setOnHoldMovies(cache.onHoldMovies || []); setOnHoldAnime(cache.onHoldAnime || []); setDroppedShows(cache.droppedShows || []); setDroppedMovies(cache.droppedMovies || []); setDroppedAnime(cache.droppedAnime || []); setContinueWatching(cache.continueWatching || []); setRatedContent(cache.ratedContent || []); // Build lookup Sets buildLookupSets( [...cache.watchingShows, ...cache.watchingMovies, ...cache.watchingAnime], [...cache.planToWatchShows, ...cache.planToWatchMovies, ...cache.planToWatchAnime], [...cache.completedShows, ...cache.completedMovies, ...cache.completedAnime], [...cache.onHoldShows, ...cache.onHoldMovies, ...cache.onHoldAnime], [...cache.droppedShows, ...cache.droppedMovies, ...cache.droppedAnime] ); logger.log('[useSimklIntegration] Loaded from cache'); return true; } catch (error) { logger.error('[useSimklIntegration] Failed to load from cache:', error); return false; } }, [buildLookupSets]); // Save collections to cache const saveToCache = useCallback(async (collections: Omit) => { try { const cache: CollectionsCache = { ...collections, timestamp: Date.now() }; await mmkvStorage.setItem(SIMKL_COLLECTIONS_CACHE, JSON.stringify(cache)); logger.log('[useSimklIntegration] Saved to cache'); } catch (error) { logger.error('[useSimklIntegration] Failed to save to cache:', error); } }, []); // Compare activities to check if refresh needed const compareActivities = useCallback(( newActivities: SimklActivities | null, cachedActivities: SimklActivities | null ): boolean => { if (!cachedActivities) return true; if (!newActivities) return false; // Compare timestamps const newAll = parseActivityDate(newActivities.all); const cachedAll = parseActivityDate(cachedActivities.all); if (newAll && cachedAll && newAll > cachedAll) { return true; } return false; }, []); // Check authentication status const checkAuthStatus = useCallback(async () => { setIsLoading(true); try { const authenticated = await simklService.isAuthenticated(); setIsAuthenticated(authenticated); } catch (error) { logger.error('[useSimklIntegration] Error checking auth status:', error); } finally { setIsLoading(false); } }, []); // Force refresh const refreshAuthStatus = useCallback(async () => { await checkAuthStatus(); }, [checkAuthStatus]); // Load all collections (main data loading method) const loadAllCollections = useCallback(async () => { if (!isAuthenticated) { logger.log('[useSimklIntegration] Cannot load collections: not authenticated'); return; } setIsLoading(true); try { // 1. Check activities first (efficient timestamp check) const activities = await simklService.getActivities(); // 2. Try to load from cache if activities haven't changed const cachedActivitiesStr = await mmkvStorage.getItem(SIMKL_ACTIVITIES_CACHE); const cachedActivities: SimklActivities | null = cachedActivitiesStr ? JSON.parse(cachedActivitiesStr) : null; const needsRefresh = compareActivities(activities, cachedActivities); if (!needsRefresh && cachedActivities) { const cacheLoaded = await loadFromCache(); if (cacheLoaded) { setLastActivityCheck(activities); logger.log('[useSimklIntegration] Using cached collections'); return; } } logger.log('[useSimklIntegration] Fetching fresh collections from API'); // 3. Fetch all collections in parallel const [ watchingShowsData, watchingMoviesData, watchingAnimeData, planToWatchShowsData, planToWatchMoviesData, planToWatchAnimeData, completedShowsData, completedMoviesData, completedAnimeData, onHoldShowsData, onHoldMoviesData, onHoldAnimeData, droppedShowsData, droppedMoviesData, droppedAnimeData, continueWatchingData, ratingsData ] = await Promise.all([ simklService.getAllItems('shows', 'watching'), simklService.getAllItems('movies', 'watching'), simklService.getAllItems('anime', 'watching'), simklService.getAllItems('shows', 'plantowatch'), simklService.getAllItems('movies', 'plantowatch'), simklService.getAllItems('anime', 'plantowatch'), simklService.getAllItems('shows', 'completed'), simklService.getAllItems('movies', 'completed'), simklService.getAllItems('anime', 'completed'), simklService.getAllItems('shows', 'hold'), simklService.getAllItems('movies', 'hold'), simklService.getAllItems('anime', 'hold'), simklService.getAllItems('shows', 'dropped'), simklService.getAllItems('movies', 'dropped'), simklService.getAllItems('anime', 'dropped'), simklService.getPlaybackStatus(), simklService.getRatings() ]); // 4. Update state setWatchingShows(watchingShowsData); setWatchingMovies(watchingMoviesData); setWatchingAnime(watchingAnimeData); setPlanToWatchShows(planToWatchShowsData); setPlanToWatchMovies(planToWatchMoviesData); setPlanToWatchAnime(planToWatchAnimeData); setCompletedShows(completedShowsData); setCompletedMovies(completedMoviesData); setCompletedAnime(completedAnimeData); setOnHoldShows(onHoldShowsData); setOnHoldMovies(onHoldMoviesData); setOnHoldAnime(onHoldAnimeData); setDroppedShows(droppedShowsData); setDroppedMovies(droppedMoviesData); setDroppedAnime(droppedAnimeData); setContinueWatching(continueWatchingData); setRatedContent(ratingsData); // 5. Build lookup Sets buildLookupSets( [...watchingShowsData, ...watchingMoviesData, ...watchingAnimeData], [...planToWatchShowsData, ...planToWatchMoviesData, ...planToWatchAnimeData], [...completedShowsData, ...completedMoviesData, ...completedAnimeData], [...onHoldShowsData, ...onHoldMoviesData, ...onHoldAnimeData], [...droppedShowsData, ...droppedMoviesData, ...droppedAnimeData] ); // 6. Cache everything await saveToCache({ watchingShows: watchingShowsData, watchingMovies: watchingMoviesData, watchingAnime: watchingAnimeData, planToWatchShows: planToWatchShowsData, planToWatchMovies: planToWatchMoviesData, planToWatchAnime: planToWatchAnimeData, completedShows: completedShowsData, completedMovies: completedMoviesData, completedAnime: completedAnimeData, onHoldShows: onHoldShowsData, onHoldMovies: onHoldMoviesData, onHoldAnime: onHoldAnimeData, droppedShows: droppedShowsData, droppedMovies: droppedMoviesData, droppedAnime: droppedAnimeData, continueWatching: continueWatchingData, ratedContent: ratingsData }); // Save activities if (activities) { await mmkvStorage.setItem(SIMKL_ACTIVITIES_CACHE, JSON.stringify(activities)); setLastActivityCheck(activities); } logger.log('[useSimklIntegration] Collections loaded successfully'); } catch (error) { logger.error('[useSimklIntegration] Error loading collections:', error); } finally { setIsLoading(false); } }, [isAuthenticated, buildLookupSets, compareActivities, loadFromCache, saveToCache]); // Status management methods const addToStatus = useCallback(async ( imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus ): Promise => { if (!isAuthenticated) return false; try { const success = await simklService.addToList(imdbId, type, status); if (success) { // Optimistic Set update const normalizedId = normalizeImdbId(imdbId); const key = `${type}:${normalizedId}`; // Update appropriate Set switch (status) { case 'watching': setWatchingSet(prev => new Set(prev).add(key)); break; case 'plantowatch': setPlanToWatchSet(prev => new Set(prev).add(key)); break; case 'completed': setCompletedSet(prev => new Set(prev).add(key)); break; case 'hold': setOnHoldSet(prev => new Set(prev).add(key)); break; case 'dropped': setDroppedSet(prev => new Set(prev).add(key)); break; } // Reload collections to get fresh data setTimeout(() => loadAllCollections(), 1000); } return success; } catch (error) { logger.error('[useSimklIntegration] Error adding to status:', error); return false; } }, [isAuthenticated, loadAllCollections]); const removeFromStatus = useCallback(async ( imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus ): Promise => { if (!isAuthenticated) return false; try { const success = await simklService.removeFromList(imdbId, type); if (success) { // Optimistic Set update const normalizedId = normalizeImdbId(imdbId); const key = `${type}:${normalizedId}`; // Remove from all Sets setWatchingSet(prev => { const newSet = new Set(prev); newSet.delete(key); return newSet; }); setPlanToWatchSet(prev => { const newSet = new Set(prev); newSet.delete(key); return newSet; }); setCompletedSet(prev => { const newSet = new Set(prev); newSet.delete(key); return newSet; }); setOnHoldSet(prev => { const newSet = new Set(prev); newSet.delete(key); return newSet; }); setDroppedSet(prev => { const newSet = new Set(prev); newSet.delete(key); return newSet; }); // Reload collections setTimeout(() => loadAllCollections(), 1000); } return success; } catch (error) { logger.error('[useSimklIntegration] Error removing from status:', error); return false; } }, [isAuthenticated, loadAllCollections]); const isInStatus = useCallback(( imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus ): boolean => { const normalizedId = normalizeImdbId(imdbId); const key = `${type}:${normalizedId}`; switch (status) { case 'watching': return watchingSet.has(key); case 'plantowatch': return planToWatchSet.has(key); case 'completed': return completedSet.has(key); case 'hold': return onHoldSet.has(key); case 'dropped': return droppedSet.has(key); default: return false; } }, [watchingSet, planToWatchSet, completedSet, onHoldSet, droppedSet]); // Load playback/continue watching (kept from original) const loadPlaybackStatus = useCallback(async () => { if (!isAuthenticated) return; try { const playback = await simklService.getPlaybackStatus(); setContinueWatching(playback); } catch (error) { logger.error('[useSimklIntegration] Error loading playback status:', error); } }, [isAuthenticated]); // Load user settings and stats (kept from original) const loadUserProfile = useCallback(async () => { if (!isAuthenticated) return; try { const settings = await simklService.getUserSettings(); setUserSettings(settings); cachedUserSettings = settings; const accountId = settings?.account?.id; if (accountId) { const stats = await simklService.getUserStats(accountId); setUserStats(stats); cachedUserStats = stats; } else { setUserStats(null); cachedUserStats = null; } } catch (error) { logger.error('[useSimklIntegration] Error loading user profile:', error); } }, [isAuthenticated]); // Scrobbling methods (kept from original) const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { if (!isAuthenticated) return false; try { const res = await simklService.scrobbleStart(content, progress); return !!res; } catch (error) { logger.error('[useSimklIntegration] Error starting watch:', error); return false; } }, [isAuthenticated]); const updateProgress = useCallback(async (content: SimklContentData, progress: number): Promise => { if (!isAuthenticated) return false; try { const res = await simklService.scrobblePause(content, progress); return !!res; } catch (error) { logger.error('[useSimklIntegration] Error updating progress:', error); return false; } }, [isAuthenticated]); const stopWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { if (!isAuthenticated) return false; try { const res = await simklService.scrobbleStop(content, progress); return !!res; } catch (error) { logger.error('[useSimklIntegration] Error stopping watch:', error); return false; } }, [isAuthenticated]); // Sync methods (kept from original) const syncAllProgress = useCallback(async (): Promise => { if (!isAuthenticated) return false; try { const unsynced = await storageService.getUnsyncedProgress(); const itemsToSync = unsynced.filter(i => !i.progress.simklSynced || (i.progress.simklLastSynced && i.progress.lastUpdated > i.progress.simklLastSynced)); if (itemsToSync.length === 0) return true; logger.log(`[useSimklIntegration] Found ${itemsToSync.length} items to sync to Simkl`); for (const item of itemsToSync) { try { const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined; const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined; const content: SimklContentData = { type: item.type === 'series' ? 'episode' : 'movie', title: 'Unknown', ids: { imdb: item.id }, season, episode }; const progressPercent = (item.progress.currentTime / item.progress.duration) * 100; let success = false; if (progressPercent >= 85) { if (content.type === 'movie') { await simklService.addToHistory({ movies: [{ ids: { imdb: item.id } }] }); } else { await simklService.addToHistory({ shows: [{ ids: { imdb: item.id }, seasons: [{ number: season, episodes: [{ number: episode }] }] }] }); } success = true; } else { const res = await simklService.scrobblePause(content, progressPercent); success = !!res; } if (success) { await storageService.updateSimklSyncStatus(item.id, item.type, true, progressPercent, item.episodeId); } } catch (e) { logger.error(`[useSimklIntegration] Failed to sync item ${item.id}`, e); } } return true; } catch (e) { logger.error('[useSimklIntegration] Error syncing all progress', e); return false; } }, [isAuthenticated]); const fetchAndMergeSimklProgress = useCallback(async (): Promise => { if (!isAuthenticated) return false; try { const now = Date.now(); if (now - lastActivitiesCheckAt.current < 30000) { return true; } lastActivitiesCheckAt.current = now; const activities = await simklService.getActivities(); const latestPlaybackActivity = getLatestPlaybackActivity(activities); if (latestPlaybackActivity && lastPlaybackActivityAt.current === latestPlaybackActivity) { return true; } if (latestPlaybackActivity) { lastPlaybackActivityAt.current = latestPlaybackActivity; } if (now - lastPlaybackFetchAt.current < 60000) { return true; } lastPlaybackFetchAt.current = now; const playback = await simklService.getPlaybackStatus(); logger.log(`[useSimklIntegration] fetched Simkl playback: ${playback.length}`); setContinueWatching(playback); for (const item of playback) { let id: string | undefined; let type: string; let episodeId: string | undefined; if (item.movie) { id = item.movie.ids.imdb; type = 'movie'; } else if (item.show && item.episode) { id = item.show.ids.imdb; type = 'series'; const epNum = (item.episode as any).episode ?? (item.episode as any).number; episodeId = epNum !== undefined ? `${id}:${item.episode.season}:${epNum}` : undefined; } if (id) { await storageService.mergeWithSimklProgress( id, type!, item.progress, item.paused_at, episodeId ); await storageService.updateSimklSyncStatus(id, type!, true, item.progress, episodeId); } } return true; } catch (e) { logger.error('[useSimklIntegration] Error fetching/merging Simkl progress', e); return false; } }, [isAuthenticated, getLatestPlaybackActivity]); // Effects useEffect(() => { checkAuthStatus(); }, [checkAuthStatus]); useEffect(() => { if (isAuthenticated) { fetchAndMergeSimklProgress(); if (!hasLoadedProfileOnce) { hasLoadedProfileOnce = true; loadUserProfile(); } } }, [isAuthenticated, fetchAndMergeSimklProgress, loadUserProfile]); // App state listener for sync useEffect(() => { if (!isAuthenticated) return; const sub = AppState.addEventListener('change', (state) => { if (state === 'active') { fetchAndMergeSimklProgress(); loadAllCollections(); } }); return () => sub.remove(); }, [isAuthenticated, fetchAndMergeSimklProgress, loadAllCollections]); return { // Authentication isAuthenticated, isLoading, userSettings, userStats, checkAuthStatus, refreshAuthStatus, // Collections - Shows watchingShows, planToWatchShows, completedShows, onHoldShows, droppedShows, // Collections - Movies watchingMovies, planToWatchMovies, completedMovies, onHoldMovies, droppedMovies, // Collections - Anime watchingAnime, planToWatchAnime, completedAnime, onHoldAnime, droppedAnime, // Special collections continueWatching, ratedContent, // Lookup Sets watchingSet, planToWatchSet, completedSet, onHoldSet, droppedSet, // Methods loadAllCollections, addToStatus, removeFromStatus, isInStatus, // Scrobbling (kept from original) startWatching, updateProgress, stopWatching, syncAllProgress, fetchAndMergeSimklProgress, }; }