Enhance Trakt autosync functionality with improved session management and deduplication

This update introduces several enhancements to the Trakt autosync logic, including new state tracking for session completion and stop calls. The useTraktAutosync hook now prevents duplicate session starts and rapid successive stop calls, improving the reliability of playback tracking. Additionally, the TraktService has been updated to manage stop call deduplication more effectively, ensuring accurate scrobbling and session handling. These changes enhance the overall user experience by maintaining consistent watch history and reducing unnecessary API calls.
This commit is contained in:
tapframe 2025-06-19 23:45:10 +05:30
parent 671861c207
commit 6d8666d905
3 changed files with 155 additions and 27 deletions

View file

@ -31,10 +31,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const { settings: autosyncSettings } = useTraktAutosyncSettings(); const { settings: autosyncSettings } = useTraktAutosyncSettings();
const hasStartedWatching = useRef(false); 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 lastSyncTime = useRef(0);
const lastSyncProgress = useRef(0); const lastSyncProgress = useRef(0);
const sessionKey = useRef<string | null>(null); const sessionKey = useRef<string | null>(null);
const unmountCount = useRef(0); const unmountCount = useRef(0);
const lastStopCall = useRef(0); // New: Track last stop call timestamp
// Generate a unique session key for this content instance // Generate a unique session key for this content instance
useEffect(() => { useEffect(() => {
@ -43,6 +46,12 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
: `episode:${options.imdbId}:${options.season}:${options.episode}`; : `episode:${options.imdbId}:${options.season}:${options.episode}`;
sessionKey.current = `${contentKey}:${Date.now()}`; 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}`); logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
return () => { return () => {
@ -93,10 +102,27 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Start watching (scrobble start) // Start watching (scrobble start)
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { 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) { if (!isAuthenticated || !autosyncSettings.enabled) {
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}`); 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; return;
} }
@ -112,6 +138,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const success = await startWatching(contentData, progressPercent); const success = await startWatching(contentData, progressPercent);
if (success) { if (success) {
hasStartedWatching.current = true; hasStartedWatching.current = true;
hasStopped.current = false; // Reset stop flag when starting
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
} }
} catch (error) { } catch (error) {
@ -129,6 +156,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
return; return;
} }
// Skip if session is already complete
if (isSessionComplete.current) {
return;
}
try { try {
const progressPercent = (currentTime / duration) * 100; const progressPercent = (currentTime / duration) * 100;
const now = Date.now(); const now = Date.now();
@ -166,13 +198,33 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Handle playback end/pause // Handle playback end/pause
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => { 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) { if (!isAuthenticated || !autosyncSettings.enabled) {
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
return; 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) // Skip rapid unmount calls (likely from React strict mode or component remounts)
if (reason === 'unmount' && unmountCount.current > 1) { if (reason === 'unmount' && unmountCount.current > 1) {
logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`); logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
@ -208,6 +260,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
return; return;
} }
// Mark stop attempt and update timestamp
lastStopCall.current = now;
hasStopped.current = true;
const contentData = buildContentData(); const contentData = buildContentData();
// Use stopWatching for proper scrobble stop // Use stopWatching for proper scrobble stop
@ -222,6 +278,18 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
progressPercent, progressPercent,
options.episodeId 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 // 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] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`);
} }
logger.log(`[TraktAutosync] Ended watching: ${options.title} (${reason})`);
} catch (error) { } catch (error) {
logger.error('[TraktAutosync] Error ending watch:', 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]); }, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]);
// Reset state (useful when switching content) // Reset state (useful when switching content)
const resetState = useCallback(() => { const resetState = useCallback(() => {
hasStartedWatching.current = false; hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false;
lastSyncTime.current = 0; lastSyncTime.current = 0;
lastSyncProgress.current = 0; lastSyncProgress.current = 0;
unmountCount.current = 0; unmountCount.current = 0;
sessionKey.current = null; sessionKey.current = null;
lastStopCall.current = 0;
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
}, [options.title]); }, [options.title]);

View file

