import { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { storageService } from '../../../services/storageService'; import { TraktService, TraktWatchedItem, } from '../../../services/traktService'; import { logger } from '../../../utils/logger'; import { TRAKT_RECONCILE_COOLDOWN, TRAKT_SYNC_COOLDOWN } from './constants'; import { GetCachedMetadata, LocalProgressEntry } from './dataTypes'; import { buildTraktContentData, filterRemovedItems, findNextEpisode, getHighestLocalMatch, getLocalMatches, getMostRecentLocalMatch, } from './dataShared'; import { ContinueWatchingItem } from './types'; import { compareContinueWatchingItems } from './utils'; interface MergeTraktContinueWatchingParams { traktService: TraktService; getCachedMetadata: GetCachedMetadata; localProgressIndex: Map | null; localWatchedShowsMapPromise: Promise>; recentlyRemoved: Set; lastTraktSyncRef: MutableRefObject; lastTraktReconcileRef: MutableRefObject>; setContinueWatchingItems: Dispatch>; } export async function mergeTraktContinueWatching({ traktService, getCachedMetadata, localProgressIndex, localWatchedShowsMapPromise, recentlyRemoved, lastTraktSyncRef, lastTraktReconcileRef, setContinueWatchingItems, }: MergeTraktContinueWatchingParams): Promise { const now = Date.now(); if ( TRAKT_SYNC_COOLDOWN > 0 && now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN ) { logger.log( `[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)` ); return; } lastTraktSyncRef.current = now; const playbackItems = await traktService.getPlaybackProgress(); const traktBatch: ContinueWatchingItem[] = []; let watchedShowsData: TraktWatchedItem[] = []; const watchedEpisodeSetByShow = new Map>(); try { watchedShowsData = await traktService.getWatchedShows(); for (const watchedShow of watchedShowsData) { if (!watchedShow.show?.ids?.imdb) continue; const imdb = watchedShow.show.ids.imdb.startsWith('tt') ? watchedShow.show.ids.imdb : `tt${watchedShow.show.ids.imdb}`; const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0; const episodeSet = new Set(); if (watchedShow.seasons) { for (const season of watchedShow.seasons) { for (const episode of season.episodes) { 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 { // Continue without watched-show acceleration. } const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); const sortedPlaybackItems = [...playbackItems] .sort((a, b) => { const getBaseTime = (item: any) => new Date( item.paused_at || item.updated_at || item.last_watched_at || 0 ).getTime(); const getPriorityTime = (item: any) => { const base = getBaseTime(item); // NEW EPISODE PRIORITY BOOST if (item.episode && (item.progress ?? 0) < 1) { const aired = new Date(item.episode.first_aired || 0).getTime(); const daysSinceAired = (Date.now() - aired) / (1000 * 60 * 60 * 24); if (daysSinceAired >= 0 && daysSinceAired < 60) { return base + 1000000000; // boost to top on aired ep } } return base; }; return getPriorityTime(b) - getPriorityTime(a); }) .slice(0, 30); for (const item of sortedPlaybackItems) { try { if (item.progress < 2) continue; const pausedAt = new Date(item.paused_at).getTime(); if (pausedAt < thirtyDaysAgo) 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, 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, lastUpdated: nextEpisodeResult.lastWatched, 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, season: item.episode.season, episode: item.episode.number, episodeTitle: item.episode.title || `Episode ${item.episode.number}`, addonId: undefined, traktPlaybackId: item.id, } as ContinueWatchingItem); } } catch { // Continue with remaining playback items. } } try { const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); for (const watchedShow of watchedShowsData) { try { if (!watchedShow.show?.ids?.imdb) continue; const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime(); if (lastWatchedAt < thirtyDaysAgoForShows) continue; const showImdb = watchedShow.show.ids.imdb.startsWith('tt') ? watchedShow.show.ids.imdb : `tt${watchedShow.show.ids.imdb}`; if (recentlyRemoved.has(`series:${showImdb}`)) continue; const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0; let lastWatchedSeason = 0; let lastWatchedEpisode = 0; let latestEpisodeTimestamp = 0; if (watchedShow.seasons) { for (const season of watchedShow.seasons) { for (const episode of season.episodes) { const episodeTimestamp = new Date(episode.last_watched_at).getTime(); if (resetAt > 0 && episodeTimestamp < resetAt) continue; if (episodeTimestamp > latestEpisodeTimestamp) { latestEpisodeTimestamp = episodeTimestamp; lastWatchedSeason = season.number; lastWatchedEpisode = episode.number; } } } } 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, lastUpdated: nextEpisodeResult.lastWatched, season: nextEpisode.season, episode: nextEpisode.episode, episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, addonId: undefined, } as ContinueWatchingItem); } } catch { // Continue with remaining watched shows. } } } catch (err) { logger.warn('[TraktSync] Error fetching watched shows for Up Next:', err); } if (traktBatch.length === 0) { return; } const deduped = new Map(); for (const item of traktBatch) { const key = `${item.type}:${item.id}`; const existing = deduped.get(key); if (!existing) { deduped.set(key, item); continue; } const existingHasProgress = (existing.progress ?? 0) > 0; const candidateHasProgress = (item.progress ?? 0) > 0; if (candidateHasProgress && !existingHasProgress) { const mergedTs = Math.max(item.lastUpdated ?? 0, existing.lastUpdated ?? 0); deduped.set( key, mergedTs !== (item.lastUpdated ?? 0) ? { ...item, lastUpdated: mergedTs } : item ); } else if (!candidateHasProgress && existingHasProgress) { if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { deduped.set(key, { ...existing, lastUpdated: item.lastUpdated }); } } else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { deduped.set(key, item); } } const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved); const reconcilePromises: Promise[] = []; const reconcileLocalPromises: Promise[] = []; const adjustedItems = filteredItems .map((item) => { const matches = getLocalMatches(item, localProgressIndex); if (matches.length === 0) return item; const mostRecentLocal = getMostRecentLocalMatch(matches); const highestLocal = getHighestLocalMatch(matches); if (!mostRecentLocal || !highestLocal) { return item; } const mergedLastUpdated = Math.max( mostRecentLocal.lastUpdated ?? 0, item.lastUpdated ?? 0 ); const localProgress = mostRecentLocal.progressPercent; const traktProgress = item.progress ?? 0; const traktTs = item.lastUpdated ?? 0; const localTs = mostRecentLocal.lastUpdated ?? 0; const isAhead = isFinite(localProgress) && localProgress > traktProgress + 0.5; const isLocalNewer = localTs > traktTs + 5000; const isLocalRecent = localTs > 0 && Date.now() - localTs < 5 * 60 * 1000; const isDifferent = Math.abs((localProgress || 0) - (traktProgress || 0)) > 0.5; const isTraktAhead = isFinite(traktProgress) && traktProgress > localProgress + 0.5; if (isTraktAhead && !isLocalRecent && mostRecentLocal.duration > 0) { const reconcileKey = `local:${item.type}:${item.id}:${item.season ?? ''}:${item.episode ?? ''}`; const last = lastTraktReconcileRef.current.get(reconcileKey) ?? 0; const now = Date.now(); if (now - last >= TRAKT_RECONCILE_COOLDOWN) { lastTraktReconcileRef.current.set(reconcileKey, now); const targetEpisodeId = item.type === 'series' ? mostRecentLocal.episodeId || (item.season && item.episode ? `${item.id}:${item.season}:${item.episode}` : undefined) : undefined; const newCurrentTime = (traktProgress / 100) * mostRecentLocal.duration; reconcileLocalPromises.push( (async () => { try { const existing = await storageService.getWatchProgress( item.id, item.type, targetEpisodeId ); if (!existing || !existing.duration || existing.duration <= 0) { return; } await storageService.setWatchProgress( item.id, item.type, { ...existing, currentTime: Math.max(existing.currentTime ?? 0, newCurrentTime), duration: existing.duration, traktSynced: true, traktLastSynced: Date.now(), traktProgress: Math.max(existing.traktProgress ?? 0, traktProgress), lastUpdated: existing.lastUpdated, } as any, targetEpisodeId, { preserveTimestamp: true, forceWrite: true } ); } catch { // Ignore background sync failures. } })() ); } } if ((isAhead || ((isLocalNewer || isLocalRecent) && isDifferent)) && localProgress >= 2) { const reconcileKey = `${item.type}:${item.id}:${item.season ?? ''}:${item.episode ?? ''}`; const last = lastTraktReconcileRef.current.get(reconcileKey) ?? 0; const now = Date.now(); if (now - last >= TRAKT_RECONCILE_COOLDOWN) { lastTraktReconcileRef.current.set(reconcileKey, now); const contentData = buildTraktContentData(item); if (contentData) { const progressToSend = localProgress >= 85 ? Math.min(localProgress, 100) : Math.min(localProgress, 79.9); reconcilePromises.push( traktService.pauseWatching(contentData, progressToSend).catch(() => null) ); } } } if (((isLocalNewer || isLocalRecent) && isDifferent) || isAhead) { return { ...item, progress: localProgress, lastUpdated: mergedLastUpdated, }; } return { ...item, lastUpdated: mergedLastUpdated, }; }) .filter((item) => (item.progress ?? 0) < 85); adjustedItems.sort(compareContinueWatchingItems); setContinueWatchingItems(adjustedItems); if (reconcilePromises.length > 0) { Promise.allSettled(reconcilePromises).catch(() => null); } if (reconcileLocalPromises.length > 0) { Promise.allSettled(reconcileLocalPromises).catch(() => null); } }