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); }; 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]; 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]); 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; }