@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService'; import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@ -383,22 +384,28 @@ export function useTraktIntegration() {
} }
}, [isAuthenticated, fetchAndMergeTraktProgress]); }, [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(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const intervalId = setInterval(() => { const handleAppStateChange = (nextAppState: AppStateStatus) => {
logger.log('[useTraktIntegration] Periodic Trakt sync check'); if (nextAppState === 'active') {
fetchAndMergeTraktProgress().then((success) => { logger.log('[useTraktIntegration] App became active, syncing Trakt data');
if (success) { fetchAndMergeTraktProgress().then((success) => {
logger.log('[useTraktIntegration] Periodic sync completed successfully'); if (success) {
} logger.log('[useTraktIntegration] App focus sync completed successfully');
}).catch(error => { }
logger.error('[useTraktIntegration] Periodic sync failed:', error); }).catch(error => {
}); logger.error('[useTraktIntegration] App focus sync failed:', error);
}, 2 * 60 * 1000); // 2 minutes });
}
};
return () => clearInterval(intervalId); const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription?.remove();
};
}, [isAuthenticated, fetchAndMergeTraktProgress]); }, [isAuthenticated, fetchAndMergeTraktProgress]);
// Trigger sync when auth status is manually refreshed (for login scenarios) // Trigger sync when auth status is manually refreshed (for login scenarios)

View file

@ -163,8 +163,40 @@ export class TraktService {
private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window) private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window)
private scrobbledTimestamps: Map<string, number> = new Map(); private scrobbledTimestamps: Map<string, number> = new Map();
// Track currently watching sessions to avoid duplicate starts
private currentlyWatching: Set<string> = new Set();
private lastSyncTime: number = 0;
private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds
// Enhanced deduplication for stop calls
private lastStopCalls: Map<string, number> = new Map();
private readonly STOP_DEBOUNCE_MS = 10000; // 10 seconds debounce for stop calls
private constructor() { private constructor() {
// Initialization happens in initialize method // 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 { public static getInstance(): TraktService {
@ -876,13 +908,6 @@ export class TraktService {
}); });
} }
/**
* Track currently watching sessions to avoid duplicate starts
*/
private currentlyWatching: Set<string> = new Set();
private lastSyncTime: number = 0;
private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds
/** /**
* Generate a unique key for content being watched * Generate a unique key for content being watched
*/ */
@ -903,12 +928,22 @@ export class TraktService {
return false; return false;
} }
const watchingKey = this.getWatchingKey(contentData);
// Check if this content was recently scrobbled (to prevent duplicates from component remounts) // Check if this content was recently scrobbled (to prevent duplicates from component remounts)
if (this.isRecentlyScrobbled(contentData)) { if (this.isRecentlyScrobbled(contentData)) {
logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`); logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`);
return true; 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 // Debug log the content data being sent
logger.log(`[TraktService] DEBUG scrobbleStart payload:`, { logger.log(`[TraktService] DEBUG scrobbleStart payload:`, {
type: contentData.type, type: contentData.type,
@ -920,11 +955,10 @@ export class TraktService {
showTitle: contentData.showTitle, showTitle: contentData.showTitle,
progress: progress progress: progress
}); });
const watchingKey = this.getWatchingKey(contentData);
// Only start if not already watching this content // Only start if not already watching this content
if (this.currentlyWatching.has(watchingKey)) { if (this.currentlyWatching.has(watchingKey)) {
logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`);
return true; // Already started return true; // Already started
} }
@ -995,6 +1029,17 @@ export class TraktService {
} }
const watchingKey = this.getWatchingKey(contentData); 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 () => { const result = await this.queueRequest(async () => {
return await this.stopWatching(contentData, progress); return await this.stopWatching(contentData, progress);
@ -1003,10 +1048,11 @@ export class TraktService {
if (result) { if (result) {
this.currentlyWatching.delete(watchingKey); 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) { if (progress >= 80) {
this.scrobbledItems.add(watchingKey); this.scrobbledItems.add(watchingKey);
this.scrobbledTimestamps.set(watchingKey, Date.now()); 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 // 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})`); logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true; return true;
} else {
// If failed, remove from lastStopCalls so we can try again
this.lastStopCalls.delete(watchingKey);
} }
return false; return false;