From 1cadc2473f9640af8fb8481a15be45844df56173 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Fri, 20 Mar 2026 19:59:06 +0530 Subject: [PATCH 01/15] fix duplicate trakt entries while pausing and resuming content --- src/components/player/hooks/useWatchProgress.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts index 99a7b63b..a8ac8a17 100644 --- a/src/components/player/hooks/useWatchProgress.ts +++ b/src/components/player/hooks/useWatchProgress.ts @@ -210,6 +210,11 @@ export const useWatchProgress = ( wasPausedRef.current = paused; if (becamePaused) { void saveWatchProgress(); + } else { + // Became unpaused — open/re-open the Trakt scrobble session + if (durationRef.current > 0) { + void traktAutosyncRef.current.handlePlaybackStart(currentTimeRef.current, durationRef.current); + } } } @@ -238,7 +243,8 @@ export const useWatchProgress = ( setTimeout(() => { if (id && type && durationRef.current > 0) { saveWatchProgress(); - traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount'); + // Use ref to avoid stale closure capturing an old traktAutosync instance + traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount'); } }, 0); }; From 54c594e0c5dc05ffd2faf4463034ac87b728b0ea Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Fri, 20 Mar 2026 20:00:47 +0530 Subject: [PATCH 02/15] fix duplicate trakt entries --- src/hooks/useTraktAutosync.ts | 83 +++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 0966accb..4c2f681a 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -22,6 +22,23 @@ interface TraktAutosyncOptions { episodeId?: string; } +// Module-level map: contentKey → { stoppedAt, progress, isComplete } +// Survives component unmount/remount so re-mounting the player for the same +// content (e.g. app background → resume) doesn't fire a duplicate scrobble/start. +const recentlyStoppedSessions = new Map(); +const SESSION_RESUME_WINDOW_MS = 20 * 60 * 1000; // 20 minutes + +function getContentKey(opts: TraktAutosyncOptions): string { + const resolvedId = (opts.imdbId && opts.imdbId.trim()) ? opts.imdbId : (opts.id || ''); + return opts.type === 'movie' + ? `movie:${resolvedId}` + : `episode:${opts.showImdbId || resolvedId}:${opts.season}:${opts.episode}`; +} + export function useTraktAutosync(options: TraktAutosyncOptions) { const { isAuthenticated, @@ -53,24 +70,39 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Generate a unique session key for this content instance useEffect(() => { - const resolvedId = (options.imdbId && options.imdbId.trim()) ? options.imdbId : (options.id || ''); - const contentKey = options.type === 'movie' - ? `movie:${resolvedId}` - : `episode:${options.showImdbId || resolvedId}:${options.season}:${options.episode}`; + const contentKey = getContentKey(options); sessionKey.current = `${contentKey}:${Date.now()}`; + isUnmounted.current = false; + unmountCount.current = 0; - // Reset all session state for new content - hasStartedWatching.current = false; - hasStopped.current = false; - isSessionComplete.current = false; - isUnmounted.current = false; // Reset unmount flag for new mount - lastStopCall.current = 0; - - logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); + // Check if we're re-mounting for the same content within the resume window. + // If so, restore the stopped/complete state so we don't fire a duplicate start. + const prior = recentlyStoppedSessions.get(contentKey); + const now = Date.now(); + if (prior && (now - prior.stoppedAt) < SESSION_RESUME_WINDOW_MS) { + hasStartedWatching.current = false; // will re-start cleanly if needed + hasStopped.current = prior.isComplete ? true : prior.progress > 0; // block restart if already stopped + isSessionComplete.current = prior.isComplete; + lastSyncProgress.current = prior.progress; + lastStopCall.current = prior.stoppedAt; + logger.log(`[TraktAutosync] Remount detected for same content within ${SESSION_RESUME_WINDOW_MS / 1000}s window. Restoring: hasStopped=${hasStopped.current}, isComplete=${isSessionComplete.current}, progress=${prior.progress.toFixed(1)}%`); + } else { + // Genuinely new content or window expired — reset everything + hasStartedWatching.current = false; + hasStopped.current = false; + isSessionComplete.current = false; + lastStopCall.current = 0; + lastSyncProgress.current = 0; + lastSyncTime.current = 0; + if (prior) { + recentlyStoppedSessions.delete(contentKey); + } + logger.log(`[TraktAutosync] New session started for: ${sessionKey.current}`); + } return () => { unmountCount.current++; - isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations + isUnmounted.current = true; logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`); }; }, [options.imdbId, options.season, options.episode, options.type]); @@ -277,6 +309,14 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } + // Skip if session was already stopped (e.g. after background/pause). + // Without this, the fallback "force start" block inside handleProgressUpdate + // would fire a new /scrobble/start on the first periodic save after a remount, + // bypassing the hasStopped guard in handlePlaybackStart entirely. + if (hasStopped.current) { + return; + } + try { const rawProgress = (currentTime / duration) * 100; const progressPercent = Math.min(100, Math.max(0, rawProgress)); @@ -511,6 +551,15 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { lastStopCall.current = now; hasStopped.current = true; + // Persist to module-level map so a remount for the same content within the + // resume window won't fire a duplicate scrobble/start. + const contentKey = getContentKey(options); + recentlyStoppedSessions.set(contentKey, { + stoppedAt: now, + progress: progressPercent, + isComplete: false // updated below if scrobble succeeds at threshold + }); + const contentData = buildContentData(); // Skip if content data is invalid @@ -549,6 +598,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } else if (shouldSyncTrakt) { // If Trakt stop failed, reset the stop flag so we can try again later hasStopped.current = false; + recentlyStoppedSessions.delete(getContentKey(options)); logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`); } @@ -573,6 +623,12 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Mark session as complete if >= user completion threshold if (progressPercent >= autosyncSettings.completionThreshold) { isSessionComplete.current = true; + // Update module-level map to reflect completion so a remount won't restart + const ck = getContentKey(options); + const existing = recentlyStoppedSessions.get(ck); + if (existing) { + recentlyStoppedSessions.set(ck, { ...existing, isComplete: true, progress: progressPercent }); + } logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); // Ensure local watch progress reflects completion so UI shows as watched @@ -633,6 +689,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { unmountCount.current = 0; sessionKey.current = null; lastStopCall.current = 0; + recentlyStoppedSessions.delete(getContentKey(options)); logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); }, [options.title]); From d96a3046fd742c5da6e7c1adc68984d1e933ae38 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Fri, 20 Mar 2026 20:01:59 +0530 Subject: [PATCH 03/15] fix duplicate trakt entries caused by pausing --- src/services/traktService.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 1c7f7406..be5c689a 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -690,9 +690,13 @@ export class TraktService { const now = Date.now(); let cleanupCount = 0; - // Remove stop calls older than the debounce window + // Retain stop records for 5 minutes so the restart-prevention guard in + // scrobbleStart() has time to work. The old value was STOP_DEBOUNCE_MS (1s), + // which meant every 15-minute cleanup tick wiped all stop records immediately, + // completely defeating the 30s restart window. + const STOP_RETENTION_MS = 5 * 60 * 1000; for (const [key, timestamp] of this.lastStopCalls.entries()) { - if (now - timestamp > this.STOP_DEBOUNCE_MS) { + if (now - timestamp > STOP_RETENTION_MS) { this.lastStopCalls.delete(key); cleanupCount++; } @@ -3246,14 +3250,12 @@ export class TraktService { */ private handleAppStateChange = (nextState: AppStateStatus) => { if (nextState !== 'active') { - // Clear tracking maps to reduce memory pressure when app goes to background - this.scrobbledItems.clear(); - this.scrobbledTimestamps.clear(); - this.currentlyWatching.clear(); - this.lastSyncTimes.clear(); - this.lastStopCalls.clear(); - - // Clear request queue to prevent background processing + // Only clear the request queue to prevent background processing. + // DO NOT clear scrobbledItems / currentlyWatching / lastStopCalls here. + // Clearing them causes duplicate scrobble entries when the app backgrounds + // during a long pause and then resumes — all dedup guards are gone and + // scrobbleStart fires a fresh /scrobble/start for the same content. + // These maps are small and already expire via cleanupOldStopCalls(). this.requestQueue = []; this.isProcessingQueue = false; } From 90169370f547d57583a98cedf3bf162f4091c6e1 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Fri, 20 Mar 2026 21:14:08 +0530 Subject: [PATCH 04/15] fix type "other" and any other unusual type streams fetching --- src/hooks/useMetadata.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index d8291a74..2049b701 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -167,6 +167,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const [addonResponseOrder, setAddonResponseOrder] = useState([]); // Prevent re-initializing season selection repeatedly for the same series const initializedSeasonRef = useRef(false); + const resolvedTypeRef = useRef(normalizedType); // stores TMDB-resolved type for loadStreams // Memory optimization: Track stream counts and implement cleanup (limits removed) const streamCountRef = useRef(0); @@ -725,6 +726,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // If normalizedType is not a known type (e.g. "other" from Gemini/AI search), // resolve the correct type via TMDB before fetching addon metadata. let effectiveType = normalizedType; + resolvedTypeRef.current = normalizedType; // reset each load if (normalizedType !== 'movie' && normalizedType !== 'series') { try { if (actualId.startsWith('tt')) { @@ -734,6 +736,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const resolved = await tmdbSvc.findTypeAndIdByIMDB(actualId); if (resolved) { effectiveType = resolved.type; + resolvedTypeRef.current = resolved.type; setTmdbId(resolved.tmdbId); if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB /find`); } @@ -751,6 +754,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Prefer series when both exist (anime/TV tagged as "other" is usually a series) if (hasSeries) effectiveType = 'series'; else if (hasMovie) effectiveType = 'movie'; + resolvedTypeRef.current = effectiveType; if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB parallel check`); } } @@ -1571,7 +1575,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (__DEV__) logger.log('🔍 [loadStreams] Getting TMDB ID for:', id); let tmdbId; let stremioId = id; - let effectiveStreamType: string = type; + // Use TMDB-resolved type if available — handles "other", "Movie", etc. + let effectiveStreamType: string = resolvedTypeRef.current || normalizedType; if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; From 9b936e169c6df1a7b24cc2a76f7051a668ae8b8c Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sat, 21 Mar 2026 13:43:03 +0530 Subject: [PATCH 05/15] fix unusual type content streams fetching, use addon provided type --- src/services/catalog/search.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/services/catalog/search.ts b/src/services/catalog/search.ts index 95407c8e..ffd75805 100644 --- a/src/services/catalog/search.ts +++ b/src/services/catalog/search.ts @@ -325,12 +325,21 @@ async function searchAddonCatalog( // meta addon by ID prefix matching. Setting it here causes 404s when two addons // are installed and one returns IDs the other can't serve metadata for. - const normalizedCatalogType = type ? type.toLowerCase() : type; - if (normalizedCatalogType && content.type !== normalizedCatalogType) { - content.type = normalizedCatalogType; - } else if (content.type) { + // Always lowercase the item's own type first + if (content.type) { content.type = content.type.toLowerCase(); } + + // Only stamp the catalog type if the item doesn't already have a standard type. + // Prevents catalog type "other" from overwriting correct types like "movie"/"series" + // that the addon already set on individual items. + const normalizedCatalogType = type ? type.toLowerCase() : type; + const STANDARD_TYPES = new Set(['movie', 'series', 'anime.movie', 'anime.series', 'anime', 'tv', 'channel']); + if (normalizedCatalogType && !STANDARD_TYPES.has(content.type) && STANDARD_TYPES.has(normalizedCatalogType)) { + content.type = normalizedCatalogType; + } else if (normalizedCatalogType && !content.type) { + content.type = normalizedCatalogType; + } return content; }); From 792c8a9187fcacef0c754d637ed1840db4562164 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sat, 21 Mar 2026 14:00:58 +0530 Subject: [PATCH 06/15] fix streams fetching for unusual type and use addon's meta response --- src/hooks/useMetadata.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 2049b701..f0231d7d 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -1576,7 +1576,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat let tmdbId; let stremioId = id; // Use TMDB-resolved type if available — handles "other", "Movie", etc. - let effectiveStreamType: string = resolvedTypeRef.current || normalizedType; + // Use metadata.type first (most reliable — comes directly from the addon's meta response), + // then fall back to TMDB-resolved type, then normalizedType. + let effectiveStreamType: string = metadata?.type || resolvedTypeRef.current || normalizedType; if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; From 8265fe1eac5571ea5cab34455e41dc139f330cff Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sat, 21 Mar 2026 14:41:04 +0530 Subject: [PATCH 07/15] minor scrobble fix --- src/hooks/useTraktAutosync.ts | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 4c2f681a..f4cbe5a7 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -309,14 +309,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } - // Skip if session was already stopped (e.g. after background/pause). - // Without this, the fallback "force start" block inside handleProgressUpdate - // would fire a new /scrobble/start on the first periodic save after a remount, - // bypassing the hasStopped guard in handlePlaybackStart entirely. - if (hasStopped.current) { - return; - } - try { const rawProgress = (currentTime / duration) * 100; const progressPercent = Math.min(100, Math.max(0, rawProgress)); @@ -340,6 +332,19 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { lastSyncTime.current = now; lastSyncProgress.current = progressPercent; + // If this update crossed the completion threshold, Trakt will have silently + // scrobbled it. Mark complete now so unmount/background don't fire a second + // /scrobble/stop above threshold and create a duplicate history entry. + if (progressPercent >= autosyncSettings.completionThreshold) { + isSessionComplete.current = true; + const ck = getContentKey(options); + const existing = recentlyStoppedSessions.get(ck); + if (existing) { + recentlyStoppedSessions.set(ck, { ...existing, isComplete: true, progress: progressPercent }); + } + logger.log(`[TraktAutosync] Threshold reached via immediate progress update (${progressPercent.toFixed(1)}%), marking session complete`); + } + // Update local storage sync status await storageService.updateTraktSyncStatus( options.id, @@ -375,6 +380,19 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { lastSyncTime.current = now; lastSyncProgress.current = progressPercent; + // If this periodic update crossed the completion threshold, Trakt will have + // silently scrobbled it. Mark complete now so unmount/background don't fire + // a second /scrobble/stop above threshold and create a duplicate history entry. + if (progressPercent >= autosyncSettings.completionThreshold) { + isSessionComplete.current = true; + const ck = getContentKey(options); + const existing = recentlyStoppedSessions.get(ck); + if (existing) { + recentlyStoppedSessions.set(ck, { ...existing, isComplete: true, progress: progressPercent }); + } + logger.log(`[TraktAutosync] Threshold reached via progress update (${progressPercent.toFixed(1)}%), marking session complete to prevent duplicate scrobble`); + } + // Update local storage sync status await storageService.updateTraktSyncStatus( options.id, @@ -385,7 +403,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { currentTime ); - // Progress sync logging removed logger.log(`[TraktAutosync] Trakt: Progress updated to ${progressPercent.toFixed(1)}%`); } } From abdbffbc7ff1b82ae794ebca2076dafdb5a83fe4 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sat, 21 Mar 2026 20:42:28 +0530 Subject: [PATCH 08/15] fix duplicate declaration introduced in the mal PR --- src/components/player/AndroidVideoPlayer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 5933a1ef..6623f9d9 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -271,7 +271,6 @@ const AndroidVideoPlayer: React.FC = () => { const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); - const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; const { segments: skipIntervals, outroSegment } = useSkipSegments({ imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined), From 7f7f6afc81b574871dfb4c585916a7bc33ccf9b3 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sat, 21 Mar 2026 22:07:32 +0530 Subject: [PATCH 09/15] minor fix --- src/hooks/useMetadata.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index f0231d7d..0b7db54c 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -1576,8 +1576,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat let tmdbId; let stremioId = id; // Use TMDB-resolved type if available — handles "other", "Movie", etc. - // Use metadata.type first (most reliable — comes directly from the addon's meta response), - // then fall back to TMDB-resolved type, then normalizedType. + // Use metadata.type first (from addon meta response), then TMDB-resolved, then normalized let effectiveStreamType: string = metadata?.type || resolvedTypeRef.current || normalizedType; if (id.startsWith('tmdb:')) { @@ -1633,7 +1632,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const allStremioAddons = await stremioService.getInstalledAddons(); const localScrapers = await localScraperService.getInstalledScrapers(); - const requestedStreamType = type; + // Use the best available type — not raw type which may be "other" + const requestedStreamType = metadata?.type || resolvedTypeRef.current || normalizedType; const pickEligibleStreamAddons = (requestType: string) => allStremioAddons.filter(addon => { From 2d8a07472bd1f07df167c20c0c89bdc6dfb85b98 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 22 Mar 2026 01:00:15 +0530 Subject: [PATCH 10/15] minor fix --- src/services/catalog/search.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/services/catalog/search.ts b/src/services/catalog/search.ts index ffd75805..b9142a48 100644 --- a/src/services/catalog/search.ts +++ b/src/services/catalog/search.ts @@ -393,9 +393,17 @@ function dedupeAndStampResults(results: StreamingContent[], catalogType: string) } } - return Array.from(bestById.values()).map(item => - catalogType && item.type !== catalogType ? { ...item, type: catalogType } : item - ); + const normalizedCatalogType = catalogType ? catalogType.toLowerCase() : catalogType; + const STANDARD_TYPES = new Set(['movie', 'series', 'anime.movie', 'anime.series', 'anime', 'tv', 'channel']); + + return Array.from(bestById.values()).map(item => { + // Only stamp catalog type if item doesn't already have a standard type. + // Prevents "other" from overwriting correct types like "movie"/"series". + if (normalizedCatalogType && !STANDARD_TYPES.has(item.type) && STANDARD_TYPES.has(normalizedCatalogType)) { + return { ...item, type: normalizedCatalogType }; + } + return item; + }); } function buildSectionName( From 6b805c7fd428a76884ab7d6a0a0442a363369469 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:42:41 +0530 Subject: [PATCH 11/15] fix duplicate declaration --- src/components/player/AndroidVideoPlayer.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 5933a1ef..955c5d85 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -271,8 +271,6 @@ const AndroidVideoPlayer: React.FC = () => { const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); - const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; - const { segments: skipIntervals, outroSegment } = useSkipSegments({ imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined), type, From 8850edd0300cd733b63752a065cd4531eb4dcda9 Mon Sep 17 00:00:00 2001 From: Ramon <20274410+ram130@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:26:39 -0400 Subject: [PATCH 12/15] fix: improve sort order for Trakt and clear stale items on disconnect. --- .../mergeTraktContinueWatching.ts | 220 +++++++++++------- 1 file changed, 130 insertions(+), 90 deletions(-) diff --git a/src/components/home/continueWatching/mergeTraktContinueWatching.ts b/src/components/home/continueWatching/mergeTraktContinueWatching.ts index 07e548d6..ccbdf4b1 100644 --- a/src/components/home/continueWatching/mergeTraktContinueWatching.ts +++ b/src/components/home/continueWatching/mergeTraktContinueWatching.ts @@ -10,7 +10,7 @@ import { logger } from '../../../utils/logger'; import { TRAKT_RECONCILE_COOLDOWN, TRAKT_SYNC_COOLDOWN } from './constants'; import { GetCachedMetadata, LocalProgressEntry } from './dataTypes'; import { - buildTraktContentData, + // CHANGE: removed unused buildTraktContentData import filterRemovedItems, findNextEpisode, getHighestLocalMatch, @@ -18,7 +18,7 @@ import { getMostRecentLocalMatch, } from './dataShared'; import { ContinueWatchingItem } from './types'; -import { compareContinueWatchingItems } from './utils'; +// CHANGE: removed unused compareContinueWatchingItems import (final sort now inline) interface MergeTraktContinueWatchingParams { traktService: TraktService; @@ -31,6 +31,23 @@ interface MergeTraktContinueWatchingParams { setContinueWatchingItems: Dispatch>; } +// CHANGE: Added bulletproof time parser to prevent NaN from breaking sort algorithm. +// Previously used `new Date(value).getTime()` inline which could produce NaN and +// cause unpredictable sort order. +const getValidTime = (dateVal: any): number => { + if (!dateVal) return 0; + if (typeof dateVal === 'number') return isNaN(dateVal) ? 0 : dateVal; + if (typeof dateVal === 'string') { + const parsed = new Date(dateVal).getTime(); + return isNaN(parsed) ? 0 : parsed; + } + if (dateVal instanceof Date) { + const parsed = dateVal.getTime(); + return isNaN(parsed) ? 0 : parsed; + } + return 0; +}; + export async function mergeTraktContinueWatching({ traktService, getCachedMetadata, @@ -41,6 +58,16 @@ export async function mergeTraktContinueWatching({ lastTraktReconcileRef, setContinueWatchingItems, }: MergeTraktContinueWatchingParams): Promise { + + // CHANGE: Added auth check at the top. If user is not authenticated, + // clear the list immediately and return. The `await` is required — + // without it isAuthenticated() returns a Promise (always truthy) and + // the check never fires. + if (!await traktService.isAuthenticated()) { + setContinueWatchingItems([]); + return; + } + const now = Date.now(); if ( TRAKT_SYNC_COOLDOWN > 0 && @@ -53,76 +80,77 @@ export async function mergeTraktContinueWatching({ } lastTraktSyncRef.current = now; - const playbackItems = await traktService.getPlaybackProgress(); 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. + let playbackItems: any[] = []; let watchedShowsData: TraktWatchedItem[] = []; + + try { + playbackItems = await traktService.getPlaybackProgress(); + watchedShowsData = await traktService.getWatchedShows(); + } catch (err) { + logger.warn('[TraktSync] API failed (likely disconnected or expired token):', err); + setContinueWatchingItems([]); + return; + } + 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; + + // CHANGE: Use getValidTime instead of `new Date(...).getTime()` + const resetAt = getValidTime(watchedShow.reset_at); 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(); + const watchedAt = getValidTime(episode.last_watched_at); if (watchedAt < resetAt) continue; } - episodeSet.add(`${imdb}:${season.number}:${episode.number}`); } } } - watchedEpisodeSetByShow.set(imdb, episodeSet); } - } catch { - // Continue without watched-show acceleration. + } catch (err) { + logger.warn('[TraktSync] Error mapping watched shows:', err); } 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); + // 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 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); for (const item of sortedPlaybackItems) { try { if (item.progress < 2) continue; - const pausedAt = new Date(item.paused_at).getTime(); - if (pausedAt < thirtyDaysAgo) 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 (item.type === 'movie' && item.movie?.ids?.imdb) { if (item.progress >= 85) continue; @@ -176,7 +204,10 @@ export async function mergeTraktContinueWatching({ id: showImdb, type: 'series', progress: 0, - lastUpdated: nextEpisodeResult.lastWatched, + // 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}`, @@ -185,7 +216,6 @@ export async function mergeTraktContinueWatching({ } as ContinueWatchingItem); } } - continue; } @@ -208,22 +238,36 @@ export async function mergeTraktContinueWatching({ } try { - const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); + // 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); - for (const watchedShow of watchedShowsData) { + // 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. + const sortedWatchedShows = [...(watchedShowsData || [])] + .filter((show) => { + const watchedAt = getValidTime(show.last_watched_at); + return watchedAt > sixMonthsAgo; + }) + .sort((a, b) => { + 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 { 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; + const resetAt = getValidTime(watchedShow.reset_at); let lastWatchedSeason = 0; let lastWatchedEpisode = 0; let latestEpisodeTimestamp = 0; @@ -231,7 +275,7 @@ export async function mergeTraktContinueWatching({ if (watchedShow.seasons) { for (const season of watchedShow.seasons) { for (const episode of season.episodes) { - const episodeTimestamp = new Date(episode.last_watched_at).getTime(); + const episodeTimestamp = getValidTime(episode.last_watched_at); if (resetAt > 0 && episodeTimestamp < resetAt) continue; if (episodeTimestamp > latestEpisodeTimestamp) { @@ -267,7 +311,9 @@ export async function mergeTraktContinueWatching({ id: showImdb, type: 'series', progress: 0, - lastUpdated: nextEpisodeResult.lastWatched, + // 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}`, @@ -279,10 +325,14 @@ export async function mergeTraktContinueWatching({ } } } catch (err) { - logger.warn('[TraktSync] Error fetching watched shows for Up Next:', err); + 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). if (traktBatch.length === 0) { + setContinueWatchingItems([]); return; } @@ -299,26 +349,31 @@ 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); + if (candidateHasProgress && !existingHasProgress) { - const mergedTs = Math.max(item.lastUpdated ?? 0, existing.lastUpdated ?? 0); + const mergedTs = Math.max(safeItemTs, safeExistingTs); deduped.set( key, - mergedTs !== (item.lastUpdated ?? 0) + mergedTs !== safeItemTs ? { ...item, lastUpdated: mergedTs } : item ); } else if (!candidateHasProgress && existingHasProgress) { - if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { - deduped.set(key, { ...existing, lastUpdated: item.lastUpdated }); + if (safeItemTs > safeExistingTs) { + deduped.set(key, { ...existing, lastUpdated: safeItemTs }); } - } else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + } else if (safeItemTs > safeExistingTs) { deduped.set(key, item); } } const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved); - const reconcilePromises: Promise[] = []; 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) => { @@ -332,14 +387,14 @@ export async function mergeTraktContinueWatching({ return item; } - const mergedLastUpdated = Math.max( - mostRecentLocal.lastUpdated ?? 0, - item.lastUpdated ?? 0 - ); + // CHANGE: Use getValidTime for safe timestamp extraction + const safeLocalTs = getValidTime(mostRecentLocal.lastUpdated); + const safeItemTs = getValidTime(item.lastUpdated); + const localProgress = mostRecentLocal.progressPercent; const traktProgress = item.progress ?? 0; - const traktTs = item.lastUpdated ?? 0; - const localTs = mostRecentLocal.lastUpdated ?? 0; + const traktTs = safeItemTs; + const localTs = safeLocalTs; const isAhead = isFinite(localProgress) && localProgress > traktProgress + 0.5; const isLocalNewer = localTs > traktTs + 5000; @@ -401,51 +456,36 @@ export async function mergeTraktContinueWatching({ } } - 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) - ); - } - } - } - + // 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 (((isLocalNewer || isLocalRecent) && isDifferent) || isAhead) { return { ...item, progress: localProgress, - lastUpdated: mergedLastUpdated, + lastUpdated: safeItemTs, // keep Trakt timestamp, only update progress }; } return { ...item, - lastUpdated: mergedLastUpdated, + lastUpdated: safeItemTs, // keep Trakt timestamp for sort stability }; }) .filter((item) => (item.progress ?? 0) < 85); - adjustedItems.sort(compareContinueWatchingItems); - setContinueWatchingItems(adjustedItems); + // 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); - if (reconcilePromises.length > 0) { - Promise.allSettled(reconcilePromises).catch(() => null); - } + setContinueWatchingItems(finalItems); if (reconcileLocalPromises.length > 0) { Promise.allSettled(reconcileLocalPromises).catch(() => null); } -} +} \ No newline at end of file From 38cdf993566c4fc6de1d14eb78271dbe4aaf8a35 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:56:45 +0530 Subject: [PATCH 13/15] refactor: improve key parsing logic in SupabaseSyncService --- src/services/supabaseSyncService.ts | 60 +++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts index 09816061..6d511ed5 100644 --- a/src/services/supabaseSyncService.ts +++ b/src/services/supabaseSyncService.ts @@ -1038,27 +1038,55 @@ class SupabaseSyncService { videoId: string; progressKey: string; } | null { - const parts = key.split(':'); - if (parts.length < 2) return null; + // Key format from buildWpKeyString: "{type}:{contentId}" or "{type}:{contentId}:{episodeId}" + // contentId may contain colons (e.g., "tmdb:1399", "kitsu:12345") + // episodeId ends with ":{season}:{episode}" digits + const typeIdx = key.indexOf(':'); + if (typeIdx < 0) return null; - const contentType: 'movie' | 'series' = parts[0] === 'movie' ? 'movie' : 'series'; - const contentId = parts[1]; - const episodeId = parts.length > 2 ? parts.slice(2).join(':') : ''; + const typePart = key.substring(0, typeIdx); + if (typePart !== 'movie' && typePart !== 'series') return null; + const contentType: 'movie' | 'series' = typePart; + + const rest = key.substring(typeIdx + 1); + if (!rest) return null; + + // Extract content ID: detect known prefixed patterns (tmdb:NNN, kitsu:NNN), + // otherwise take the first colon-free segment (e.g., tt12345). + const cidPrefixMatch = rest.match(/^((?:tmdb|kitsu):\d+)/); + const contentId = cidPrefixMatch ? cidPrefixMatch[1] : rest.split(':')[0]; + if (!contentId) return null; + + const afterContentId = rest.substring(contentId.length); + + if (!afterContentId || afterContentId === ':') { + // No episode info (movie or series-level) + return { + contentType, + contentId, + season: null, + episode: null, + videoId: contentId, + progressKey: contentId, + }; + } + + // Strip leading ":" to get episodeId + const episodeId = afterContentId.substring(1); + + // Extract season:episode from the end of episodeId let season: number | null = null; let episode: number | null = null; - - if (episodeId) { - const match = episodeId.match(/:(\d+):(\d+)$/); - if (match) { - season = Number(match[1]); - episode = Number(match[2]); - } + const seMatch = episodeId.match(/:(\d+):(\d+)$/); + if (seMatch) { + season = Number(seMatch[1]); + episode = Number(seMatch[2]); } const videoId = episodeId || contentId; - const progressKey = contentType === 'movie' - ? contentId - : (season != null && episode != null ? `${contentId}_s${season}e${episode}` : `${contentId}_${videoId}`); + const progressKey = season != null && episode != null + ? `${contentId}_s${season}e${episode}` + : `${contentId}_${episodeId}`; return { contentType, @@ -1365,7 +1393,7 @@ class SupabaseSyncService { const season = row.season == null ? null : Number(row.season); const episode = row.episode == null ? null : Number(row.episode); const episodeId = type === 'series' && season != null && episode != null - ? `${row.content_id}:${season}:${episode}` + ? (row.video_id && row.video_id !== row.content_id ? row.video_id : `${row.content_id}:${season}:${episode}`) : undefined; remoteSet.add(this.buildLocalWatchProgressKey(type, row.content_id, episodeId)); From 42b5f04c8a54365018024f66c2ffdae239448331 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:57:19 +0530 Subject: [PATCH 14/15] refactor: update Trakt autosync settings and scrobble logic --- src/components/player/AndroidVideoPlayer.tsx | 6 +- src/components/player/KSPlayerCore.tsx | 4 +- .../player/hooks/useWatchProgress.ts | 8 +- src/hooks/useTraktAutosync.ts | 612 ++++-------------- src/hooks/useTraktAutosyncSettings.ts | 2 +- src/services/traktService.ts | 116 ++-- 6 files changed, 213 insertions(+), 535 deletions(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 955c5d85..d40f25b6 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -488,6 +488,10 @@ const AndroidVideoPlayer: React.FC = () => { } }, 300); } + + if (videoDuration > 0) { + traktAutosync.handlePlaybackStart(0, videoDuration); + } }, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]); const handleProgress = useCallback((data: any) => { @@ -943,7 +947,7 @@ const AndroidVideoPlayer: React.FC = () => { addonId: currentStreamProvider }, episodeId); } - traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true); + traktAutosync.handlePlaybackStart(data.currentTime, playerState.duration); } }} onEnd={() => { diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index dc745f46..da597369 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -242,6 +242,7 @@ const KSPlayerCore: React.FC = () => { duration, lastUpdated: Date.now() }, episodeId); + traktAutosync.handlePlaybackStart(timeInSeconds, duration); } }); @@ -650,9 +651,6 @@ const KSPlayerCore: React.FC = () => { if (isSyncingBeforeClose.current) return; isSyncingBeforeClose.current = true; - // Fire and forget - don't block navigation on async operations - // The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount - traktAutosync.handleProgressUpdate(currentTime, duration, true); traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close'); navigation.goBack(); diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts index a8ac8a17..aaa8fb15 100644 --- a/src/components/player/hooks/useWatchProgress.ts +++ b/src/components/player/hooks/useWatchProgress.ts @@ -162,7 +162,6 @@ export const useWatchProgress = ( }; try { await storageService.setWatchProgress(id, type, progress, episodeId); - await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current); // Requirement 1: Auto Episode Tracking (>= 90% completion) const progressPercent = (currentTimeRef.current / durationRef.current) * 100; @@ -204,25 +203,24 @@ export const useWatchProgress = ( useEffect(() => { - // Handle pause transitions (upstream) if (wasPausedRef.current !== paused) { const becamePaused = paused; wasPausedRef.current = paused; if (becamePaused) { void saveWatchProgress(); + if (durationRef.current > 0) { + void traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close'); + } } else { - // Became unpaused — open/re-open the Trakt scrobble session if (durationRef.current > 0) { void traktAutosyncRef.current.handlePlaybackStart(currentTimeRef.current, durationRef.current); } } } - // Handle periodic save when playing (MAL branch) if (id && type && !paused) { if (progressSaveInterval) clearInterval(progressSaveInterval); - // Use refs inside the interval so we don't need to restart it on every second const interval = setInterval(() => { saveWatchProgress(); }, 10000); diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index f4cbe5a7..b6ca9153 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -7,30 +7,27 @@ import { SimklContentData } from '../services/simklService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; +const TRAKT_SCROBBLE_THRESHOLD = 80; + interface TraktAutosyncOptions { id: string; type: 'movie' | 'series'; title: string; - year: number | string; // Allow both for compatibility + year: number | string; imdbId: string; - // For episodes season?: number; episode?: number; showTitle?: string; - showYear?: number | string; // Allow both for compatibility + showYear?: number | string; showImdbId?: string; episodeId?: string; } -// Module-level map: contentKey → { stoppedAt, progress, isComplete } -// Survives component unmount/remount so re-mounting the player for the same -// content (e.g. app background → resume) doesn't fire a duplicate scrobble/start. -const recentlyStoppedSessions = new Map(); -const SESSION_RESUME_WINDOW_MS = 20 * 60 * 1000; // 20 minutes +const SCROBBLE_DEDUP_WINDOW_MS = 60 * 60 * 1000; function getContentKey(opts: TraktAutosyncOptions): string { const resolvedId = (opts.imdbId && opts.imdbId.trim()) ? opts.imdbId : (opts.id || ''); @@ -43,8 +40,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const { isAuthenticated, startWatching, - updateProgress, - updateProgressImmediate, stopWatching, stopWatchingImmediate } = useTraktIntegration(); @@ -58,44 +53,34 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const { settings: autosyncSettings } = useTraktAutosyncSettings(); - const hasStartedWatching = useRef(false); - const hasStopped = useRef(false); // New: Track if we've already stopped for this session - const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled) - const isUnmounted = useRef(false); // New: Track if component has unmounted - const lastSyncTime = useRef(0); + // Session state refs + const isSessionComplete = useRef(false); // True once scrobbled (>= 80%) — blocks ALL further payloads + const isUnmounted = useRef(false); const lastSyncProgress = useRef(0); const sessionKey = useRef(null); const unmountCount = useRef(0); - const lastStopCall = useRef(0); // New: Track last stop call timestamp + const lastStopCall = useRef(0); - // Generate a unique session key for this content instance + // Initialise session on mount / content change useEffect(() => { const contentKey = getContentKey(options); sessionKey.current = `${contentKey}:${Date.now()}`; isUnmounted.current = false; unmountCount.current = 0; - // Check if we're re-mounting for the same content within the resume window. - // If so, restore the stopped/complete state so we don't fire a duplicate start. - const prior = recentlyStoppedSessions.get(contentKey); + // Check if this content was recently scrobbled (prevents duplicate on remount) + const prior = recentlyScrobbledSessions.get(contentKey); const now = Date.now(); - if (prior && (now - prior.stoppedAt) < SESSION_RESUME_WINDOW_MS) { - hasStartedWatching.current = false; // will re-start cleanly if needed - hasStopped.current = prior.isComplete ? true : prior.progress > 0; // block restart if already stopped - isSessionComplete.current = prior.isComplete; + if (prior && (now - prior.scrobbledAt) < SCROBBLE_DEDUP_WINDOW_MS) { + isSessionComplete.current = true; lastSyncProgress.current = prior.progress; - lastStopCall.current = prior.stoppedAt; - logger.log(`[TraktAutosync] Remount detected for same content within ${SESSION_RESUME_WINDOW_MS / 1000}s window. Restoring: hasStopped=${hasStopped.current}, isComplete=${isSessionComplete.current}, progress=${prior.progress.toFixed(1)}%`); + logger.log(`[TraktAutosync] Remount detected — content already scrobbled (${prior.progress.toFixed(1)}%), blocking all payloads`); } else { - // Genuinely new content or window expired — reset everything - hasStartedWatching.current = false; - hasStopped.current = false; isSessionComplete.current = false; - lastStopCall.current = 0; lastSyncProgress.current = 0; - lastSyncTime.current = 0; + lastStopCall.current = 0; if (prior) { - recentlyStoppedSessions.delete(contentKey); + recentlyScrobbledSessions.delete(contentKey); } logger.log(`[TraktAutosync] New session started for: ${sessionKey.current}`); } @@ -107,74 +92,48 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { }; }, [options.imdbId, options.season, options.episode, options.type]); - // Build Trakt content data from options - // Returns null if required fields are missing or invalid + // ── Build content data helpers ────────────────────────────────────── + const buildContentData = useCallback((): TraktContentData | null => { - // Parse and validate year - returns undefined for invalid/missing years const parseYear = (year: number | string | undefined): number | undefined => { if (year === undefined || year === null || year === '') return undefined; if (typeof year === 'number') { - // Year must be a reasonable value (between 1800 and current year + 10) const currentYear = new Date().getFullYear(); - if (year <= 0 || year < 1800 || year > currentYear + 10) { - logger.warn(`[TraktAutosync] Invalid year value: ${year}`); - return undefined; - } + if (year < 1800 || year > currentYear + 10) return undefined; return year; } const parsed = parseInt(year.toString(), 10); - if (isNaN(parsed) || parsed <= 0) { - logger.warn(`[TraktAutosync] Failed to parse year: ${year}`); - return undefined; - } - // Validate parsed year range + if (isNaN(parsed) || parsed <= 0) return undefined; const currentYear = new Date().getFullYear(); - if (parsed < 1800 || parsed > currentYear + 10) { - logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`); - return undefined; - } + if (parsed < 1800 || parsed > currentYear + 10) return undefined; return parsed; }; - // Validate required fields early if (!options.title || options.title.trim() === '') { - logger.error('[TraktAutosync] Cannot build content data: missing or empty title'); + logger.error('[TraktAutosync] Cannot build content data: missing title'); return null; } - // Resolve the best available ID: prefer a proper IMDb ID, fall back to the Stremio content ID. - // This allows scrobbling for content with special IDs (e.g. "kitsu:123", "tmdb:456") where - // the IMDb ID hasn't been resolved yet — Trakt will match by title + season/episode instead. const imdbIdRaw = options.imdbId && options.imdbId.trim() ? options.imdbId.trim() : ''; const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : ''; const resolvedImdbId = imdbIdRaw || stremioIdRaw; if (!resolvedImdbId) { - logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId and id'); + logger.error('[TraktAutosync] Cannot build content data: missing imdbId and id'); return null; } - if (!imdbIdRaw && stremioIdRaw) { - logger.warn(`[TraktAutosync] imdbId is empty, falling back to stremio id "${stremioIdRaw}" — Trakt will match by title`); - } - const numericYear = parseYear(options.year); const numericShowYear = parseYear(options.showYear); - // Log warning if year is missing (but don't fail - Trakt can sometimes work with IMDb ID alone) - if (numericYear === undefined) { - logger.warn('[TraktAutosync] Year is missing or invalid, proceeding without year'); - } - if (options.type === 'movie') { return { type: 'movie', imdbId: resolvedImdbId, title: options.title.trim(), - year: numericYear // Can be undefined now + year: numericYear }; } else { - // For episodes, also validate season and episode numbers if (options.season === undefined || options.season === null || options.season < 0) { logger.error('[TraktAutosync] Cannot build episode content data: invalid season'); return null; @@ -203,510 +162,219 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { }, [options]); const buildSimklContentData = useCallback((): SimklContentData => { - // Use the same fallback logic: prefer imdbId, fall back to stremio id const resolvedId = (options.imdbId && options.imdbId.trim()) ? options.imdbId.trim() : (options.id && options.id.trim()) ? options.id.trim() : ''; return { type: options.type === 'series' ? 'episode' : 'movie', title: options.title, - ids: { - imdb: resolvedId - }, + ids: { imdb: resolvedId }, season: options.season, episode: options.episode }; }, [options]); - // Start watching (scrobble start) - const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { - if (isUnmounted.current) return; // Prevent execution after component unmount + // ── /scrobble/start — play, unpause, seek ────────────────────────── - logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`); + const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { + console.log(`[TraktAutosync] START | time=${currentTime} dur=${duration} unmounted=${isUnmounted.current} complete=${isSessionComplete.current} traktAuth=${isAuthenticated} enabled=${autosyncSettings.enabled} simklAuth=${isSimklAuthenticated}`); + if (isUnmounted.current) return; const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; const shouldSyncSimkl = isSimklAuthenticated; - if (!shouldSyncTrakt && !shouldSyncSimkl) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`); - return; - } + if (!shouldSyncTrakt && !shouldSyncSimkl) return; - // PREVENT SESSION RESTART: Don't start if session is complete (scrobbled) + // After scrobble (>= 80%), send NO more payloads if (isSessionComplete.current) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`); + logger.log(`[TraktAutosync] Session complete — skipping /scrobble/start`); return; } - // PREVENT SESSION RESTART: Don't start if we've already stopped this session - if (hasStopped.current) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session already stopped, preventing restart`); - return; - } - - if (hasStartedWatching.current) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: already started=${hasStartedWatching.current}`); - return; - } - - if (duration <= 0) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`); - return; - } + if (duration <= 0) return; try { - // Clamp progress between 0 and 100 const rawProgress = (currentTime / duration) * 100; const progressPercent = Math.min(100, Math.max(0, rawProgress)); - const contentData = buildContentData(); - // Skip if content data is invalid - if (!contentData) { - logger.warn('[TraktAutosync] Skipping start: invalid content data'); + // If we're already past 80%, don't send start — it's already scrobbled or will be + if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) { + logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% >= ${TRAKT_SCROBBLE_THRESHOLD}%, skipping start`); return; } + const contentData = buildContentData(); + if (!contentData) return; + if (shouldSyncTrakt) { const success = await startWatching(contentData, progressPercent); if (success) { - hasStartedWatching.current = true; - hasStopped.current = false; // Reset stop flag when starting - logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); + lastSyncProgress.current = progressPercent; + logger.log(`[TraktAutosync] /scrobble/start sent: ${contentData.title} at ${progressPercent.toFixed(1)}%`); } - } else { - // If Trakt is disabled but Simkl is enabled, we still mark stated/stopped flags for local logic - hasStartedWatching.current = true; - hasStopped.current = false; } - // Simkl Start if (shouldSyncSimkl) { const simklData = buildSimklContentData(); await startSimkl(simklData, progressPercent); } } catch (error) { - logger.error('[TraktAutosync] Error starting watch:', error); + logger.error('[TraktAutosync] Error in handlePlaybackStart:', error); } }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]); - // Sync progress during playback - const handleProgressUpdate = useCallback(async ( - currentTime: number, - duration: number, - force: boolean = false - ) => { - if (isUnmounted.current) return; // Prevent execution after component unmount + // ── /scrobble/stop — pause, close, unmount, video end ────────────── - const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; - const shouldSyncSimkl = isSimklAuthenticated; - - if ((!shouldSyncTrakt && !shouldSyncSimkl) || duration <= 0) { - return; - } - - // Skip if session is already complete - if (isSessionComplete.current) { - return; - } - - try { - const rawProgress = (currentTime / duration) * 100; - const progressPercent = Math.min(100, Math.max(0, rawProgress)); - const now = Date.now(); - - // IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true) - // Use regular queued method for background periodic syncs - let traktSuccess: boolean = false; - - if (shouldSyncTrakt) { - if (force) { - // IMMEDIATE: User action (pause/unpause) - bypass queue - const contentData = buildContentData(); - if (!contentData) { - logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data'); - return; - } - traktSuccess = await updateProgressImmediate(contentData, progressPercent); - - if (traktSuccess) { - lastSyncTime.current = now; - lastSyncProgress.current = progressPercent; - - // If this update crossed the completion threshold, Trakt will have silently - // scrobbled it. Mark complete now so unmount/background don't fire a second - // /scrobble/stop above threshold and create a duplicate history entry. - if (progressPercent >= autosyncSettings.completionThreshold) { - isSessionComplete.current = true; - const ck = getContentKey(options); - const existing = recentlyStoppedSessions.get(ck); - if (existing) { - recentlyStoppedSessions.set(ck, { ...existing, isComplete: true, progress: progressPercent }); - } - logger.log(`[TraktAutosync] Threshold reached via immediate progress update (${progressPercent.toFixed(1)}%), marking session complete`); - } - - // Update local storage sync status - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); - - logger.log(`[TraktAutosync] Trakt IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`); - } - } else { - // BACKGROUND: Periodic sync - use queued method - const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); - - // Only skip if not forced and progress difference is minimal (< 0.5%) - if (progressDiff < 0.5) { - logger.log(`[TraktAutosync] Trakt: Skipping periodic progress update, progress diff too small (${progressDiff.toFixed(2)}%)`); - // If only Trakt is active and we skip, we should return here. - // If Simkl is also active, we continue to let Simkl update. - if (!shouldSyncSimkl) return; - } - - const contentData = buildContentData(); - if (!contentData) { - logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data'); - return; - } - traktSuccess = await updateProgress(contentData, progressPercent, force); - - if (traktSuccess) { - lastSyncTime.current = now; - lastSyncProgress.current = progressPercent; - - // If this periodic update crossed the completion threshold, Trakt will have - // silently scrobbled it. Mark complete now so unmount/background don't fire - // a second /scrobble/stop above threshold and create a duplicate history entry. - if (progressPercent >= autosyncSettings.completionThreshold) { - isSessionComplete.current = true; - const ck = getContentKey(options); - const existing = recentlyStoppedSessions.get(ck); - if (existing) { - recentlyStoppedSessions.set(ck, { ...existing, isComplete: true, progress: progressPercent }); - } - logger.log(`[TraktAutosync] Threshold reached via progress update (${progressPercent.toFixed(1)}%), marking session complete to prevent duplicate scrobble`); - } - - // Update local storage sync status - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); - - logger.log(`[TraktAutosync] Trakt: Progress updated to ${progressPercent.toFixed(1)}%`); - } - } - } - - // Simkl Update (No immediate/queued differentiation for now in Simkl hook, just call update) - if (shouldSyncSimkl) { - // Debounce simkl updates slightly if needed, but hook handles calls. - // We do basic difference check here - const simklData = buildSimklContentData(); - await updateSimkl(simklData, progressPercent); - - // Update local storage for Simkl - await storageService.updateSimklSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId - ); - logger.log(`[TraktAutosync] Simkl: Progress updated to ${progressPercent.toFixed(1)}%`); - } - - } catch (error) { - logger.error('[TraktAutosync] Error syncing progress:', error); - } - }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, updateProgress, updateSimkl, updateProgressImmediate, buildContentData, buildSimklContentData, options]); - - // Handle playback end/pause const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => { - if (isUnmounted.current) return; // Prevent execution after component unmount + console.log(`[TraktAutosync] STOP | time=${currentTime} dur=${duration} reason=${reason} unmounted=${isUnmounted.current} complete=${isSessionComplete.current} traktAuth=${isAuthenticated} enabled=${autosyncSettings.enabled}`); + if (isUnmounted.current && reason !== 'unmount') return; const now = Date.now(); - - // Removed excessive logging for handlePlaybackEnd calls - const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; const shouldSyncSimkl = isSimklAuthenticated; - if (!shouldSyncTrakt && !shouldSyncSimkl) { - logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`); - return; - } + if (!shouldSyncTrakt && !shouldSyncSimkl) return; - // ENHANCED DEDUPLICATION: Check if session is already complete + // After scrobble (>= 80%), send NO more payloads — prevents duplicate entries if (isSessionComplete.current) { - logger.log(`[TraktAutosync] Session already complete, skipping end call (reason: ${reason})`); + logger.log(`[TraktAutosync] Session complete — skipping /scrobble/stop (reason: ${reason})`); return; } - // ENHANCED DEDUPLICATION: Check if we've already stopped this session - // However, allow updates if the new progress is significantly higher (>5% improvement) - let isSignificantUpdate = false; - if (hasStopped.current) { - const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; - const progressImprovement = currentProgressPercent - lastSyncProgress.current; - - if (progressImprovement > 5) { - logger.log(`[TraktAutosync] Session already stopped, but progress improved significantly by ${progressImprovement.toFixed(1)}% (${lastSyncProgress.current.toFixed(1)}% → ${currentProgressPercent.toFixed(1)}%), allowing update`); - // Reset stopped flag to allow this significant update - hasStopped.current = false; - isSignificantUpdate = true; - } else { - // Already stopped this session, skipping duplicate call - logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: session already stopped and no significant progress improvement.`); - return; - } - } - - // IMMEDIATE SYNC: Use immediate method for user-initiated actions (user_close) - let useImmediate = reason === 'user_close'; - - // IMMEDIATE SYNC: Remove debouncing for instant sync when closing - // Only prevent truly duplicate calls (within 500ms for regular, 100ms for immediate) - const debounceThreshold = useImmediate ? 100 : 500; - if (!isSignificantUpdate && now - lastStopCall.current < debounceThreshold) { - logger.log(`[TraktAutosync] Ignoring duplicate stop call within ${debounceThreshold}ms (reason: ${reason})`); + // Debounce: prevent duplicate stop calls within 500ms + if (now - lastStopCall.current < 500) { + logger.log(`[TraktAutosync] Ignoring duplicate stop call within 500ms (reason: ${reason})`); return; } - // Skip rapid unmount calls (likely from React strict mode or component remounts) - if (reason === 'unmount' && unmountCount.current > 1) { - logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`); - return; - } + // Skip duplicate unmount calls (React strict mode) + if (reason === 'unmount' && unmountCount.current > 1) return; try { let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; - // Clamp progress between 0 and 100 progressPercent = Math.min(100, Math.max(0, progressPercent)); - // Initial progress calculation logging removed - // For unmount calls, always use the highest available progress - // Check current progress, last synced progress, and local storage progress + // For unmount, use highest known progress if (reason === 'unmount') { - let maxProgress = progressPercent; - - // Check last synced progress - if (lastSyncProgress.current > maxProgress) { - maxProgress = lastSyncProgress.current; + if (lastSyncProgress.current > progressPercent) { + progressPercent = lastSyncProgress.current; } - - // Also check local storage for the highest recorded progress try { - const savedProgress = await storageService.getWatchProgress( - options.id, - options.type, - options.episodeId - ); - + const savedProgress = await storageService.getWatchProgress(options.id, options.type, options.episodeId); if (savedProgress && savedProgress.duration > 0) { - const savedProgressPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100)); - if (savedProgressPercent > maxProgress) { - maxProgress = savedProgressPercent; - } + const savedPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100)); + if (savedPercent > progressPercent) progressPercent = savedPercent; } - } catch (error) { - logger.error('[TraktAutosync] Error checking saved progress:', error); - } - - if (maxProgress !== progressPercent) { - // Highest progress logging removed - progressPercent = maxProgress; - } else { - // Current progress logging removed - } + } catch {} } - // If we have valid progress but no started session, force start one first - if (!hasStartedWatching.current && progressPercent > 1) { - const contentData = buildContentData(); - if (contentData) { - let started = false; - // Try starting Trakt if enabled - if (shouldSyncTrakt) { - const s = await startWatching(contentData, progressPercent); - if (s) started = true; - } - // Try starting Simkl if enabled (always 'true' effectively if authenticated) - if (shouldSyncSimkl) { - const simklData = buildSimklContentData(); - await startSimkl(simklData, progressPercent); - started = true; - } - - if (started) { - hasStartedWatching.current = true; - } - } - } - - // Only stop if we have meaningful progress (>= 0.5%) or it's a natural video end - // Lower threshold for unmount calls to catch more edge cases - if (reason === 'unmount' && progressPercent < 0.5) { - // Early unmount stop logging removed - logger.log(`[TraktAutosync] Skipping unmount stop call due to minimal progress (${progressPercent.toFixed(1)}%)`); + // Trakt ignores progress < 1% (returns 422) + if (progressPercent < 1) { + logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% < 1%, skipping stop`); return; } - // Note: No longer boosting progress since Trakt API handles 80% threshold correctly - - // Mark stop attempt and update timestamp lastStopCall.current = now; - hasStopped.current = true; - - // Persist to module-level map so a remount for the same content within the - // resume window won't fire a duplicate scrobble/start. - const contentKey = getContentKey(options); - recentlyStoppedSessions.set(contentKey, { - stoppedAt: now, - progress: progressPercent, - isComplete: false // updated below if scrobble succeeds at threshold - }); + lastSyncProgress.current = progressPercent; const contentData = buildContentData(); + if (!contentData) return; - // Skip if content data is invalid - if (!contentData) { - logger.warn('[TraktAutosync] Skipping stop: invalid content data'); - hasStopped.current = false; // Allow retry with valid data - return; - } - - let overallSuccess = false; - - // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends - let traktStopSuccess = false; + // Send /scrobble/stop to Trakt + // Trakt API: >= 80% → scrobble (marks watched), 1-79% → pause (saves progress) + let traktSuccess = false; if (shouldSyncTrakt) { - traktStopSuccess = useImmediate + const useImmediate = reason === 'user_close'; + traktSuccess = useImmediate ? await stopWatchingImmediate(contentData, progressPercent) : await stopWatching(contentData, progressPercent); - if (traktStopSuccess) { - logger.log(`[TraktAutosync] Trakt: ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); - overallSuccess = true; - } else { - logger.warn(`[TraktAutosync] Trakt: Failed to stop watching.`); - } - } - if (traktStopSuccess) { - // Update local storage sync status for Trakt - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); - } else if (shouldSyncTrakt) { - // If Trakt stop failed, reset the stop flag so we can try again later - hasStopped.current = false; - recentlyStoppedSessions.delete(getContentKey(options)); - logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`); + if (traktSuccess) { + logger.log(`[TraktAutosync] /scrobble/stop sent: ${contentData.title} at ${progressPercent.toFixed(1)}% (${reason})`); + + await storageService.updateTraktSyncStatus( + options.id, options.type, true, progressPercent, options.episodeId, currentTime + ); + + // If >= 80%, Trakt has scrobbled it — mark session complete, no more payloads + if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) { + isSessionComplete.current = true; + recentlyScrobbledSessions.set(getContentKey(options), { + scrobbledAt: now, + progress: progressPercent + }); + logger.log(`[TraktAutosync] Scrobbled at ${progressPercent.toFixed(1)}% — session complete, no more payloads`); + + // Update local storage to reflect watched status + try { + if (duration > 0) { + await storageService.setWatchProgress( + options.id, options.type, + { + currentTime: duration, + duration, + lastUpdated: Date.now(), + traktSynced: true, + traktProgress: Math.max(progressPercent, 100), + simklSynced: shouldSyncSimkl ? true : undefined, + simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined, + } as any, + options.episodeId, + { forceNotify: true } + ); + } + } catch {} + } + } else { + logger.warn(`[TraktAutosync] Failed to send /scrobble/stop`); + } } // Simkl Stop if (shouldSyncSimkl) { const simklData = buildSimklContentData(); await stopSimkl(simklData, progressPercent); - - // Update local storage sync status for Simkl await storageService.updateSimklSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId + options.id, options.type, true, progressPercent, options.episodeId ); - logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`); - overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet) + logger.log(`[TraktAutosync] Simkl stop sent: ${simklData.title} at ${progressPercent.toFixed(1)}%`); } - - if (overallSuccess) { - // Mark session as complete if >= user completion threshold - if (progressPercent >= autosyncSettings.completionThreshold) { - isSessionComplete.current = true; - // Update module-level map to reflect completion so a remount won't restart - const ck = getContentKey(options); - const existing = recentlyStoppedSessions.get(ck); - if (existing) { - recentlyStoppedSessions.set(ck, { ...existing, isComplete: true, progress: progressPercent }); - } - logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); - - // Ensure local watch progress reflects completion so UI shows as watched - try { - if (duration > 0) { - await storageService.setWatchProgress( - options.id, - options.type, - { - currentTime: duration, - duration, - lastUpdated: Date.now(), - traktSynced: shouldSyncTrakt ? true : undefined, - traktProgress: shouldSyncTrakt ? Math.max(progressPercent, 100) : undefined, - simklSynced: shouldSyncSimkl ? true : undefined, - simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined, - } as any, - options.episodeId, - { forceNotify: true } - ); - } - } catch { } - } - - // General success log if at least one service succeeded - if (!shouldSyncTrakt || traktStopSuccess) { // Only log this if Trakt succeeded or wasn't active - logger.log(`[TraktAutosync] Overall: Successfully processed stop for: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); - } - } else { - // If neither service succeeded, reset the stop flag - hasStopped.current = false; - logger.warn(`[TraktAutosync] Overall: Failed to stop watching, reset stop flag for retry`); - } - - // Reset state only for natural end or very high progress unmounts - if (reason === 'ended' || progressPercent >= 80) { - hasStartedWatching.current = false; - lastSyncTime.current = 0; - lastSyncProgress.current = 0; - logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`); - } - } catch (error) { - logger.error('[TraktAutosync] Error ending watch:', error); - // Reset stop flag on error so we can try again - hasStopped.current = false; + logger.error('[TraktAutosync] Error in handlePlaybackEnd:', error); } - }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, startWatching, buildContentData, buildSimklContentData, options]); + }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, buildContentData, buildSimklContentData, options]); + + // handleProgressUpdate — kept for Simkl compatibility only. + // Trakt does NOT need periodic progress updates; only start/stop events. + const handleProgressUpdate = useCallback(async ( + currentTime: number, + duration: number, + _force: boolean = false + ) => { + if (isUnmounted.current || duration <= 0) return; + if (isSessionComplete.current) return; + + // Only update Simkl if authenticated — Trakt needs no periodic updates + if (isSimklAuthenticated) { + try { + const rawProgress = (currentTime / duration) * 100; + const progressPercent = Math.min(100, Math.max(0, rawProgress)); + const simklData = buildSimklContentData(); + await updateSimkl(simklData, progressPercent); + } catch (error) { + logger.error('[TraktAutosync] Error updating Simkl progress:', error); + } + } + }, [isSimklAuthenticated, updateSimkl, buildSimklContentData]); - // Reset state (useful when switching content) const resetState = useCallback(() => { - hasStartedWatching.current = false; - hasStopped.current = false; isSessionComplete.current = false; isUnmounted.current = false; - lastSyncTime.current = 0; lastSyncProgress.current = 0; unmountCount.current = 0; sessionKey.current = null; lastStopCall.current = 0; - recentlyStoppedSessions.delete(getContentKey(options)); + recentlyScrobbledSessions.delete(getContentKey(options)); logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); }, [options.title]); diff --git a/src/hooks/useTraktAutosyncSettings.ts b/src/hooks/useTraktAutosyncSettings.ts index 9b898eef..fcef23ed 100644 --- a/src/hooks/useTraktAutosyncSettings.ts +++ b/src/hooks/useTraktAutosyncSettings.ts @@ -16,7 +16,7 @@ export interface TraktAutosyncSettings { const DEFAULT_SETTINGS: TraktAutosyncSettings = { enabled: true, syncFrequency: 60000, // 60 seconds - completionThreshold: 95, // 95% + completionThreshold: 80, // 80% — Trakt API hardcoded threshold }; export function useTraktAutosyncSettings() { diff --git a/src/services/traktService.ts b/src/services/traktService.ts index be5c689a..f671d091 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -17,6 +17,11 @@ if (!TRAKT_CLIENT_ID || !TRAKT_CLIENT_SECRET) { logger.warn('[TraktService] Missing Trakt env vars. Trakt integration will be disabled.'); } +// Trakt API scrobble threshold — hardcoded per API spec. +// /scrobble/stop with progress >= 80% → scrobble (marks watched). +// /scrobble/stop with progress 1-79% → pause (saves playback progress). +const TRAKT_SCROBBLE_THRESHOLD = 80; + // Types export interface TraktUser { username: string; @@ -1871,21 +1876,24 @@ export class TraktService { */ public async startWatching(contentData: TraktContentData, progress: number): Promise { try { - // Validate content data before making API call const validation = this.validateContentData(contentData); if (!validation.isValid) { - logger.error('[TraktService] Invalid content data for start watching:', validation.errors); + console.log('[TraktService] /scrobble/start INVALID content:', validation.errors); return null; } const payload = await this.buildScrobblePayload(contentData, progress); if (!payload) { + console.log('[TraktService] /scrobble/start payload is null'); return null; } - return this.apiRequest('/scrobble/start', 'POST', payload); + console.log('[TraktService] /scrobble/start PAYLOAD:', JSON.stringify(payload)); + const response = await this.apiRequest('/scrobble/start', 'POST', payload); + console.log('[TraktService] /scrobble/start RESPONSE:', JSON.stringify(response)); + return response; } catch (error) { - logger.error('[TraktService] Failed to start watching:', error); + console.log('[TraktService] /scrobble/start ERROR:', error); return null; } } @@ -1927,21 +1935,24 @@ export class TraktService { */ public async stopWatching(contentData: TraktContentData, progress: number): Promise { try { - // Validate content data before making API call const validation = this.validateContentData(contentData); if (!validation.isValid) { - logger.error('[TraktService] Invalid content data for stop watching:', validation.errors); + console.log('[TraktService] /scrobble/stop INVALID content:', validation.errors); return null; } const payload = await this.buildScrobblePayload(contentData, progress); if (!payload) { + console.log('[TraktService] /scrobble/stop payload is null'); return null; } - return this.apiRequest('/scrobble/stop', 'POST', payload); + console.log('[TraktService] /scrobble/stop PAYLOAD:', JSON.stringify(payload)); + const response = await this.apiRequest('/scrobble/stop', 'POST', payload); + console.log('[TraktService] /scrobble/stop RESPONSE:', JSON.stringify(response)); + return response; } catch (error) { - logger.error('[TraktService] Failed to stop watching:', error); + console.log('[TraktService] /scrobble/stop ERROR:', error); return null; } } @@ -2239,31 +2250,28 @@ export class TraktService { public async scrobbleStart(contentData: TraktContentData, progress: number): Promise { try { if (!await this.isAuthenticated()) { + console.log('[TraktService] scrobbleStart: not authenticated'); return false; } const watchingKey = this.getWatchingKey(contentData); + console.log(`[TraktService] scrobbleStart: key=${watchingKey} recentlyScrobbled=${this.isRecentlyScrobbled(contentData)} scrobbled=${this.scrobbledItems.has(watchingKey)} currentlyWatching=${this.currentlyWatching.has(watchingKey)}`); - // Check if this content was recently scrobbled (to prevent duplicates from component remounts) if (this.isRecentlyScrobbled(contentData)) { - logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`); + console.log(`[TraktService] scrobbleStart BLOCKED: recently scrobbled`); return true; } - // ENHANCED PROTECTION: Check if we recently stopped this content with high progress - // This prevents restarting sessions for content that was just completed - const lastStopTime = this.lastStopCalls.get(watchingKey); - if (lastStopTime && (Date.now() - lastStopTime) < 30000) { // 30 seconds - logger.log(`[TraktService] Recently stopped this content (${((Date.now() - lastStopTime) / 1000).toFixed(1)}s ago), preventing restart: ${contentData.title}`); - return true; + if (this.scrobbledItems.has(watchingKey)) { + const scrobbledTime = this.scrobbledTimestamps.get(watchingKey); + if (scrobbledTime && (Date.now() - scrobbledTime) < 30000) { + console.log(`[TraktService] scrobbleStart BLOCKED: scrobbled ${((Date.now() - scrobbledTime) / 1000).toFixed(1)}s ago`); + return true; + } } - // Debug log removed to reduce terminal noise - - // Only start if not already watching this content if (this.currentlyWatching.has(watchingKey)) { - logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`); - return true; // Already started + this.currentlyWatching.delete(watchingKey); } const result = await this.queueRequest(async () => { @@ -2272,13 +2280,14 @@ export class TraktService { if (result) { this.currentlyWatching.add(watchingKey); - logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`); + console.log(`[TraktService] scrobbleStart SUCCESS: ${contentData.title}`); return true; } + console.log(`[TraktService] scrobbleStart FAILED: result was null`); return false; } catch (error) { - logger.error('[TraktService] Failed to start scrobbling:', error); + console.log('[TraktService] scrobbleStart ERROR:', error); return false; } } @@ -2327,7 +2336,11 @@ export class TraktService { } /** - * Stop watching content (use when playback ends or stops) + * Stop watching content (use when playback ends, pauses, or stops) + * Always sends /scrobble/stop — Trakt API automatically handles: + * - progress >= 80% → scrobble (marks as watched) + * - progress 1-79% → pause (saves playback progress) + * - progress < 1% → 422 ignored */ public async scrobbleStop(contentData: TraktContentData, progress: number): Promise { try { @@ -2338,52 +2351,48 @@ export class TraktService { const watchingKey = this.getWatchingKey(contentData); const now = Date.now(); - // IMMEDIATE SYNC: Reduce debouncing for instant sync, only prevent truly duplicate calls (< 1 second) + if (this.isRecentlyScrobbled(contentData)) { + logger.log(`[TraktService] Already scrobbled, skipping stop: ${contentData.title}`); + return true; + } + + // Prevent truly duplicate calls (< 1 second) const lastStopTime = this.lastStopCalls.get(watchingKey); if (lastStopTime && (now - lastStopTime) < 1000) { logger.log(`[TraktService] Ignoring duplicate stop call for ${contentData.title} (last stop ${((now - lastStopTime) / 1000).toFixed(1)}s ago)`); - return true; // Return success to avoid error handling + return true; } - // Record this stop attempt this.lastStopCalls.set(watchingKey, now); - // Use pause if below user threshold, stop only when ready to scrobble - const useStop = progress >= this.completionThreshold; + // Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress const result = await this.queueRequest(async () => { - return useStop - ? await this.stopWatching(contentData, progress) - : await this.pauseWatching(contentData, progress); + return await this.stopWatching(contentData, progress); }); if (result) { this.currentlyWatching.delete(watchingKey); - // Mark as scrobbled if >= user threshold to prevent future duplicates and restarts - if (progress >= this.completionThreshold) { + // Mark as scrobbled if >= 80% to prevent future duplicates + if (progress >= TRAKT_SCROBBLE_THRESHOLD) { this.scrobbledItems.add(watchingKey); this.scrobbledTimestamps.set(watchingKey, Date.now()); - logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`); + logger.log(`[TraktService] Scrobbled (>= 80%): ${watchingKey}`); } - // Action reflects actual endpoint used based on user threshold - const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; - logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); - + const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused'; + logger.log(`[TraktService] /scrobble/stop sent: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); return true; } else { - // If failed, remove from lastStopCalls so we can try again this.lastStopCalls.delete(watchingKey); } return false; } catch (error) { - // Handle rate limiting errors more gracefully if (error instanceof Error && error.message.includes('429')) { logger.warn('[TraktService] Rate limited, will retry later'); return true; } - logger.error('[TraktService] Failed to stop scrobbling:', error); return false; } @@ -2429,6 +2438,7 @@ export class TraktService { /** * Immediate scrobble stop - bypasses queue for instant user feedback + * Always sends /scrobble/stop — Trakt handles pause vs scrobble based on progress. */ public async scrobbleStopImmediate(contentData: TraktContentData, progress: number): Promise { try { @@ -2438,7 +2448,12 @@ export class TraktService { const watchingKey = this.getWatchingKey(contentData); - // MINIMAL DEDUPLICATION: Only prevent calls within 200ms for immediate actions + if (this.isRecentlyScrobbled(contentData)) { + logger.log(`[TraktService] Already scrobbled, skipping immediate stop: ${contentData.title}`); + return true; + } + + // Prevent calls within 200ms for immediate actions const lastStopTime = this.lastStopCalls.get(watchingKey); if (lastStopTime && (Date.now() - lastStopTime) < 200) { return true; @@ -2446,24 +2461,19 @@ export class TraktService { this.lastStopCalls.set(watchingKey, Date.now()); - // BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble - const useStop = progress >= this.completionThreshold; - const result = useStop - ? await this.stopWatching(contentData, progress) - : await this.pauseWatching(contentData, progress); + // Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress + const result = await this.stopWatching(contentData, progress); if (result) { this.currentlyWatching.delete(watchingKey); - // Mark as scrobbled if >= user threshold to prevent future duplicates and restarts - if (progress >= this.completionThreshold) { + if (progress >= TRAKT_SCROBBLE_THRESHOLD) { this.scrobbledItems.add(watchingKey); this.scrobbledTimestamps.set(watchingKey, Date.now()); } - // Action reflects actual endpoint used based on user threshold - const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; - logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); + const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused'; + logger.log(`[TraktService] IMMEDIATE /scrobble/stop: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); return true; } 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 15/15] 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[]) {