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