diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 586d66e1..0d4617a5 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -855,6 +855,36 @@ 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); @@ -918,11 +948,13 @@ 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, - undefined, // No watched set needed, findNextEpisode handles it + watchedSetForShow, showImdb ); @@ -965,13 +997,13 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // STEP 2: Get watched shows and find "Up Next" episodes - // This handles cases where episodes are fully completed and removed from playback progress + // 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) try { - const watchedShows = await traktService.getWatchedShows(); const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); - for (const watchedShow of watchedShows) { + for (const watchedShow of watchedShowsData) { try { if (!watchedShow.show?.ids?.imdb) continue; @@ -987,7 +1019,9 @@ const ContinueWatchingSection = React.forwardRef((props, re const showKey = `series:${showImdb}`; if (recentlyRemovedRef.current.has(showKey)) continue; - // Find the last watched episode + const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0; + + // Find the last watched episode (respecting reset_at) let lastWatchedSeason = 0; let lastWatchedEpisode = 0; let latestEpisodeTimestamp = 0; @@ -996,6 +1030,8 @@ 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; @@ -1011,15 +1047,8 @@ const ContinueWatchingSection = React.forwardRef((props, re const cachedData = await getCachedMetadata('series', showImdb); if (!cachedData?.basicContent || !cachedData?.metadata?.videos) continue; - // 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}`); - } - } - } + // Use pre-built watched episode set (already respects reset_at) + const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set(); // Find the next unwatched episode const nextEpisode = findNextEpisode( @@ -1054,13 +1083,24 @@ 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 most recent per show/movie) + // Dedupe (keep in-progress over "Up Next"; then prefer most recent) const deduped = new Map(); for (const item of traktBatch) { const key = `${item.type}:${item.id}`; const existing = deduped.get(key); - if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + if (!existing) { 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); + } } } @@ -2223,14 +2263,7 @@ 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); - })} + data={continueWatchingItems} renderItem={renderContinueWatchingItem} keyExtractor={keyExtractor} horizontal diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts index c3748fd6..e2884cd3 100644 --- a/src/hooks/useCalendarData.ts +++ b/src/hooks/useCalendarData.ts @@ -68,7 +68,38 @@ export const useCalendarData = (): UseCalendarDataReturn => { ); if (cachedData) { - setCalendarData(cachedData); + // Apply watched filter even on cached data + if (traktAuthenticated && watchedShows && watchedShows.length > 0) { + const cachedWatchedSet = new Set(); + for (const ws of watchedShows) { + const imdb = ws.show?.ids?.imdb; + if (!imdb) continue; + const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`; + const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0; + if (ws.seasons) { + for (const season of ws.seasons) { + for (const episode of season.episodes) { + if (resetAt > 0 && new Date(episode.last_watched_at).getTime() < resetAt) continue; + cachedWatchedSet.add(`${showId}:${season.number}:${episode.number}`); + } + } + } + } + const filtered = cachedData.map(section => { + if (section.title !== 'This Week') return section; + return { + ...section, + data: section.data.filter((ep: any) => { + const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`; + return !cachedWatchedSet.has(`${showId}:${ep.season}:${ep.episode}`) && + !cachedWatchedSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`); + }) + }; + }); + setCalendarData(filtered); + } else { + setCalendarData(cachedData); + } setLoading(false); return; } @@ -314,6 +345,29 @@ export const useCalendarData = (): UseCalendarDataReturn => { logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`); + // Build a set of watched episodes from Trakt so we can filter them out of This Week + const watchedEpisodeSet = new Set(); + if (traktAuthenticated && watchedShows) { + for (const ws of watchedShows) { + const imdb = ws.show?.ids?.imdb; + if (!imdb) continue; + const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`; + const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0; + if (ws.seasons) { + for (const season of ws.seasons) { + for (const episode of season.episodes) { + // Respect reset_at + if (resetAt > 0) { + const watchedAt = new Date(episode.last_watched_at).getTime(); + if (watchedAt < resetAt) continue; + } + watchedEpisodeSet.add(`${showId}:${season.number}:${episode.number}`); + } + } + } + } + } + // Use memory-efficient filtering with error handling const thisWeekEpisodes = await memoryManager.filterLargeArray( allEpisodes, @@ -321,8 +375,18 @@ export const useCalendarData = (): UseCalendarDataReturn => { try { if (!ep.releaseDate) return false; const parsed = parseISO(ep.releaseDate); - // Show all episodes for this week, including released ones - return isThisWeek(parsed); + if (!isThisWeek(parsed)) return false; + // Filter out already-watched episodes when Trakt is authenticated + if (traktAuthenticated && watchedEpisodeSet.size > 0) { + const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`; + if ( + watchedEpisodeSet.has(`${showId}:${ep.season}:${ep.episode}`) || + watchedEpisodeSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`) + ) { + return false; + } + } + return true; } catch (error) { logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error); return false;