mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-17 21:05:43 +00:00
Refactor AuthScreen and SyncSettingsScreen; remove warning details and sync code generation
- Removed warning details and associated animations from AuthScreen. - Simplified AuthScreen layout by eliminating unused state and styles. - Updated SyncSettingsScreen to fetch remote sync stats and display them. - Introduced new actions for pulling from cloud and uploading local data. - Removed legacy code related to sync code generation and claiming. - Enhanced UI with new section headers and improved layout for better user experience. - Added reconciliation logic for remote watched items in watchedService. - Implemented addon order reconciliation based on remote manifest URLs in stremioService.
This commit is contained in:
parent
e27b6de202
commit
b5ae55da9e
7 changed files with 447 additions and 413 deletions
|
|
@ -119,6 +119,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const [loading, setLoading] = useState(true);
|
||||
const appState = useRef(AppState.currentState);
|
||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingRefreshRef = useRef(false);
|
||||
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
|
||||
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
|
@ -326,6 +327,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
// Modified loadContinueWatching to render incrementally
|
||||
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
|
||||
if (isRefreshingRef.current) {
|
||||
pendingRefreshRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -368,6 +370,20 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
return candidateProgress > existingProgress;
|
||||
};
|
||||
|
||||
const compareCwItems = (a: ContinueWatchingItem, b: ContinueWatchingItem): number => {
|
||||
const aProgress = a.progress ?? 0;
|
||||
const bProgress = b.progress ?? 0;
|
||||
const aIsUpNext = a.type === 'series' && aProgress <= 0;
|
||||
const bIsUpNext = b.type === 'series' && bProgress <= 0;
|
||||
|
||||
// Keep active in-progress items ahead of "Up Next" placeholders.
|
||||
if (aIsUpNext !== bIsUpNext) {
|
||||
return aIsUpNext ? 1 : -1;
|
||||
}
|
||||
|
||||
return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0);
|
||||
};
|
||||
|
||||
type LocalProgressEntry = {
|
||||
episodeId?: string;
|
||||
season?: number;
|
||||
|
|
@ -466,7 +482,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
|
||||
const merged = Array.from(map.values());
|
||||
merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
|
||||
merged.sort(compareCwItems);
|
||||
|
||||
return merged;
|
||||
});
|
||||
|
|
@ -1272,7 +1288,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
});
|
||||
|
||||
// Sort by lastUpdated descending and set directly
|
||||
adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
|
||||
adjustedItems.sort(compareCwItems);
|
||||
|
||||
// Debug final order (only if changed)
|
||||
try {
|
||||
|
|
@ -1515,7 +1531,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
return it;
|
||||
});
|
||||
|
||||
adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
|
||||
adjustedItems.sort(compareCwItems);
|
||||
setContinueWatchingItems(adjustedItems);
|
||||
} catch (err) {
|
||||
logger.error('[SimklSync] Error in Simkl merge:', err);
|
||||
|
|
@ -1529,6 +1545,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
} finally {
|
||||
setLoading(false);
|
||||
isRefreshingRef.current = false;
|
||||
if (pendingRefreshRef.current) {
|
||||
pendingRefreshRef.current = false;
|
||||
setTimeout(() => {
|
||||
loadContinueWatching(true);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [getCachedMetadata]);
|
||||
|
||||
|
|
@ -1602,6 +1624,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
// Initial load
|
||||
useEffect(() => {
|
||||
loadContinueWatching();
|
||||
const trailingRefreshId = setTimeout(() => {
|
||||
loadContinueWatching(true);
|
||||
}, 4000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(trailingRefreshId);
|
||||
};
|
||||
}, [loadContinueWatching]);
|
||||
|
||||
// Refresh on screen focus (lightweight, no polling)
|
||||
|
|
@ -1879,7 +1908,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}, [computedPosterWidth]);
|
||||
|
||||
// Memoized render function for poster-style continue watching items
|
||||
const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
|
||||
const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.posterContentItem,
|
||||
|
|
@ -1978,10 +2008,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]);
|
||||
);
|
||||
}, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]);
|
||||
|
||||
// Memoized render function for wide-style continue watching items
|
||||
const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
|
||||
const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.wideContentItem,
|
||||
|
|
@ -2143,7 +2175,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius]);
|
||||
);
|
||||
}, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius, t]);
|
||||
|
||||
// Choose the appropriate render function based on settings
|
||||
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => {
|
||||
|
|
@ -2190,7 +2223,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
</View>
|
||||
|
||||
<FlatList
|
||||
data={[...continueWatchingItems].sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0))}
|
||||
data={[...continueWatchingItems].sort((a, b) => {
|
||||
const aProgress = a.progress ?? 0;
|
||||
const bProgress = b.progress ?? 0;
|
||||
const aIsUpNext = a.type === 'series' && aProgress <= 0;
|
||||
const bIsUpNext = b.type === 'series' && bProgress <= 0;
|
||||
if (aIsUpNext !== bIsUpNext) return aIsUpNext ? 1 : -1;
|
||||
return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0);
|
||||
})}
|
||||
renderItem={renderContinueWatchingItem}
|
||||
keyExtractor={keyExtractor}
|
||||
horizontal
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -29,8 +29,6 @@ const AuthScreen: React.FC = () => {
|
|||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showWarningDetails, setShowWarningDetails] = useState(false);
|
||||
const authCardOpacity = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Subtle, performant animations
|
||||
const introOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
|
@ -191,27 +189,6 @@ const AuthScreen: React.FC = () => {
|
|||
navigation.reset({ index: 0, routes: [{ name: 'MainTabs' as never }] } as any);
|
||||
};
|
||||
|
||||
const toggleWarningDetails = () => {
|
||||
if (showWarningDetails) {
|
||||
// Fade in auth card
|
||||
Animated.timing(authCardOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else {
|
||||
// Fade out auth card
|
||||
Animated.timing(authCardOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
setShowWarningDetails(!showWarningDetails);
|
||||
};
|
||||
|
||||
// showToast helper replaced with direct calls to toast.* API
|
||||
|
||||
return (
|
||||
|
|
@ -279,72 +256,12 @@ const AuthScreen: React.FC = () => {
|
|||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Important Warning Message */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.warningContainer,
|
||||
{
|
||||
opacity: introOpacity,
|
||||
transform: [{ translateY: introTranslateY }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.warningCard, { backgroundColor: 'rgba(255, 193, 7, 0.1)', borderColor: 'rgba(255, 193, 7, 0.3)' }]}
|
||||
onPress={toggleWarningDetails}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons name="warning" size={20} color="#FFC107" style={styles.warningIcon} />
|
||||
<View style={styles.warningContent}>
|
||||
<Text style={[styles.warningTitle, { color: '#FFC107' }]}>
|
||||
Important Notice
|
||||
</Text>
|
||||
<Text style={[styles.warningText, { color: currentTheme.colors.white }]}>
|
||||
This authentication system will be completely replaced by local backup/restore functionality by October 8th. Please create backup files as your cloud data will be permanently destroyed.
|
||||
</Text>
|
||||
<Text style={[styles.readMoreText, { color: '#FFC107' }]}>
|
||||
Read more {showWarningDetails ? '▼' : '▶'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{showWarningDetails && (
|
||||
<Animated.View style={[styles.warningDetails, { backgroundColor: 'rgba(255, 193, 7, 0.05)', borderColor: 'rgba(255, 193, 7, 0.2)' }]}>
|
||||
<View style={styles.detailsContent}>
|
||||
<Text style={[styles.detailsTitle, { color: '#FFC107' }]}>
|
||||
Why is this system being discontinued?
|
||||
</Text>
|
||||
<Text style={[styles.detailsText, { color: currentTheme.colors.white }]}>
|
||||
• Lack of real-time support for addon synchronization{'\n'}
|
||||
• Database synchronization issues with addons and settings{'\n'}
|
||||
• Unreliable cloud data management{'\n'}
|
||||
• Performance problems with remote data access
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.detailsTitle, { color: '#FFC107', marginTop: 16 }]}>
|
||||
Benefits of Local Backup System:
|
||||
</Text>
|
||||
<Text style={[styles.detailsText, { color: currentTheme.colors.white }]}>
|
||||
• Instant addon synchronization across devices{'\n'}
|
||||
• Reliable offline access to all your data{'\n'}
|
||||
• Complete control over your backup files{'\n'}
|
||||
• Faster performance with local data storage{'\n'}
|
||||
• No dependency on external servers{'\n'}
|
||||
• Easy migration between devices
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? headerHeight : 0}
|
||||
>
|
||||
{/* Main Card - Hide when warning details are expanded */}
|
||||
<Animated.View style={[styles.centerContainer, { opacity: authCardOpacity }]}>
|
||||
<Animated.View style={styles.centerContainer}>
|
||||
<Animated.View style={[styles.card, {
|
||||
backgroundColor: Platform.OS === 'android' ? '#121212' : 'rgba(255,255,255,0.02)',
|
||||
borderColor: Platform.OS === 'android' ? '#1f1f1f' : 'rgba(255,255,255,0.06)',
|
||||
|
|
@ -774,63 +691,6 @@ const styles = StyleSheet.create({
|
|||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
warningContainer: {
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 24,
|
||||
marginBottom: 8,
|
||||
},
|
||||
warningCard: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
warningIcon: {
|
||||
marginRight: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
warningContent: {
|
||||
flex: 1,
|
||||
},
|
||||
warningTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 6,
|
||||
},
|
||||
warningText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: '500',
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
readMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginTop: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
warningDetails: {
|
||||
marginTop: 8,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
detailsContent: {
|
||||
padding: 16,
|
||||
},
|
||||
detailsTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
},
|
||||
detailsText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default AuthScreen;
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import {
|
|||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NavigationProp, useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import { supabaseSyncService, SupabaseUser, LinkedDevice } from '../services/supabaseSyncService';
|
||||
import { supabaseSyncService, SupabaseUser, RemoteSyncStats } from '../services/supabaseSyncService';
|
||||
import { useAccount } from '../contexts/AccountContext';
|
||||
|
||||
const SyncSettingsScreen: React.FC = () => {
|
||||
|
|
@ -28,12 +28,7 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
const [syncCodeLoading, setSyncCodeLoading] = useState(false);
|
||||
const [sessionUser, setSessionUser] = useState<SupabaseUser | null>(null);
|
||||
const [ownerId, setOwnerId] = useState<string | null>(null);
|
||||
const [linkedDevices, setLinkedDevices] = useState<LinkedDevice[]>([]);
|
||||
const [lastCode, setLastCode] = useState<string>('');
|
||||
const [pin, setPin] = useState('');
|
||||
const [claimCode, setClaimCode] = useState('');
|
||||
const [claimPin, setClaimPin] = useState('');
|
||||
const [deviceName, setDeviceName] = useState('');
|
||||
const [remoteStats, setRemoteStats] = useState<RemoteSyncStats | null>(null);
|
||||
|
||||
const [alertVisible, setAlertVisible] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
|
|
@ -57,8 +52,8 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
setSessionUser(supabaseSyncService.getCurrentSessionUser());
|
||||
const owner = await supabaseSyncService.getEffectiveOwnerId();
|
||||
setOwnerId(owner);
|
||||
const devices = await supabaseSyncService.getLinkedDevices();
|
||||
setLinkedDevices(devices);
|
||||
const stats = await supabaseSyncService.getRemoteStats();
|
||||
setRemoteStats(stats);
|
||||
} catch (error: any) {
|
||||
openAlert('Sync Error', error?.message || 'Failed to load sync state');
|
||||
} finally {
|
||||
|
|
@ -78,103 +73,42 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
return `Email session${sessionUser.email ? ` (${sessionUser.email})` : ''}`;
|
||||
}, [sessionUser]);
|
||||
|
||||
const handleGenerateCode = async () => {
|
||||
if (!pin.trim()) {
|
||||
openAlert('PIN Required', 'Enter a PIN before generating a sync code.');
|
||||
return;
|
||||
}
|
||||
setSyncCodeLoading(true);
|
||||
try {
|
||||
const result = await supabaseSyncService.generateSyncCode(pin.trim());
|
||||
if (result.error || !result.code) {
|
||||
openAlert('Generate Failed', result.error || 'Unable to generate sync code');
|
||||
} else {
|
||||
setLastCode(result.code);
|
||||
openAlert('Sync Code Ready', `Code: ${result.code}`);
|
||||
await loadSyncState();
|
||||
}
|
||||
} finally {
|
||||
setSyncCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetCode = async () => {
|
||||
if (!pin.trim()) {
|
||||
openAlert('PIN Required', 'Enter your PIN to retrieve the current sync code.');
|
||||
return;
|
||||
}
|
||||
setSyncCodeLoading(true);
|
||||
try {
|
||||
const result = await supabaseSyncService.getSyncCode(pin.trim());
|
||||
if (result.error || !result.code) {
|
||||
openAlert('Fetch Failed', result.error || 'Unable to fetch sync code');
|
||||
} else {
|
||||
setLastCode(result.code);
|
||||
openAlert('Current Sync Code', `Code: ${result.code}`);
|
||||
}
|
||||
} finally {
|
||||
setSyncCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClaimCode = async () => {
|
||||
if (!claimCode.trim() || !claimPin.trim()) {
|
||||
openAlert('Missing Details', 'Enter both sync code and PIN to claim.');
|
||||
return;
|
||||
}
|
||||
setSyncCodeLoading(true);
|
||||
try {
|
||||
const result = await supabaseSyncService.claimSyncCode(
|
||||
claimCode.trim().toUpperCase(),
|
||||
claimPin.trim(),
|
||||
deviceName.trim() || undefined
|
||||
);
|
||||
if (!result.success) {
|
||||
openAlert('Claim Failed', result.message);
|
||||
} else {
|
||||
openAlert('Device Linked', result.message);
|
||||
setClaimCode('');
|
||||
setClaimPin('');
|
||||
await loadSyncState();
|
||||
}
|
||||
} finally {
|
||||
setSyncCodeLoading(false);
|
||||
}
|
||||
};
|
||||
const statItems = useMemo(() => {
|
||||
if (!remoteStats) return [];
|
||||
return [
|
||||
{ label: 'Plugins', value: remoteStats.plugins },
|
||||
{ label: 'Addons', value: remoteStats.addons },
|
||||
{ label: 'Watch Progress', value: remoteStats.watchProgress },
|
||||
{ label: 'Library Items', value: remoteStats.libraryItems },
|
||||
{ label: 'Watched Items', value: remoteStats.watchedItems },
|
||||
{ label: 'Linked Devices', value: remoteStats.linkedDevices },
|
||||
];
|
||||
}, [remoteStats]);
|
||||
|
||||
const handleManualSync = async () => {
|
||||
setSyncCodeLoading(true);
|
||||
try {
|
||||
await supabaseSyncService.syncNow();
|
||||
openAlert('Sync Complete', 'Manual sync completed successfully.');
|
||||
await supabaseSyncService.pullAllToLocal();
|
||||
openAlert('Cloud Data Pulled', 'Latest cloud data was pulled to this device.');
|
||||
await loadSyncState();
|
||||
} catch (error: any) {
|
||||
openAlert('Sync Failed', error?.message || 'Manual sync failed');
|
||||
openAlert('Pull Failed', error?.message || 'Failed to pull cloud data');
|
||||
} finally {
|
||||
setSyncCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkDevice = (deviceUserId: string) => {
|
||||
openAlert('Unlink Device', 'Are you sure you want to unlink this device?', [
|
||||
{ label: 'Cancel', onPress: () => {} },
|
||||
{
|
||||
label: 'Unlink',
|
||||
onPress: async () => {
|
||||
setSyncCodeLoading(true);
|
||||
try {
|
||||
const result = await supabaseSyncService.unlinkDevice(deviceUserId);
|
||||
if (!result.success) {
|
||||
openAlert('Unlink Failed', result.error || 'Unable to unlink device');
|
||||
} else {
|
||||
await loadSyncState();
|
||||
}
|
||||
} finally {
|
||||
setSyncCodeLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
const handleUploadLocalData = async () => {
|
||||
setSyncCodeLoading(true);
|
||||
try {
|
||||
await supabaseSyncService.pushAllLocalData();
|
||||
openAlert('Upload Complete', 'This device data has been uploaded to cloud.');
|
||||
await loadSyncState();
|
||||
} catch (error: any) {
|
||||
openAlert('Upload Failed', error?.message || 'Failed to upload local data');
|
||||
} finally {
|
||||
setSyncCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
|
|
@ -207,8 +141,22 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
<ScreenHeader title="Nuvio Sync" showBackButton onBackPress={() => navigation.goBack()} />
|
||||
|
||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 24 }]}>
|
||||
<View style={[styles.heroCard, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.heroTopRow}>
|
||||
<View style={styles.heroTitleWrap}>
|
||||
<Text style={[styles.heroTitle, { color: currentTheme.colors.highEmphasis }]}>Cloud Sync</Text>
|
||||
<Text style={[styles.heroSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Keep your addons, progress and library aligned across devices.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Account</Text>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="person-outline" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Account</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{user?.email ? `Signed in as ${user.email}` : 'Not signed in'}
|
||||
</Text>
|
||||
|
|
@ -241,7 +189,10 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Connection Status</Text>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="link" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Connection</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>{authLabel}</Text>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Effective owner: {ownerId || 'Unavailable'}
|
||||
|
|
@ -254,108 +205,65 @@ const SyncSettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Sync Code</Text>
|
||||
<TextInput
|
||||
value={pin}
|
||||
onChangeText={setPin}
|
||||
placeholder="PIN"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
|
||||
secureTextEntry
|
||||
/>
|
||||
{!!lastCode && (
|
||||
<Text style={[styles.codeText, { color: currentTheme.colors.primary }]}>
|
||||
Latest code: {lastCode}
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="storage" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Database Stats</Text>
|
||||
</View>
|
||||
{!remoteStats ? (
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Sign in to load remote data counts.
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.statsGrid}>
|
||||
{statItems.map((item) => (
|
||||
<View key={item.label} style={[styles.statTile, { backgroundColor: currentTheme.colors.darkBackground, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.statValue, { color: currentTheme.colors.highEmphasis }]}>{item.value}</Text>
|
||||
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="sync" size={18} color={currentTheme.colors.highEmphasis} />
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Actions</Text>
|
||||
</View>
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Pull to refresh this device from cloud, or upload this device as the latest source.
|
||||
</Text>
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleGenerateCode}
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
(syncCodeLoading || !supabaseSyncService.isConfigured()) && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleManualSync}
|
||||
>
|
||||
<Text style={styles.buttonText}>Generate Code</Text>
|
||||
{syncCodeLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Pull From Cloud</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={handleGetCode}
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{ backgroundColor: currentTheme.colors.elevation2, borderColor: currentTheme.colors.elevation2 },
|
||||
(syncCodeLoading || !supabaseSyncService.isConfigured()) && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleUploadLocalData}
|
||||
>
|
||||
<Text style={styles.buttonText}>Get Existing Code</Text>
|
||||
<Text style={styles.buttonText}>Upload This Device</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Claim Sync Code</Text>
|
||||
<TextInput
|
||||
value={claimCode}
|
||||
onChangeText={setClaimCode}
|
||||
placeholder="SYNC-CODE"
|
||||
autoCapitalize="characters"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
|
||||
/>
|
||||
<TextInput
|
||||
value={claimPin}
|
||||
onChangeText={setClaimPin}
|
||||
placeholder="PIN"
|
||||
secureTextEntry
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
|
||||
/>
|
||||
<TextInput
|
||||
value={deviceName}
|
||||
onChangeText={setDeviceName}
|
||||
placeholder="Device name (optional)"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
style={[styles.input, { color: currentTheme.colors.white, borderColor: currentTheme.colors.elevation2 }]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleClaimCode}
|
||||
>
|
||||
<Text style={styles.buttonText}>Claim Code</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1, borderColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.highEmphasis }]}>Linked Devices</Text>
|
||||
{linkedDevices.length === 0 && (
|
||||
<Text style={[styles.cardText, { color: currentTheme.colors.mediumEmphasis }]}>No linked devices.</Text>
|
||||
)}
|
||||
{linkedDevices.map((device) => (
|
||||
<View key={`${device.owner_id}:${device.device_user_id}`} style={styles.deviceRow}>
|
||||
<View style={styles.deviceInfo}>
|
||||
<Text style={[styles.deviceName, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{device.device_name || 'Unnamed device'}
|
||||
</Text>
|
||||
<Text style={[styles.deviceMeta, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{device.device_user_id}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.unlinkButton, { borderColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={() => handleUnlinkDevice(device.device_user_id)}
|
||||
>
|
||||
<Text style={[styles.unlinkText, { color: currentTheme.colors.white }]}>Unlink</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
disabled={syncCodeLoading || !supabaseSyncService.isConfigured()}
|
||||
style={[styles.syncNowButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleManualSync}
|
||||
>
|
||||
{syncCodeLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sync Now</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
||||
<CustomAlert
|
||||
|
|
@ -380,7 +288,29 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
gap: 14,
|
||||
},
|
||||
heroCard: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
},
|
||||
heroTopRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroTitleWrap: {
|
||||
flex: 1,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
|
|
@ -388,8 +318,13 @@ const styles = StyleSheet.create({
|
|||
padding: 14,
|
||||
gap: 10,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
cardText: {
|
||||
|
|
@ -400,12 +335,27 @@ const styles = StyleSheet.create({
|
|||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
input: {
|
||||
statsGrid: {
|
||||
marginTop: 2,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
statTile: {
|
||||
width: '48%',
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
marginBottom: 2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -419,49 +369,20 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
primaryButton: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
secondaryButton: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
fontSize: 13,
|
||||
},
|
||||
codeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deviceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
deviceInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
deviceName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deviceMeta: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
unlinkButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
unlinkText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
syncNowButton: {
|
||||
minHeight: 48,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SyncSettingsScreen;
|
||||
|
|
|
|||
|
|
@ -1987,6 +1987,65 @@ class StremioService {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Reconcile local addon order to match a remote ordered list of addon manifest URLs.
|
||||
// Any local addons not present in the remote list are appended in their current order.
|
||||
async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise<boolean> {
|
||||
await this.ensureInitialized();
|
||||
if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) return false;
|
||||
|
||||
const normalizeManifestUrl = (raw: string): string => {
|
||||
const value = (raw || '').trim();
|
||||
if (!value) return '';
|
||||
const withManifest = value.includes('manifest.json')
|
||||
? value
|
||||
: `${value.replace(/\/$/, '')}/manifest.json`;
|
||||
return withManifest.toLowerCase();
|
||||
};
|
||||
|
||||
const localByNormalizedUrl = new Map<string, string[]>();
|
||||
for (const installationId of this.addonOrder) {
|
||||
const addon = this.installedAddons.get(installationId);
|
||||
if (!addon) continue;
|
||||
const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || '');
|
||||
if (!normalized) continue;
|
||||
const list = localByNormalizedUrl.get(normalized) || [];
|
||||
list.push(installationId);
|
||||
localByNormalizedUrl.set(normalized, list);
|
||||
}
|
||||
|
||||
const nextOrder: string[] = [];
|
||||
const seenInstallations = new Set<string>();
|
||||
|
||||
for (const remoteUrl of manifestUrls) {
|
||||
const normalizedRemote = normalizeManifestUrl(remoteUrl);
|
||||
if (!normalizedRemote) continue;
|
||||
const candidates = localByNormalizedUrl.get(normalizedRemote);
|
||||
if (!candidates || candidates.length === 0) continue;
|
||||
const installationId = candidates.shift();
|
||||
if (!installationId || seenInstallations.has(installationId)) continue;
|
||||
nextOrder.push(installationId);
|
||||
seenInstallations.add(installationId);
|
||||
}
|
||||
|
||||
for (const installationId of this.addonOrder) {
|
||||
if (!this.installedAddons.has(installationId)) continue;
|
||||
if (seenInstallations.has(installationId)) continue;
|
||||
nextOrder.push(installationId);
|
||||
seenInstallations.add(installationId);
|
||||
}
|
||||
|
||||
const changed =
|
||||
nextOrder.length !== this.addonOrder.length ||
|
||||
nextOrder.some((id, index) => id !== this.addonOrder[index]);
|
||||
|
||||
if (!changed) return false;
|
||||
|
||||
this.addonOrder = nextOrder;
|
||||
await this.saveAddonOrder();
|
||||
addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any installed addons can provide streams (including embedded streams in metadata)
|
||||
async hasStreamProviders(type?: string): Promise<boolean> {
|
||||
await this.ensureInitialized();
|
||||
|
|
@ -2089,4 +2148,4 @@ export interface AddonCatalogItem {
|
|||
}
|
||||
|
||||
export const stremioService = StremioService.getInstance();
|
||||
export default stremioService;
|
||||
export default stremioService;
|
||||
|
|
|
|||
|
|
@ -97,6 +97,15 @@ export type LinkedDevice = {
|
|||
linked_at: string;
|
||||
};
|
||||
|
||||
export type RemoteSyncStats = {
|
||||
plugins: number;
|
||||
addons: number;
|
||||
watchProgress: number;
|
||||
libraryItems: number;
|
||||
watchedItems: number;
|
||||
linkedDevices: number;
|
||||
};
|
||||
|
||||
type PushTarget = 'plugins' | 'addons' | 'watch_progress' | 'library' | 'watched_items';
|
||||
|
||||
class SupabaseSyncService {
|
||||
|
|
@ -372,6 +381,63 @@ class SupabaseSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
public async getRemoteStats(): Promise<RemoteSyncStats | null> {
|
||||
try {
|
||||
const token = await this.getValidAccessToken();
|
||||
if (!token) return null;
|
||||
|
||||
const ownerId = await this.getEffectiveOwnerId();
|
||||
if (!ownerId) return null;
|
||||
|
||||
const ownerFilter = encodeURIComponent(ownerId);
|
||||
const [
|
||||
pluginRows,
|
||||
addonRows,
|
||||
watchRows,
|
||||
libraryRows,
|
||||
watchedRows,
|
||||
deviceRows,
|
||||
] = await Promise.all([
|
||||
this.request<Array<{ id: string }>>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}`, {
|
||||
method: 'GET',
|
||||
authToken: token,
|
||||
}),
|
||||
this.request<Array<{ id: string }>>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}`, {
|
||||
method: 'GET',
|
||||
authToken: token,
|
||||
}),
|
||||
this.request<Array<{ id: string }>>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}`, {
|
||||
method: 'GET',
|
||||
authToken: token,
|
||||
}),
|
||||
this.request<Array<{ id: string }>>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}`, {
|
||||
method: 'GET',
|
||||
authToken: token,
|
||||
}),
|
||||
this.request<Array<{ id: string }>>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}`, {
|
||||
method: 'GET',
|
||||
authToken: token,
|
||||
}),
|
||||
this.request<Array<{ device_user_id: string }>>(`/rest/v1/linked_devices?select=device_user_id&owner_id=eq.${ownerFilter}`, {
|
||||
method: 'GET',
|
||||
authToken: token,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
plugins: pluginRows?.length || 0,
|
||||
addons: addonRows?.length || 0,
|
||||
watchProgress: watchRows?.length || 0,
|
||||
libraryItems: libraryRows?.length || 0,
|
||||
watchedItems: watchedRows?.length || 0,
|
||||
linkedDevices: deviceRows?.length || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('[SupabaseSyncService] Failed to fetch remote stats:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async unlinkDevice(deviceUserId: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await this.callRpc<void>('unlink_device', { p_device_user_id: deviceUserId });
|
||||
|
|
@ -423,21 +489,8 @@ class SupabaseSyncService {
|
|||
});
|
||||
});
|
||||
logger.log(`[SupabaseSyncService] runStartupSync: step=pull_addons:done ok=${addonPullOk}`);
|
||||
|
||||
if (pluginPullOk) {
|
||||
logger.log('[SupabaseSyncService] runStartupSync: step=push_plugins:start');
|
||||
await this.safeRun('push_plugins', async () => {
|
||||
await this.pushPluginsFromLocal();
|
||||
});
|
||||
logger.log('[SupabaseSyncService] runStartupSync: step=push_plugins:done');
|
||||
}
|
||||
|
||||
if (addonPullOk) {
|
||||
logger.log('[SupabaseSyncService] runStartupSync: step=push_addons:start');
|
||||
await this.safeRun('push_addons', async () => {
|
||||
await this.pushAddonsFromLocal();
|
||||
});
|
||||
logger.log('[SupabaseSyncService] runStartupSync: step=push_addons:done');
|
||||
if (!pluginPullOk || !addonPullOk) {
|
||||
logger.warn('[SupabaseSyncService] runStartupSync: one or more pull steps failed; skipped startup push-by-design');
|
||||
}
|
||||
|
||||
const traktConnected = await this.isTraktConnected();
|
||||
|
|
@ -464,22 +517,8 @@ class SupabaseSyncService {
|
|||
});
|
||||
});
|
||||
|
||||
if (watchPullOk) {
|
||||
await this.safeRun('push_watch_progress', async () => {
|
||||
await this.pushWatchProgressFromLocal();
|
||||
});
|
||||
}
|
||||
|
||||
if (libraryPullOk) {
|
||||
await this.safeRun('push_library', async () => {
|
||||
await this.pushLibraryFromLocal();
|
||||
});
|
||||
}
|
||||
|
||||
if (watchedPullOk) {
|
||||
await this.safeRun('push_watched_items', async () => {
|
||||
await this.pushWatchedItemsFromLocal();
|
||||
});
|
||||
if (!watchPullOk || !libraryPullOk || !watchedPullOk) {
|
||||
logger.warn('[SupabaseSyncService] runStartupSync: one or more content pulls failed; skipped startup push-by-design');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -896,6 +935,11 @@ class SupabaseSyncService {
|
|||
|
||||
const localRepos = await localScraperService.getRepositories();
|
||||
const byUrl = new Map(localRepos.map((repo) => [this.normalizeUrl(repo.url), repo]));
|
||||
const remoteSet = new Set(
|
||||
(rows || [])
|
||||
.map((row) => (row?.url ? this.normalizeUrl(row.url) : null))
|
||||
.filter((url): url is string => Boolean(url))
|
||||
);
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (!row.url) continue;
|
||||
|
|
@ -923,6 +967,25 @@ class SupabaseSyncService {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile removals only when remote has at least one entry to avoid wiping local
|
||||
// data if backend temporarily returns an empty set.
|
||||
if (remoteSet.size > 0) {
|
||||
let removedCount = 0;
|
||||
for (const repo of localRepos) {
|
||||
const normalized = this.normalizeUrl(repo.url);
|
||||
if (remoteSet.has(normalized)) continue;
|
||||
try {
|
||||
await localScraperService.removeRepository(repo.id);
|
||||
removedCount += 1;
|
||||
} catch (error) {
|
||||
logger.warn('[SupabaseSyncService] Failed to remove local plugin repository missing in remote set:', repo.name, error);
|
||||
}
|
||||
}
|
||||
logger.log(`[SupabaseSyncService] pullPluginsToLocal: removedLocalExtras=${removedCount}`);
|
||||
} else {
|
||||
logger.log('[SupabaseSyncService] pullPluginsToLocal: remote set empty, skipped local prune');
|
||||
}
|
||||
}
|
||||
|
||||
private async pushPluginsFromLocal(): Promise<void> {
|
||||
|
|
@ -951,6 +1014,15 @@ class SupabaseSyncService {
|
|||
}
|
||||
);
|
||||
logger.log(`[SupabaseSyncService] pullAddonsToLocal: remoteCount=${rows?.length || 0}`);
|
||||
const orderedRemoteUrls: string[] = [];
|
||||
const seenRemoteUrls = new Set<string>();
|
||||
for (const row of rows || []) {
|
||||
if (!row?.url) continue;
|
||||
const normalized = this.normalizeUrl(row.url);
|
||||
if (seenRemoteUrls.has(normalized)) continue;
|
||||
seenRemoteUrls.add(normalized);
|
||||
orderedRemoteUrls.push(row.url);
|
||||
}
|
||||
|
||||
const installed = await stremioService.getInstalledAddonsAsync();
|
||||
logger.log(`[SupabaseSyncService] pullAddonsToLocal: localInstalledBefore=${installed.length}`);
|
||||
|
|
@ -999,6 +1071,15 @@ class SupabaseSyncService {
|
|||
} else {
|
||||
logger.log('[SupabaseSyncService] pullAddonsToLocal: remote set empty, skipped local prune');
|
||||
}
|
||||
|
||||
if (orderedRemoteUrls.length > 0) {
|
||||
try {
|
||||
const changed = await stremioService.applyAddonOrderFromManifestUrls(orderedRemoteUrls);
|
||||
logger.log(`[SupabaseSyncService] pullAddonsToLocal: orderReconciled changed=${changed}`);
|
||||
} catch (error) {
|
||||
logger.warn('[SupabaseSyncService] pullAddonsToLocal: failed to reconcile addon order:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pushAddonsFromLocal(): Promise<void> {
|
||||
|
|
@ -1024,12 +1105,14 @@ class SupabaseSyncService {
|
|||
|
||||
private async pullWatchProgressToLocal(): Promise<void> {
|
||||
const rows = await this.callRpc<WatchProgressRow[]>('sync_pull_watch_progress', {});
|
||||
const remoteSet = new Set<string>();
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (!row.content_id) continue;
|
||||
const type = row.content_type === 'movie' ? 'movie' : 'series';
|
||||
const season = row.season == null ? null : Number(row.season);
|
||||
const episode = row.episode == null ? null : Number(row.episode);
|
||||
remoteSet.add(`${type}:${row.content_id}:${season ?? ''}:${episode ?? ''}`);
|
||||
|
||||
const episodeId = type === 'series' && season != null && episode != null
|
||||
? `${row.content_id}:${season}:${episode}`
|
||||
|
|
@ -1054,9 +1137,34 @@ class SupabaseSyncService {
|
|||
{
|
||||
preserveTimestamp: true,
|
||||
forceWrite: true,
|
||||
forceNotify: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Reconcile removals only when remote has at least one entry to avoid wiping local
|
||||
// data if backend temporarily returns an empty set.
|
||||
if (remoteSet.size > 0) {
|
||||
const allLocal = await storageService.getAllWatchProgress();
|
||||
let removedCount = 0;
|
||||
|
||||
for (const [key] of Object.entries(allLocal)) {
|
||||
const parsed = this.parseWatchProgressKey(key);
|
||||
if (!parsed) continue;
|
||||
const localSig = `${parsed.contentType}:${parsed.contentId}:${parsed.season ?? ''}:${parsed.episode ?? ''}`;
|
||||
if (remoteSet.has(localSig)) continue;
|
||||
|
||||
const episodeId = parsed.contentType === 'series' && parsed.season != null && parsed.episode != null
|
||||
? `${parsed.contentId}:${parsed.season}:${parsed.episode}`
|
||||
: undefined;
|
||||
|
||||
await storageService.removeWatchProgress(parsed.contentId, parsed.contentType, episodeId);
|
||||
removedCount += 1;
|
||||
}
|
||||
logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: removedLocalExtras=${removedCount}`);
|
||||
} else {
|
||||
logger.log('[SupabaseSyncService] pullWatchProgressToLocal: remote set empty, skipped local prune');
|
||||
}
|
||||
}
|
||||
|
||||
private async pushWatchProgressFromLocal(): Promise<void> {
|
||||
|
|
@ -1085,11 +1193,13 @@ class SupabaseSyncService {
|
|||
const rows = await this.callRpc<LibraryRow[]>('sync_pull_library', {});
|
||||
const localItems = await catalogService.getLibraryItems();
|
||||
const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`));
|
||||
const remoteSet = new Set<string>();
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (!row.content_id || !row.content_type) continue;
|
||||
const type = row.content_type === 'movie' ? 'movie' : 'series';
|
||||
const key = `${type}:${row.content_id}`;
|
||||
remoteSet.add(key);
|
||||
if (existing.has(key)) continue;
|
||||
|
||||
try {
|
||||
|
|
@ -1099,6 +1209,25 @@ class SupabaseSyncService {
|
|||
logger.warn('[SupabaseSyncService] Failed to merge library item from sync:', key, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile removals only when remote has at least one entry to avoid wiping local
|
||||
// data if backend temporarily returns an empty set.
|
||||
if (remoteSet.size > 0) {
|
||||
let removedCount = 0;
|
||||
for (const item of localItems) {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
if (remoteSet.has(key)) continue;
|
||||
try {
|
||||
await catalogService.removeFromLibrary(item.type, item.id);
|
||||
removedCount += 1;
|
||||
} catch (error) {
|
||||
logger.warn('[SupabaseSyncService] Failed to remove local library item missing in remote set:', key, error);
|
||||
}
|
||||
}
|
||||
logger.log(`[SupabaseSyncService] pullLibraryToLocal: removedLocalExtras=${removedCount}`);
|
||||
} else {
|
||||
logger.log('[SupabaseSyncService] pullLibraryToLocal: remote set empty, skipped local prune');
|
||||
}
|
||||
}
|
||||
|
||||
private async pushLibraryFromLocal(): Promise<void> {
|
||||
|
|
@ -1124,7 +1253,7 @@ class SupabaseSyncService {
|
|||
private async pullWatchedItemsToLocal(): Promise<void> {
|
||||
const rows = await this.callRpc<WatchedRow[]>('sync_pull_watched_items', {});
|
||||
const mapped = (rows || []).map((row) => this.toWatchedItem(row));
|
||||
await watchedService.mergeRemoteWatchedItems(mapped);
|
||||
await watchedService.reconcileRemoteWatchedItems(mapped);
|
||||
}
|
||||
|
||||
private async pushWatchedItemsFromLocal(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -166,6 +166,31 @@ class WatchedService {
|
|||
}
|
||||
}
|
||||
|
||||
public async reconcileRemoteWatchedItems(items: LocalWatchedItem[]): Promise<void> {
|
||||
const normalizedRemote = items
|
||||
.map((item) => this.normalizeWatchedItem(item))
|
||||
.filter((item) => Boolean(item.content_id));
|
||||
|
||||
// Guard: do not wipe local watched data if backend temporarily returns empty.
|
||||
if (normalizedRemote.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.saveWatchedItems(normalizedRemote);
|
||||
this.notifyWatchedSubscribers();
|
||||
|
||||
for (const item of normalizedRemote) {
|
||||
if (item.content_type === 'movie') {
|
||||
await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.season == null || item.episode == null) continue;
|
||||
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
|
||||
await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a movie as watched
|
||||
* @param imdbId - The IMDb ID of the movie
|
||||
|
|
|
|||
Loading…
Reference in a new issue