From cbc9fc4fa6508446b522ee62808983ab6dcb2c31 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:31:28 +0530 Subject: [PATCH] refactor: update continue watching logic and constants for improved functionality --- .../home/continueWatching/constants.ts | 9 + .../home/continueWatching/dataShared.ts | 43 +- .../mergeTraktContinueWatching.ts | 485 ++++++++++++------ .../useContinueWatchingData.ts | 8 +- src/utils/logger.ts | 2 +- 5 files changed, 397 insertions(+), 150 deletions(-) diff --git a/src/components/home/continueWatching/constants.ts b/src/components/home/continueWatching/constants.ts index 1f82d3b7..1029ce4a 100644 --- a/src/components/home/continueWatching/constants.ts +++ b/src/components/home/continueWatching/constants.ts @@ -10,3 +10,12 @@ export const CACHE_DURATION = 5 * 60 * 1000; export const TRAKT_SYNC_COOLDOWN = 0; export const SIMKL_SYNC_COOLDOWN = 0; export const TRAKT_RECONCILE_COOLDOWN = 0; + +// Match NuvioTV: 60-day window (was 30), 300 max items (was 30), 24 max next-up lookups +export const CW_DEFAULT_DAYS_CAP = 60; +export const CW_MAX_RECENT_PROGRESS_ITEMS = 300; +export const CW_MAX_NEXT_UP_LOOKUPS = 24; +export const CW_MAX_DISPLAY_ITEMS = 30; +export const CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS = 7; +export const CW_HISTORY_MAX_PAGES = 5; +export const CW_HISTORY_PAGE_LIMIT = 100; diff --git a/src/components/home/continueWatching/dataShared.ts b/src/components/home/continueWatching/dataShared.ts index e8f98bee..1ac8ed11 100644 --- a/src/components/home/continueWatching/dataShared.ts +++ b/src/components/home/continueWatching/dataShared.ts @@ -5,7 +5,7 @@ import { storageService } from '../../../services/storageService'; import { stremioService } from '../../../services/stremioService'; import { TraktContentData } from '../../../services/traktService'; -import { CACHE_DURATION } from './constants'; +import { CACHE_DURATION, CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS } from './constants'; import { CachedMetadataEntry, GetCachedMetadata, @@ -133,7 +133,8 @@ export function findNextEpisode( watchedSet?: Set, showId?: string, localWatchedMap?: Map, - baseTimestamp: number = 0 + baseTimestamp: number = 0, + showUnairedNextUp: boolean = true ): { video: any; lastWatched: number } | null { if (!videos || !Array.isArray(videos)) return null; @@ -170,12 +171,48 @@ export function findNextEpisode( return false; }; + const now = new Date(); + const todayMs = now.getTime(); + for (const video of sortedVideos) { if (video.season < currentSeason) continue; if (video.season === currentSeason && video.episode <= currentEpisode) continue; if (isAlreadyWatched(video.season, video.episode)) continue; - if (isEpisodeReleased(video)) { + const isSeasonRollover = video.season !== currentSeason; + const releaseDate = video.released ? new Date(video.released) : null; + const isValidDate = releaseDate && !isNaN(releaseDate.getTime()); + + if (isSeasonRollover) { + // Match NuvioTV: for season rollovers, require a valid release date + if (!isValidDate) continue; + + if (releaseDate!.getTime() <= todayMs) { + // Already aired — include it + return { video, lastWatched: latestWatchedTimestamp }; + } + + if (!showUnairedNextUp) continue; + + // Only show unaired next-season episodes within 7-day window + const daysUntil = Math.ceil((releaseDate!.getTime() - todayMs) / (24 * 60 * 60 * 1000)); + if (daysUntil <= CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS) { + return { video, lastWatched: latestWatchedTimestamp }; + } + continue; + } + + // Same season + if (isValidDate && releaseDate!.getTime() > todayMs) { + // Unaired same-season episode + if (showUnairedNextUp) { + return { video, lastWatched: latestWatchedTimestamp }; + } + continue; + } + + // Aired or no date (same season) — include it + if (isEpisodeReleased(video) || !video.released) { return { video, lastWatched: latestWatchedTimestamp }; } } diff --git a/src/components/home/continueWatching/mergeTraktContinueWatching.ts b/src/components/home/continueWatching/mergeTraktContinueWatching.ts index ccbdf4b1..7707e90b 100644 --- a/src/components/home/continueWatching/mergeTraktContinueWatching.ts +++ b/src/components/home/continueWatching/mergeTraktContinueWatching.ts @@ -7,10 +7,18 @@ import { } from '../../../services/traktService'; import { logger } from '../../../utils/logger'; -import { TRAKT_RECONCILE_COOLDOWN, TRAKT_SYNC_COOLDOWN } from './constants'; +import { + CW_DEFAULT_DAYS_CAP, + CW_HISTORY_MAX_PAGES, + CW_HISTORY_PAGE_LIMIT, + CW_MAX_DISPLAY_ITEMS, + CW_MAX_NEXT_UP_LOOKUPS, + CW_MAX_RECENT_PROGRESS_ITEMS, + TRAKT_RECONCILE_COOLDOWN, + TRAKT_SYNC_COOLDOWN, +} from './constants'; import { GetCachedMetadata, LocalProgressEntry } from './dataTypes'; import { - // CHANGE: removed unused buildTraktContentData import filterRemovedItems, findNextEpisode, getHighestLocalMatch, @@ -18,7 +26,6 @@ import { getMostRecentLocalMatch, } from './dataShared'; import { ContinueWatchingItem } from './types'; -// CHANGE: removed unused compareContinueWatchingItems import (final sort now inline) interface MergeTraktContinueWatchingParams { traktService: TraktService; @@ -80,22 +87,55 @@ export async function mergeTraktContinueWatching({ } lastTraktSyncRef.current = now; - const traktBatch: ContinueWatchingItem[] = []; - // CHANGE: Moved API calls into a try/catch so that a failed/expired token - // clears the list instead of leaving stale items on screen. + // ─── 1. Fetch all Trakt data sources (matching NuvioTV) ─── let playbackItems: any[] = []; let watchedShowsData: TraktWatchedItem[] = []; + let episodeHistoryItems: any[] = []; try { - playbackItems = await traktService.getPlaybackProgress(); - watchedShowsData = await traktService.getWatchedShows(); + const [playbackResult, watchedResult] = await Promise.all([ + traktService.getPlaybackProgress(), + traktService.getWatchedShows(), + ]); + playbackItems = playbackResult; + watchedShowsData = watchedResult; + logger.log(`[TraktCW] Fetched ${playbackItems?.length ?? 0} playback items, ${watchedShowsData?.length ?? 0} watched shows`); } catch (err) { logger.warn('[TraktSync] API failed (likely disconnected or expired token):', err); setContinueWatchingItems([]); return; } + // Fetch episode history (matching NuvioTV's fetchRecentEpisodeHistorySnapshot) + try { + const historyResults: any[] = []; + const seenContentIds = new Set(); + for (let page = 1; page <= CW_HISTORY_MAX_PAGES; page++) { + const pageItems = await traktService.getWatchedEpisodesHistory(page, CW_HISTORY_PAGE_LIMIT); + if (!pageItems || pageItems.length === 0) break; + + for (const item of pageItems) { + const showImdb = item.show?.ids?.imdb; + if (!showImdb) continue; + const normalizedId = showImdb.startsWith('tt') ? showImdb : `tt${showImdb}`; + // NuvioTV deduplicates by contentId (one per show), keeping the most recent + if (seenContentIds.has(normalizedId)) continue; + seenContentIds.add(normalizedId); + historyResults.push(item); + if (historyResults.length >= CW_MAX_RECENT_PROGRESS_ITEMS) break; + } + + if (historyResults.length >= CW_MAX_RECENT_PROGRESS_ITEMS) break; + if (pageItems.length < CW_HISTORY_PAGE_LIMIT) break; + } + episodeHistoryItems = historyResults; + logger.log(`[TraktCW] Fetched ${episodeHistoryItems.length} episode history items (unique shows)`); + } catch (err) { + logger.warn('[TraktSync] Failed to fetch episode history:', err); + } + + // ─── 2. Build watched episode sets per show ─── const watchedEpisodeSetByShow = new Map>(); try { @@ -106,7 +146,6 @@ export async function mergeTraktContinueWatching({ ? watchedShow.show.ids.imdb : `tt${watchedShow.show.ids.imdb}`; - // CHANGE: Use getValidTime instead of `new Date(...).getTime()` const resetAt = getValidTime(watchedShow.reset_at); const episodeSet = new Set(); @@ -127,124 +166,205 @@ export async function mergeTraktContinueWatching({ logger.warn('[TraktSync] Error mapping watched shows:', err); } - const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); + // ─── 3. Merge sources: history first, then playback overwrites (matching NuvioTV) ─── + // NuvioTV merges in order: recentCompletedEpisodes → (inProgressMovies + inProgressEpisodes) + // Later entries overwrite earlier ones by key, so playback (in-progress) takes priority. - // CHANGE: Simplified sort — removed the +1000000000 "new episode priority boost" - // that was added by a previous AI suggestion. That boost caused recently aired - // episodes to incorrectly sort above items the user actually paused recently, - // breaking the expected Trakt continue watching order on initial login. - // Now sorts purely by most recent timestamp, newest first. + const daysCutoff = Date.now() - (CW_DEFAULT_DAYS_CAP * 24 * 60 * 60 * 1000); + + // Internal progress items keyed by "type:contentId" for series or "type:contentId" for movies + interface ProgressEntry { + contentId: string; + contentType: 'movie' | 'series'; + season?: number; + episode?: number; + episodeTitle?: string; + progressPercent: number; // 0-100 + lastWatched: number; + source: 'playback' | 'history' | 'watched_show'; + traktPlaybackId?: number; + } + + const mergedByKey = new Map(); + + // 3a. Episode history items (completed episodes) — go in first, can be overwritten by playback + for (const item of episodeHistoryItems) { + try { + const show = item.show; + const episode = item.episode; + if (!show?.ids?.imdb || !episode) continue; + + const showImdb = show.ids.imdb.startsWith('tt') + ? show.ids.imdb + : `tt${show.ids.imdb}`; + const lastWatched = getValidTime(item.watched_at); + if (lastWatched > 0 && lastWatched < daysCutoff) continue; + + const key = showImdb; // NuvioTV uses contentId as key (one per show) + mergedByKey.set(key, { + contentId: showImdb, + contentType: 'series', + season: episode.season, + episode: episode.number, + episodeTitle: episode.title, + progressPercent: 100, // Completed + lastWatched, + source: 'history', + }); + } catch { + // Skip bad items + } + } + + // 3b. Playback items (in-progress) — overwrite history entries for the same content const sortedPlaybackItems = [...(playbackItems || [])] .sort((a, b) => { const timeA = getValidTime(a.paused_at || a.updated_at || a.last_watched_at); const timeB = getValidTime(b.paused_at || b.updated_at || b.last_watched_at); return timeB - timeA; }) - .slice(0, 30); + .slice(0, CW_MAX_RECENT_PROGRESS_ITEMS); for (const item of sortedPlaybackItems) { try { if (item.progress < 2) continue; - // CHANGE: Use getValidTime with fallback to updated_at for items missing paused_at const pausedAt = getValidTime(item.paused_at || item.updated_at); - - // CHANGE: Guard against items where pausedAt resolved to 0 (missing/invalid date) - if (pausedAt > 0 && pausedAt < thirtyDaysAgo) continue; + if (pausedAt > 0 && pausedAt < daysCutoff) continue; if (item.type === 'movie' && item.movie?.ids?.imdb) { - if (item.progress >= 85) continue; - const imdbId = item.movie.ids.imdb.startsWith('tt') ? item.movie.ids.imdb : `tt${item.movie.ids.imdb}`; - if (recentlyRemoved.has(`movie:${imdbId}`)) continue; - - const cachedData = await getCachedMetadata('movie', imdbId); - if (!cachedData?.basicContent) continue; - - traktBatch.push({ - ...cachedData.basicContent, - id: imdbId, - type: 'movie', - progress: item.progress, - lastUpdated: pausedAt, - addonId: undefined, + const key = imdbId; + mergedByKey.set(key, { + contentId: imdbId, + contentType: 'movie', + progressPercent: item.progress, + lastWatched: pausedAt, + source: 'playback', traktPlaybackId: item.id, - } as ContinueWatchingItem); + }); } else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) { const showImdb = item.show.ids.imdb.startsWith('tt') ? item.show.ids.imdb : `tt${item.show.ids.imdb}`; - if (recentlyRemoved.has(`series:${showImdb}`)) continue; - - const cachedData = await getCachedMetadata('series', showImdb); - if (!cachedData?.basicContent) continue; - - if (item.progress >= 85) { - if (cachedData.metadata?.videos) { - const watchedSetForShow = watchedEpisodeSetByShow.get(showImdb); - const localWatchedMap = await localWatchedShowsMapPromise; - const nextEpisodeResult = findNextEpisode( - item.episode.season, - item.episode.number, - cachedData.metadata.videos, - watchedSetForShow, - showImdb, - localWatchedMap, - pausedAt - ); - - if (nextEpisodeResult) { - const nextEpisode = nextEpisodeResult.video; - traktBatch.push({ - ...cachedData.basicContent, - id: showImdb, - type: 'series', - progress: 0, - // CHANGE: Use pausedAt (from playback item) instead of - // nextEpisodeResult.lastWatched so sort order stays consistent - // with when the user actually paused, not local watch timestamps. - lastUpdated: pausedAt, - season: nextEpisode.season, - episode: nextEpisode.episode, - episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, - addonId: undefined, - traktPlaybackId: item.id, - } as ContinueWatchingItem); - } - } - continue; - } - - traktBatch.push({ - ...cachedData.basicContent, - id: showImdb, - type: 'series', - progress: item.progress, - lastUpdated: pausedAt, + const key = showImdb; + mergedByKey.set(key, { + contentId: showImdb, + contentType: 'series', season: item.episode.season, episode: item.episode.number, - episodeTitle: item.episode.title || `Episode ${item.episode.number}`, - addonId: undefined, + episodeTitle: item.episode.title, + progressPercent: item.progress, + lastWatched: pausedAt, + source: 'playback', traktPlaybackId: item.id, - } as ContinueWatchingItem); + }); } } catch { // Continue with remaining playback items. } } - try { - // CHANGE: Extended window from 30 days to 6 months for watched shows - // so up next items from less frequent viewing aren't excluded. - const sixMonthsAgo = Date.now() - (180 * 24 * 60 * 60 * 1000); + // ─── 4. Sort merged items by lastWatched and apply cap ─── + const allMerged = Array.from(mergedByKey.values()) + .sort((a, b) => b.lastWatched - a.lastWatched) + .slice(0, CW_MAX_RECENT_PROGRESS_ITEMS); - // CHANGE: Pre-sort and slice watched shows by recency before processing, - // so the most recently watched shows are processed first and up next items - // sort correctly alongside playback items. + logger.log(`[TraktCW] Merged ${allMerged.length} items (history→playback). Breakdown: ${allMerged.filter(e => e.source === 'history').length} history, ${allMerged.filter(e => e.source === 'playback').length} playback`); + for (const entry of allMerged.slice(0, 15)) { + logger.log(`[TraktCW] ${entry.contentType} ${entry.contentId} S${entry.season ?? '-'}E${entry.episode ?? '-'} progress=${entry.progressPercent.toFixed(1)}% src=${entry.source} last=${new Date(entry.lastWatched).toISOString()}`); + } + if (allMerged.length > 15) logger.log(`[TraktCW] ... and ${allMerged.length - 15} more`); + + // ─── 5. Separate in-progress items vs completed seeds (matching NuvioTV pipeline) ─── + // In-progress: 2% ≤ progress < 85% + // Completed seed: progress ≥ 85% (will be used for Up Next) + const inProgressEntries: ProgressEntry[] = []; + const completedSeeds: ProgressEntry[] = []; + + for (const entry of allMerged) { + if (entry.progressPercent >= 2 && entry.progressPercent < 85) { + inProgressEntries.push(entry); + } else if (entry.progressPercent >= 85) { + completedSeeds.push(entry); + } + } + + logger.log(`[TraktCW] Separated: ${inProgressEntries.length} in-progress (2-85%), ${completedSeeds.length} completed seeds (≥85%)`); + + // ─── 6. Episode deduplication for in-progress (matching NuvioTV deduplicateInProgress) ─── + // For series: only keep the latest-watched episode per series + const dedupedInProgress: ProgressEntry[] = []; + const seriesLatest = new Map(); + + for (const entry of inProgressEntries) { + if (entry.contentType === 'series') { + const existing = seriesLatest.get(entry.contentId); + if (!existing || entry.lastWatched > existing.lastWatched) { + seriesLatest.set(entry.contentId, entry); + } + } else { + dedupedInProgress.push(entry); + } + } + dedupedInProgress.push(...seriesLatest.values()); + dedupedInProgress.sort((a, b) => b.lastWatched - a.lastWatched); + + logger.log(`[TraktCW] After series dedup: ${dedupedInProgress.length} in-progress items (was ${inProgressEntries.length})`); + for (const entry of dedupedInProgress) { + logger.log(`[TraktCW] IN-PROGRESS: ${entry.contentType} ${entry.contentId} S${entry.season ?? '-'}E${entry.episode ?? '-'} progress=${entry.progressPercent.toFixed(1)}% last=${new Date(entry.lastWatched).toISOString()}`); + } + + // ─── 7. Build in-progress ContinueWatchingItems ─── + const traktBatch: ContinueWatchingItem[] = []; + const inProgressSeriesIds = new Set(); + + for (const entry of dedupedInProgress) { + if (recentlyRemoved.has(`${entry.contentType}:${entry.contentId}`)) continue; + + const type = entry.contentType === 'movie' ? 'movie' : 'series'; + const cachedData = await getCachedMetadata(type, entry.contentId); + if (!cachedData?.basicContent) continue; + + if (entry.contentType === 'series') { + inProgressSeriesIds.add(entry.contentId); + } + + traktBatch.push({ + ...cachedData.basicContent, + id: entry.contentId, + type: type, + progress: entry.progressPercent, + lastUpdated: entry.lastWatched, + season: entry.season, + episode: entry.episode, + episodeTitle: entry.episodeTitle || (entry.episode ? `Episode ${entry.episode}` : undefined), + addonId: undefined, + traktPlaybackId: entry.traktPlaybackId, + } as ContinueWatchingItem); + } + + logger.log(`[TraktCW] Built ${traktBatch.length} in-progress CW items. Suppressed series IDs: [${Array.from(inProgressSeriesIds).join(', ')}]`); + + // ─── 8. Build Up Next items from completed seeds (matching NuvioTV buildLightweightNextUpItems) ─── + // Completed seeds from playback + history: find next episode for each + const nextUpSeeds: ProgressEntry[] = []; + + // Add completed entries from merged data + for (const entry of completedSeeds) { + if (entry.contentType !== 'series') continue; + if (inProgressSeriesIds.has(entry.contentId)) continue; // Next-up suppression + if (recentlyRemoved.has(`series:${entry.contentId}`)) continue; + nextUpSeeds.push(entry); + } + + // ─── 9. Add watched show seeds (matching NuvioTV observeWatchedShowSeeds) ─── + try { + const sixMonthsAgo = Date.now() - (180 * 24 * 60 * 60 * 1000); const sortedWatchedShows = [...(watchedShowsData || [])] .filter((show) => { const watchedAt = getValidTime(show.last_watched_at); @@ -254,8 +374,7 @@ export async function mergeTraktContinueWatching({ const timeA = getValidTime(a.last_watched_at); const timeB = getValidTime(b.last_watched_at); return timeB - timeA; - }) - .slice(0, 30); + }); for (const watchedShow of sortedWatchedShows) { try { @@ -265,8 +384,13 @@ export async function mergeTraktContinueWatching({ ? watchedShow.show.ids.imdb : `tt${watchedShow.show.ids.imdb}`; + // Skip if already in in-progress (next-up suppression) + if (inProgressSeriesIds.has(showImdb)) continue; if (recentlyRemoved.has(`series:${showImdb}`)) continue; + // Skip if we already have a seed for this show (from playback/history) + if (nextUpSeeds.some(s => s.contentId === showImdb)) continue; + const resetAt = getValidTime(watchedShow.reset_at); let lastWatchedSeason = 0; let lastWatchedEpisode = 0; @@ -289,37 +413,15 @@ export async function mergeTraktContinueWatching({ if (lastWatchedSeason === 0 && lastWatchedEpisode === 0) continue; - const cachedData = await getCachedMetadata('series', showImdb); - if (!cachedData?.basicContent || !cachedData.metadata?.videos) continue; - - const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set(); - const localWatchedMap = await localWatchedShowsMapPromise; - const nextEpisodeResult = findNextEpisode( - lastWatchedSeason, - lastWatchedEpisode, - cachedData.metadata.videos, - watchedEpisodeSet, - showImdb, - localWatchedMap, - latestEpisodeTimestamp - ); - - if (nextEpisodeResult) { - const nextEpisode = nextEpisodeResult.video; - traktBatch.push({ - ...cachedData.basicContent, - id: showImdb, - type: 'series', - progress: 0, - // CHANGE: Use latestEpisodeTimestamp directly (when user finished the - // last episode) so up next items sort by actual watch recency. - lastUpdated: latestEpisodeTimestamp, - season: nextEpisode.season, - episode: nextEpisode.episode, - episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, - addonId: undefined, - } as ContinueWatchingItem); - } + nextUpSeeds.push({ + contentId: showImdb, + contentType: 'series', + season: lastWatchedSeason, + episode: lastWatchedEpisode, + progressPercent: 100, + lastWatched: latestEpisodeTimestamp, + source: 'watched_show', + }); } catch { // Continue with remaining watched shows. } @@ -328,14 +430,101 @@ export async function mergeTraktContinueWatching({ logger.warn('[TraktSync] Error processing watched shows for Up Next:', err); } - // CHANGE: Clear list on empty batch instead of silently returning. - // Previously `return` here left stale items on screen when Trakt returned - // nothing (e.g. fresh login or just after disconnect). + // ─── 10. Choose preferred seed per show (matching NuvioTV choosePreferredNextUpSeed) ─── + // Source ranking: playback (0) > history (1) > watched_show (2) + const seedSourceRank = (source: string): number => { + switch (source) { + case 'playback': return 0; + case 'history': return 1; + case 'watched_show': return 2; + default: return 4; + } + }; + + const seedsByShow = new Map(); + for (const seed of nextUpSeeds) { + const existing = seedsByShow.get(seed.contentId) || []; + existing.push(seed); + seedsByShow.set(seed.contentId, existing); + } + + const bestSeeds: ProgressEntry[] = []; + for (const [, seeds] of seedsByShow) { + const bestRank = Math.min(...seeds.map(s => seedSourceRank(s.source))); + const bestRanked = seeds.filter(s => seedSourceRank(s.source) === bestRank); + // Among same-rank seeds, pick highest season/episode, then most recent + bestRanked.sort((a, b) => { + if ((a.season ?? -1) !== (b.season ?? -1)) return (b.season ?? -1) - (a.season ?? -1); + if ((a.episode ?? -1) !== (b.episode ?? -1)) return (b.episode ?? -1) - (a.episode ?? -1); + return b.lastWatched - a.lastWatched; + }); + if (bestRanked.length > 0) bestSeeds.push(bestRanked[0]); + } + + // Sort by lastWatched and limit to CW_MAX_NEXT_UP_LOOKUPS (24) + bestSeeds.sort((a, b) => b.lastWatched - a.lastWatched); + const topSeeds = bestSeeds.slice(0, CW_MAX_NEXT_UP_LOOKUPS); + + logger.log(`[TraktCW] Up Next seeds: ${nextUpSeeds.length} total → ${bestSeeds.length} deduped → ${topSeeds.length} top seeds`); + for (const seed of topSeeds) { + logger.log(`[TraktCW] SEED: ${seed.contentId} S${seed.season}E${seed.episode} src=${seed.source} rank=${seedSourceRank(seed.source)} last=${new Date(seed.lastWatched).toISOString()}`); + } + + // ─── 11. Resolve next episodes for each seed ─── + const localWatchedMap = await localWatchedShowsMapPromise; + + for (const seed of topSeeds) { + try { + if (!seed.season || !seed.episode) continue; + + const cachedData = await getCachedMetadata('series', seed.contentId); + if (!cachedData?.basicContent || !cachedData.metadata?.videos) continue; + + const watchedEpisodeSet = watchedEpisodeSetByShow.get(seed.contentId) ?? new Set(); + const nextEpisodeResult = findNextEpisode( + seed.season, + seed.episode, + cachedData.metadata.videos, + watchedEpisodeSet, + seed.contentId, + localWatchedMap, + seed.lastWatched, + true // showUnairedNextUp + ); + + if (nextEpisodeResult) { + const nextEpisode = nextEpisodeResult.video; + logger.log(`[TraktCW] UP-NEXT RESOLVED: ${seed.contentId} seed=S${seed.season}E${seed.episode} → next=S${nextEpisode.season}E${nextEpisode.episode} "${nextEpisode.title || ''}" last=${new Date(seed.lastWatched).toISOString()}`); + traktBatch.push({ + ...cachedData.basicContent, + id: seed.contentId, + type: 'series', + progress: 0, + lastUpdated: seed.lastWatched, + season: nextEpisode.season, + episode: nextEpisode.episode, + episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, + addonId: undefined, + traktPlaybackId: seed.traktPlaybackId, + } as ContinueWatchingItem); + } else { + logger.log(`[TraktCW] UP-NEXT DROPPED: ${seed.contentId} seed=S${seed.season}E${seed.episode} — no next episode found (no videos or all watched)`); + } + } catch (err) { + logger.warn(`[TraktCW] UP-NEXT ERROR: ${seed.contentId}`, err); + } + } + + // ─── 12. Final dedup, reconcile, and sort ─── + logger.log(`[TraktCW] Pre-dedup batch: ${traktBatch.length} items (${traktBatch.filter(i => (i.progress ?? 0) > 0).length} in-progress + ${traktBatch.filter(i => (i.progress ?? 0) === 0).length} up-next)`); + if (traktBatch.length === 0) { + logger.log('[TraktCW] No items — clearing continue watching list'); setContinueWatchingItems([]); return; } + // Deduplicate: for same content, prefer items with progress > 0 (in-progress over up-next) const deduped = new Map(); for (const item of traktBatch) { const key = `${item.type}:${item.id}`; @@ -349,7 +538,6 @@ export async function mergeTraktContinueWatching({ const existingHasProgress = (existing.progress ?? 0) > 0; const candidateHasProgress = (item.progress ?? 0) > 0; - // CHANGE: Use getValidTime for safe timestamp comparison in dedup logic const safeItemTs = getValidTime(item.lastUpdated); const safeExistingTs = getValidTime(existing.lastUpdated); @@ -372,8 +560,6 @@ export async function mergeTraktContinueWatching({ const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved); const reconcileLocalPromises: Promise[] = []; - // CHANGE: Removed reconcilePromises (Trakt back-sync) — that logic was pushing - // local progress back to Trakt which is out of scope for continue watching display. const adjustedItems = filteredItems .map((item) => { @@ -387,7 +573,7 @@ export async function mergeTraktContinueWatching({ return item; } - // CHANGE: Use getValidTime for safe timestamp extraction + // Use getValidTime for safe timestamp extraction const safeLocalTs = getValidTime(mostRecentLocal.lastUpdated); const safeItemTs = getValidTime(item.lastUpdated); @@ -456,11 +642,17 @@ export async function mergeTraktContinueWatching({ } } - // CHANGE: Return safeItemTs (Trakt's paused_at timestamp) instead of - // mergedLastUpdated (which took the MAX of local and Trakt timestamps). - // The old approach let local storage timestamps corrupt sort order on the - // 4-second trailing refresh — a show watched locally months ago would get - // a recent local timestamp and jump to the top of the list. + // If Trakt says in-progress (2-85%) but local says completed (>=85%), + // trust Trakt's playback endpoint — it's authoritative for paused items. + const traktIsInProgress = traktProgress >= 2 && traktProgress < 85; + const localSaysCompleted = localProgress >= 85; + if (traktIsInProgress && localSaysCompleted) { + return { + ...item, + lastUpdated: safeItemTs, + }; + } + if (((isLocalNewer || isLocalRecent) && isDifferent) || isAhead) { return { ...item, @@ -473,15 +665,22 @@ export async function mergeTraktContinueWatching({ ...item, lastUpdated: safeItemTs, // keep Trakt timestamp for sort stability }; - }) - .filter((item) => (item.progress ?? 0) < 85); + }); - // CHANGE: Replaced compareContinueWatchingItems (from utils) with an inline - // sort using getValidTime so NaN timestamps can't affect order, and all items - // (both playback and up next) sort together by recency. const finalItems = adjustedItems .sort((a, b) => getValidTime(b.lastUpdated) - getValidTime(a.lastUpdated)) - .slice(0, 30); + .slice(0, CW_MAX_DISPLAY_ITEMS); + + logger.log(`[TraktCW] ═══ FINAL LIST: ${finalItems.length} items (capped at ${CW_MAX_DISPLAY_ITEMS}) ═══`); + for (let i = 0; i < finalItems.length; i++) { + const item = finalItems[i]; + const isUpNext = (item.progress ?? 0) === 0 && item.type === 'series'; + const tag = isUpNext ? 'UP-NEXT' : 'RESUME'; + const epLabel = item.type === 'series' ? ` S${item.season ?? '?'}E${item.episode ?? '?'}` : ''; + const ts = getValidTime(item.lastUpdated); + logger.log(`[TraktCW] #${i + 1} [${tag}] ${item.name || item.id}${epLabel} — ${item.type} progress=${(item.progress ?? 0).toFixed(1)}% last=${ts ? new Date(ts).toISOString() : 'N/A'}`); + } + logger.log(`[TraktCW] ═══ END FINAL LIST ═══`); setContinueWatchingItems(finalItems); diff --git a/src/components/home/continueWatching/useContinueWatchingData.ts b/src/components/home/continueWatching/useContinueWatchingData.ts index a82cc4af..69cc64dc 100644 --- a/src/components/home/continueWatching/useContinueWatchingData.ts +++ b/src/components/home/continueWatching/useContinueWatchingData.ts @@ -209,6 +209,8 @@ export function useContinueWatchingData() { const simklService = SimklService.getInstance(); const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false; + console.log(`[CW-Hook] Auth state: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`); + const traktMoviesSetPromise = getTraktMoviesSet(isTraktAuthed, traktService); const traktShowsSetPromise = getTraktShowsSet(isTraktAuthed, traktService); const localWatchedShowsMapPromise = getLocalWatchedShowsMap(); @@ -239,7 +241,7 @@ export function useContinueWatchingData() { await Promise.allSettled([ isTraktAuthed - ? mergeTraktContinueWatching({ + ? (console.log('[CW-Hook] Calling mergeTraktContinueWatching...'), mergeTraktContinueWatching({ traktService, getCachedMetadata, localProgressIndex, @@ -248,8 +250,8 @@ export function useContinueWatchingData() { lastTraktSyncRef, lastTraktReconcileRef, setContinueWatchingItems, - }) - : Promise.resolve(), + })) + : (console.log('[CW-Hook] Trakt NOT authed, skipping merge'), Promise.resolve()), isSimklAuthed && !isTraktAuthed ? mergeSimklContinueWatching({ simklService, diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 445592f9..d25ce076 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -3,7 +3,7 @@ class Logger { constructor() { // __DEV__ is a global variable in React Native - this.isEnabled = false; + this.isEnabled = __DEV__; } log(...args: any[]) {