import { useCallback, useRef, useEffect } from 'react'; import { useTraktIntegration } from './useTraktIntegration'; import { useTraktAutosyncSettings } from './useTraktAutosyncSettings'; import { TraktContentData } from '../services/traktService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; interface TraktAutosyncOptions { id: string; type: 'movie' | 'series'; title: string; year: number | string; // Allow both for compatibility imdbId: string; // For episodes season?: number; episode?: number; showTitle?: string; showYear?: number | string; // Allow both for compatibility showImdbId?: string; episodeId?: string; } export function useTraktAutosync(options: TraktAutosyncOptions) { const { isAuthenticated, startWatching, updateProgress, updateProgressImmediate, stopWatching, stopWatchingImmediate } = useTraktIntegration(); 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); 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(() => { const contentKey = options.type === 'movie' ? `movie:${options.imdbId}` : `episode:${options.showImdbId || 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; isUnmounted.current = false; // Reset unmount flag for new mount lastStopCall.current = 0; logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); return () => { unmountCount.current++; isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`); }; }, [options.imdbId, options.season, options.episode, options.type]); // Build Trakt content data from options // Returns null if required fields are missing or invalid 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; } 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 const currentYear = new Date().getFullYear(); if (parsed < 1800 || parsed > currentYear + 10) { logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`); 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'); return null; } if (!options.imdbId || options.imdbId.trim() === '') { logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId'); return null; } 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: options.imdbId.trim(), title: options.title.trim(), year: numericYear // Can be undefined now }; } 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; } if (options.episode === undefined || options.episode === null || options.episode < 0) { logger.error('[TraktAutosync] Cannot build episode content data: invalid episode'); return null; } return { type: 'episode', imdbId: options.imdbId.trim(), title: options.title.trim(), year: numericYear, season: options.season, episode: options.episode, showTitle: (options.showTitle || options.title).trim(), showYear: numericShowYear || numericYear, showImdbId: (options.showImdbId || options.imdbId).trim() }; } }, [options]); // Start watching (scrobble start) const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { if (isUnmounted.current) return; // Prevent execution after component unmount 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) { 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; } if (duration <= 0) { logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`); 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'); return; } 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) { logger.error('[TraktAutosync] Error starting watch:', error); } }, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]); // Sync progress during playback const handleProgressUpdate = useCallback(async ( currentTime: number, duration: number, force: boolean = false ) => { if (isUnmounted.current) return; // Prevent execution after component unmount if (!isAuthenticated || !autosyncSettings.enabled || 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 success: boolean; if (force) { // IMMEDIATE: User action (pause/unpause) - bypass queue const contentData = buildContentData(); if (!contentData) { logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); return; } success = await updateProgressImmediate(contentData, progressPercent); if (success) { lastSyncTime.current = now; lastSyncProgress.current = progressPercent; // Update local storage sync status await storageService.updateTraktSyncStatus( options.id, options.type, true, progressPercent, options.episodeId, currentTime ); logger.log(`[TraktAutosync] 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) { return; } const contentData = buildContentData(); if (!contentData) { logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); return; } success = await updateProgress(contentData, progressPercent, force); if (success) { lastSyncTime.current = now; lastSyncProgress.current = progressPercent; // Update local storage sync status await storageService.updateTraktSyncStatus( options.id, options.type, true, progressPercent, options.episodeId, currentTime ); // Progress sync logging removed } } } catch (error) { logger.error('[TraktAutosync] Error syncing progress:', error); } }, [isAuthenticated, autosyncSettings.enabled, updateProgress, updateProgressImmediate, buildContentData, 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 const now = Date.now(); // Removed excessive logging for handlePlaybackEnd calls 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 // 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 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})`); 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; } 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 if (reason === 'unmount') { let maxProgress = progressPercent; // Check last synced progress if (lastSyncProgress.current > maxProgress) { maxProgress = lastSyncProgress.current; } // Also check local storage for the highest recorded progress try { 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; } } } catch (error) { logger.error('[TraktAutosync] Error checking saved progress:', error); } if (maxProgress !== progressPercent) { // Highest progress logging removed progressPercent = maxProgress; } else { // Current progress logging removed } } // If we have valid progress but no started session, force start one first if (!hasStartedWatching.current && progressPercent > 1) { const contentData = buildContentData(); if (contentData) { const success = await startWatching(contentData, progressPercent); if (success) { 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 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; const contentData = buildContentData(); // 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; } // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends const success = useImmediate ? await stopWatchingImmediate(contentData, progressPercent) : await stopWatching(contentData, progressPercent); if (success) { // Update local storage sync status await storageService.updateTraktSyncStatus( options.id, options.type, true, progressPercent, options.episodeId, currentTime ); // Mark session as complete if >= user completion threshold if (progressPercent >= autosyncSettings.completionThreshold) { isSessionComplete.current = true; 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: true, traktProgress: Math.max(progressPercent, 100), } as any, options.episodeId, { forceNotify: true } ); } } catch { } } logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}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 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; } }, [isAuthenticated, autosyncSettings.enabled, stopWatching, stopWatchingImmediate, startWatching, buildContentData, options]); // 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; logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); }, [options.title]); return { isAuthenticated, handlePlaybackStart, handleProgressUpdate, handlePlaybackEnd, resetState }; }