refactor: update Trakt autosync settings and scrobble logic

This commit is contained in:
tapframe 2026-03-23 23:57:19 +05:30
parent 38cdf99356
commit 42b5f04c8a
6 changed files with 213 additions and 535 deletions

View file

@ -488,6 +488,10 @@ const AndroidVideoPlayer: React.FC = () => {
} }
}, 300); }, 300);
} }
if (videoDuration > 0) {
traktAutosync.handlePlaybackStart(0, videoDuration);
}
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]); }, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]);
const handleProgress = useCallback((data: any) => { const handleProgress = useCallback((data: any) => {
@ -943,7 +947,7 @@ const AndroidVideoPlayer: React.FC = () => {
addonId: currentStreamProvider addonId: currentStreamProvider
}, episodeId); }, episodeId);
} }
traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true); traktAutosync.handlePlaybackStart(data.currentTime, playerState.duration);
} }
}} }}
onEnd={() => { onEnd={() => {

View file

@ -242,6 +242,7 @@ const KSPlayerCore: React.FC = () => {
duration, duration,
lastUpdated: Date.now() lastUpdated: Date.now()
}, episodeId); }, episodeId);
traktAutosync.handlePlaybackStart(timeInSeconds, duration);
} }
}); });
@ -650,9 +651,6 @@ const KSPlayerCore: React.FC = () => {
if (isSyncingBeforeClose.current) return; if (isSyncingBeforeClose.current) return;
isSyncingBeforeClose.current = true; 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'); traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close');
navigation.goBack(); navigation.goBack();

View file

