Added option to sync Trakt watchlist into local library of Nuvio

This commit is contained in:
Matt 2026-02-08 20:46:12 +01:00
parent dbb5337204
commit cc9fa995b0
2 changed files with 511 additions and 11 deletions

View file

@ -42,6 +42,7 @@ import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner';
import { useSettings } from '../hooks/useSettings';
import { useTranslation } from 'react-i18next';
import { useScrollToTop } from '../contexts/ScrollToTopContext';
import { TMDBService } from '../services/tmdbService';
interface LibraryItem extends StreamingContent {
progress?: number;
@ -75,6 +76,7 @@ interface TraktFolder {
}
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const TRAKT_LIBRARY_SYNC_MODE_KEY = 'trakt_library_sync_mode';
function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: number } {
const horizontalPadding = 26;
@ -89,8 +91,6 @@ function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: nu
return { numColumns, itemWidth };
}
import { TMDBService } from '../services/tmdbService';
const TraktItem = React.memo(({
item,
width,
@ -126,10 +126,6 @@ const TraktItem = React.memo(({
tmdbId = await tmdbService.findTMDBIdByIMDB(item.imdbId);
}
if (!tmdbId && item.traktId) {
}
if (tmdbId) {
let posterPath: string | null = null;
@ -270,6 +266,9 @@ const LibraryScreen = () => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
const flashListRef = useRef<any>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [traktSyncMode, setTraktSyncMode] = useState<'off' | 'manual' | 'automatic'>('off');
const hasAutoSyncedThisSession = useRef(false);
const scrollToTop = useCallback(() => {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
@ -315,6 +314,29 @@ const LibraryScreen = () => {
loadAllCollections: loadSimklCollections
} = useSimklContext();
// Load Trakt sync mode preferences
useEffect(() => {
const loadSyncMode = async () => {
try {
const mode = await mmkvStorage.getItem(TRAKT_LIBRARY_SYNC_MODE_KEY);
if (mode === 'manual' || mode === 'automatic') {
setTraktSyncMode(mode);
} else {
setTraktSyncMode('off');
}
} catch (error) {
logger.error('[LibraryScreen] Failed to load sync mode:', error);
setTraktSyncMode('off');
}
};
loadSyncMode();
// Reload when screen is focused (to pick up changes from settings)
const unsubscribe = navigation.addListener('focus', loadSyncMode);
return unsubscribe;
}, [navigation]);
useEffect(() => {
const applyStatusBarConfig = () => {
StatusBar.setBarStyle('light-content');
@ -422,6 +444,248 @@ const LibraryScreen = () => {
};
}, [navigation]);
// Refs to always have access to latest context values (avoids stale closure)
const watchlistMoviesRef = useRef(watchlistMovies);
const watchlistShowsRef = useRef(watchlistShows);
useEffect(() => {
watchlistMoviesRef.current = watchlistMovies;
watchlistShowsRef.current = watchlistShows;
}, [watchlistMovies, watchlistShows]);
// Sync Trakt watchlist to local library
const syncTraktWatchlistToLibrary = useCallback(async () => {
if (!traktAuthenticated) {
showError('Sync Failed', 'Please connect to Trakt first');
return;
}
setIsSyncing(true);
logger.log('[LibraryScreen] Starting Trakt watchlist sync...');
try {
// Load Trakt data fresh before syncing
logger.log('[LibraryScreen] Loading Trakt collections...');
await loadAllCollections();
// Wait for React to process state updates
await new Promise(resolve => setTimeout(resolve, 100));
// Access FRESH values from refs (updated by useEffect)
const currentMovies = watchlistMoviesRef.current;
const currentShows = watchlistShowsRef.current;
logger.log(`[LibraryScreen] Syncing ${currentMovies?.length || 0} movies and ${currentShows?.length || 0} shows`);
const hasMovies = currentMovies && currentMovies.length > 0;
const hasShows = currentShows && currentShows.length > 0;
if (!hasMovies && !hasShows) {
logger.error('[LibraryScreen] No Trakt watchlist data available');
showError(
'Sync Failed',
'No items found in your Trakt watchlist. Add some movies or shows to your Trakt watchlist first.'
);
return;
}
const tmdbService = TMDBService.getInstance();
let addedCount = 0;
let updatedCount = 0;
let removedCount = 0;
const currentLibraryItems = await catalogService.getLibraryItems();
const traktWatchlistImdbIds = new Set<string>();
// Collect IMDb IDs from watchlist (using FRESH refs)
if (currentMovies) {
currentMovies.forEach(item => {
if (item.movie?.ids?.imdb) {
traktWatchlistImdbIds.add(item.movie.ids.imdb);
}
});
}
if (currentShows) {
currentShows.forEach(item => {
if (item.show?.ids?.imdb) {
traktWatchlistImdbIds.add(item.show.ids.imdb);
}
});
}
// Remove items not in watchlist
for (const libraryItem of currentLibraryItems) {
const imdbId = libraryItem.id;
if (imdbId.startsWith('tt') && !traktWatchlistImdbIds.has(imdbId)) {
logger.log(`[LibraryScreen] Removing: ${libraryItem.name}`);
await catalogService.removeFromLibrary(libraryItem.type, imdbId);
removedCount++;
}
}
// Add/update movies (using FRESH refs)
if (currentMovies) {
for (const watchlistItem of currentMovies) {
const movie = watchlistItem.movie;
if (!movie?.ids?.imdb) continue;
const imdbId = movie.ids.imdb;
const existingItem = currentLibraryItems.find(
item => item.id === imdbId && item.type === 'movie'
);
let posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
let overview = '';
let genres: string[] = [];
let year = movie.year;
try {
const tmdbId = await tmdbService.findTMDBIdByIMDB(imdbId);
if (tmdbId) {
const details = await tmdbService.getMovieDetails(String(tmdbId));
if (details) {
if (details.poster_path) {
posterUrl = tmdbService.getImageUrl(details.poster_path, 'w500') || posterUrl;
}
overview = details.overview || '';
genres = details.genres?.map((g: any) => g.name) || [];
year = details.release_date ? new Date(details.release_date).getFullYear() : year;
}
}
} catch (error) {
logger.error(`Failed to fetch TMDB data for ${movie.title}:`, error);
}
if (posterUrl === 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' && movie.images) {
const traktPosterUrl = TraktService.getTraktPosterUrl(movie.images);
if (traktPosterUrl) posterUrl = traktPosterUrl;
}
const contentToAdd: StreamingContent = {
id: imdbId,
type: 'movie',
name: movie.title,
poster: posterUrl,
posterShape: 'poster',
year,
description: overview,
genres,
imdbRating: undefined,
inLibrary: true,
};
if (existingItem) {
if (existingItem.poster !== posterUrl) {
await catalogService.addToLibrary(contentToAdd);
updatedCount++;
}
} else {
await catalogService.addToLibrary(contentToAdd);
addedCount++;
}
}
}
// Add/update shows (using FRESH refs)
if (currentShows) {
for (const watchlistItem of currentShows) {
const show = watchlistItem.show;
if (!show?.ids?.imdb) continue;
const imdbId = show.ids.imdb;
const existingItem = currentLibraryItems.find(
item => item.id === imdbId && item.type === 'series'
);
let posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
let overview = '';
let genres: string[] = [];
let year = show.year;
try {
const tmdbId = await tmdbService.findTMDBIdByIMDB(imdbId);
if (tmdbId) {
const details = await tmdbService.getTVShowDetails(tmdbId);
if (details) {
if (details.poster_path) {
posterUrl = tmdbService.getImageUrl(details.poster_path, 'w500') || posterUrl;
}
overview = details.overview || '';
genres = details.genres?.map((g: any) => g.name) || [];
year = details.first_air_date ? new Date(details.first_air_date).getFullYear() : year;
}
}
} catch (error) {
logger.error(`Failed to fetch TMDB data for ${show.title}:`, error);
}
if (posterUrl === 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' && show.images) {
const traktPosterUrl = TraktService.getTraktPosterUrl(show.images);
if (traktPosterUrl) posterUrl = traktPosterUrl;
}
const contentToAdd: StreamingContent = {
id: imdbId,
type: 'series',
name: show.title,
poster: posterUrl,
posterShape: 'poster',
year,
description: overview,
genres,
imdbRating: undefined,
inLibrary: true,
};
if (existingItem) {
if (existingItem.poster !== posterUrl) {
await catalogService.addToLibrary(contentToAdd);
updatedCount++;
}
} else {
await catalogService.addToLibrary(contentToAdd);
addedCount++;
}
}
}
// Show result
if (addedCount > 0 || updatedCount > 0 || removedCount > 0) {
let message = '';
if (addedCount > 0) message += `Added ${addedCount}`;
if (updatedCount > 0) message += `${message ? ', updated ' : 'Updated '}${updatedCount}`;
if (removedCount > 0) message += `${message ? ', removed ' : 'Removed '}${removedCount}`;
showInfo('Sync Complete', message);
logger.log(`[LibraryScreen] Sync complete: ${message}`);
} else {
showInfo('Sync Complete', 'Library is up to date');
}
} catch (error) {
logger.error('[LibraryScreen] Sync failed:', error);
showError('Sync Failed', 'Unable to sync. Please try again.');
} finally {
setIsSyncing(false);
}
}, [traktAuthenticated, loadAllCollections, showInfo, showError]);
// Removed watchlistMovies and watchlistShows from deps - we access via refs!
// Automatic sync on first visit
useEffect(() => {
if (
traktSyncMode === 'automatic' &&
traktAuthenticated &&
!hasAutoSyncedThisSession.current &&
!showTraktContent &&
!showSimklContent
) {
hasAutoSyncedThisSession.current = true;
logger.log('[LibraryScreen] Performing automatic sync');
syncTraktWatchlistToLibrary();
}
}, [traktSyncMode, traktAuthenticated, showTraktContent, showSimklContent, syncTraktWatchlistToLibrary]);
const filteredItems = libraryItems.filter(item => {
if (filter === 'movies') return item.type === 'movie';
if (filter === 'series') return item.type === 'series';
@ -465,7 +729,7 @@ const LibraryScreen = () => {
];
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent, t]);
const simklFolders = useMemo((): TraktFolder[] => {
if (!simklAuthenticated) return [];
@ -1438,6 +1702,9 @@ const LibraryScreen = () => {
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
}, [width, height]);
// Show sync button only when mode is 'manual' and viewing local library
const shouldShowSyncButton = traktSyncMode === 'manual' && !showTraktContent && !showSimklContent && traktAuthenticated;
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<ScreenHeader
@ -1486,6 +1753,29 @@ const LibraryScreen = () => {
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()}
</View>
{/* Sync FAB - Bottom Right (only in manual mode) */}
{shouldShowSyncButton && (
<TouchableOpacity
style={[
styles.syncFab,
{
backgroundColor: currentTheme.colors.primary,
bottom: insets.bottom + 80,
shadowColor: currentTheme.colors.black,
}
]}
onPress={syncTraktWatchlistToLibrary}
activeOpacity={0.8}
disabled={isSyncing}
>
{isSyncing ? (
<ActivityIndicator color={currentTheme.colors.white} size="small" />
) : (
<MaterialIcons name="sync" size={24} color={currentTheme.colors.white} />
)}
</TouchableOpacity>
)}
{selectedItem && (
<DropUpMenu
visible={menuVisible}
@ -1831,6 +2121,19 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
syncFab: {
position: 'absolute',
right: 16,
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
elevation: 6,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
});
export default LibraryScreen;
export default LibraryScreen;

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
View,
Text,
@ -27,6 +27,8 @@ import { useSimklIntegration } from '../hooks/useSimklIntegration';
import { colors } from '../styles';
import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { mmkvStorage } from '../services/mmkvStorage';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -36,6 +38,7 @@ const TRAKT_CLIENT_ID = process.env.EXPO_PUBLIC_TRAKT_CLIENT_ID as string;
if (!TRAKT_CLIENT_ID) {
throw new Error('Missing EXPO_PUBLIC_TRAKT_CLIENT_ID environment variable');
}
const discovery = {
authorizationEndpoint: 'https://trakt.tv/oauth/authorize',
tokenEndpoint: 'https://api.trakt.tv/oauth/token',
@ -47,6 +50,15 @@ const redirectUri = makeRedirectUri({
path: 'auth/trakt',
});
// Library Sync Mode constants
const TRAKT_LIBRARY_SYNC_MODE_KEY = 'trakt_library_sync_mode';
const LIBRARY_SYNC_MODE_OPTIONS = [
{ value: 'off', label: 'Off', description: 'Disable Trakt library sync completely' },
{ value: 'manual', label: 'Manual', description: 'Sync only when you tap the sync button' },
{ value: 'automatic', label: 'Automatic', description: 'Sync automatically when you open Library' },
];
const TraktSettingsScreen: React.FC = () => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings();
@ -80,6 +92,11 @@ const TraktSettingsScreen: React.FC = () => {
{ label: t('common.ok'), onPress: () => setAlertVisible(false) },
]);
// Library Sync Mode state
const [librarySyncMode, setLibrarySyncMode] = useState<'off' | 'manual' | 'automatic'>('off');
const librarySyncSheetRef = useRef<BottomSheetModal>(null);
const librarySyncSnapPoints = useMemo(() => ['45%'], []);
const openAlert = (
title: string,
message: string,
@ -132,6 +149,26 @@ const TraktSettingsScreen: React.FC = () => {
checkAuthStatus();
}, [checkAuthStatus]);
// Load library sync mode on mount
useEffect(() => {
const loadLibrarySyncMode = async () => {
if (isAuthenticated) {
try {
const mode = await mmkvStorage.getItem(TRAKT_LIBRARY_SYNC_MODE_KEY);
if (mode === 'manual' || mode === 'automatic') {
setLibrarySyncMode(mode);
} else {
setLibrarySyncMode('off');
}
} catch (error) {
logger.error('[TraktSettingsScreen] Failed to load library sync mode:', error);
}
}
};
loadLibrarySyncMode();
}, [isAuthenticated]);
// Setup expo-auth-session hook with PKCE
const [request, response, promptAsync] = useAuthRequest(
{
@ -230,6 +267,42 @@ const TraktSettingsScreen: React.FC = () => {
);
};
// Library Sync Mode handlers
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const handleSelectLibrarySyncMode = async (mode: 'off' | 'manual' | 'automatic') => {
try {
setLibrarySyncMode(mode);
await mmkvStorage.setItem(TRAKT_LIBRARY_SYNC_MODE_KEY, mode);
librarySyncSheetRef.current?.dismiss();
// Show confirmation
const modeLabel = LIBRARY_SYNC_MODE_OPTIONS.find(o => o.value === mode)?.label || mode;
openAlert(
'Library Sync Mode Updated',
`Trakt library sync is now set to: ${modeLabel}`
);
} catch (error) {
logger.error('[TraktSettingsScreen] Failed to save library sync mode:', error);
openAlert('Error', 'Failed to update library sync mode');
}
};
const getLibrarySyncModeLabel = (mode: string): string => {
const option = LIBRARY_SYNC_MODE_OPTIONS.find(o => o.value === mode);
return option?.label || 'Off';
};
return (
<SafeAreaView style={[
styles.container,
@ -502,6 +575,35 @@ const TraktSettingsScreen: React.FC = () => {
</View>
</View>
</View>
{/* Library Sync Mode Setting */}
<View style={styles.settingItem}>
<TouchableOpacity
style={styles.settingContent}
onPress={() => librarySyncSheetRef.current?.present()}
>
<View style={styles.settingTextContainer}>
<Text style={[
styles.settingLabel,
{ color: currentTheme.colors.highEmphasis }
]}>
Library Sync Mode
</Text>
<Text style={[
styles.settingDescription,
{ color: currentTheme.colors.mediumEmphasis }
]}>
{getLibrarySyncModeLabel(librarySyncMode)} - Sync your Trakt watchlist to local library
</Text>
</View>
<MaterialIcons
name="chevron-right"
size={24}
color={currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
</View>
<View style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
@ -589,8 +691,6 @@ const TraktSettingsScreen: React.FC = () => {
</View>
</View>
</View>
</View>
</View>
)}
@ -606,6 +706,64 @@ const TraktSettingsScreen: React.FC = () => {
onClose={() => setAlertVisible(false)}
actions={alertActions}
/>
{/* Library Sync Mode Bottom Sheet */}
<BottomSheetModal
ref={librarySyncSheetRef}
index={0}
snapPoints={librarySyncSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
>
<View style={styles.sheetHeader}>
<Text style={[styles.sheetTitle, { color: currentTheme.colors.highEmphasis }]}>
Library Sync Mode
</Text>
</View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{LIBRARY_SYNC_MODE_OPTIONS.map((option) => {
const isSelected = option.value === librarySyncMode;
return (
<TouchableOpacity
key={option.value}
style={[
styles.sourceItem,
isSelected && {
backgroundColor: currentTheme.colors.primary + '20',
borderColor: currentTheme.colors.primary
}
]}
onPress={() => handleSelectLibrarySyncMode(option.value as 'off' | 'manual' | 'automatic')}
>
<View style={styles.sourceItemContent}>
<Text style={[
styles.sourceLabel,
{ color: isSelected ? currentTheme.colors.primary : currentTheme.colors.highEmphasis }
]}>
{option.label}
</Text>
<Text style={[
styles.sourceDescription,
{ color: currentTheme.colors.mediumEmphasis }
]}>
{option.description}
</Text>
</View>
{isSelected && (
<MaterialIcons
name="check"
size={20}
color={currentTheme.colors.primary}
/>
)}
</TouchableOpacity>
);
})}
</BottomSheetScrollView>
</BottomSheetModal>
</SafeAreaView>
);
};
@ -857,6 +1015,45 @@ const styles = StyleSheet.create({
marginVertical: 20,
paddingHorizontal: 20,
},
// Bottom Sheet styles
sheetHeader: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
sheetTitle: {
fontSize: 18,
fontWeight: '600',
},
sheetContent: {
paddingHorizontal: 16,
paddingVertical: 12,
},
sourceItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
marginBottom: 12,
},
sourceItemContent: {
flex: 1,
marginRight: 12,
},
sourceLabel: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
sourceDescription: {
fontSize: 13,
lineHeight: 18,
},
});
export default TraktSettingsScreen;