mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
506 lines
No EOL
19 KiB
TypeScript
506 lines
No EOL
19 KiB
TypeScript
import { useCallback, useRef, useEffect } from 'react';
|
|
import { useTraktIntegration } from './useTraktIntegration';
|
|
import { useTraktAutosyncSettings } from './useTraktAutosyncSettings';
|
|
import { TraktContentData } from '../services/traktService';
|
|
import { storageService } from '../services/storageService';
|
|
import { logger } from '../utils/logger';
|
|
|
|
interface TraktAutosyncOptions {
|
|
id: string;
|
|
type: 'movie' | 'series';
|
|
title: string;
|
|
year: number | string; // Allow both for compatibility
|
|
imdbId: string;
|
|
// For episodes
|
|
season?: number;
|
|
episode?: number;
|
|
showTitle?: string;
|
|
showYear?: number | string; // Allow both for compatibility
|
|
showImdbId?: string;
|
|
episodeId?: string;
|
|
}
|
|
|
|
export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|
const {
|
|
isAuthenticated,
|
|
startWatching,
|
|
updateProgress,
|
|
updateProgressImmediate,
|
|
stopWatching,
|
|
stopWatchingImmediate
|
|
} = useTraktIntegration();
|
|
|
|
const { settings: autosyncSettings } = useTraktAutosyncSettings();
|
|
|
|
const hasStartedWatching = useRef(false);
|
|
const hasStopped = useRef(false); // New: Track if we've already stopped for this session
|
|
const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled)
|
|
const isUnmounted = useRef(false); // New: Track if component has unmounted
|
|
const lastSyncTime = useRef(0);
|
|
const lastSyncProgress = useRef(0);
|
|
const sessionKey = useRef<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(() => {
|
|
const contentKey = options.type === 'movie'
|
|
? `movie:${options.imdbId}`
|
|
: `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`;
|
|
sessionKey.current = `${contentKey}:${Date.now()}`;
|
|
|
|
// Reset all session state for new content
|
|
hasStartedWatching.current = false;
|
|
hasStopped.current = false;
|
|
isSessionComplete.current = false;
|
|
isUnmounted.current = false; // Reset unmount flag for new mount
|
|
lastStopCall.current = 0;
|
|
|
|
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
|
|
|
|
return () => {
|
|
unmountCount.current++;
|
|
isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations
|
|
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
|
|
};
|
|
}, [options.imdbId, options.season, options.episode, options.type]);
|
|
|
|
// Build Trakt content data from options
|
|
// Returns null if required fields are missing or invalid
|
|
const buildContentData = useCallback((): TraktContentData | null => {
|
|
// Parse and validate year - returns undefined for invalid/missing years
|
|
const parseYear = (year: number | string | undefined): number | undefined => {
|
|
if (year === undefined || year === null || year === '') return undefined;
|
|
if (typeof year === 'number') {
|
|
// Year must be a reasonable value (between 1800 and current year + 10)
|
|
const currentYear = new Date().getFullYear();
|
|
if (year <= 0 || year < 1800 || year > currentYear + 10) {
|
|
logger.warn(`[TraktAutosync] Invalid year value: ${year}`);
|
|
return undefined;
|
|
}
|
|
return year;
|
|
}
|
|
const parsed = parseInt(year.toString(), 10);
|
|
if (isNaN(parsed) || parsed <= 0) {
|
|
logger.warn(`[TraktAutosync] Failed to parse year: ${year}`);
|
|
return undefined;
|
|
}
|
|
// Validate parsed year range
|
|
const currentYear = new Date().getFullYear();
|
|
if (parsed < 1800 || parsed > currentYear + 10) {
|
|
logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`);
|
|
return undefined;
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
// Validate required fields early
|
|
if (!options.title || options.title.trim() === '') {
|
|
logger.error('[TraktAutosync] Cannot build content data: missing or empty title');
|
|
return null;
|
|
}
|
|
|
|
if (!options.imdbId || options.imdbId.trim() === '') {
|
|
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId');
|
|
return null;
|
|
}
|
|
|
|
const numericYear = parseYear(options.year);
|
|
const numericShowYear = parseYear(options.showYear);
|
|
|
|
// Log warning if year is missing (but don't fail - Trakt can sometimes work with IMDb ID alone)
|
|
if (numericYear === undefined) {
|
|
logger.warn('[TraktAutosync] Year is missing or invalid, proceeding without year');
|
|
}
|
|
|
|
if (options.type === 'movie') {
|
|
return {
|
|
type: 'movie',
|
|
imdbId: options.imdbId.trim(),
|
|
title: options.title.trim(),
|
|
year: numericYear // Can be undefined now
|
|
};
|
|
} else {
|
|
// For episodes, also validate season and episode numbers
|
|
if (options.season === undefined || options.season === null || options.season < 0) {
|
|
logger.error('[TraktAutosync] Cannot build episode content data: invalid season');
|
|
return null;
|
|
}
|
|
if (options.episode === undefined || options.episode === null || options.episode < 0) {
|
|
logger.error('[TraktAutosync] Cannot build episode content data: invalid episode');
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: 'episode',
|
|
imdbId: options.imdbId.trim(),
|
|
title: options.title.trim(),
|
|
year: numericYear,
|
|
season: options.season,
|
|
episode: options.episode,
|
|
showTitle: (options.showTitle || options.title).trim(),
|
|
showYear: numericShowYear || numericYear,
|
|
showImdbId: (options.showImdbId || options.imdbId).trim()
|
|
};
|
|
}
|
|
}, [options]);
|
|
|
|
// Start watching (scrobble start)
|
|
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
|
if (isUnmounted.current) return; // Prevent execution after component unmount
|
|
|
|
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`);
|
|
|
|
if (!isAuthenticated || !autosyncSettings.enabled) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
|
return;
|
|
}
|
|
|
|
// PREVENT SESSION RESTART: Don't start if session is complete (scrobbled)
|
|
if (isSessionComplete.current) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`);
|
|
return;
|
|
}
|
|
|
|
// PREVENT SESSION RESTART: Don't start if we've already stopped this session
|
|
if (hasStopped.current) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session already stopped, preventing restart`);
|
|
return;
|
|
}
|
|
|
|
if (hasStartedWatching.current) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: already started=${hasStartedWatching.current}`);
|
|
return;
|
|
}
|
|
|
|
if (duration <= 0) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Clamp progress between 0 and 100
|
|
const rawProgress = (currentTime / duration) * 100;
|
|
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
|
const contentData = buildContentData();
|
|
|
|
// Skip if content data is invalid
|
|
if (!contentData) {
|
|
logger.warn('[TraktAutosync] Skipping start: invalid content data');
|
|
return;
|
|
}
|
|
|
|
const success = await startWatching(contentData, progressPercent);
|
|
if (success) {
|
|
hasStartedWatching.current = true;
|
|
hasStopped.current = false; // Reset stop flag when starting
|
|
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[TraktAutosync] Error starting watch:', error);
|
|
}
|
|
}, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]);
|
|
|
|
// Sync progress during playback
|
|
const handleProgressUpdate = useCallback(async (
|
|
currentTime: number,
|
|
duration: number,
|
|
force: boolean = false
|
|
) => {
|
|
if (isUnmounted.current) return; // Prevent execution after component unmount
|
|
|
|
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
|
|
return;
|
|
}
|
|
|
|
// Skip if session is already complete
|
|
if (isSessionComplete.current) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const rawProgress = (currentTime / duration) * 100;
|
|
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
|
const now = Date.now();
|
|
|
|
// IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true)
|
|
// Use regular queued method for background periodic syncs
|
|
let success: boolean;
|
|
|
|
if (force) {
|
|
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
|
const contentData = buildContentData();
|
|
if (!contentData) {
|
|
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
|
return;
|
|
}
|
|
success = await updateProgressImmediate(contentData, progressPercent);
|
|
|
|
if (success) {
|
|
lastSyncTime.current = now;
|
|
lastSyncProgress.current = progressPercent;
|
|
|
|
// Update local storage sync status
|
|
await storageService.updateTraktSyncStatus(
|
|
options.id,
|
|
options.type,
|
|
true,
|
|
progressPercent,
|
|
options.episodeId,
|
|
currentTime
|
|
);
|
|
|
|
logger.log(`[TraktAutosync] IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`);
|
|
}
|
|
} else {
|
|
// BACKGROUND: Periodic sync - use queued method
|
|
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
|
|
|
|
// Only skip if not forced and progress difference is minimal (< 0.5%)
|
|
if (progressDiff < 0.5) {
|
|
return;
|
|
}
|
|
|
|
const contentData = buildContentData();
|
|
if (!contentData) {
|
|
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
|
return;
|
|
}
|
|
success = await updateProgress(contentData, progressPercent, force);
|
|
|
|
if (success) {
|
|
lastSyncTime.current = now;
|
|
lastSyncProgress.current = progressPercent;
|
|
|
|
// Update local storage sync status
|
|
await storageService.updateTraktSyncStatus(
|
|
options.id,
|
|
options.type,
|
|
true,
|
|
progressPercent,
|
|
options.episodeId,
|
|
currentTime
|
|
);
|
|
|
|
// Progress sync logging removed
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('[TraktAutosync] Error syncing progress:', error);
|
|
}
|
|
}, [isAuthenticated, autosyncSettings.enabled, updateProgress, updateProgressImmediate, buildContentData, options]);
|
|
|
|
// Handle playback end/pause
|
|
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => {
|
|
if (isUnmounted.current) return; // Prevent execution after component unmount
|
|
|
|
const now = Date.now();
|
|
|
|
// Removed excessive logging for handlePlaybackEnd calls
|
|
|
|
if (!isAuthenticated || !autosyncSettings.enabled) {
|
|
// logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
|
return;
|
|
}
|
|
|
|
// ENHANCED DEDUPLICATION: Check if session is already complete
|
|
if (isSessionComplete.current) {
|
|
logger.log(`[TraktAutosync] Session already complete, skipping end call (reason: ${reason})`);
|
|
return;
|
|
}
|
|
|
|
// ENHANCED DEDUPLICATION: Check if we've already stopped this session
|
|
// However, allow updates if the new progress is significantly higher (>5% improvement)
|
|
let isSignificantUpdate = false;
|
|
if (hasStopped.current) {
|
|
const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
const progressImprovement = currentProgressPercent - lastSyncProgress.current;
|
|
|
|
if (progressImprovement > 5) {
|
|
logger.log(`[TraktAutosync] Session already stopped, but progress improved significantly by ${progressImprovement.toFixed(1)}% (${lastSyncProgress.current.toFixed(1)}% → ${currentProgressPercent.toFixed(1)}%), allowing update`);
|
|
// Reset stopped flag to allow this significant update
|
|
hasStopped.current = false;
|
|
isSignificantUpdate = true;
|
|
} else {
|
|
// Already stopped this session, skipping duplicate call
|
|
return;
|
|
}
|
|
}
|
|
|
|
// IMMEDIATE SYNC: Use immediate method for user-initiated actions (user_close)
|
|
let useImmediate = reason === 'user_close';
|
|
|
|
// IMMEDIATE SYNC: Remove debouncing for instant sync when closing
|
|
// Only prevent truly duplicate calls (within 500ms for regular, 100ms for immediate)
|
|
const debounceThreshold = useImmediate ? 100 : 500;
|
|
if (!isSignificantUpdate && now - lastStopCall.current < debounceThreshold) {
|
|
logger.log(`[TraktAutosync] Ignoring duplicate stop call within ${debounceThreshold}ms (reason: ${reason})`);
|
|
return;
|
|
}
|
|
|
|
// Skip rapid unmount calls (likely from React strict mode or component remounts)
|
|
if (reason === 'unmount' && unmountCount.current > 1) {
|
|
logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
// Clamp progress between 0 and 100
|
|
progressPercent = Math.min(100, Math.max(0, progressPercent));
|
|
// Initial progress calculation logging removed
|
|
|
|
// For unmount calls, always use the highest available progress
|
|
// Check current progress, last synced progress, and local storage progress
|
|
if (reason === 'unmount') {
|
|
let maxProgress = progressPercent;
|
|
|
|
// Check last synced progress
|
|
if (lastSyncProgress.current > maxProgress) {
|
|
maxProgress = lastSyncProgress.current;
|
|
}
|
|
|
|
// Also check local storage for the highest recorded progress
|
|
try {
|
|
const savedProgress = await storageService.getWatchProgress(
|
|
options.id,
|
|
options.type,
|
|
options.episodeId
|
|
);
|
|
|
|
if (savedProgress && savedProgress.duration > 0) {
|
|
const savedProgressPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100));
|
|
if (savedProgressPercent > maxProgress) {
|
|
maxProgress = savedProgressPercent;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('[TraktAutosync] Error checking saved progress:', error);
|
|
}
|
|
|
|
if (maxProgress !== progressPercent) {
|
|
// Highest progress logging removed
|
|
progressPercent = maxProgress;
|
|
} else {
|
|
// Current progress logging removed
|
|
}
|
|
}
|
|
|
|
// If we have valid progress but no started session, force start one first
|
|
if (!hasStartedWatching.current && progressPercent > 1) {
|
|
const contentData = buildContentData();
|
|
if (contentData) {
|
|
const success = await startWatching(contentData, progressPercent);
|
|
if (success) {
|
|
hasStartedWatching.current = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only stop if we have meaningful progress (>= 0.5%) or it's a natural video end
|
|
// Lower threshold for unmount calls to catch more edge cases
|
|
if (reason === 'unmount' && progressPercent < 0.5) {
|
|
// Early unmount stop logging removed
|
|
return;
|
|
}
|
|
|
|
// Note: No longer boosting progress since Trakt API handles 80% threshold correctly
|
|
|
|
// Mark stop attempt and update timestamp
|
|
lastStopCall.current = now;
|
|
hasStopped.current = true;
|
|
|
|
const contentData = buildContentData();
|
|
|
|
// Skip if content data is invalid
|
|
if (!contentData) {
|
|
logger.warn('[TraktAutosync] Skipping stop: invalid content data');
|
|
hasStopped.current = false; // Allow retry with valid data
|
|
return;
|
|
}
|
|
|
|
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
|
const success = useImmediate
|
|
? await stopWatchingImmediate(contentData, progressPercent)
|
|
: await stopWatching(contentData, progressPercent);
|
|
|
|
if (success) {
|
|
// Update local storage sync status
|
|
await storageService.updateTraktSyncStatus(
|
|
options.id,
|
|
options.type,
|
|
true,
|
|
progressPercent,
|
|
options.episodeId,
|
|
currentTime
|
|
);
|
|
|
|
// Mark session as complete if >= user completion threshold
|
|
if (progressPercent >= autosyncSettings.completionThreshold) {
|
|
isSessionComplete.current = true;
|
|
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
|
|
|
|
// Ensure local watch progress reflects completion so UI shows as watched
|
|
try {
|
|
if (duration > 0) {
|
|
await storageService.setWatchProgress(
|
|
options.id,
|
|
options.type,
|
|
{
|
|
currentTime: duration,
|
|
duration,
|
|
lastUpdated: Date.now(),
|
|
traktSynced: true,
|
|
traktProgress: Math.max(progressPercent, 100),
|
|
} as any,
|
|
options.episodeId,
|
|
{ forceNotify: true }
|
|
);
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
|
} else {
|
|
// If stop failed, reset the stop flag so we can try again later
|
|
hasStopped.current = false;
|
|
logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`);
|
|
}
|
|
|
|
// Reset state only for natural end or very high progress unmounts
|
|
if (reason === 'ended' || progressPercent >= 80) {
|
|
hasStartedWatching.current = false;
|
|
lastSyncTime.current = 0;
|
|
lastSyncProgress.current = 0;
|
|
logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`);
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error('[TraktAutosync] Error ending watch:', error);
|
|
// Reset stop flag on error so we can try again
|
|
hasStopped.current = false;
|
|
}
|
|
}, [isAuthenticated, autosyncSettings.enabled, stopWatching, stopWatchingImmediate, startWatching, buildContentData, options]);
|
|
|
|
// Reset state (useful when switching content)
|
|
const resetState = useCallback(() => {
|
|
hasStartedWatching.current = false;
|
|
hasStopped.current = false;
|
|
isSessionComplete.current = false;
|
|
isUnmounted.current = false;
|
|
lastSyncTime.current = 0;
|
|
lastSyncProgress.current = 0;
|
|
unmountCount.current = 0;
|
|
sessionKey.current = null;
|
|
lastStopCall.current = 0;
|
|
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
|
|
}, [options.title]);
|
|
|
|
return {
|
|
isAuthenticated,
|
|
handlePlaybackStart,
|
|
handleProgressUpdate,
|
|
handlePlaybackEnd,
|
|
resetState
|
|
};
|
|
} |