From c18f1130fc1e831b5a6874158fb58788869cee77 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:50:11 +0530 Subject: [PATCH] fixed mark as watched items not syncing --- src/components/home/ContentItem.tsx | 60 ++++++++++--------- src/services/stremioService.ts | 1 - src/services/supabaseSyncService.ts | 90 +++++++++++++++++++++-------- src/services/watchedService.ts | 52 +++++++++++++---- 4 files changed, 137 insertions(+), 66 deletions(-) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index fcbba38e..648ff5ed 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -193,38 +193,36 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe case 'watched': { const targetWatched = !isWatched; setIsWatched(targetWatched); - try { - await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false'); - } catch { } - showInfo(targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched')); - setTimeout(() => { - DeviceEventEmitter.emit('watchedStatusChanged'); - }, 100); - - // Best-effort sync: record local progress and push to Trakt if available - if (targetWatched) { - try { - await storageService.setWatchProgress( - item.id, - item.type, - { currentTime: 1, duration: 1, lastUpdated: Date.now() }, - undefined, - { forceNotify: true, forceWrite: true } - ); - } catch { } - - if (item.type === 'movie') { - try { - const trakt = TraktService.getInstance(); - if (await trakt.isAuthenticated()) { - await trakt.addToWatchedMovies(item.id); - try { - await storageService.updateTraktSyncStatus(item.id, item.type, true, 100); - } catch { } - } - } catch { } + + // Use the centralized watchedService to handle all the sync logic (Supabase, Trakt, Simkl, MAL) + import('../../services/watchedService').then(({ watchedService }) => { + if (targetWatched) { + if (item.type === 'movie') { + // Pass the title so it correctly populates the database instead of defaulting to IMDb ID + watchedService.markMovieAsWatched(item.id, new Date(), undefined, undefined, item.name); + } else { + // For series from the homescreen drop-down, we mark S1E1 as watched as a baseline + watchedService.markEpisodeAsWatched(item.id, item.id, 1, 1, new Date(), undefined, item.name); + } + } else { + if (item.type === 'movie') { + watchedService.unmarkMovieAsWatched(item.id); + } else { + // Unmarking a series from the top level is tricky as we don't know the exact episodes. + // For safety and consistency with old behavior, we just clear the legacy flag. + mmkvStorage.removeItem(`watched:${item.type}:${item.id}`); + } } - } + + showInfo( + targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), + targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched') + ); + setTimeout(() => { + DeviceEventEmitter.emit('watchedStatusChanged'); + }, 100); + }); + setMenuVisible(false); break; } diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 5a211431..f30b0af2 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -335,7 +335,6 @@ class StremioService { if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true; return lowerId.length > lowerPrefix.length; }); - if (__DEV__) console.log(`🔍 [isValidContentId] Prefix match result: ${result} for ID '${id}'`); return result; } diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts index fa0d0e70..09816061 100644 --- a/src/services/supabaseSyncService.ts +++ b/src/services/supabaseSyncService.ts @@ -1135,8 +1135,16 @@ class SupabaseSyncService { } private async isExternalProgressSyncConnected(): Promise { - if (await this.isTraktConnected()) return true; - return await this.isSimklConnected(); + const trakt = await this.isTraktConnected(); + if (trakt) { + logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Trakt is connected, returning true'); + return true; + } + const simkl = await this.isSimklConnected(); + if (simkl) { + logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Simkl is connected, returning true'); + } + return simkl; } private async pullPluginsToLocal(): Promise { @@ -1409,62 +1417,90 @@ class SupabaseSyncService { private async pushWatchProgressFromLocal(): Promise { const all = await storageService.getAllWatchProgress(); + const allKeys = Object.keys(all); + const nextSeenKeys = new Set(); const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = []; + let skippedSameSignature = 0; + let skippedParseFailure = 0; for (const [key, value] of Object.entries(all)) { nextSeenKeys.add(key); const signature = this.getWatchProgressEntrySignature(value); if (this.watchProgressPushedSignatures.get(key) === signature) { + skippedSameSignature++; continue; } const parsed = this.parseWatchProgressKey(key); if (!parsed) { + skippedParseFailure++; continue; } - changedEntries.push({ - key, - signature, - row: { - content_id: parsed.contentId, - content_type: parsed.contentType, - video_id: parsed.videoId, - season: parsed.season, - episode: parsed.episode, - position: this.secondsToMsLong(value.currentTime), - duration: this.secondsToMsLong(value.duration), - last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()), - progress_key: parsed.progressKey, - }, - }); + const row: WatchProgressRow = { + content_id: parsed.contentId, + content_type: parsed.contentType, + video_id: parsed.videoId, + season: parsed.season, + episode: parsed.episode, + position: this.secondsToMsLong(value.currentTime), + duration: this.secondsToMsLong(value.duration), + last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()), + progress_key: parsed.progressKey, + }; + + changedEntries.push({ key, signature, row }); } // Prune signatures for entries no longer present locally (deletes are handled separately). + let prunedSignatures = 0; for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) { if (!nextSeenKeys.has(existingKey)) { this.watchProgressPushedSignatures.delete(existingKey); + prunedSignatures++; } } + logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: skippedSameSignature=${skippedSameSignature} skippedParseFailure=${skippedParseFailure} prunedStaleSignatures=${prunedSignatures}`); + if (changedEntries.length === 0) { logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push'); return; } - await this.callRpc('sync_push_watch_progress', { - p_entries: changedEntries.map((entry) => entry.row), - }); + const rpcPayload = changedEntries.map((entry) => entry.row); + logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: calling sync_push_watch_progress with ${rpcPayload.length} entries`); + try { + await this.callRpc('sync_push_watch_progress', { + p_entries: rpcPayload, + }); + logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC success`); + } catch (rpcError: any) { + logger.error(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC FAILED`, rpcError?.message || rpcError); + throw rpcError; + } for (const entry of changedEntries) { this.watchProgressPushedSignatures.set(entry.key, entry.signature); } - logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${Object.keys(all).length}`); + logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${allKeys.length}`); } private async pullLibraryToLocal(): Promise { - const rows = await this.callRpc('sync_pull_library', {}); + const PAGE_SIZE = 500; + const rows: LibraryRow[] = []; + let offset = 0; + while (true) { + const page = await this.callRpc('sync_pull_library', { + p_limit: PAGE_SIZE, + p_offset: offset, + }); + if (!page || page.length === 0) break; + rows.push(...page); + if (page.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } const localItems = await catalogService.getLibraryItems(); const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`)); const remoteSet = new Set(); @@ -1532,6 +1568,8 @@ class SupabaseSyncService { private async pushWatchedItemsFromLocal(): Promise { const items = await watchedService.getAllWatchedItems(); + if (items.length === 0) return; + const payload: WatchedRow[] = items.map((item) => ({ content_id: item.content_id, content_type: item.content_type, @@ -1540,7 +1578,13 @@ class SupabaseSyncService { episode: item.episode, watched_at: item.watched_at, })); - await this.callRpc('sync_push_watched_items', { p_items: payload }); + + try { + await this.callRpc('sync_push_watched_items', { p_items: payload }); + } catch (rpcError: any) { + logger.error(`[SupabaseSyncService] pushWatchedItemsFromLocal: RPC FAILED`, rpcError?.message || rpcError); + throw rpcError; + } } } diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 5a09cb5e..9214a9fd 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -175,13 +175,22 @@ class WatchedService { .filter((item) => Boolean(item.content_id)); // Guard: do not wipe local watched data if backend temporarily returns empty. + if (normalizedRemote.length === 0) { + logger.log('[WatchedService] reconcileRemoteWatchedItems: remote is empty, doing nothing'); return; } + const currentLocal = await this.loadWatchedItems(); + const remoteKeys = new Set(normalizedRemote.map(r => this.watchedKey(r))); + + // Find local items that need to be removed because they don't exist remotely + const toRemove = currentLocal.filter(l => !remoteKeys.has(this.watchedKey(l))); + await this.saveWatchedItems(normalizedRemote); this.notifyWatchedSubscribers(); + // 1. Set watched status for all remote items for (const item of normalizedRemote) { if (item.content_type === 'movie') { await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at)); @@ -192,8 +201,27 @@ class WatchedService { const episodeId = `${item.content_id}:${item.season}:${item.episode}`; await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at)); } + + // 2. Unset watched status for local items that were deleted remotely + for (const item of toRemove) { + if (item.content_type === 'movie') { + await this.setLocalWatchedStatus(item.content_id, 'movie', false); + } else if (item.season != null && item.episode != null) { + const episodeId = `${item.content_id}:${item.season}:${item.episode}`; + await this.setLocalWatchedStatus(item.content_id, 'series', false, episodeId); + } + } + + if (toRemove.length > 0) { + logger.log(`[WatchedService] reconcileRemoteWatchedItems: Removed ${toRemove.length} local items that were deleted remotely`); + } } + /** + * Mark a movie as watched + * @param imdbId - The IMDb ID of the movie + * @param watchedAt - Optional date when watched + */ /** * Mark a movie as watched * @param imdbId - The IMDb ID of the movie @@ -207,7 +235,7 @@ class WatchedService { title?: string ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { - logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`); + logger.log(`[WatchedService] Marking movie as watched: ${imdbId} (${title || 'No title'})`); const isTraktAuth = await this.traktService.isAuthenticated(); let syncedToTrakt = false; @@ -222,10 +250,10 @@ class WatchedService { if (MalAuth.isAuthenticated()) { MalSync.scrobbleEpisode( title || 'Movie', // Use real title or generic fallback - 1, - 1, - 'movie', - undefined, + 1, + 1, + 'movie', + undefined, imdbId, undefined, malId, @@ -247,7 +275,7 @@ class WatchedService { { content_id: imdbId, content_type: 'movie', - title: imdbId, + title: title || imdbId, season: null, episode: null, watched_at: watchedAt.getTime(), @@ -342,7 +370,7 @@ class WatchedService { 'series', season, showImdbId, - releaseDate, + releaseDate, malId, dayIndex, tmdbId @@ -373,7 +401,7 @@ class WatchedService { { content_id: showImdbId, content_type: 'series', - title: showImdbId, + title: showTitle || showImdbId, season, episode, watched_at: watchedAt.getTime(), @@ -398,7 +426,8 @@ class WatchedService { showImdbId: string, showId: string, episodes: Array<{ season: number; episode: number }>, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + showTitle?: string ): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> { try { if (episodes.length === 0) { @@ -479,7 +508,8 @@ class WatchedService { showId: string, season: number, episodeNumbers: number[], - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + showTitle?: string ): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> { try { logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`); @@ -525,7 +555,7 @@ class WatchedService { episodeNumbers.map((episode) => ({ content_id: showImdbId, content_type: 'series' as const, - title: showImdbId, + title: showTitle || showImdbId, season, episode, watched_at: watchedAt.getTime(),