diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 104f4d9f3..62bdd020a 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -119,6 +119,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const [loading, setLoading] = useState(true); const appState = useRef(AppState.currentState); const refreshTimerRef = useRef(null); + const pendingRefreshRef = useRef(false); const [deletingItemId, setDeletingItemId] = useState(null); const longPressTimeoutRef = useRef(null); @@ -326,6 +327,7 @@ const ContinueWatchingSection = React.forwardRef((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((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((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((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((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((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((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((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 ( ((props, re )} - ), [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 ( ((props, re )} - ), [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((props, re (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 diff --git a/src/components/metadata/.HeroSection.tsx.swp b/src/components/metadata/.HeroSection.tsx.swp deleted file mode 100644 index 64b827bed..000000000 Binary files a/src/components/metadata/.HeroSection.tsx.swp and /dev/null differ diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index d19b8d21c..6c435883e 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -29,8 +29,6 @@ const AuthScreen: React.FC = () => { const [mode, setMode] = useState<'signin' | 'signup'>('signin'); const [error, setError] = useState(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 = () => { - {/* Important Warning Message */} - - - - - - Important Notice - - - 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. - - - Read more {showWarningDetails ? '▼' : '▶'} - - - - - {/* Expanded Details */} - {showWarningDetails && ( - - - - Why is this system being discontinued? - - - • 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 - - - - Benefits of Local Backup System: - - - • 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 - - - - )} - - - {/* Main Card - Hide when warning details are expanded */} - + { @@ -28,12 +28,7 @@ const SyncSettingsScreen: React.FC = () => { const [syncCodeLoading, setSyncCodeLoading] = useState(false); const [sessionUser, setSessionUser] = useState(null); const [ownerId, setOwnerId] = useState(null); - const [linkedDevices, setLinkedDevices] = useState([]); - const [lastCode, setLastCode] = useState(''); - const [pin, setPin] = useState(''); - const [claimCode, setClaimCode] = useState(''); - const [claimPin, setClaimPin] = useState(''); - const [deviceName, setDeviceName] = useState(''); + const [remoteStats, setRemoteStats] = useState(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 = () => { navigation.goBack()} /> + + + + Cloud Sync + + Keep your addons, progress and library aligned across devices. + + + + + - Account + + + Account + {user?.email ? `Signed in as ${user.email}` : 'Not signed in'} @@ -241,7 +189,10 @@ const SyncSettingsScreen: React.FC = () => { - Connection Status + + + Connection + {authLabel} Effective owner: {ownerId || 'Unavailable'} @@ -254,108 +205,65 @@ const SyncSettingsScreen: React.FC = () => { - Sync Code - - {!!lastCode && ( - - Latest code: {lastCode} + + + Database Stats + + {!remoteStats ? ( + + Sign in to load remote data counts. + ) : ( + + {statItems.map((item) => ( + + {item.value} + {item.label} + + ))} + )} + + + + + + Actions + + + Pull to refresh this device from cloud, or upload this device as the latest source. + - Generate Code + {syncCodeLoading ? ( + + ) : ( + Pull From Cloud + )} - Get Existing Code + Upload This Device - - - Claim Sync Code - - - - - Claim Code - - - - - Linked Devices - {linkedDevices.length === 0 && ( - No linked devices. - )} - {linkedDevices.map((device) => ( - - - - {device.device_name || 'Unnamed device'} - - - {device.device_user_id} - - - handleUnlinkDevice(device.device_user_id)} - > - Unlink - - - ))} - - - - {syncCodeLoading ? ( - - ) : ( - Sync Now - )} - { + 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(); + 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(); + + 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 { await this.ensureInitialized(); @@ -2089,4 +2148,4 @@ export interface AddonCatalogItem { } export const stremioService = StremioService.getInstance(); -export default stremioService; \ No newline at end of file +export default stremioService; diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts index 6eae07cb5..9cb59b3f1 100644 --- a/src/services/supabaseSyncService.ts +++ b/src/services/supabaseSyncService.ts @@ -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 { + 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>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/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('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 { @@ -951,6 +1014,15 @@ class SupabaseSyncService { } ); logger.log(`[SupabaseSyncService] pullAddonsToLocal: remoteCount=${rows?.length || 0}`); + const orderedRemoteUrls: string[] = []; + const seenRemoteUrls = new Set(); + 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 { @@ -1024,12 +1105,14 @@ class SupabaseSyncService { private async pullWatchProgressToLocal(): Promise { const rows = await this.callRpc('sync_pull_watch_progress', {}); + const remoteSet = new Set(); 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 { @@ -1085,11 +1193,13 @@ class SupabaseSyncService { const rows = await this.callRpc('sync_pull_library', {}); const localItems = await catalogService.getLibraryItems(); const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`)); + const remoteSet = new Set(); 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 { @@ -1124,7 +1253,7 @@ class SupabaseSyncService { private async pullWatchedItemsToLocal(): Promise { const rows = await this.callRpc('sync_pull_watched_items', {}); const mapped = (rows || []).map((row) => this.toWatchedItem(row)); - await watchedService.mergeRemoteWatchedItems(mapped); + await watchedService.reconcileRemoteWatchedItems(mapped); } private async pushWatchedItemsFromLocal(): Promise { diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 71344e082..5b609ff2d 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -166,6 +166,31 @@ class WatchedService { } } + public async reconcileRemoteWatchedItems(items: LocalWatchedItem[]): Promise { + 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