From 64183894de11d251c81adcbb6a53baf419a6af09 Mon Sep 17 00:00:00 2001 From: Abhishek Vankar Date: Fri, 17 Apr 2026 11:31:43 +0530 Subject: [PATCH] feat: Trackt recommendations added --- src/hooks/useTraktRecommendations.ts | 138 ++++++++++++++++++++++++++ src/screens/CatalogScreen.tsx | 47 ++++++++- src/screens/CatalogSettingsScreen.tsx | 66 ++++++++++++ src/screens/HomeScreen.tsx | 9 +- src/screens/TraktSettingsScreen.tsx | 2 +- src/services/traktService.ts | 25 ++++- 6 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useTraktRecommendations.ts diff --git a/src/hooks/useTraktRecommendations.ts b/src/hooks/useTraktRecommendations.ts new file mode 100644 index 00000000..ba16065a --- /dev/null +++ b/src/hooks/useTraktRecommendations.ts @@ -0,0 +1,138 @@ +import { useState, useEffect } from 'react'; +import { traktService } from '../services/traktService'; +import { mmkvStorage } from '../services/mmkvStorage'; +import { logger } from '../utils/logger'; +import type { CatalogContent, StreamingContent } from '../services/catalog/types'; +import { useCatalogContext } from '../contexts/CatalogContext'; + +const TRAKT_RECOMMENDED_MOVIES_KEY = 'trakt_recommended_movies_enabled'; +const TRAKT_RECOMMENDED_SHOWS_KEY = 'trakt_recommended_shows_enabled'; + +const FALLBACK_POSTER = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image'; + +/** Convert a Trakt extended item (movie or show) into a StreamingContent row. */ +function traktItemToStreamingContent( + item: any, + mediaType: 'movie' | 'series' +): StreamingContent | null { + const media = mediaType === 'movie' ? item : item; // same shape, different fields + const title: string = media.title; + const imdbId: string | undefined = media.ids?.imdb; + const tmdbId: number | undefined = media.ids?.tmdb; + + if (!title) return null; + + // Use IMDb ID as content ID (same convention as Stremio addons) + const id = imdbId ?? (tmdbId ? `tmdb:${tmdbId}` : `trakt:${media.ids?.trakt}`); + + // Metahub is already used by the app for Stremio content — use it for posters too + const poster = imdbId + ? `https://images.metahub.space/poster/medium/${imdbId}/img` + : FALLBACK_POSTER; + + return { + id, + type: mediaType, + name: title, + poster, + posterShape: 'poster', + year: media.year ?? undefined, + description: media.overview ?? undefined, + genres: Array.isArray(media.genres) + ? media.genres.map((g: string) => g.charAt(0).toUpperCase() + g.slice(1)) + : undefined, + runtime: media.runtime ? `${media.runtime} min` : undefined, + certification: media.certification ?? undefined, + imdb_id: imdbId, + } as StreamingContent; +} + +/** + * Fetches personalized Trakt recommendations for the authenticated user + * and returns them as two CatalogContent rows (movies + shows). + * Returns an empty array when not authenticated. + */ +export function useTraktRecommendations(): { + catalogs: CatalogContent[]; + loading: boolean; +} { + const [catalogs, setCatalogs] = useState([]); + const [loading, setLoading] = useState(false); + const { lastUpdate } = useCatalogContext(); // re-run when refreshCatalogs() is called + + useEffect(() => { + let cancelled = false; + + const load = async () => { + const isAuth = await traktService.isAuthenticated(); + if (!isAuth || cancelled) return; + + // Read user toggles (default enabled) + const [moviesEnabledVal, showsEnabledVal] = await Promise.all([ + mmkvStorage.getItem(TRAKT_RECOMMENDED_MOVIES_KEY), + mmkvStorage.getItem(TRAKT_RECOMMENDED_SHOWS_KEY), + ]); + const moviesEnabled = moviesEnabledVal !== 'false'; + const showsEnabled = showsEnabledVal !== 'false'; + + if (!moviesEnabled && !showsEnabled) { + setCatalogs([]); + return; + } + + setLoading(true); + try { + const [movies, shows] = await Promise.all([ + moviesEnabled ? traktService.getRecommendations('movies', 20) : Promise.resolve([]), + showsEnabled ? traktService.getRecommendations('shows', 20) : Promise.resolve([]), + ]); + + if (cancelled) return; + + const result: CatalogContent[] = []; + + if (moviesEnabled) { + const movieItems = movies + .map((m: any) => traktItemToStreamingContent(m, 'movie')) + .filter(Boolean) as StreamingContent[]; + if (movieItems.length > 0) { + result.push({ + addon: 'trakt', + type: 'movie', + id: 'trakt-recommended-movies', + name: 'Recommended Movies', + items: movieItems, + }); + } + } + + if (showsEnabled) { + const showItems = shows + .map((s: any) => traktItemToStreamingContent(s, 'series')) + .filter(Boolean) as StreamingContent[]; + if (showItems.length > 0) { + result.push({ + addon: 'trakt', + type: 'series', + id: 'trakt-recommended-shows', + name: 'Recommended Shows', + items: showItems, + }); + } + } + + setCatalogs(result); + logger.log(`[useTraktRecommendations] movies=${moviesEnabled} shows=${showsEnabled}`); + } catch (err) { + logger.error('[useTraktRecommendations] error:', err); + } finally { + if (!cancelled) setLoading(false); + } + }; + + load(); + return () => { cancelled = true; }; + }, [lastUpdate]); // re-runs whenever refreshCatalogs() is called from settings + + return { catalogs, loading }; +} diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 5eedf574..e2de4323 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -19,6 +19,7 @@ import { RouteProp } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { RootStackParamList } from '../navigation/AppNavigator'; import { Meta, stremioService, CatalogExtra } from '../services/stremioService'; +import { traktService } from '../services/traktService'; import { useTheme } from '../contexts/ThemeContext'; import FastImage from '@d11/react-native-fast-image'; import { BlurView } from 'expo-blur'; @@ -500,10 +501,12 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { setItems(prev => [...prev, ...nextBatch]); displayedCountRef.current += nextBatch.length; - // Check if we still have more in buffer OR if we should try fetching more from network - // If buffer is exhausted, we might need to fetch next page from server + // Check if we still have more in buffer OR if we should try fetching more from network. + // Trakt catalogs are fully buffered on first load — no server-side next page exists, + // so once the buffer is drained we must not trigger another fetch. const hasMoreInBuffer = displayedCountRef.current < allFetchedItemsRef.current.length; - setHasMore(hasMoreInBuffer || (addonId ? true : false)); // Simplified: if addon, assume potential server side more + const hasServerSideMore = addonId && addonId !== 'trakt'; + setHasMore(hasMoreInBuffer || (hasServerSideMore ? true : false)); setIsFetchingMore(false); setLoading(false); }); @@ -598,6 +601,44 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { } } + // Handle Trakt recommendation catalogs (not a Stremio addon). + // Trakt caps recommendations at 100 items. We fetch all at once and + // paginate client-side using the existing allFetchedItemsRef buffer. + if (addonId === 'trakt') { + const traktType = type === 'movie' ? 'movies' : 'shows'; + // Fetch the maximum Trakt allows so the buffer is fully populated. + const raw = await traktService.getRecommendations(traktType, 100); + const metas: Meta[] = raw + .filter((item: any) => item?.title && item?.ids?.imdb) + .map((item: any): Meta => ({ + id: item.ids.imdb, + type, + name: item.title, + poster: `https://images.metahub.space/poster/medium/${item.ids.imdb}/img`, + year: item.year, + description: item.overview, + genres: item.genres?.map((g: string) => g.charAt(0).toUpperCase() + g.slice(1)), + runtime: item.runtime ? `${item.runtime} min` : undefined, + certification: item.certification, + imdb_id: item.ids.imdb, + })); + + InteractionManager.runAfterInteractions(() => { + allFetchedItemsRef.current = metas; + displayedCountRef.current = 0; + const firstBatch = metas.slice(0, CLIENT_PAGE_SIZE); + setItems(firstBatch); + displayedCountRef.current = firstBatch.length; + // Only enable load-more if the buffer has items beyond the first page. + // Never set true unconditionally — there is no server-side next page + // for Trakt recommendations, so once the buffer is drained we stop. + setHasMore(metas.length > CLIENT_PAGE_SIZE); + setLoading(false); + setRefreshing(false); + }); + return; + } + // addon logic let foundItems = false; let allItems: Meta[] = []; diff --git a/src/screens/CatalogSettingsScreen.tsx b/src/screens/CatalogSettingsScreen.tsx index 320d7252..c8a88823 100644 --- a/src/screens/CatalogSettingsScreen.tsx +++ b/src/screens/CatalogSettingsScreen.tsx @@ -18,6 +18,7 @@ import { import { mmkvStorage } from '../services/mmkvStorage'; import { useNavigation } from '@react-navigation/native'; import { useTheme } from '../contexts/ThemeContext'; +import { traktService } from '../services/traktService'; import { stremioService } from '../services/stremioService'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { useCatalogContext } from '../contexts/CatalogContext'; @@ -68,6 +69,8 @@ interface GroupedCatalogs { const CATALOG_SETTINGS_KEY = 'catalog_settings'; const CATALOG_CUSTOM_NAMES_KEY = 'catalog_custom_names'; const CATALOG_MOBILE_COLUMNS_KEY = 'catalog_mobile_columns'; +export const TRAKT_RECOMMENDED_MOVIES_KEY = 'trakt_recommended_movies_enabled'; +export const TRAKT_RECOMMENDED_SHOWS_KEY = 'trakt_recommended_shows_enabled'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Create a styles creator function that accepts the theme colors @@ -270,6 +273,9 @@ const CatalogSettingsScreen = () => { const [groupedSettings, setGroupedSettings] = useState({}); const [mobileColumns, setMobileColumns] = useState<'auto' | 2 | 3>('auto'); const [showTitles, setShowTitles] = useState(true); // Default to showing titles + const [isTraktAuthenticated, setIsTraktAuthenticated] = useState(false); + const [traktMoviesEnabled, setTraktMoviesEnabled] = useState(true); + const [traktShowsEnabled, setTraktShowsEnabled] = useState(true); const navigation = useNavigation(); const { refreshCatalogs } = useCatalogContext(); const { currentTheme } = useTheme(); @@ -385,6 +391,20 @@ const CatalogSettingsScreen = () => { } catch (e) { // ignore } + + // Load Trakt auth state and recommendation toggles + try { + const auth = await traktService.isAuthenticated(); + setIsTraktAuthenticated(auth); + if (auth) { + const moviesVal = await mmkvStorage.getItem(TRAKT_RECOMMENDED_MOVIES_KEY); + const showsVal = await mmkvStorage.getItem(TRAKT_RECOMMENDED_SHOWS_KEY); + setTraktMoviesEnabled(moviesVal !== 'false'); + setTraktShowsEnabled(showsVal !== 'false'); + } + } catch (e) { + // ignore + } } catch (error) { logger.error('Failed to load catalog settings:', error); } finally { @@ -618,6 +638,52 @@ const CatalogSettingsScreen = () => { )} + {/* Trakt Recommendations — only when logged in */} + {isTraktAuthenticated && ( + + TRAKT + + + Recommendations + + + + Recommended Movies + Movie + + { + setTraktMoviesEnabled(value); + await mmkvStorage.setItem(TRAKT_RECOMMENDED_MOVIES_KEY, value ? 'true' : 'false'); + refreshCatalogs(); + }} + trackColor={{ false: '#505050', true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.white : undefined} + ios_backgroundColor="#505050" + /> + + + + Recommended Shows + Series + + { + setTraktShowsEnabled(value); + await mmkvStorage.setItem(TRAKT_RECOMMENDED_SHOWS_KEY, value ? 'true' : 'false'); + refreshCatalogs(); + }} + trackColor={{ false: '#505050', true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.white : undefined} + ios_backgroundColor="#505050" + /> + + + + )} + {Object.entries(groupedSettings).map(([addonId, group]) => ( diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index b7baa4c0..45b66361 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -51,6 +51,7 @@ import { clearCustomNameCache } from '../utils/catalogNameUtils'; import { useHomeCatalogs } from '../hooks/useHomeCatalogs'; +import { useTraktRecommendations } from '../hooks/useTraktRecommendations'; import { useFeaturedContent } from '../hooks/useFeaturedContent'; import { useSettings, settingsEmitter } from '../hooks/useSettings'; import FeaturedContent from '../components/home/FeaturedContent'; @@ -153,6 +154,7 @@ const HomeScreen = () => { const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]); const [catalogsLoading, setCatalogsLoading] = useState(true); + const { catalogs: traktRecommendedCatalogs } = useTraktRecommendations(); const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); const [pendingCatalogIndexes, setPendingCatalogIndexes] = useState>({}); const [hasAddons, setHasAddons] = useState(null); @@ -755,6 +757,11 @@ const HomeScreen = () => { data.push({ type: 'thisWeek', key: 'thisWeek' }); } + // Inject Trakt recommended catalogs at the top when authenticated + traktRecommendedCatalogs.forEach((catalog, index) => { + data.push({ type: 'catalog', catalog, key: `trakt-recommended-${index}` }); + }); + // Only show a limited number of catalogs initially for performance const catalogsToShow = catalogs.slice(0, visibleCatalogCount); @@ -773,7 +780,7 @@ const HomeScreen = () => { } return data; - }, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection]); + }, [hasAddons, catalogs, traktRecommendedCatalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection]); const handleLoadMoreCatalogs = useCallback(() => { setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length)); diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index c02119f1..b310435e 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -191,7 +191,7 @@ const TraktSettingsScreen: React.FC = () => { if (response.type === 'success' && request?.codeVerifier) { const { code } = response.params; logger.log('[TraktSettingsScreen] Auth code received:', code); - traktService.exchangeCodeForToken(code, request.codeVerifier) + traktService.exchangeCodeForToken(code, request.codeVerifier, redirectUri) .then(success => { if (success) { logger.log('[TraktSettingsScreen] Token exchange successful'); diff --git a/src/services/traktService.ts b/src/services/traktService.ts index f671d091..39e5e519 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -800,7 +800,7 @@ export class TraktService { /** * Exchange the authorization code for an access token */ - public async exchangeCodeForToken(code: string, codeVerifier: string): Promise { + public async exchangeCodeForToken(code: string, codeVerifier: string, redirectUri?: string): Promise { // Block authentication during maintenance if (this.isMaintenanceMode()) { logger.warn('[TraktService] Maintenance mode: blocking new authentication'); @@ -819,7 +819,7 @@ export class TraktService { code, client_id: TRAKT_CLIENT_ID, client_secret: TRAKT_CLIENT_SECRET, - redirect_uri: TRAKT_REDIRECT_URI, + redirect_uri: redirectUri ?? TRAKT_REDIRECT_URI, grant_type: 'authorization_code', code_verifier: codeVerifier }) @@ -1350,6 +1350,27 @@ export class TraktService { return this.apiRequest(endpoint); } + /** + * Get personalized movie/show recommendations for the authenticated user. + * Returns extended metadata including IMDb/TMDB IDs, overview, genres, etc. + */ + public async getRecommendations( + type: 'movies' | 'shows', + limit: number = 20 + ): Promise { + const isAuth = await this.isAuthenticated(); + if (!isAuth) return []; + try { + const data = await this.apiRequest( + `/recommendations/${type}?extended=full&limit=${limit}` + ); + return Array.isArray(data) ? data : []; + } catch (err) { + logger.error(`[TraktService] getRecommendations(${type}) error:`, err); + return []; + } + } + /** * Get the user's watched movies with images */