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 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<string | null>(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]);

View file

@ -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)

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 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() {
// 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<string> = 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;