mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
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:
parent
671861c207
commit
6d8666d905
3 changed files with 155 additions and 27 deletions
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue