diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index e5a7d095..01fb778a 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -10,7 +10,7 @@ import { ActivityIndicator, Platform } from 'react-native'; -import { FlashList } from '@shopify/flash-list'; +import { FlatList } from 'react-native'; import Animated, { FadeIn, Layout } from 'react-native-reanimated'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -363,300 +363,237 @@ const ContinueWatchingSection = React.forwardRef((props, re }; try { - const allProgress = await storageService.getAllWatchProgress(); - if (Object.keys(allProgress).length === 0) { - setContinueWatchingItems([]); - return; - } + // If Trakt is authenticated, skip local storage and only use Trakt playback + const traktService = TraktService.getInstance(); + const isTraktAuthed = await traktService.isAuthenticated(); - // Group progress items by content ID - const contentGroups: Record }> = {}; - for (const key in allProgress) { - const keyParts = key.split(':'); - const [type, id, ...episodeIdParts] = keyParts; - const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; - const progress = allProgress[key]; - const progressPercent = - progress.duration > 0 - ? (progress.currentTime / progress.duration) * 100 - : 0; - // Skip fully watched movies - if (type === 'movie' && progressPercent >= 85) continue; - // Skip movies with no actual progress (ensure > 0%) - if (type === 'movie' && (!isFinite(progressPercent) || progressPercent <= 0)) continue; - const contentKey = `${type}:${id}`; - if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] }; - contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent }); - } + // Declare groupPromises outside the if/else block + let groupPromises: Promise[] = []; - // Fetch Trakt watched movies once and reuse - const traktMoviesSetPromise = (async () => { - try { - const traktService = TraktService.getInstance(); - const isAuthed = await traktService.isAuthenticated(); - if (!isAuthed) return new Set(); - if (typeof (traktService as any).getWatchedMovies === 'function') { - const watched = await (traktService as any).getWatchedMovies(); - const watchedSet = new Set(); - - if (Array.isArray(watched)) { - watched.forEach((w: any) => { - const ids = w?.movie?.ids; - if (!ids) return; - - if (ids.imdb) { - const imdb = ids.imdb; - watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`); - } - if (ids.tmdb) { - watchedSet.add(ids.tmdb.toString()); - } - }); - } - return watchedSet; - } - return new Set(); - } catch { - return new Set(); + if (isTraktAuthed) { + // Just skip local storage - Trakt will populate with correct timestamps + // Don't clear existing items to avoid flicker + } else { + // Non-Trakt: use local storage + const allProgress = await storageService.getAllWatchProgress(); + if (Object.keys(allProgress).length === 0) { + setContinueWatchingItems([]); + return; } - })(); - // Fetch Trakt watched shows once and reuse - const traktShowsSetPromise = (async () => { - try { - const traktService = TraktService.getInstance(); - const isAuthed = await traktService.isAuthenticated(); - if (!isAuthed) return new Set(); - - if (typeof (traktService as any).getWatchedShows === 'function') { - const watched = await (traktService as any).getWatchedShows(); - const watchedSet = new Set(); - - if (Array.isArray(watched)) { - watched.forEach((show: any) => { - const ids = show?.show?.ids; - if (!ids) return; - - const imdbId = ids.imdb; - const tmdbId = ids.tmdb; - - if (show.seasons && Array.isArray(show.seasons)) { - show.seasons.forEach((season: any) => { - if (season.episodes && Array.isArray(season.episodes)) { - season.episodes.forEach((episode: any) => { - if (imdbId) { - const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`); - } - if (tmdbId) { - watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`); - } - }); - } - }); - } - }); - } - return watchedSet; - } - return new Set(); - } catch { - return new Set(); + // Group progress items by content ID + const contentGroups: Record }> = {}; + for (const key in allProgress) { + const keyParts = key.split(':'); + const [type, id, ...episodeIdParts] = keyParts; + const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; + const progress = allProgress[key]; + const progressPercent = + progress.duration > 0 + ? (progress.currentTime / progress.duration) * 100 + : 0; + // Skip fully watched movies + if (type === 'movie' && progressPercent >= 85) continue; + // Skip movies with no actual progress (ensure > 0%) + if (type === 'movie' && (!isFinite(progressPercent) || progressPercent <= 0)) continue; + const contentKey = `${type}:${id}`; + if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] }; + contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent }); } - })(); - // Process each content group concurrently, merging results as they arrive - const groupPromises = Object.values(contentGroups).map(async (group) => { - try { - if (!isSupportedId(group.id)) return; - // Skip movies that are already watched on Trakt - if (group.type === 'movie') { - const watchedSet = await traktMoviesSetPromise; - const imdbId = group.id.startsWith('tt') - ? group.id - : `tt${group.id}`; - if (watchedSet.has(imdbId)) { - // Optional: sync local store to watched to prevent reappearance - try { - await storageService.setWatchProgress(group.id, 'movie', { - currentTime: 1, - duration: 1, - lastUpdated: Date.now(), - traktSynced: true, - traktProgress: 100, - } as any); - } catch (_e) { } - return; + // Fetch Trakt watched movies once and reuse + const traktMoviesSetPromise = (async () => { + try { + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (!isAuthed) return new Set(); + if (typeof (traktService as any).getWatchedMovies === 'function') { + const watched = await (traktService as any).getWatchedMovies(); + const watchedSet = new Set(); + + if (Array.isArray(watched)) { + watched.forEach((w: any) => { + const ids = w?.movie?.ids; + if (!ids) return; + + if (ids.imdb) { + const imdb = ids.imdb; + watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`); + } + if (ids.tmdb) { + watchedSet.add(ids.tmdb.toString()); + } + }); + } + return watchedSet; } + return new Set(); + } catch { + return new Set(); } - const cachedData = await getCachedMetadata(group.type, group.id, group.episodes[0]?.progress?.addonId); - if (!cachedData?.basicContent) return; - const { metadata, basicContent } = cachedData; + })(); - const batch: ContinueWatchingItem[] = []; - for (const episode of group.episodes) { - const { episodeId, progress, progressPercent } = episode; + // Fetch Trakt watched shows once and reuse + const traktShowsSetPromise = (async () => { + try { + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (!isAuthed) return new Set(); - if (group.type === 'series' && progressPercent >= 85) { - // Local progress completion check - if (episodeId) { - let currentSeason: number | undefined; - let currentEpisode: number | undefined; + if (typeof (traktService as any).getWatchedShows === 'function') { + const watched = await (traktService as any).getWatchedShows(); + const watchedSet = new Set(); - const match = episodeId.match(/s(\d+)e(\d+)/i); + if (Array.isArray(watched)) { + watched.forEach((show: any) => { + const ids = show?.show?.ids; + if (!ids) return; + + const imdbId = ids.imdb; + const tmdbId = ids.tmdb; + + if (show.seasons && Array.isArray(show.seasons)) { + show.seasons.forEach((season: any) => { + if (season.episodes && Array.isArray(season.episodes)) { + season.episodes.forEach((episode: any) => { + if (imdbId) { + const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`); + } + if (tmdbId) { + watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`); + } + }); + } + }); + } + }); + } + return watchedSet; + } + return new Set(); + } catch { + return new Set(); + } + })(); + + // Process each content group concurrently, merging results as they arrive + groupPromises = Object.values(contentGroups).map(async (group) => { + try { + if (!isSupportedId(group.id)) return; + // Skip movies that are already watched on Trakt + if (group.type === 'movie') { + const watchedSet = await traktMoviesSetPromise; + const imdbId = group.id.startsWith('tt') + ? group.id + : `tt${group.id}`; + if (watchedSet.has(imdbId)) { + // Optional: sync local store to watched to prevent reappearance + try { + await storageService.setWatchProgress(group.id, 'movie', { + currentTime: 1, + duration: 1, + lastUpdated: Date.now(), + traktSynced: true, + traktProgress: 100, + } as any); + } catch (_e) { } + return; + } + } + const cachedData = await getCachedMetadata(group.type, group.id, group.episodes[0]?.progress?.addonId); + if (!cachedData?.basicContent) return; + const { metadata, basicContent } = cachedData; + + const batch: ContinueWatchingItem[] = []; + for (const episode of group.episodes) { + const { episodeId, progress, progressPercent } = episode; + + if (group.type === 'series' && progressPercent >= 85) { + // Skip completed episodes - don't add "next episode" here + // The Trakt playback endpoint handles in-progress items + continue; + } + + let season: number | undefined; + let episodeNumber: number | undefined; + let episodeTitle: string | undefined; + let isWatchedOnTrakt = false; + + if (episodeId && group.type === 'series') { + let match = episodeId.match(/s(\d+)e(\d+)/i); if (match) { - currentSeason = parseInt(match[1], 10); - currentEpisode = parseInt(match[2], 10); + season = parseInt(match[1], 10); + episodeNumber = parseInt(match[2], 10); + episodeTitle = `Episode ${episodeNumber}`; } else { const parts = episodeId.split(':'); - if (parts.length >= 2) { - const seasonNum = parseInt(parts[parts.length - 2], 10); - const episodeNum = parseInt(parts[parts.length - 1], 10); + if (parts.length >= 3) { + const seasonPart = parts[parts.length - 2]; + const episodePart = parts[parts.length - 1]; + const seasonNum = parseInt(seasonPart, 10); + const episodeNum = parseInt(episodePart, 10); if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - currentSeason = seasonNum; - currentEpisode = episodeNum; + season = seasonNum; + episodeNumber = episodeNum; + episodeTitle = `Episode ${episodeNumber}`; } } } - if (currentSeason !== undefined && currentEpisode !== undefined) { - const traktService = TraktService.getInstance(); - let nextEpisode: any = null; + // Check if this specific episode is watched on Trakt + if (season !== undefined && episodeNumber !== undefined) { + const watchedEpisodesSet = await traktShowsSetPromise; + // Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string) + const rawId = group.id.replace(/^tt/, ''); + const ttId = `tt${rawId}`; - try { - const isAuthed = await traktService.isAuthenticated(); - if (isAuthed && typeof (traktService as any).getShowWatchedProgress === 'function') { - const showProgress = await (traktService as any).getShowWatchedProgress(group.id); + if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) || + watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) || + watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) { + isWatchedOnTrakt = true; - if (showProgress && !showProgress.completed && showProgress.next_episode) { - nextEpisode = showProgress.next_episode; - } - } - } catch { - - } - - if (!nextEpisode && metadata?.videos) { - nextEpisode = findNextEpisode( - currentSeason, - currentEpisode, - metadata.videos - ); - } - - if (nextEpisode) { - batch.push({ - ...basicContent, - id: group.id, - type: group.type, - progress: 0, - lastUpdated: progress.lastUpdated, - season: nextEpisode.season, - episode: nextEpisode.number ?? nextEpisode.episode, - episodeTitle: nextEpisode.title || `Episode ${nextEpisode.number ?? nextEpisode.episode}`, - addonId: progress.addonId, - } as ContinueWatchingItem); + // Update local storage to reflect watched status + try { + await storageService.setWatchProgress( + group.id, + 'series', + { + currentTime: 1, + duration: 1, + lastUpdated: Date.now(), + traktSynced: true, + traktProgress: 100, + } as any, + episodeId + ); + } catch (_e) { } } } - } - continue; + + // If watched on Trakt, skip it - Trakt playback handles in-progress items + if (isWatchedOnTrakt) { + continue; + } + + batch.push({ + ...basicContent, + progress: progressPercent, + lastUpdated: progress.lastUpdated, + season, + episode: episodeNumber, + episodeTitle, + addonId: progress.addonId, + } as ContinueWatchingItem); } - let season: number | undefined; - let episodeNumber: number | undefined; - let episodeTitle: string | undefined; - let isWatchedOnTrakt = false; - - if (episodeId && group.type === 'series') { - let match = episodeId.match(/s(\d+)e(\d+)/i); - if (match) { - season = parseInt(match[1], 10); - episodeNumber = parseInt(match[2], 10); - episodeTitle = `Episode ${episodeNumber}`; - } else { - const parts = episodeId.split(':'); - if (parts.length >= 3) { - const seasonPart = parts[parts.length - 2]; - const episodePart = parts[parts.length - 1]; - const seasonNum = parseInt(seasonPart, 10); - const episodeNum = parseInt(episodePart, 10); - if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - season = seasonNum; - episodeNumber = episodeNum; - episodeTitle = `Episode ${episodeNumber}`; - } - } - } - - // Check if this specific episode is watched on Trakt - if (season !== undefined && episodeNumber !== undefined) { - const watchedEpisodesSet = await traktShowsSetPromise; - // Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string) - const rawId = group.id.replace(/^tt/, ''); - const ttId = `tt${rawId}`; - - if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) || - watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) || - watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) { - isWatchedOnTrakt = true; - - // Update local storage to reflect watched status - try { - await storageService.setWatchProgress( - group.id, - 'series', - { - currentTime: 1, - duration: 1, - lastUpdated: Date.now(), - traktSynced: true, - traktProgress: 100, - } as any, - episodeId - ); - } catch (_e) { } - } - } - } - - // If watched on Trakt, treat it as completed (try to find next episode) - if (isWatchedOnTrakt) { - if (season !== undefined && episodeNumber !== undefined && metadata?.videos) { - const nextEpisodeVideo = findNextEpisode(season, episodeNumber, metadata.videos); - if (nextEpisodeVideo) { - batch.push({ - ...basicContent, - id: group.id, - type: group.type, - progress: 0, - lastUpdated: progress.lastUpdated, - season: nextEpisodeVideo.season, - episode: nextEpisodeVideo.episode, - episodeTitle: `Episode ${nextEpisodeVideo.episode}`, - addonId: progress.addonId, - } as ContinueWatchingItem); - } - } - continue; - } - - batch.push({ - ...basicContent, - progress: progressPercent, - lastUpdated: progress.lastUpdated, - season, - episode: episodeNumber, - episodeTitle, - addonId: progress.addonId, - } as ContinueWatchingItem); + if (batch.length > 0) await mergeBatchIntoState(batch); + } catch (error) { + // Continue processing other groups even if one fails } - - if (batch.length > 0) await mergeBatchIntoState(batch); - } catch (error) { - // Continue processing other groups even if one fails - } - }); + }); + } // End of else block for non-Trakt users // TRAKT: fetch playback progress (in-progress items) and history, merge incrementally const traktMergePromise = (async () => { @@ -674,33 +611,26 @@ const ContinueWatchingSection = React.forwardRef((props, re lastTraktSyncRef.current = now; - // 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 - ]); + // Fetch only playback progress (paused items with actual progress %) + // Removed: history items and watched shows - redundant with local logic + const playbackItems = await traktService.getPlaybackProgress(); + - // 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 + const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); for (const item of playbackItems) { try { - // Skip items with very low or very high progress - if (item.progress <= 0 || item.progress >= 85) continue; + // Skip items with < 2% progress (accidental clicks) + if (item.progress < 2) continue; + // Skip items with >= 85% progress (completed) + if (item.progress >= 85) continue; + // Skip items older than 30 days + const pausedAt = new Date(item.paused_at).getTime(); + if (pausedAt < thirtyDaysAgo) continue; if (item.type === 'movie' && item.movie?.ids?.imdb) { const imdbId = item.movie.ids.imdb.startsWith('tt') @@ -735,13 +665,7 @@ const ContinueWatchingSection = React.forwardRef((props, re 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; @@ -758,7 +682,6 @@ const ContinueWatchingSection = React.forwardRef((props, re addonId: undefined, } 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) { @@ -766,110 +689,22 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // 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 - ? (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 }; - } - } - - // Add next episodes for completed shows - for (const [showId, info] of Object.entries(latestWatchedByShow)) { - try { - // Check if this show was recently removed - const showKey = `series:${showId}`; - if (recentlyRemovedRef.current.has(showKey)) { - logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`); - continue; - } - - const cachedData = await getCachedMetadata('series', showId); - if (!cachedData?.basicContent) continue; - const { metadata, basicContent } = cachedData; - - const traktService = TraktService.getInstance(); - let showProgress: any = null; - - try { - showProgress = await (traktService as any).getShowWatchedProgress?.(showId); - } catch { - showProgress = null; - } - - if (!showProgress || showProgress.completed || !showProgress.next_episode) { - logger.log(`🚫 [TraktSync] Skipping completed show: ${showId}`); - continue; - } - - const nextEp = showProgress.next_episode; - - logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${nextEp.season}E${nextEp.number}`); - - traktBatch.push({ - ...basicContent, - id: showId, - type: 'series', - progress: 0, - lastUpdated: info.watchedAt, - season: nextEp.season, - episode: nextEp.number, - episodeTitle: nextEp.title || `Episode ${nextEp.number}`, - addonId: undefined, - } as ContinueWatchingItem); - - // 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) { - await storageService.setWatchProgress( - showId, - 'series', - { - currentTime: 1, - duration: 1, - lastUpdated: info.watchedAt, - traktSynced: true, - traktProgress: 100, - } as any, - `${info.season}:${info.episode}` - ); - } - } - } catch (err) { - // Continue with other shows - } - } - - // Merge all Trakt items as a single batch to ensure proper sorting + // Set Trakt playback items as state (replace, don't merge with local storage) if (traktBatch.length > 0) { - logger.log(`📋 [TraktSync] Merging ${traktBatch.length} items from Trakt (playback + history)`); - await mergeBatchIntoState(traktBatch); + // Dedupe: for series, keep only the latest episode per show + 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)) { + deduped.set(key, item); + } + } + const uniqueItems = Array.from(deduped.values()); + logger.log(`📋 [TraktSync] Setting ${uniqueItems.length} items from Trakt playback (deduped from ${traktBatch.length})`); + // Sort by lastUpdated descending and set directly + const sortedBatch = uniqueItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + setContinueWatchingItems(sortedBatch); } } catch (err) { logger.error('[TraktSync] Error in Trakt merge:', err); @@ -1353,8 +1188,8 @@ const ContinueWatchingSection = React.forwardRef((props, re - (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0))} renderItem={renderContinueWatchingItem} keyExtractor={keyExtractor} horizontal diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 8a51f910..ae97e2ec 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -578,7 +578,7 @@ export type TraktContentCommentLegacy = | TraktListComment; -const TRAKT_MAINTENANCE_MODE = true; +const TRAKT_MAINTENANCE_MODE = false; const TRAKT_MAINTENANCE_MESSAGE = 'Trakt integration is temporarily unavailable for maintenance. Please try again later.'; export class TraktService {