diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 4c3ae83f..1dbe89d7 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -604,7 +604,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } }); - // TRÅKT: fetch history and merge incrementally as well + // TRAKT: fetch playback progress (in-progress items) and history, merge incrementally const traktMergePromise = (async () => { try { const traktService = TraktService.getInstance(); @@ -619,28 +619,132 @@ const ContinueWatchingSection = React.forwardRef((props, re } lastTraktSyncRef.current = now; - const historyItems = await traktService.getWatchedEpisodesHistory(1, 200); + + // Fetch both playback progress (paused items) and watch history in parallel + const [playbackItems, historyItems, watchedShows] = await Promise.all([ + traktService.getPlaybackProgress(), // Items with actual progress % + traktService.getWatchedEpisodesHistory(1, 200), // Completed episodes + traktService.getWatchedShows(), // For reset_at handling + ]); + + // Build a map of shows with reset_at for re-watching support + const showResetMap: Record = {}; + for (const show of watchedShows) { + if (show.show?.ids?.imdb && show.reset_at) { + const imdbId = show.show.ids.imdb.startsWith('tt') + ? show.show.ids.imdb + : `tt${show.show.ids.imdb}`; + showResetMap[imdbId] = new Date(show.reset_at).getTime(); + } + } + + const traktBatch: ContinueWatchingItem[] = []; + const processedShows = new Set(); // Track which shows we've added + + // STEP 1: Process playback progress items (in-progress, paused) + // These have actual progress percentage from Trakt + for (const item of playbackItems) { + try { + // Skip items with very low or very high progress + if (item.progress <= 0 || item.progress >= 85) continue; + + if (item.type === 'movie' && item.movie?.ids?.imdb) { + const imdbId = item.movie.ids.imdb.startsWith('tt') + ? item.movie.ids.imdb + : `tt${item.movie.ids.imdb}`; + + // Check if recently removed + const movieKey = `movie:${imdbId}`; + if (recentlyRemovedRef.current.has(movieKey)) continue; + + const cachedData = await getCachedMetadata('movie', imdbId); + if (!cachedData?.basicContent) continue; + + const pausedAt = new Date(item.paused_at).getTime(); + traktBatch.push({ + ...cachedData.basicContent, + id: imdbId, + type: 'movie', + progress: item.progress, + lastUpdated: pausedAt, + } as ContinueWatchingItem); + + logger.log(`📺 [TraktPlayback] Adding movie ${item.movie.title} with ${item.progress.toFixed(1)}% progress`); + + } 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}`; + + // Check if recently removed + const showKey = `series:${showImdb}`; + if (recentlyRemovedRef.current.has(showKey)) continue; + + // Check reset_at - skip if this was paused before re-watch started + const resetTime = showResetMap[showImdb]; + const pausedAt = new Date(item.paused_at).getTime(); + if (resetTime && pausedAt < resetTime) { + logger.log(`🔄 [TraktPlayback] Skipping ${showImdb} S${item.episode.season}E${item.episode.number} - paused before reset_at`); + continue; + } + + const cachedData = await getCachedMetadata('series', showImdb); + if (!cachedData?.basicContent) continue; + + traktBatch.push({ + ...cachedData.basicContent, + id: showImdb, + type: 'series', + progress: item.progress, + lastUpdated: pausedAt, + season: item.episode.season, + episode: item.episode.number, + episodeTitle: item.episode.title || `Episode ${item.episode.number}`, + } as ContinueWatchingItem); + + processedShows.add(showImdb); + logger.log(`📺 [TraktPlayback] Adding ${item.show.title} S${item.episode.season}E${item.episode.number} with ${item.progress.toFixed(1)}% progress`); + } + } catch (err) { + // Continue with other items + } + } + + // STEP 2: Process watch history for shows NOT in playback progress + // Find the next episode for completed shows const latestWatchedByShow: Record = {}; for (const item of historyItems) { if (item.type !== 'episode') continue; - const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null; + const showImdb = item.show?.ids?.imdb + ? (item.show.ids.imdb.startsWith('tt') ? item.show.ids.imdb : `tt${item.show.ids.imdb}`) + : null; if (!showImdb) continue; + + // Skip if we already have an in-progress episode for this show + if (processedShows.has(showImdb)) continue; + const season = item.episode?.season; const epNum = item.episode?.number; if (season === undefined || epNum === undefined) continue; + const watchedAt = new Date(item.watched_at).getTime(); + + // Check reset_at - skip episodes watched before re-watch started + const resetTime = showResetMap[showImdb]; + if (resetTime && watchedAt < resetTime) { + continue; // This was watched in a previous viewing + } + const existing = latestWatchedByShow[showImdb]; if (!existing || existing.watchedAt < watchedAt) { latestWatchedByShow[showImdb] = { season, episode: epNum, watchedAt }; } } - // Collect all valid Trakt items first, then merge as a batch - const traktBatch: ContinueWatchingItem[] = []; - + // Add next episodes for completed shows for (const [showId, info] of Object.entries(latestWatchedByShow)) { try { - // Check if this show was recently removed by the user + // Check if this show was recently removed const showKey = `series:${showId}`; if (recentlyRemovedRef.current.has(showKey)) { logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`); @@ -659,7 +763,7 @@ const ContinueWatchingSection = React.forwardRef((props, re ...basicContent, id: showId, type: 'series', - progress: 0, + progress: 0, // Next episode, not started lastUpdated: info.watchedAt, season: nextEpisodeVideo.season, episode: nextEpisodeVideo.episode, @@ -668,13 +772,12 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // Persist "watched" progress for the episode that Trakt reported (only if not recently removed) + // Persist "watched" progress for the episode that Trakt reported if (!recentlyRemovedRef.current.has(showKey)) { const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`; const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`]; const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0; if (!existingProgress || existingPercent < 85) { - logger.log(`💾 [TraktSync] Adding local progress for ${showId}: S${info.season}E${info.episode}`); await storageService.setWatchProgress( showId, 'series', @@ -688,20 +791,19 @@ const ContinueWatchingSection = React.forwardRef((props, re `${info.season}:${info.episode}` ); } - } else { - logger.log(`🚫 [TraktSync] Skipping local progress for recently removed show: ${showKey}`); } } catch (err) { - // Continue with other shows even if one fails + // Continue with other shows } } // Merge all Trakt items as a single batch to ensure proper sorting if (traktBatch.length > 0) { + logger.log(`📋 [TraktSync] Merging ${traktBatch.length} items from Trakt (playback + history)`); await mergeBatchIntoState(traktBatch); } } catch (err) { - // Continue even if Trakt history merge fails + logger.error('[TraktSync] Error in Trakt merge:', err); } })(); diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index d27fd0d5..2b7ef7e5 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1821,7 +1821,7 @@ const AndroidVideoPlayer: React.FC = () => { const backgroundSync = async () => { try { logger.log('[AndroidVideoPlayer] Starting background Trakt sync'); - // IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time + // IMMEDIATE: Force immediate progress update (uses scrobble/stop which handles pause/scrobble) await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); // IMMEDIATE: Use user_close reason to trigger immediate scrobble stop diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 9729ac2d..f201fda6 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -1432,7 +1432,7 @@ const KSPlayerCore: React.FC = () => { const backgroundSync = async () => { try { logger.log('[VideoPlayer] Starting background Trakt sync'); - // IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time + // IMMEDIATE: Force immediate progress update (uses scrobble/stop which handles pause/scrobble) await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); // IMMEDIATE: Use user_close reason to trigger immediate scrobble stop diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 408a6295..4a207e00 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -52,6 +52,8 @@ export interface TraktWatchedItem { }; plays: number; last_watched_at: string; + last_updated_at?: string; // Timestamp for syncing - only re-process if newer + reset_at?: string | null; // When user started re-watching - ignore episodes watched before this seasons?: { number: number; episodes: { @@ -1637,7 +1639,14 @@ export class TraktService { } /** - * Pause watching content (scrobble pause) + * Pause watching content - saves playback progress + * + * NOTE: Trakt API does NOT have a /scrobble/pause endpoint. + * Instead, /scrobble/stop handles both cases: + * - Progress 1-79%: Treated as "pause", saves playback progress to /sync/playback + * - Progress ≥80%: Treated as "scrobble", marks as watched + * + * This method uses /scrobble/stop which automatically handles the pause/scrobble logic. */ public async pauseWatching(contentData: TraktContentData, progress: number): Promise { try { @@ -1653,7 +1662,8 @@ export class TraktService { return null; } - return this.apiRequest('/scrobble/pause', 'POST', payload); + // Use /scrobble/stop - Trakt automatically treats <80% as pause, ≥80% as scrobble + return this.apiRequest('/scrobble/stop', 'POST', payload); } catch (error) { logger.error('[TraktService] Failed to pause watching:', error); return null;