@ -162,7 +162,6 @@ export const useWatchProgress = (
}; };
try { try {
await storageService.setWatchProgress(id, type, progress, episodeId); await storageService.setWatchProgress(id, type, progress, episodeId);
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
// Requirement 1: Auto Episode Tracking (>= 90% completion) // Requirement 1: Auto Episode Tracking (>= 90% completion)
const progressPercent = (currentTimeRef.current / durationRef.current) * 100; const progressPercent = (currentTimeRef.current / durationRef.current) * 100;
@ -204,25 +203,24 @@ export const useWatchProgress = (
useEffect(() => { useEffect(() => {
// Handle pause transitions (upstream)
if (wasPausedRef.current !== paused) { if (wasPausedRef.current !== paused) {
const becamePaused = paused; const becamePaused = paused;
wasPausedRef.current = paused; wasPausedRef.current = paused;
if (becamePaused) { if (becamePaused) {
void saveWatchProgress(); void saveWatchProgress();
if (durationRef.current > 0) {
void traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close');
}
} else { } else {
// Became unpaused — open/re-open the Trakt scrobble session
if (durationRef.current > 0) { if (durationRef.current > 0) {
void traktAutosyncRef.current.handlePlaybackStart(currentTimeRef.current, durationRef.current); void traktAutosyncRef.current.handlePlaybackStart(currentTimeRef.current, durationRef.current);
} }
} }
} }
// Handle periodic save when playing (MAL branch)
if (id && type && !paused) { if (id && type && !paused) {
if (progressSaveInterval) clearInterval(progressSaveInterval); if (progressSaveInterval) clearInterval(progressSaveInterval);
// Use refs inside the interval so we don't need to restart it on every second
const interval = setInterval(() => { const interval = setInterval(() => {
saveWatchProgress(); saveWatchProgress();
}, 10000); }, 10000);

View file

@ -7,30 +7,27 @@ import { SimklContentData } from '../services/simklService';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
const TRAKT_SCROBBLE_THRESHOLD = 80;
interface TraktAutosyncOptions { interface TraktAutosyncOptions {
id: string; id: string;
type: 'movie' | 'series'; type: 'movie' | 'series';
title: string; title: string;
year: number | string; // Allow both for compatibility year: number | string;
imdbId: string; imdbId: string;
// For episodes
season?: number; season?: number;
episode?: number; episode?: number;
showTitle?: string; showTitle?: string;
showYear?: number | string; // Allow both for compatibility showYear?: number | string;
showImdbId?: string; showImdbId?: string;
episodeId?: string; episodeId?: string;
} }
// Module-level map: contentKey → { stoppedAt, progress, isComplete } const recentlyScrobbledSessions = new Map<string, {
// Survives component unmount/remount so re-mounting the player for the same scrobbledAt: number;
// content (e.g. app background → resume) doesn't fire a duplicate scrobble/start.
const recentlyStoppedSessions = new Map<string, {
stoppedAt: number;
progress: number; progress: number;
isComplete: boolean;
}>(); }>();
const SESSION_RESUME_WINDOW_MS = 20 * 60 * 1000; // 20 minutes const SCROBBLE_DEDUP_WINDOW_MS = 60 * 60 * 1000;
function getContentKey(opts: TraktAutosyncOptions): string { function getContentKey(opts: TraktAutosyncOptions): string {
const resolvedId = (opts.imdbId && opts.imdbId.trim()) ? opts.imdbId : (opts.id || ''); const resolvedId = (opts.imdbId && opts.imdbId.trim()) ? opts.imdbId : (opts.id || '');
@ -43,8 +40,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const { const {
isAuthenticated, isAuthenticated,
startWatching, startWatching,
updateProgress,
updateProgressImmediate,
stopWatching, stopWatching,
stopWatchingImmediate stopWatchingImmediate
} = useTraktIntegration(); } = useTraktIntegration();
@ -58,44 +53,34 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const { settings: autosyncSettings } = useTraktAutosyncSettings(); const { settings: autosyncSettings } = useTraktAutosyncSettings();
const hasStartedWatching = useRef(false); // Session state refs
const hasStopped = useRef(false); // New: Track if we've already stopped for this session const isSessionComplete = useRef(false); // True once scrobbled (>= 80%) — blocks ALL further payloads
const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled) const isUnmounted = useRef(false);
const isUnmounted = useRef(false); // New: Track if component has unmounted
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 const lastStopCall = useRef(0);
// Generate a unique session key for this content instance // Initialise session on mount / content change
useEffect(() => { useEffect(() => {
const contentKey = getContentKey(options); const contentKey = getContentKey(options);
sessionKey.current = `${contentKey}:${Date.now()}`; sessionKey.current = `${contentKey}:${Date.now()}`;
isUnmounted.current = false; isUnmounted.current = false;
unmountCount.current = 0; unmountCount.current = 0;
// Check if we're re-mounting for the same content within the resume window. // Check if this content was recently scrobbled (prevents duplicate on remount)
// If so, restore the stopped/complete state so we don't fire a duplicate start. const prior = recentlyScrobbledSessions.get(contentKey);
const prior = recentlyStoppedSessions.get(contentKey);
const now = Date.now(); const now = Date.now();
if (prior && (now - prior.stoppedAt) < SESSION_RESUME_WINDOW_MS) { if (prior && (now - prior.scrobbledAt) < SCROBBLE_DEDUP_WINDOW_MS) {
hasStartedWatching.current = false; // will re-start cleanly if needed isSessionComplete.current = true;
hasStopped.current = prior.isComplete ? true : prior.progress > 0; // block restart if already stopped
isSessionComplete.current = prior.isComplete;
lastSyncProgress.current = prior.progress; lastSyncProgress.current = prior.progress;
lastStopCall.current = prior.stoppedAt; logger.log(`[TraktAutosync] Remount detected — content already scrobbled (${prior.progress.toFixed(1)}%), blocking all payloads`);
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)}%`);
} else { } else {
// Genuinely new content or window expired — reset everything
hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false; isSessionComplete.current = false;
lastStopCall.current = 0;
lastSyncProgress.current = 0; lastSyncProgress.current = 0;
lastSyncTime.current = 0; lastStopCall.current = 0;
if (prior) { if (prior) {
recentlyStoppedSessions.delete(contentKey); recentlyScrobbledSessions.delete(contentKey);
} }
logger.log(`[TraktAutosync] New session started for: ${sessionKey.current}`); 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]); }, [options.imdbId, options.season, options.episode, options.type]);
// Build Trakt content data from options // ── Build content data helpers ──────────────────────────────────────
// Returns null if required fields are missing or invalid
const buildContentData = useCallback((): TraktContentData | null => { const buildContentData = useCallback((): TraktContentData | null => {
// Parse and validate year - returns undefined for invalid/missing years
const parseYear = (year: number | string | undefined): number | undefined => { const parseYear = (year: number | string | undefined): number | undefined => {
if (year === undefined || year === null || year === '') return undefined; if (year === undefined || year === null || year === '') return undefined;
if (typeof year === 'number') { if (typeof year === 'number') {
// Year must be a reasonable value (between 1800 and current year + 10)
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
if (year <= 0 || year < 1800 || year > currentYear + 10) { if (year < 1800 || year > currentYear + 10) return undefined;
logger.warn(`[TraktAutosync] Invalid year value: ${year}`);
return undefined;
}
return year; return year;
} }
const parsed = parseInt(year.toString(), 10); const parsed = parseInt(year.toString(), 10);
if (isNaN(parsed) || parsed <= 0) { if (isNaN(parsed) || parsed <= 0) return undefined;
logger.warn(`[TraktAutosync] Failed to parse year: ${year}`);
return undefined;
}
// Validate parsed year range
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
if (parsed < 1800 || parsed > currentYear + 10) { if (parsed < 1800 || parsed > currentYear + 10) return undefined;
logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`);
return undefined;
}
return parsed; return parsed;
}; };
// Validate required fields early
if (!options.title || options.title.trim() === '') { 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; 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 imdbIdRaw = options.imdbId && options.imdbId.trim() ? options.imdbId.trim() : '';
const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : ''; const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : '';
const resolvedImdbId = imdbIdRaw || stremioIdRaw; const resolvedImdbId = imdbIdRaw || stremioIdRaw;
if (!resolvedImdbId) { 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; 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 numericYear = parseYear(options.year);
const numericShowYear = parseYear(options.showYear); 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') { if (options.type === 'movie') {
return { return {
type: 'movie', type: 'movie',
imdbId: resolvedImdbId, imdbId: resolvedImdbId,
title: options.title.trim(), title: options.title.trim(),
year: numericYear // Can be undefined now year: numericYear
}; };
} else { } else {
// For episodes, also validate season and episode numbers
if (options.season === undefined || options.season === null || options.season < 0) { if (options.season === undefined || options.season === null || options.season < 0) {
logger.error('[TraktAutosync] Cannot build episode content data: invalid season'); logger.error('[TraktAutosync] Cannot build episode content data: invalid season');
return null; return null;
@ -203,510 +162,219 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
}, [options]); }, [options]);
const buildSimklContentData = useCallback((): SimklContentData => { const buildSimklContentData = useCallback((): SimklContentData => {
// Use the same fallback logic: prefer imdbId, fall back to stremio id
const resolvedId = (options.imdbId && options.imdbId.trim()) const resolvedId = (options.imdbId && options.imdbId.trim())
? options.imdbId.trim() ? options.imdbId.trim()
: (options.id && options.id.trim()) ? options.id.trim() : ''; : (options.id && options.id.trim()) ? options.id.trim() : '';
return { return {
type: options.type === 'series' ? 'episode' : 'movie', type: options.type === 'series' ? 'episode' : 'movie',
title: options.title, title: options.title,
ids: { ids: { imdb: resolvedId },
imdb: resolvedId
},
season: options.season, season: options.season,
episode: options.episode episode: options.episode
}; };
}, [options]); }, [options]);
// Start watching (scrobble start) // ── /scrobble/start — play, unpause, seek ──────────────────────────
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}`); 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 shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
const shouldSyncSimkl = isSimklAuthenticated; const shouldSyncSimkl = isSimklAuthenticated;
if (!shouldSyncTrakt && !shouldSyncSimkl) { if (!shouldSyncTrakt && !shouldSyncSimkl) return;
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`);
return;
}
// PREVENT SESSION RESTART: Don't start if session is complete (scrobbled) // After scrobble (>= 80%), send NO more payloads
if (isSessionComplete.current) { if (isSessionComplete.current) {
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`); logger.log(`[TraktAutosync] Session complete — skipping /scrobble/start`);
return; return;
} }
// PREVENT SESSION RESTART: Don't start if we've already stopped this session if (duration <= 0) return;
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 { try {
// Clamp progress between 0 and 100
const rawProgress = (currentTime / duration) * 100; const rawProgress = (currentTime / duration) * 100;
const progressPercent = Math.min(100, Math.max(0, rawProgress)); const progressPercent = Math.min(100, Math.max(0, rawProgress));
const contentData = buildContentData();
// Skip if content data is invalid // If we're already past 80%, don't send start — it's already scrobbled or will be
if (!contentData) { if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
logger.warn('[TraktAutosync] Skipping start: invalid content data'); logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% >= ${TRAKT_SCROBBLE_THRESHOLD}%, skipping start`);
return; return;
} }
const contentData = buildContentData();
if (!contentData) return;
if (shouldSyncTrakt) { if (shouldSyncTrakt) {
const success = await startWatching(contentData, progressPercent); const success = await startWatching(contentData, progressPercent);
if (success) { if (success) {
hasStartedWatching.current = true; lastSyncProgress.current = progressPercent;
hasStopped.current = false; // Reset stop flag when starting logger.log(`[TraktAutosync] /scrobble/start sent: ${contentData.title} at ${progressPercent.toFixed(1)}%`);
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
} }
} 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) { if (shouldSyncSimkl) {
const simklData = buildSimklContentData(); const simklData = buildSimklContentData();
await startSimkl(simklData, progressPercent); await startSimkl(simklData, progressPercent);
} }
} catch (error) { } catch (error) {
logger.error('[TraktAutosync] Error starting watch:', error); logger.error('[TraktAutosync] Error in handlePlaybackStart:', error);
} }
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]); }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]);
// Sync progress during playback // ── /scrobble/stop — pause, close, unmount, video end ──────────────
const handleProgressUpdate = useCallback(async (
currentTime: number,
duration: number,
force: boolean = false
) => {
if (isUnmounted.current) return; // Prevent execution after component unmount
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') => { 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(); const now = Date.now();
// Removed excessive logging for handlePlaybackEnd calls
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
const shouldSyncSimkl = isSimklAuthenticated; const shouldSyncSimkl = isSimklAuthenticated;
if (!shouldSyncTrakt && !shouldSyncSimkl) { if (!shouldSyncTrakt && !shouldSyncSimkl) return;
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`);
return;
}
// ENHANCED DEDUPLICATION: Check if session is already complete // After scrobble (>= 80%), send NO more payloads — prevents duplicate entries
if (isSessionComplete.current) { 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; return;
} }
// ENHANCED DEDUPLICATION: Check if we've already stopped this session // Debounce: prevent duplicate stop calls within 500ms
// However, allow updates if the new progress is significantly higher (>5% improvement) if (now - lastStopCall.current < 500) {
let isSignificantUpdate = false; logger.log(`[TraktAutosync] Ignoring duplicate stop call within 500ms (reason: ${reason})`);
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})`);
return; return;
} }
// Skip rapid unmount calls (likely from React strict mode or component remounts) // Skip duplicate unmount calls (React strict mode)
if (reason === 'unmount' && unmountCount.current > 1) { if (reason === 'unmount' && unmountCount.current > 1) return;
logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
return;
}
try { try {
let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
// Clamp progress between 0 and 100
progressPercent = Math.min(100, Math.max(0, progressPercent)); progressPercent = Math.min(100, Math.max(0, progressPercent));
// Initial progress calculation logging removed
// For unmount calls, always use the highest available progress // For unmount, use highest known progress
// Check current progress, last synced progress, and local storage progress
if (reason === 'unmount') { if (reason === 'unmount') {
let maxProgress = progressPercent; if (lastSyncProgress.current > progressPercent) {
progressPercent = lastSyncProgress.current;
// Check last synced progress
if (lastSyncProgress.current > maxProgress) {
maxProgress = lastSyncProgress.current;
} }
// Also check local storage for the highest recorded progress
try { try {
const savedProgress = await storageService.getWatchProgress( const savedProgress = await storageService.getWatchProgress(options.id, options.type, options.episodeId);
options.id,
options.type,
options.episodeId
);
if (savedProgress && savedProgress.duration > 0) { if (savedProgress && savedProgress.duration > 0) {
const savedProgressPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100)); const savedPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100));
if (savedProgressPercent > maxProgress) { if (savedPercent > progressPercent) progressPercent = savedPercent;
maxProgress = savedProgressPercent;
}
} }
} catch (error) { } catch {}
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 // Trakt ignores progress < 1% (returns 422)
if (!hasStartedWatching.current && progressPercent > 1) { if (progressPercent < 1) {
const contentData = buildContentData(); logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% < 1%, skipping stop`);
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)}%)`);
return; return;
} }
// Note: No longer boosting progress since Trakt API handles 80% threshold correctly
// Mark stop attempt and update timestamp
lastStopCall.current = now; lastStopCall.current = now;
hasStopped.current = true; lastSyncProgress.current = progressPercent;
// 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
});
const contentData = buildContentData(); const contentData = buildContentData();
if (!contentData) return;
// Skip if content data is invalid // Send /scrobble/stop to Trakt
if (!contentData) { // Trakt API: >= 80% → scrobble (marks watched), 1-79% → pause (saves progress)
logger.warn('[TraktAutosync] Skipping stop: invalid content data'); let traktSuccess = false;
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;
if (shouldSyncTrakt) { if (shouldSyncTrakt) {
traktStopSuccess = useImmediate const useImmediate = reason === 'user_close';
traktSuccess = useImmediate
? await stopWatchingImmediate(contentData, progressPercent) ? await stopWatchingImmediate(contentData, progressPercent)
: await stopWatching(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) { if (traktSuccess) {
// Update local storage sync status for Trakt logger.log(`[TraktAutosync] /scrobble/stop sent: ${contentData.title} at ${progressPercent.toFixed(1)}% (${reason})`);
await storageService.updateTraktSyncStatus(
options.id, await storageService.updateTraktSyncStatus(
options.type, options.id, options.type, true, progressPercent, options.episodeId, currentTime
true, );
progressPercent,
options.episodeId, // If >= 80%, Trakt has scrobbled it — mark session complete, no more payloads
currentTime if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
); isSessionComplete.current = true;
} else if (shouldSyncTrakt) { recentlyScrobbledSessions.set(getContentKey(options), {
// If Trakt stop failed, reset the stop flag so we can try again later scrobbledAt: now,
hasStopped.current = false; progress: progressPercent
recentlyStoppedSessions.delete(getContentKey(options)); });
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`); 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 // Simkl Stop
if (shouldSyncSimkl) { if (shouldSyncSimkl) {
const simklData = buildSimklContentData(); const simklData = buildSimklContentData();
await stopSimkl(simklData, progressPercent); await stopSimkl(simklData, progressPercent);
// Update local storage sync status for Simkl
await storageService.updateSimklSyncStatus( await storageService.updateSimklSyncStatus(
options.id, options.id, options.type, true, progressPercent, options.episodeId
options.type,
true,
progressPercent,
options.episodeId
); );
logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`); logger.log(`[TraktAutosync] Simkl stop sent: ${simklData.title} at ${progressPercent.toFixed(1)}%`);
overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet)
} }
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) { } catch (error) {
logger.error('[TraktAutosync] Error ending watch:', error); logger.error('[TraktAutosync] Error in handlePlaybackEnd:', error);
// Reset stop flag on error so we can try again
hasStopped.current = false;
} }
}, [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(() => { const resetState = useCallback(() => {
hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false; isSessionComplete.current = false;
isUnmounted.current = false; isUnmounted.current = false;
lastSyncTime.current = 0;
lastSyncProgress.current = 0; lastSyncProgress.current = 0;
unmountCount.current = 0; unmountCount.current = 0;
sessionKey.current = null; sessionKey.current = null;
lastStopCall.current = 0; lastStopCall.current = 0;
recentlyStoppedSessions.delete(getContentKey(options)); recentlyScrobbledSessions.delete(getContentKey(options));
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
}, [options.title]); }, [options.title]);

View file

@ -16,7 +16,7 @@ export interface TraktAutosyncSettings {
const DEFAULT_SETTINGS: TraktAutosyncSettings = { const DEFAULT_SETTINGS: TraktAutosyncSettings = {
enabled: true, enabled: true,
syncFrequency: 60000, // 60 seconds syncFrequency: 60000, // 60 seconds
completionThreshold: 95, // 95% completionThreshold: 80, // 80% — Trakt API hardcoded threshold
}; };
export function useTraktAutosyncSettings() { export function useTraktAutosyncSettings() {

View file

@ -17,6 +17,11 @@ if (!TRAKT_CLIENT_ID || !TRAKT_CLIENT_SECRET) {
logger.warn('[TraktService] Missing Trakt env vars. Trakt integration will be disabled.'); 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 // Types
export interface TraktUser { export interface TraktUser {
username: string; username: string;
@ -1871,21 +1876,24 @@ export class TraktService {
*/ */
public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try { try {
// Validate content data before making API call
const validation = this.validateContentData(contentData); const validation = this.validateContentData(contentData);
if (!validation.isValid) { 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; return null;
} }
const payload = await this.buildScrobblePayload(contentData, progress); const payload = await this.buildScrobblePayload(contentData, progress);
if (!payload) { if (!payload) {
console.log('[TraktService] /scrobble/start payload is null');
return null; return null;
} }
return this.apiRequest<TraktScrobbleResponse>('/scrobble/start', 'POST', payload); console.log('[TraktService] /scrobble/start PAYLOAD:', JSON.stringify(payload));
const response = await this.apiRequest<TraktScrobbleResponse>('/scrobble/start', 'POST', payload);
console.log('[TraktService] /scrobble/start RESPONSE:', JSON.stringify(response));
return response;
} catch (error) { } catch (error) {
logger.error('[TraktService] Failed to start watching:', error); console.log('[TraktService] /scrobble/start ERROR:', error);
return null; return null;
} }
} }
@ -1927,21 +1935,24 @@ export class TraktService {
*/ */
public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try { try {
// Validate content data before making API call
const validation = this.validateContentData(contentData); const validation = this.validateContentData(contentData);
if (!validation.isValid) { 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; return null;
} }
const payload = await this.buildScrobblePayload(contentData, progress); const payload = await this.buildScrobblePayload(contentData, progress);
if (!payload) { if (!payload) {
console.log('[TraktService] /scrobble/stop payload is null');
return null; return null;
} }
return this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload); console.log('[TraktService] /scrobble/stop PAYLOAD:', JSON.stringify(payload));
const response = await this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload);
console.log('[TraktService] /scrobble/stop RESPONSE:', JSON.stringify(response));
return response;
} catch (error) { } catch (error) {
logger.error('[TraktService] Failed to stop watching:', error); console.log('[TraktService] /scrobble/stop ERROR:', error);
return null; return null;
} }
} }
@ -2239,31 +2250,28 @@ export class TraktService {
public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> { public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> {
try { try {
if (!await this.isAuthenticated()) { if (!await this.isAuthenticated()) {
console.log('[TraktService] scrobbleStart: not authenticated');
return false; return false;
} }
const watchingKey = this.getWatchingKey(contentData); 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)) { if (this.isRecentlyScrobbled(contentData)) {
logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`); console.log(`[TraktService] scrobbleStart BLOCKED: recently scrobbled`);
return true; return true;
} }
// ENHANCED PROTECTION: Check if we recently stopped this content with high progress if (this.scrobbledItems.has(watchingKey)) {
// This prevents restarting sessions for content that was just completed const scrobbledTime = this.scrobbledTimestamps.get(watchingKey);
const lastStopTime = this.lastStopCalls.get(watchingKey); if (scrobbledTime && (Date.now() - scrobbledTime) < 30000) {
if (lastStopTime && (Date.now() - lastStopTime) < 30000) { // 30 seconds console.log(`[TraktService] scrobbleStart BLOCKED: scrobbled ${((Date.now() - scrobbledTime) / 1000).toFixed(1)}s ago`);
logger.log(`[TraktService] Recently stopped this content (${((Date.now() - lastStopTime) / 1000).toFixed(1)}s ago), preventing restart: ${contentData.title}`); return true;
return true; }
} }
// Debug log removed to reduce terminal noise
// 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}`); this.currentlyWatching.delete(watchingKey);
return true; // Already started
} }
const result = await this.queueRequest(async () => { const result = await this.queueRequest(async () => {
@ -2272,13 +2280,14 @@ export class TraktService {
if (result) { if (result) {
this.currentlyWatching.add(watchingKey); this.currentlyWatching.add(watchingKey);
logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`); console.log(`[TraktService] scrobbleStart SUCCESS: ${contentData.title}`);
return true; return true;
} }
console.log(`[TraktService] scrobbleStart FAILED: result was null`);
return false; return false;
} catch (error) { } catch (error) {
logger.error('[TraktService] Failed to start scrobbling:', error); console.log('[TraktService] scrobbleStart ERROR:', error);
return false; 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<boolean> { public async scrobbleStop(contentData: TraktContentData, progress: number): Promise<boolean> {
try { try {
@ -2338,52 +2351,48 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData); const watchingKey = this.getWatchingKey(contentData);
const now = Date.now(); 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); const lastStopTime = this.lastStopCalls.get(watchingKey);
if (lastStopTime && (now - lastStopTime) < 1000) { if (lastStopTime && (now - lastStopTime) < 1000) {
logger.log(`[TraktService] Ignoring duplicate stop call for ${contentData.title} (last stop ${((now - lastStopTime) / 1000).toFixed(1)}s ago)`); 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); this.lastStopCalls.set(watchingKey, now);
// Use pause if below user threshold, stop only when ready to scrobble // Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
const useStop = progress >= this.completionThreshold;
const result = await this.queueRequest(async () => { const result = await this.queueRequest(async () => {
return useStop return await this.stopWatching(contentData, progress);
? await this.stopWatching(contentData, progress)
: await this.pauseWatching(contentData, progress);
}); });
if (result) { if (result) {
this.currentlyWatching.delete(watchingKey); this.currentlyWatching.delete(watchingKey);
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts // Mark as scrobbled if >= 80% to prevent future duplicates
if (progress >= this.completionThreshold) { if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
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}`); logger.log(`[TraktService] Scrobbled (>= 80%): ${watchingKey}`);
} }
// Action reflects actual endpoint used based on user threshold const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] /scrobble/stop sent: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true; return true;
} else { } else {
// If failed, remove from lastStopCalls so we can try again
this.lastStopCalls.delete(watchingKey); this.lastStopCalls.delete(watchingKey);
} }
return false; return false;
} catch (error) { } catch (error) {
// Handle rate limiting errors more gracefully
if (error instanceof Error && error.message.includes('429')) { if (error instanceof Error && error.message.includes('429')) {
logger.warn('[TraktService] Rate limited, will retry later'); logger.warn('[TraktService] Rate limited, will retry later');
return true; return true;
} }
logger.error('[TraktService] Failed to stop scrobbling:', error); logger.error('[TraktService] Failed to stop scrobbling:', error);
return false; return false;
} }
@ -2429,6 +2438,7 @@ export class TraktService {
/** /**
* Immediate scrobble stop - bypasses queue for instant user feedback * 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<boolean> { public async scrobbleStopImmediate(contentData: TraktContentData, progress: number): Promise<boolean> {
try { try {
@ -2438,7 +2448,12 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData); 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); const lastStopTime = this.lastStopCalls.get(watchingKey);
if (lastStopTime && (Date.now() - lastStopTime) < 200) { if (lastStopTime && (Date.now() - lastStopTime) < 200) {
return true; return true;
@ -2446,24 +2461,19 @@ export class TraktService {
this.lastStopCalls.set(watchingKey, Date.now()); this.lastStopCalls.set(watchingKey, Date.now());
// BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble // Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
const useStop = progress >= this.completionThreshold; const result = await this.stopWatching(contentData, progress);
const result = useStop
? await this.stopWatching(contentData, progress)
: await this.pauseWatching(contentData, progress);
if (result) { if (result) {
this.currentlyWatching.delete(watchingKey); this.currentlyWatching.delete(watchingKey);
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
if (progress >= this.completionThreshold) {
this.scrobbledItems.add(watchingKey); this.scrobbledItems.add(watchingKey);
this.scrobbledTimestamps.set(watchingKey, Date.now()); this.scrobbledTimestamps.set(watchingKey, Date.now());
} }
// Action reflects actual endpoint used based on user threshold const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] IMMEDIATE /scrobble/stop: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true; return true;
} }