diff --git a/App.tsx b/App.tsx index 4be43b96..ffe6425f 100644 --- a/App.tsx +++ b/App.tsx @@ -30,6 +30,7 @@ import 'react-native-reanimated'; import { CatalogProvider } from './src/contexts/CatalogContext'; import { GenreProvider } from './src/contexts/GenreContext'; import { TraktProvider } from './src/contexts/TraktContext'; +import { SimklProvider } from './src/contexts/SimklContext'; import { ThemeProvider, useTheme } from './src/contexts/ThemeContext'; import { TrailerProvider } from './src/contexts/TrailerContext'; import { DownloadsProvider } from './src/contexts/DownloadsContext'; @@ -263,13 +264,15 @@ function App(): React.JSX.Element { - - - - - - - + + + + + + + + + diff --git a/src/contexts/SimklContext.tsx b/src/contexts/SimklContext.tsx new file mode 100644 index 00000000..b0384009 --- /dev/null +++ b/src/contexts/SimklContext.tsx @@ -0,0 +1,85 @@ +import React, { createContext, useContext, ReactNode } from 'react'; +import { useSimklIntegration } from '../hooks/useSimklIntegration'; +import { + SimklWatchlistItem, + SimklPlaybackData, + SimklRatingItem, + SimklUserSettings, + SimklStats, + SimklStatus +} from '../services/simklService'; + +export interface SimklContextProps { + // Authentication + isAuthenticated: boolean; + isLoading: boolean; + userSettings: SimklUserSettings | null; + userStats: SimklStats | null; + + // Collections - Shows + watchingShows: SimklWatchlistItem[]; + planToWatchShows: SimklWatchlistItem[]; + completedShows: SimklWatchlistItem[]; + onHoldShows: SimklWatchlistItem[]; + droppedShows: SimklWatchlistItem[]; + + // Collections - Movies + watchingMovies: SimklWatchlistItem[]; + planToWatchMovies: SimklWatchlistItem[]; + completedMovies: SimklWatchlistItem[]; + onHoldMovies: SimklWatchlistItem[]; + droppedMovies: SimklWatchlistItem[]; + + // Collections - Anime + watchingAnime: SimklWatchlistItem[]; + planToWatchAnime: SimklWatchlistItem[]; + completedAnime: SimklWatchlistItem[]; + onHoldAnime: SimklWatchlistItem[]; + droppedAnime: SimklWatchlistItem[]; + + // Special collections + continueWatching: SimklPlaybackData[]; + ratedContent: SimklRatingItem[]; + + // Lookup Sets (for O(1) status checks) + watchingSet: Set; + planToWatchSet: Set; + completedSet: Set; + onHoldSet: Set; + droppedSet: Set; + + // Methods + checkAuthStatus: () => Promise; + refreshAuthStatus: () => Promise; + loadAllCollections: () => Promise; + addToStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise; + removeFromStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise; + isInStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => boolean; + + // Scrobbling methods (from existing hook) + startWatching?: (content: any, progress: number) => Promise; + updateProgress?: (content: any, progress: number) => Promise; + stopWatching?: (content: any, progress: number) => Promise; + syncAllProgress?: () => Promise; + fetchAndMergeSimklProgress?: () => Promise; +} + +const SimklContext = createContext(undefined); + +export function SimklProvider({ children }: { children: ReactNode }) { + const simklIntegration = useSimklIntegration(); + + return ( + + {children} + + ); +} + +export function useSimklContext() { + const context = useContext(SimklContext); + if (context === undefined) { + throw new Error('useSimklContext must be used within a SimklProvider'); + } + return context; +} diff --git a/src/hooks/useSimklIntegration.ts b/src/hooks/useSimklIntegration.ts index 3fe37d53..e073951c 100644 --- a/src/hooks/useSimklIntegration.ts +++ b/src/hooks/useSimklIntegration.ts @@ -6,36 +6,106 @@ import { SimklPlaybackData, SimklUserSettings, SimklStats, - SimklActivities + 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); - - // Basic lists - const [continueWatching, setContinueWatching] = useState([]); 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; @@ -55,6 +125,127 @@ export function useSimklIntegration() { 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); @@ -73,7 +264,262 @@ export function useSimklIntegration() { await checkAuthStatus(); }, [checkAuthStatus]); - // Load playback/continue watching + // 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 { @@ -84,7 +530,7 @@ export function useSimklIntegration() { } }, [isAuthenticated]); - // Load user settings and stats + // Load user settings and stats (kept from original) const loadUserProfile = useCallback(async () => { if (!isAuthenticated) return; try { @@ -106,7 +552,7 @@ export function useSimklIntegration() { } }, [isAuthenticated]); - // Start watching (scrobble start) + // Scrobbling methods (kept from original) const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { if (!isAuthenticated) return false; try { @@ -118,7 +564,6 @@ export function useSimklIntegration() { } }, [isAuthenticated]); - // Update progress (scrobble pause) const updateProgress = useCallback(async (content: SimklContentData, progress: number): Promise => { if (!isAuthenticated) return false; try { @@ -130,7 +575,6 @@ export function useSimklIntegration() { } }, [isAuthenticated]); - // Stop watching (scrobble stop) const stopWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { if (!isAuthenticated) return false; try { @@ -142,16 +586,12 @@ export function useSimklIntegration() { } }, [isAuthenticated]); - // Sync All Local Progress -> Simkl + // Sync methods (kept from original) const syncAllProgress = useCallback(async (): Promise => { if (!isAuthenticated) return false; try { const unsynced = await storageService.getUnsyncedProgress(); - // Filter for items that specifically need SIMKL sync (unsynced.filter(i => !i.progress.simklSynced...)) - // storageService.getUnsyncedProgress currently returns items that need Trakt OR Simkl sync. - // We should check simklSynced specifically here. - const itemsToSync = unsynced.filter(i => !i.progress.simklSynced || (i.progress.simklLastSynced && i.progress.lastUpdated > i.progress.simklLastSynced)); if (itemsToSync.length === 0) return true; @@ -163,10 +603,9 @@ export function useSimklIntegration() { 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; - // Construct content data const content: SimklContentData = { type: item.type === 'series' ? 'episode' : 'movie', - title: 'Unknown', // Ideally storage has title, but it might not. Simkl needs IDs mainly. + title: 'Unknown', ids: { imdb: item.id }, season, episode @@ -174,21 +613,15 @@ export function useSimklIntegration() { const progressPercent = (item.progress.currentTime / item.progress.duration) * 100; - // If completed (>=80% or 95% depending on logic, let's say 85% safe), add to history - // Simkl: Stop with >= 80% marks as watched. - // Or explicitly add to history. - let success = false; if (progressPercent >= 85) { - // Add to history 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; // Assume success if no throw + success = true; } else { - // Pause (scrobble) const res = await simklService.scrobblePause(content, progressPercent); success = !!res; } @@ -207,7 +640,6 @@ export function useSimklIntegration() { } }, [isAuthenticated]); - // Fetch Simkl -> Merge Local const fetchAndMergeSimklProgress = useCallback(async (): Promise => { if (!isAuthenticated) return false; @@ -263,7 +695,6 @@ export function useSimklIntegration() { episodeId ); - // Mark as synced locally so we don't push it back await storageService.updateSimklSyncStatus(id, type!, true, item.progress, episodeId); } } @@ -272,7 +703,7 @@ export function useSimklIntegration() { logger.error('[useSimklIntegration] Error fetching/merging Simkl progress', e); return false; } - }, [isAuthenticated]); + }, [isAuthenticated, getLatestPlaybackActivity]); // Effects useEffect(() => { @@ -287,7 +718,7 @@ export function useSimklIntegration() { loadUserProfile(); } } - }, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress, loadUserProfile]); + }, [isAuthenticated, fetchAndMergeSimklProgress, loadUserProfile]); // App state listener for sync useEffect(() => { @@ -295,24 +726,64 @@ export function useSimklIntegration() { const sub = AppState.addEventListener('change', (state) => { if (state === 'active') { fetchAndMergeSimklProgress(); + loadAllCollections(); } }); return () => sub.remove(); - }, [isAuthenticated, fetchAndMergeSimklProgress]); - + }, [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, - continueWatching, - userSettings, - userStats, }; } diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 984310f1..fe37c9cc 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -19,6 +19,7 @@ import { Platform, ScrollView, BackHandler, + Image, } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import { useNavigation } from '@react-navigation/native'; @@ -34,6 +35,7 @@ import { logger } from '../utils/logger'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import { useTraktContext } from '../contexts/TraktContext'; +import { useSimklContext } from '../contexts/SimklContext'; import TraktIcon from '../../assets/rating-icons/trakt.svg'; import { traktService, TraktService, TraktImages } from '../services/traktService'; import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner'; @@ -87,6 +89,8 @@ function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: nu return { numColumns, itemWidth }; } +import { TMDBService } from '../services/tmdbService'; + const TraktItem = React.memo(({ item, width, @@ -109,12 +113,47 @@ const TraktItem = React.memo(({ const url = TraktService.getTraktPosterUrl(item.images); if (isMounted && url) { setPosterUrl(url); + return; + } + } + + if (item.imdbId || item.traktId) { + try { + const tmdbService = TMDBService.getInstance(); + let tmdbId: number | null = null; + + if (item.imdbId) { + tmdbId = await tmdbService.findTMDBIdByIMDB(item.imdbId); + } + + if (!tmdbId && item.traktId) { + + } + + if (tmdbId) { + let posterPath: string | null = null; + + if (item.type === 'movie') { + const details = await tmdbService.getMovieDetails(String(tmdbId)); + posterPath = details?.poster_path ?? null; + } else { + const details = await tmdbService.getTVShowDetails(tmdbId); + posterPath = details?.poster_path ?? null; + } + + if (isMounted && posterPath) { + const url = tmdbService.getImageUrl(posterPath, 'w500'); + setPosterUrl(url); + } + } + } catch (error) { + logger.debug('Failed to fetch poster from TMDB', error); } } }; fetchPoster(); return () => { isMounted = false; }; - }, [item.images]); + }, [item.images, item.imdbId, item.traktId, item.type]); const handlePress = useCallback(() => { if (item.imdbId) { @@ -219,9 +258,11 @@ const LibraryScreen = () => { const { numColumns, itemWidth } = useMemo(() => getGridLayout(width), [width]); const [loading, setLoading] = useState(true); const [libraryItems, setLibraryItems] = useState([]); - const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies'); + const [filter, setFilter] = useState<'trakt' | 'simkl' | 'movies' | 'series'>('movies'); const [showTraktContent, setShowTraktContent] = useState(false); const [selectedTraktFolder, setSelectedTraktFolder] = useState(null); + const [showSimklContent, setShowSimklContent] = useState(false); + const [selectedSimklFolder, setSelectedSimklFolder] = useState(null); const { showInfo, showError } = useToast(); const [menuVisible, setMenuVisible] = useState(false); const [selectedItem, setSelectedItem] = useState(null); @@ -230,7 +271,6 @@ const LibraryScreen = () => { const { settings } = useSettings(); const flashListRef = useRef(null); - // Scroll to top handler const scrollToTop = useCallback(() => { flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); }, []); @@ -252,6 +292,29 @@ const LibraryScreen = () => { loadAllCollections } = useTraktContext(); + const { + isAuthenticated: simklAuthenticated, + isLoading: simklLoading, + watchingShows, + watchingMovies, + watchingAnime, + planToWatchShows, + planToWatchMovies, + planToWatchAnime, + completedShows, + completedMovies, + completedAnime, + onHoldShows, + onHoldMovies, + onHoldAnime, + droppedShows, + droppedMovies, + droppedAnime, + continueWatching: simklContinueWatching, + ratedContent: simklRatedContent, + loadAllCollections: loadSimklCollections + } = useSimklContext(); + useEffect(() => { const applyStatusBarConfig = () => { StatusBar.setBarStyle('light-content'); @@ -276,12 +339,20 @@ const LibraryScreen = () => { } return true; } + if (showSimklContent) { + if (selectedSimklFolder) { + setSelectedSimklFolder(null); + } else { + setShowSimklContent(false); + } + return true; + } return false; }; const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); return () => backHandler.remove(); - }, [showTraktContent, selectedTraktFolder]); + }, [showTraktContent, showSimklContent, selectedTraktFolder, selectedSimklFolder]); useEffect(() => { const loadLibrary = async () => { @@ -396,6 +467,117 @@ const LibraryScreen = () => { return folders.filter(folder => folder.itemCount > 0); }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + const simklFolders = useMemo((): TraktFolder[] => { + if (!simklAuthenticated) return []; + + const folders: TraktFolder[] = [ + { + id: 'continue-watching', + name: t('library.continue'), + icon: 'play-circle-outline', + itemCount: simklContinueWatching?.length || 0, + }, + { + id: 'watching-shows', + name: 'Watching Shows', + icon: 'visibility', + itemCount: watchingShows?.length || 0, + }, + { + id: 'watching-movies', + name: 'Watching Movies', + icon: 'visibility', + itemCount: watchingMovies?.length || 0, + }, + { + id: 'watching-anime', + name: 'Watching Anime', + icon: 'visibility', + itemCount: watchingAnime?.length || 0, + }, + { + id: 'plantowatch-shows', + name: 'Plan to Watch Shows', + icon: 'bookmark-border', + itemCount: planToWatchShows?.length || 0, + }, + { + id: 'plantowatch-movies', + name: 'Plan to Watch Movies', + icon: 'bookmark-border', + itemCount: planToWatchMovies?.length || 0, + }, + { + id: 'plantowatch-anime', + name: 'Plan to Watch Anime', + icon: 'bookmark-border', + itemCount: planToWatchAnime?.length || 0, + }, + { + id: 'completed-shows', + name: 'Completed Shows', + icon: 'check-circle', + itemCount: completedShows?.length || 0, + }, + { + id: 'completed-movies', + name: 'Completed Movies', + icon: 'check-circle', + itemCount: completedMovies?.length || 0, + }, + { + id: 'completed-anime', + name: 'Completed Anime', + icon: 'check-circle', + itemCount: completedAnime?.length || 0, + }, + { + id: 'onhold-shows', + name: 'On Hold Shows', + icon: 'pause-circle-outline', + itemCount: onHoldShows?.length || 0, + }, + { + id: 'onhold-movies', + name: 'On Hold Movies', + icon: 'pause-circle-outline', + itemCount: onHoldMovies?.length || 0, + }, + { + id: 'onhold-anime', + name: 'On Hold Anime', + icon: 'pause-circle-outline', + itemCount: onHoldAnime?.length || 0, + }, + { + id: 'dropped-shows', + name: 'Dropped Shows', + icon: 'cancel', + itemCount: droppedShows?.length || 0, + }, + { + id: 'dropped-movies', + name: 'Dropped Movies', + icon: 'cancel', + itemCount: droppedMovies?.length || 0, + }, + { + id: 'dropped-anime', + name: 'Dropped Anime', + icon: 'cancel', + itemCount: droppedAnime?.length || 0, + }, + { + id: 'ratings', + name: t('library.rated'), + icon: 'star', + itemCount: simklRatedContent?.length || 0, + } + ]; + + return folders.filter(folder => folder.itemCount > 0); + }, [simklAuthenticated, watchingShows, watchingMovies, watchingAnime, planToWatchShows, planToWatchMovies, planToWatchAnime, completedShows, completedMovies, completedAnime, onHoldShows, onHoldMovies, onHoldAnime, droppedShows, droppedMovies, droppedAnime, simklContinueWatching, simklRatedContent, t]); + const renderItem = ({ item }: { item: LibraryItem }) => ( { }); }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + const getSimklFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { + const items: TraktDisplayItem[] = []; + + switch (folderId) { + case 'continue-watching': + return (simklContinueWatching || []).map(item => { + const content = item.show || item.movie; + return { + id: String(content?.ids?.simkl || Math.random()), + name: content?.title || 'Unknown', + type: item.show ? 'series' : 'movie', + poster: '', + year: content?.year, + lastWatched: item.paused_at, + imdbId: content?.ids?.imdb, + traktId: content?.ids?.simkl || 0, + }; + }); + + case 'watching-shows': + return (watchingShows || []).map(item => ({ + id: String(item.show?.ids?.simkl || Math.random()), + name: item.show?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.show?.year, + lastWatched: item.last_watched_at, + rating: item.user_rating, + imdbId: item.show?.ids?.imdb, + traktId: item.show?.ids?.simkl || 0, + })); + + case 'watching-movies': + return (watchingMovies || []).map(item => ({ + id: String(item.movie?.ids?.simkl || Math.random()), + name: item.movie?.title || 'Unknown', + type: 'movie' as const, + poster: '', + year: item.movie?.year, + lastWatched: item.last_watched_at, + rating: item.user_rating, + imdbId: item.movie?.ids?.imdb, + traktId: item.movie?.ids?.simkl || 0, + })); + + case 'watching-anime': + return (watchingAnime || []).map(item => ({ + id: String(item.anime?.ids?.simkl || Math.random()), + name: item.anime?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.anime?.year, + lastWatched: item.last_watched_at, + rating: item.user_rating, + imdbId: item.anime?.ids?.imdb, + traktId: item.anime?.ids?.simkl || 0, + })); + + case 'plantowatch-shows': + return (planToWatchShows || []).map(item => ({ + id: String(item.show?.ids?.simkl || Math.random()), + name: item.show?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.show?.year, + lastWatched: item.added_to_watchlist_at, + imdbId: item.show?.ids?.imdb, + traktId: item.show?.ids?.simkl || 0, + })); + + case 'plantowatch-movies': + return (planToWatchMovies || []).map(item => ({ + id: String(item.movie?.ids?.simkl || Math.random()), + name: item.movie?.title || 'Unknown', + type: 'movie' as const, + poster: '', + year: item.movie?.year, + lastWatched: item.added_to_watchlist_at, + imdbId: item.movie?.ids?.imdb, + traktId: item.movie?.ids?.simkl || 0, + })); + + case 'plantowatch-anime': + return (planToWatchAnime || []).map(item => ({ + id: String(item.anime?.ids?.simkl || Math.random()), + name: item.anime?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.anime?.year, + lastWatched: item.added_to_watchlist_at, + imdbId: item.anime?.ids?.imdb, + traktId: item.anime?.ids?.simkl || 0, + })); + + case 'completed-shows': + return (completedShows || []).map(item => ({ + id: String(item.show?.ids?.simkl || Math.random()), + name: item.show?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.show?.year, + lastWatched: item.last_watched_at, + imdbId: item.show?.ids?.imdb, + traktId: item.show?.ids?.simkl || 0, + })); + + case 'completed-movies': + return (completedMovies || []).map(item => ({ + id: String(item.movie?.ids?.simkl || Math.random()), + name: item.movie?.title || 'Unknown', + type: 'movie' as const, + poster: '', + year: item.movie?.year, + lastWatched: item.last_watched_at, + imdbId: item.movie?.ids?.imdb, + traktId: item.movie?.ids?.simkl || 0, + })); + + case 'completed-anime': + return (completedAnime || []).map(item => ({ + id: String(item.anime?.ids?.simkl || Math.random()), + name: item.anime?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.anime?.year, + lastWatched: item.last_watched_at, + imdbId: item.anime?.ids?.imdb, + traktId: item.anime?.ids?.simkl || 0, + })); + + case 'onhold-shows': + return (onHoldShows || []).map(item => ({ + id: String(item.show?.ids?.simkl || Math.random()), + name: item.show?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.show?.year, + lastWatched: item.last_watched_at, + imdbId: item.show?.ids?.imdb, + traktId: item.show?.ids?.simkl || 0, + })); + + case 'onhold-movies': + return (onHoldMovies || []).map(item => ({ + id: String(item.movie?.ids?.simkl || Math.random()), + name: item.movie?.title || 'Unknown', + type: 'movie' as const, + poster: '', + year: item.movie?.year, + lastWatched: item.last_watched_at, + imdbId: item.movie?.ids?.imdb, + traktId: item.movie?.ids?.simkl || 0, + })); + + case 'onhold-anime': + return (onHoldAnime || []).map(item => ({ + id: String(item.anime?.ids?.simkl || Math.random()), + name: item.anime?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.anime?.year, + lastWatched: item.last_watched_at, + imdbId: item.anime?.ids?.imdb, + traktId: item.anime?.ids?.simkl || 0, + })); + + case 'dropped-shows': + return (droppedShows || []).map(item => ({ + id: String(item.show?.ids?.simkl || Math.random()), + name: item.show?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.show?.year, + lastWatched: item.last_watched_at, + imdbId: item.show?.ids?.imdb, + traktId: item.show?.ids?.simkl || 0, + })); + + case 'dropped-movies': + return (droppedMovies || []).map(item => ({ + id: String(item.movie?.ids?.simkl || Math.random()), + name: item.movie?.title || 'Unknown', + type: 'movie' as const, + poster: '', + year: item.movie?.year, + lastWatched: item.last_watched_at, + imdbId: item.movie?.ids?.imdb, + traktId: item.movie?.ids?.simkl || 0, + })); + + case 'dropped-anime': + return (droppedAnime || []).map(item => ({ + id: String(item.anime?.ids?.simkl || Math.random()), + name: item.anime?.title || 'Unknown', + type: 'series' as const, + poster: '', + year: item.anime?.year, + lastWatched: item.last_watched_at, + imdbId: item.anime?.ids?.imdb, + traktId: item.anime?.ids?.simkl || 0, + })); + + case 'ratings': + return (simklRatedContent || []).map(item => { + const content = item.show || item.movie || item.anime; + const type = item.show ? 'series' : item.movie ? 'movie' : 'series'; + return { + id: String(content?.ids?.simkl || Math.random()), + name: content?.title || 'Unknown', + type, + poster: '', + year: content?.year, + lastWatched: item.rated_at, + rating: item.rating, + imdbId: content?.ids?.imdb, + traktId: content?.ids?.simkl || 0, + }; + }); + } + + return items.sort((a, b) => { + const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; + const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; + return dateB - dateA; + }); + }, [simklContinueWatching, watchingShows, watchingMovies, watchingAnime, planToWatchShows, planToWatchMovies, planToWatchAnime, completedShows, completedMovies, completedAnime, onHoldShows, onHoldMovies, onHoldAnime, droppedShows, droppedMovies, droppedAnime, simklRatedContent]); + const renderTraktContent = () => { if (traktLoading) { return ; @@ -800,7 +1209,133 @@ const LibraryScreen = () => { ); }; - const renderFilter = (filterType: 'trakt' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => { + const renderSimklCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( + { + setSelectedSimklFolder(folder.id); + loadSimklCollections(); + }} + activeOpacity={0.7} + > + + + + + {folder.name} + + + {folder.itemCount} {t('library.items')} + + + + + ); + + const renderSimklContent = () => { + if (simklLoading) { + return ( + + + + ); + } + + if (!selectedSimklFolder) { + if (simklFolders.length === 0) { + return ( + + + + {t('library.no_trakt')} + + + {t('library.no_trakt_desc')} + + { + loadSimklCollections(); + }} + activeOpacity={0.7} + > + {t('library.load_collections')} + + + ); + } + + return ( + renderSimklCollectionFolder({ folder: item })} + keyExtractor={item => item.id} + numColumns={numColumns} + contentContainerStyle={styles.listContainer} + showsVerticalScrollIndicator={false} + onEndReachedThreshold={0.7} + onEndReached={() => { }} + /> + ); + } + + const folderItems = getSimklFolderItems(selectedSimklFolder); + + if (folderItems.length === 0) { + const folderName = simklFolders.find(f => f.id === selectedSimklFolder)?.name || t('library.collection'); + return ( + + + {t('library.empty_folder', { folder: folderName })} + + {t('library.empty_folder_desc')} + + { + loadSimklCollections(); + }} + activeOpacity={0.7} + > + Refresh + + + ); + } + + return ( + renderTraktItem({ item })} + keyExtractor={(item) => `${item.type}-${item.id}`} + numColumns={numColumns} + style={styles.traktContainer} + contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} + showsVerticalScrollIndicator={false} + onEndReachedThreshold={0.7} + onEndReached={() => { }} + /> + ); + }; + + const renderFilter = (filterType: 'trakt' | 'simkl' | 'movies' | 'series', label: string) => { const isActive = filter === filterType; return ( @@ -821,22 +1356,20 @@ const LibraryScreen = () => { } return; } + if (filterType === 'simkl') { + if (!simklAuthenticated) { + navigation.navigate('Settings'); + } else { + setShowSimklContent(true); + setSelectedSimklFolder(null); + loadSimklCollections(); + } + return; + } setFilter(filterType); }} activeOpacity={0.7} > - {filterType === 'trakt' ? ( - - - - ) : ( - - )} { ? (selectedTraktFolder ? traktFolders.find(f => f.id === selectedTraktFolder)?.name || t('library.collection') : t('library.trakt_collection')) - : t('library.title') + : showSimklContent + ? (selectedSimklFolder + ? simklFolders.find(f => f.id === selectedSimklFolder)?.name || t('library.collection') + : 'SIMKL Collections') + : t('library.title') } - showBackButton={showTraktContent} - onBackPress={showTraktContent ? () => { - if (selectedTraktFolder) { - setSelectedTraktFolder(null); - } else { - setShowTraktContent(false); + showBackButton={showTraktContent || showSimklContent} + onBackPress={(showTraktContent || showSimklContent) ? () => { + if (showTraktContent) { + if (selectedTraktFolder) { + setSelectedTraktFolder(null); + } else { + setShowTraktContent(false); + } + } else if (showSimklContent) { + if (selectedSimklFolder) { + setSelectedSimklFolder(null); + } else { + setShowSimklContent(false); + } } } : undefined} useMaterialIcons={showTraktContent} @@ -929,15 +1474,16 @@ const LibraryScreen = () => { /> - {!showTraktContent && ( + {!showTraktContent && !showSimklContent && ( - {renderFilter('trakt', 'Trakt', 'pan-tool')} - {renderFilter('movies', t('search.movies'), 'movie')} - {renderFilter('series', t('search.tv_shows'), 'live-tv')} + {renderFilter('trakt', 'Trakt')} + {renderFilter('simkl', 'SIMKL')} + {renderFilter('movies', t('search.movies'))} + {renderFilter('series', t('search.tv_shows'))} )} - {showTraktContent ? renderTraktContent() : renderContent()} + {showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()} {selectedItem && ( @@ -1065,13 +1611,11 @@ const styles = StyleSheet.create({ overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', aspectRatio: 2 / 3, - // Consistent shadow/elevation matching ContentItem elevation: Platform.OS === 'android' ? 1 : 0, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, - // Consistent border styling borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.15)', }, diff --git a/src/services/simklService.ts b/src/services/simklService.ts index b824a138..ef027ffe 100644 --- a/src/services/simklService.ts +++ b/src/services/simklService.ts @@ -66,11 +66,13 @@ export interface SimklPlaybackData { movie?: { title: string; year: number; + poster?: string; ids: SimklIds; }; show?: { title: string; year: number; + poster?: string; ids: SimklIds; }; episode?: { @@ -130,9 +132,95 @@ export interface SimklActivities { anime?: string; [key: string]: string | undefined; }; + movies?: { + all?: string; + rated_at?: string; + plantowatch?: string; + completed?: string; + dropped?: string; + [key: string]: string | undefined; + }; + shows?: { + all?: string; + rated_at?: string; + playback?: string; + plantowatch?: string; + watching?: string; + completed?: string; + hold?: string; + dropped?: string; + [key: string]: string | undefined; + }; + anime?: { + all?: string; + rated_at?: string; + playback?: string; + plantowatch?: string; + watching?: string; + completed?: string; + hold?: string; + dropped?: string; + [key: string]: string | undefined; + }; [key: string]: any; } +export interface SimklWatchlistItem { + movie?: { + title: string; + year: number; + poster?: string; + ids: SimklIds; + }; + show?: { + title: string; + year: number; + poster?: string; + ids: SimklIds; + }; + anime?: { + title: string; + year: number; + poster?: string; + ids: SimklIds; + }; + status?: 'watching' | 'plantowatch' | 'completed' | 'hold' | 'dropped'; + last_watched_at?: string; + user_rating?: number; + watched_episodes_count?: number; + total_episodes_count?: number; + last_watched?: string; + next_to_watch?: string; + added_to_watchlist_at?: string; +} + +export interface SimklRatingItem { + movie?: { + title: string; + year: number; + poster?: string; + ids: SimklIds; + }; + show?: { + title: string; + year: number; + poster?: string; + ids: SimklIds; + }; + anime?: { + title: string; + year: number; + poster?: string; + ids: SimklIds; + }; + rating: number; + rated_at: string; + last_watched_at?: string; + user_rated_at?: string; +} + +export type SimklStatus = 'watching' | 'plantowatch' | 'completed' | 'hold' | 'dropped'; + export class SimklService { private static instance: SimklService; private accessToken: string | null = null; @@ -603,16 +691,201 @@ export class SimklService { } /** - * SYNC: Get Full Watch History (summary) - * Optimization: Check /sync/activities first in real usage. - * For now, we implement simple fetch. + * SYNC: Get all items from user's lists + * Enhanced version with type and status filtering */ - public async getAllItems(dateFrom?: string): Promise { + public async getAllItems( + type?: 'movies' | 'shows' | 'anime', + status?: SimklStatus, + dateFrom?: string + ): Promise { let url = '/sync/all-items/'; - if (dateFrom) { - url += `?date_from=${dateFrom}`; + + if (type) { + url += `${type}/`; } - return await this.apiRequest(url); + + if (status) { + url += status; + } + + const params: string[] = []; + if (dateFrom) { + params.push(`date_from=${dateFrom}`); + } + + if (params.length > 0) { + url += `?${params.join('&')}`; + } + + try { + logger.log(`[SimklService] getAllItems: Fetching ${url}`); + const response = await this.apiRequest(url); + if (!response) { + logger.log('[SimklService] getAllItems: No response from API'); + return []; + } + + logger.log('[SimklService] getAllItems: Response keys:', Object.keys(response)); + + // Parse response based on type + const items: SimklWatchlistItem[] = []; + + if (response.movies && type === 'movies') { + logger.log(`[SimklService] getAllItems: Returning ${response.movies.length} movies`); + if (response.movies.length > 0) { + logger.log('[SimklService] getAllItems: First movie sample:', JSON.stringify(response.movies[0], null, 2)); + } + return response.movies || []; + } + if (response.shows && type === 'shows') { + logger.log(`[SimklService] getAllItems: Returning ${response.shows.length} shows`); + if (response.shows.length > 0) { + logger.log('[SimklService] getAllItems: First show sample:', JSON.stringify(response.shows[0], null, 2)); + } + return response.shows || []; + } + if (response.anime && type === 'anime') { + logger.log(`[SimklService] getAllItems: Returning ${response.anime.length} anime`); + if (response.anime.length > 0) { + logger.log('[SimklService] getAllItems: First anime sample:', JSON.stringify(response.anime[0], null, 2)); + } + return response.anime || []; + } + + // If no type specified, return all + if (!type) { + if (response.movies) items.push(...response.movies); + if (response.shows) items.push(...response.shows); + if (response.anime) items.push(...response.anime); + logger.log(`[SimklService] getAllItems: Returning ${items.length} total items`); + return items; + } + + logger.log('[SimklService] getAllItems: No matching type found, returning empty array'); + return []; + } catch (error) { + logger.error('[SimklService] Failed to get all items:', error); + return []; + } + } + + /** + * SYNC: Get user ratings + */ + public async getRatings( + type?: 'movies' | 'shows' | 'anime', + ratingFilter?: string + ): Promise { + let url = '/sync/ratings/'; + + if (type) { + url += type; + if (ratingFilter) { + url += `/${ratingFilter}`; + } + } + + try { + const response = await this.apiRequest(url); + if (!response) return []; + + const items: SimklRatingItem[] = []; + + // Aggregate ratings from all types + if (response.movies) items.push(...response.movies); + if (response.shows) items.push(...response.shows); + if (response.anime) items.push(...response.anime); + + return items; + } catch (error) { + logger.error('[SimklService] Failed to get ratings:', error); + return []; + } + } + + /** + * Add item to a specific status list + */ + public async addToList( + imdbId: string, + type: 'movie' | 'show' | 'anime', + status: SimklStatus + ): Promise { + const contentType = type === 'movie' ? 'movies' : type === 'show' ? 'shows' : 'anime'; + + const payload = { + [contentType]: [{ + to: status, + ids: { imdb: imdbId } + }] + }; + + try { + const response = await this.apiRequest('/sync/add-to-list', 'POST', payload); + return !!response; + } catch (error) { + logger.error('[SimklService] Failed to add to list:', error); + return false; + } + } + + /** + * Remove item from list (marks as not interested) + */ + public async removeFromList( + imdbId: string, + type: 'movie' | 'show' | 'anime' + ): Promise { + const contentType = type === 'movie' ? 'movies' : type === 'show' ? 'shows' : 'anime'; + + const payload = { + [contentType]: [{ + ids: { imdb: imdbId } + }] + }; + + try { + const response = await this.apiRequest('/sync/remove-from-list', 'POST', payload); + return !!response; + } catch (error) { + logger.error('[SimklService] Failed to remove from list:', error); + return false; + } + } + + /** + * Add rating for item + */ + public async addRating( + imdbId: string, + type: 'movie' | 'show' | 'anime', + rating: number + ): Promise { + const contentType = type === 'movie' ? 'movies' : type === 'show' ? 'shows' : 'anime'; + + const payload = { + [contentType]: [{ + ids: { imdb: imdbId }, + rating: Math.max(1, Math.min(10, rating)) + }] + }; + + try { + const response = await this.apiRequest('/sync/ratings', 'POST', payload); + return !!response; + } catch (error) { + logger.error('[SimklService] Failed to add rating:', error); + return false; + } + } + + /** + * Get poster URL - returns empty string to let app's existing poster infrastructure handle it + * The app will use IMDB ID or TMDB ID to fetch posters through existing metadata services + */ + public static getPosterUrl(): string { + return ''; } /**