mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Merge 64183894de into cbc9fc4fa6
This commit is contained in:
commit
bc68780c65
6 changed files with 280 additions and 7 deletions
138
src/hooks/useTraktRecommendations.ts
Normal file
138
src/hooks/useTraktRecommendations.ts
Normal file
|
|
@ -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<CatalogContent[]>([]);
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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[] = [];
|
||||
|
|
|
|||
|
|
@ -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<GroupedCatalogs>({});
|
||||
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 = () => {
|
|||
</View>
|
||||
)}
|
||||
|
||||
{/* Trakt Recommendations — only when logged in */}
|
||||
{isTraktAuthenticated && (
|
||||
<View style={styles.addonSection}>
|
||||
<Text style={styles.addonTitle}>TRAKT</Text>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupTitle}>Recommendations</Text>
|
||||
</View>
|
||||
<View style={styles.catalogItem}>
|
||||
<View style={styles.catalogInfo}>
|
||||
<Text style={styles.catalogName}>Recommended Movies</Text>
|
||||
<Text style={styles.catalogType}>Movie</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={traktMoviesEnabled}
|
||||
onValueChange={async (value) => {
|
||||
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"
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.catalogItem, { borderBottomWidth: 0 }]}>
|
||||
<View style={styles.catalogInfo}>
|
||||
<Text style={styles.catalogName}>Recommended Shows</Text>
|
||||
<Text style={styles.catalogType}>Series</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={traktShowsEnabled}
|
||||
onValueChange={async (value) => {
|
||||
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"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedSettings).map(([addonId, group]) => (
|
||||
<View key={addonId} style={styles.addonSection}>
|
||||
<Text style={styles.addonTitle}>
|
||||
|
|
|
|||
|
|
@ -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<Record<number, boolean>>({});
|
||||
const [hasAddons, setHasAddons] = useState<boolean | null>(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));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -800,7 +800,7 @@ export class TraktService {
|
|||
/**
|
||||
* Exchange the authorization code for an access token
|
||||
*/
|
||||
public async exchangeCodeForToken(code: string, codeVerifier: string): Promise<boolean> {
|
||||
public async exchangeCodeForToken(code: string, codeVerifier: string, redirectUri?: string): Promise<boolean> {
|
||||
// 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<TraktRatingItem[]>(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<any[]> {
|
||||
const isAuth = await this.isAuthenticated();
|
||||
if (!isAuth) return [];
|
||||
try {
|
||||
const data = await this.apiRequest<any[]>(
|
||||
`/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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue