diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index a3893596..e316bc5a 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -31,10 +31,13 @@ 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 lastSyncTime = useRef(0); const lastSyncProgress = useRef(0); const sessionKey = useRef(null); const unmountCount = useRef(0); + const lastStopCall = useRef(0); // New: Track last stop call timestamp // Generate a unique session key for this content instance useEffect(() => { @@ -43,6 +46,12 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { : `episode:${options.imdbId}:${options.season}:${options.episode}`; sessionKey.current = `${contentKey}:${Date.now()}`; + // Reset all session state for new content + hasStartedWatching.current = false; + hasStopped.current = false; + isSessionComplete.current = false; + lastStopCall.current = 0; + logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); return () => { @@ -93,10 +102,27 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Start watching (scrobble start) const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { - logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, session=${sessionKey.current}`); + 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}`); - if (!isAuthenticated || !autosyncSettings.enabled || hasStartedWatching.current) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}`); + if (!isAuthenticated || !autosyncSettings.enabled) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); + return; + } + + // PREVENT SESSION RESTART: Don't start if session is complete (scrobbled) + if (isSessionComplete.current) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`); + 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; } @@ -112,6 +138,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { 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})`); } } catch (error) { @@ -129,6 +156,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } + // Skip if session is already complete + if (isSessionComplete.current) { + return; + } + try { const progressPercent = (currentTime / duration) * 100; const now = Date.now(); @@ -166,13 +198,33 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Handle playback end/pause const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => { - logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`); + const now = Date.now(); + + logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, stopped=${hasStopped.current}, complete=${isSessionComplete.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`); if (!isAuthenticated || !autosyncSettings.enabled) { logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); return; } + // ENHANCED DEDUPLICATION: Check if session is already complete + if (isSessionComplete.current) { + logger.log(`[TraktAutosync] Session already complete, skipping end call (reason: ${reason})`); + return; + } + + // ENHANCED DEDUPLICATION: Check if we've already stopped this session + if (hasStopped.current) { + logger.log(`[TraktAutosync] Already stopped this session, skipping duplicate call (reason: ${reason})`); + return; + } + + // ENHANCED DEDUPLICATION: Prevent rapid successive calls (within 5 seconds) + if (now - lastStopCall.current < 5000) { + logger.log(`[TraktAutosync] Ignoring rapid successive stop call within 5 seconds (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}`); @@ -208,6 +260,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } + // Mark stop attempt and update timestamp + lastStopCall.current = now; + hasStopped.current = true; + const contentData = buildContentData(); // Use stopWatching for proper scrobble stop @@ -222,6 +278,18 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { progressPercent, options.episodeId ); + + // Mark session as complete if high progress (scrobbled) + if (progressPercent >= 80) { + isSessionComplete.current = true; + logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); + } + + logger.log(`[TraktAutosync] Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + } else { + // If stop failed, reset the stop flag so we can try again later + hasStopped.current = false; + logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`); } // Reset state only for natural end or very high progress unmounts @@ -232,19 +300,23 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`); } - logger.log(`[TraktAutosync] Ended watching: ${options.title} (${reason})`); } catch (error) { logger.error('[TraktAutosync] Error ending watch:', error); + // Reset stop flag on error so we can try again + hasStopped.current = false; } }, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]); // Reset state (useful when switching content) const resetState = useCallback(() => { hasStartedWatching.current = false; + hasStopped.current = false; + isSessionComplete.current = false; lastSyncTime.current = 0; lastSyncProgress.current = 0; unmountCount.current = 0; sessionKey.current = null; + lastStopCall.current = 0; logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); }, [options.title]); diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 27c414b5..d19ba3c0 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; @@ -383,22 +384,28 @@ export function useTraktIntegration() { } }, [isAuthenticated, fetchAndMergeTraktProgress]); - // Periodic sync - check for updates every 2 minutes when authenticated + // App focus sync - sync when app comes back into focus (much smarter than periodic) useEffect(() => { if (!isAuthenticated) return; - const intervalId = setInterval(() => { - logger.log('[useTraktIntegration] Periodic Trakt sync check'); - fetchAndMergeTraktProgress().then((success) => { - if (success) { - logger.log('[useTraktIntegration] Periodic sync completed successfully'); - } - }).catch(error => { - logger.error('[useTraktIntegration] Periodic sync failed:', error); - }); - }, 2 * 60 * 1000); // 2 minutes + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState === 'active') { + logger.log('[useTraktIntegration] App became active, syncing Trakt data'); + fetchAndMergeTraktProgress().then((success) => { + if (success) { + logger.log('[useTraktIntegration] App focus sync completed successfully'); + } + }).catch(error => { + logger.error('[useTraktIntegration] App focus sync failed:', error); + }); + } + }; - return () => clearInterval(intervalId); + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + subscription?.remove(); + }; }, [isAuthenticated, fetchAndMergeTraktProgress]); // Trigger sync when auth status is manually refreshed (for login scenarios) diff --git a/src/services/traktService.ts b/src/services/traktService.ts index ffccb9a8..5b3b965c 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -163,8 +163,40 @@ export class TraktService { private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window) private scrobbledTimestamps: Map = new Map(); + // Track currently watching sessions to avoid duplicate starts + private currentlyWatching: Set = new Set(); + private lastSyncTime: number = 0; + private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds + + // Enhanced deduplication for stop calls + private lastStopCalls: Map = new Map(); + private readonly STOP_DEBOUNCE_MS = 10000; // 10 seconds debounce for stop calls + private constructor() { // Initialization happens in initialize method + + // Cleanup old stop call records every 5 minutes + setInterval(() => { + this.cleanupOldStopCalls(); + }, 5 * 60 * 1000); + } + + /** + * Cleanup old stop call records to prevent memory leaks + */ + private cleanupOldStopCalls(): void { + const now = Date.now(); + const cutoff = now - (this.STOP_DEBOUNCE_MS * 2); // Keep records for 2x the debounce time + + for (const [key, timestamp] of this.lastStopCalls.entries()) { + if (timestamp < cutoff) { + this.lastStopCalls.delete(key); + } + } + + if (this.lastStopCalls.size > 0) { + logger.log(`[TraktService] Cleaned up old stop call records. Remaining: ${this.lastStopCalls.size}`); + } } public static getInstance(): TraktService { @@ -876,13 +908,6 @@ export class TraktService { }); } - /** - * Track currently watching sessions to avoid duplicate starts - */ - private currentlyWatching: Set = new Set(); - private lastSyncTime: number = 0; - private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds - /** * Generate a unique key for content being watched */ @@ -903,12 +928,22 @@ export class TraktService { return false; } + const watchingKey = this.getWatchingKey(contentData); + // 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}`); 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; + } + // Debug log the content data being sent logger.log(`[TraktService] DEBUG scrobbleStart payload:`, { type: contentData.type, @@ -920,11 +955,10 @@ export class TraktService { showTitle: contentData.showTitle, progress: progress }); - - const watchingKey = this.getWatchingKey(contentData); // 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 } @@ -995,6 +1029,17 @@ export class TraktService { } const watchingKey = this.getWatchingKey(contentData); + const now = Date.now(); + + // Enhanced deduplication: Check if we recently stopped this content + const lastStopTime = this.lastStopCalls.get(watchingKey); + if (lastStopTime && (now - lastStopTime) < this.STOP_DEBOUNCE_MS) { + 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 + } + + // Record this stop attempt + this.lastStopCalls.set(watchingKey, now); const result = await this.queueRequest(async () => { return await this.stopWatching(contentData, progress); @@ -1003,10 +1048,11 @@ export class TraktService { if (result) { this.currentlyWatching.delete(watchingKey); - // Mark as scrobbled if >= 80% to prevent future duplicates + // Mark as scrobbled if >= 80% to prevent future duplicates and restarts if (progress >= 80) { this.scrobbledItems.add(watchingKey); this.scrobbledTimestamps.set(watchingKey, Date.now()); + logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`); } // The stop endpoint automatically handles the 80%+ completion logic @@ -1015,6 +1061,9 @@ export class TraktService { logger.log(`[TraktService] Stopped watching ${contentData.type}: ${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;