diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 2c303f096..b98a86354 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -371,9 +371,16 @@ const ContinueWatchingSection = React.forwardRef((props, re }; const compareCwItems = (a: ContinueWatchingItem, b: ContinueWatchingItem): number => { - // Sort purely by recency — most recently watched first. - // "Up Next" placeholders (progress=0) carry the timestamp of the last watched episode - // so they naturally bubble up next to the other recently-watched items. + 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); }; @@ -456,25 +463,44 @@ const ContinueWatchingSection = React.forwardRef((props, re if (validItems.length === 0) return; - // 2. Single state update for the entire batch + // 2. Single state update for the entire batch. + // Key per episode (type:id:season:episode) so different episodes of the same + // show don't overwrite each other during concurrent group resolution. + // After merging we collapse to one item per show (most recently watched) so + // the UI still shows one card per title, ordered newest-first. setContinueWatchingItems((prev) => { - const map = new Map(); - // Add existing items + const episodeMap = new Map(); + + // Seed with existing items using per-episode keys for (const it of prev) { - map.set(`${it.type}:${it.id}`, it); + const epKey = it.type === 'series' && it.season != null && it.episode != null + ? `${it.type}:${it.id}:${it.season}:${it.episode}` + : `${it.type}:${it.id}`; + episodeMap.set(epKey, it); } - // Merge new valid items + // Merge new valid items — prefer newer lastUpdated for the same episode for (const it of validItems) { - const key = `${it.type}:${it.id}`; - const existing = map.get(key); - // Prefer local when it is ahead; otherwise, prefer newer + const epKey = it.type === 'series' && it.season != null && it.episode != null + ? `${it.type}:${it.id}:${it.season}:${it.episode}` + : `${it.type}:${it.id}`; + const existing = episodeMap.get(epKey); if (!existing || shouldPreferCandidate(it, existing)) { - map.set(key, it); + episodeMap.set(epKey, it); } } - const merged = Array.from(map.values()); + // Collapse to one item per show: keep the episode with the highest lastUpdated + const showMap = new Map(); + for (const it of episodeMap.values()) { + const showKey = `${it.type}:${it.id}`; + const existing = showMap.get(showKey); + if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + showMap.set(showKey, it); + } + } + + const merged = Array.from(showMap.values()); merged.sort(compareCwItems); return merged; @@ -848,36 +874,6 @@ const ContinueWatchingSection = React.forwardRef((props, re const traktBatch: ContinueWatchingItem[] = []; - // Pre-fetch watched shows so both Step 1 and Step 2 can use the watched episode sets - // This fixes "Up Next" suggesting already-watched episodes when the watched set is missing - let watchedShowsData: Awaited> = []; - // Map from showImdb -> Set of "imdb:season:episode" strings - const watchedEpisodeSetByShow = new Map>(); - try { - watchedShowsData = await traktService.getWatchedShows(); - for (const ws of watchedShowsData) { - if (!ws.show?.ids?.imdb) continue; - const imdb = ws.show.ids.imdb.startsWith('tt') ? ws.show.ids.imdb : `tt${ws.show.ids.imdb}`; - const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0; - const episodeSet = new Set(); - if (ws.seasons) { - for (const season of ws.seasons) { - for (const episode of season.episodes) { - // Respect reset_at: skip episodes watched before the reset - if (resetAt > 0) { - const watchedAt = new Date(episode.last_watched_at).getTime(); - if (watchedAt < resetAt) continue; - } - episodeSet.add(`${imdb}:${season.number}:${episode.number}`); - } - } - } - watchedEpisodeSetByShow.set(imdb, episodeSet); - } - } catch { - // Non-fatal — fall back to no watched set - } - // STEP 1: Process playback progress items (in-progress, paused) // These have actual progress percentage from Trakt const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); @@ -941,13 +937,11 @@ const ContinueWatchingSection = React.forwardRef((props, re if (item.progress >= 85) { const metadata = cachedData.metadata; if (metadata?.videos) { - // Use pre-fetched watched set so already-watched episodes are skipped - const watchedSetForShow = watchedEpisodeSetByShow.get(showImdb); const nextEpisode = findNextEpisode( item.episode.season, item.episode.number, metadata.videos, - watchedSetForShow, + undefined, // No watched set needed, findNextEpisode handles it showImdb ); @@ -990,13 +984,13 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // STEP 2: Find "Up Next" episodes using pre-fetched watched shows data - // Reuses watchedShowsData fetched before Step 1 — no extra API call - // Also respects reset_at (Bug 4 fix) and uses pre-built watched episode sets (Bug 3 fix) + // STEP 2: Get watched shows and find "Up Next" episodes + // This handles cases where episodes are fully completed and removed from playback progress try { + const watchedShows = await traktService.getWatchedShows(); const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); - for (const watchedShow of watchedShowsData) { + for (const watchedShow of watchedShows) { try { if (!watchedShow.show?.ids?.imdb) continue; @@ -1012,9 +1006,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const showKey = `series:${showImdb}`; if (recentlyRemovedRef.current.has(showKey)) continue; - const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0; - - // Find the last watched episode (respecting reset_at) + // Find the last watched episode let lastWatchedSeason = 0; let lastWatchedEpisode = 0; let latestEpisodeTimestamp = 0; @@ -1023,8 +1015,6 @@ const ContinueWatchingSection = React.forwardRef((props, re for (const season of watchedShow.seasons) { for (const episode of season.episodes) { const episodeTimestamp = new Date(episode.last_watched_at).getTime(); - // Skip episodes watched before the user reset their progress - if (resetAt > 0 && episodeTimestamp < resetAt) continue; if (episodeTimestamp > latestEpisodeTimestamp) { latestEpisodeTimestamp = episodeTimestamp; lastWatchedSeason = season.number; @@ -1040,8 +1030,15 @@ const ContinueWatchingSection = React.forwardRef((props, re const cachedData = await getCachedMetadata('series', showImdb); if (!cachedData?.basicContent || !cachedData?.metadata?.videos) continue; - // Use pre-built watched episode set (already respects reset_at) - const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set(); + // Build a set of watched episodes for this show + const watchedEpisodeSet = new Set(); + if (watchedShow.seasons) { + for (const season of watchedShow.seasons) { + for (const episode of season.episodes) { + watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`); + } + } + } // Find the next unwatched episode const nextEpisode = findNextEpisode( @@ -1076,24 +1073,13 @@ const ContinueWatchingSection = React.forwardRef((props, re // Trakt mode: show ONLY Trakt items, but override progress with local if local is higher. if (traktBatch.length > 0) { - // Dedupe (keep in-progress over "Up Next"; then prefer most recent) + // Dedupe (keep most recent per show/movie) const deduped = new Map(); for (const item of traktBatch) { const key = `${item.type}:${item.id}`; const existing = deduped.get(key); - if (!existing) { + if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { deduped.set(key, item); - } else { - const existingHasProgress = (existing.progress ?? 0) > 0; - const candidateHasProgress = (item.progress ?? 0) > 0; - if (candidateHasProgress && !existingHasProgress) { - // Always prefer actual in-progress over "Up Next" placeholder - deduped.set(key, item); - } else if (!candidateHasProgress && existingHasProgress) { - // Keep existing in-progress item - } else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { - deduped.set(key, item); - } } } @@ -1192,13 +1178,13 @@ const ContinueWatchingSection = React.forwardRef((props, re if (!mostRecentLocal || !highestLocal) return it; - // Use the most recent timestamp between local and Trakt. - // Always preferring local was wrong: if you watched on another device, - // Trakt's paused_at is newer and should win for ordering purposes. - const mergedLastUpdated = Math.max( - (mostRecentLocal.lastUpdated ?? 0), - (it.lastUpdated ?? 0) - ); + // IMPORTANT: + // In Trakt-auth mode, the "most recently watched" ordering should reflect local playback, + // not Trakt's paused_at (which can be stale or even appear newer than local). + // So: if we have any local match, use its timestamp for ordering. + const mergedLastUpdated = (mostRecentLocal.lastUpdated ?? 0) > 0 + ? (mostRecentLocal.lastUpdated ?? 0) + : (it.lastUpdated ?? 0); try { logger.log('[CW][Trakt][Overlay] item/local summary', { @@ -2256,7 +2242,14 @@ const ContinueWatchingSection = React.forwardRef((props, re { + 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