mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-23 19:17:44 +00:00
refactor: update Trakt autosync settings and scrobble logic
This commit is contained in:
parent
38cdf99356
commit
42b5f04c8a
6 changed files with 213 additions and 535 deletions
|
|
@ -488,6 +488,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
if (videoDuration > 0) {
|
||||
traktAutosync.handlePlaybackStart(0, videoDuration);
|
||||
}
|
||||
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]);
|
||||
|
||||
const handleProgress = useCallback((data: any) => {
|
||||
|
|
@ -943,7 +947,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
addonId: currentStreamProvider
|
||||
}, episodeId);
|
||||
}
|
||||
traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true);
|
||||
traktAutosync.handlePlaybackStart(data.currentTime, playerState.duration);
|
||||
}
|
||||
}}
|
||||
onEnd={() => {
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
duration,
|
||||
lastUpdated: Date.now()
|
||||
}, episodeId);
|
||||
traktAutosync.handlePlaybackStart(timeInSeconds, duration);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -650,9 +651,6 @@ const KSPlayerCore: React.FC = () => {
|
|||
if (isSyncingBeforeClose.current) return;
|
||||
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');
|
||||
|
||||
navigation.goBack();
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@ export const useWatchProgress = (
|
|||
};
|
||||
try {
|
||||
await storageService.setWatchProgress(id, type, progress, episodeId);
|
||||
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
|
||||
|
||||
// Requirement 1: Auto Episode Tracking (>= 90% completion)
|
||||
const progressPercent = (currentTimeRef.current / durationRef.current) * 100;
|
||||
|
|
@ -204,25 +203,24 @@ export const useWatchProgress = (
|
|||
|
||||
|
||||
useEffect(() => {
|
||||
// Handle pause transitions (upstream)
|
||||
if (wasPausedRef.current !== paused) {
|
||||
const becamePaused = paused;
|
||||
wasPausedRef.current = paused;
|
||||
if (becamePaused) {
|
||||
void saveWatchProgress();
|
||||
if (durationRef.current > 0) {
|
||||
void traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close');
|
||||
}
|
||||
} else {
|
||||
// Became unpaused — open/re-open the Trakt scrobble session
|
||||
if (durationRef.current > 0) {
|
||||
void traktAutosyncRef.current.handlePlaybackStart(currentTimeRef.current, durationRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle periodic save when playing (MAL branch)
|
||||
if (id && type && !paused) {
|
||||
if (progressSaveInterval) clearInterval(progressSaveInterval);
|
||||
|
||||
// Use refs inside the interval so we don't need to restart it on every second
|
||||
const interval = setInterval(() => {
|
||||
saveWatchProgress();
|
||||
}, 10000);
|
||||
|
|
|
|||
|
|
@ -7,30 +7,27 @@ import { SimklContentData } from '../services/simklService';
|
|||
import { storageService } from '../services/storageService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const TRAKT_SCROBBLE_THRESHOLD = 80;
|
||||
|
||||
interface TraktAutosyncOptions {
|
||||
id: string;
|
||||
type: 'movie' | 'series';
|
||||
title: string;
|
||||
year: number | string; // Allow both for compatibility
|
||||
year: number | string;
|
||||
imdbId: string;
|
||||
// For episodes
|
||||
season?: number;
|
||||
episode?: number;
|
||||
showTitle?: string;
|
||||
showYear?: number | string; // Allow both for compatibility
|
||||
showYear?: number | string;
|
||||
showImdbId?: string;
|
||||
episodeId?: string;
|
||||
}
|
||||
|
||||
// Module-level map: contentKey → { stoppedAt, progress, isComplete }
|
||||
// Survives component unmount/remount so re-mounting the player for the same
|
||||
// content (e.g. app background → resume) doesn't fire a duplicate scrobble/start.
|
||||
const recentlyStoppedSessions = new Map<string, {
|
||||
stoppedAt: number;
|
||||
const recentlyScrobbledSessions = new Map<string, {
|
||||
scrobbledAt: 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 {
|
||||
const resolvedId = (opts.imdbId && opts.imdbId.trim()) ? opts.imdbId : (opts.id || '');
|
||||
|
|
@ -43,8 +40,6 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
const {
|
||||
isAuthenticated,
|
||||
startWatching,
|
||||
updateProgress,
|
||||
updateProgressImmediate,
|
||||
stopWatching,
|
||||
stopWatchingImmediate
|
||||
} = useTraktIntegration();
|
||||
|
|
@ -58,44 +53,34 @@ 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 isUnmounted = useRef(false); // New: Track if component has unmounted
|
||||
const lastSyncTime = useRef(0);
|
||||
// Session state refs
|
||||
const isSessionComplete = useRef(false); // True once scrobbled (>= 80%) — blocks ALL further payloads
|
||||
const isUnmounted = useRef(false);
|
||||
const lastSyncProgress = useRef(0);
|
||||
const sessionKey = useRef<string | null>(null);
|
||||
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(() => {
|
||||
const contentKey = getContentKey(options);
|
||||
sessionKey.current = `${contentKey}:${Date.now()}`;
|
||||
isUnmounted.current = false;
|
||||
unmountCount.current = 0;
|
||||
|
||||
// Check if we're re-mounting for the same content within the resume window.
|
||||
// If so, restore the stopped/complete state so we don't fire a duplicate start.
|
||||
const prior = recentlyStoppedSessions.get(contentKey);
|
||||
// Check if this content was recently scrobbled (prevents duplicate on remount)
|
||||
const prior = recentlyScrobbledSessions.get(contentKey);
|
||||
const now = Date.now();
|
||||
if (prior && (now - prior.stoppedAt) < SESSION_RESUME_WINDOW_MS) {
|
||||
hasStartedWatching.current = false; // will re-start cleanly if needed
|
||||
hasStopped.current = prior.isComplete ? true : prior.progress > 0; // block restart if already stopped
|
||||
isSessionComplete.current = prior.isComplete;
|
||||
if (prior && (now - prior.scrobbledAt) < SCROBBLE_DEDUP_WINDOW_MS) {
|
||||
isSessionComplete.current = true;
|
||||
lastSyncProgress.current = prior.progress;
|
||||
lastStopCall.current = prior.stoppedAt;
|
||||
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)}%`);
|
||||
logger.log(`[TraktAutosync] Remount detected — content already scrobbled (${prior.progress.toFixed(1)}%), blocking all payloads`);
|
||||
} else {
|
||||
// Genuinely new content or window expired — reset everything
|
||||
hasStartedWatching.current = false;
|
||||
hasStopped.current = false;
|
||||
isSessionComplete.current = false;
|
||||
lastStopCall.current = 0;
|
||||
lastSyncProgress.current = 0;
|
||||
lastSyncTime.current = 0;
|
||||
lastStopCall.current = 0;
|
||||
if (prior) {
|
||||
recentlyStoppedSessions.delete(contentKey);
|
||||
recentlyScrobbledSessions.delete(contentKey);
|
||||
}
|
||||
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]);
|
||||
|
||||
// Build Trakt content data from options
|
||||
// Returns null if required fields are missing or invalid
|
||||
// ── Build content data helpers ──────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
if (year < 1800 || year > currentYear + 10) 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
|
||||
if (isNaN(parsed) || parsed <= 0) return undefined;
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (parsed < 1800 || parsed > currentYear + 10) {
|
||||
logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`);
|
||||
return undefined;
|
||||
}
|
||||
if (parsed < 1800 || parsed > currentYear + 10) 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');
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing title');
|
||||
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 stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : '';
|
||||
const resolvedImdbId = imdbIdRaw || stremioIdRaw;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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: resolvedImdbId,
|
||||
title: options.title.trim(),
|
||||
year: numericYear // Can be undefined now
|
||||
year: numericYear
|
||||
};
|
||||
} 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;
|
||||
|
|
@ -203,510 +162,219 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
}, [options]);
|
||||
|
||||
const buildSimklContentData = useCallback((): SimklContentData => {
|
||||
// Use the same fallback logic: prefer imdbId, fall back to stremio id
|
||||
const resolvedId = (options.imdbId && options.imdbId.trim())
|
||||
? options.imdbId.trim()
|
||||
: (options.id && options.id.trim()) ? options.id.trim() : '';
|
||||
return {
|
||||
type: options.type === 'series' ? 'episode' : 'movie',
|
||||
title: options.title,
|
||||
ids: {
|
||||
imdb: resolvedId
|
||||
},
|
||||
ids: { imdb: resolvedId },
|
||||
season: options.season,
|
||||
episode: options.episode
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
// Start watching (scrobble start)
|
||||
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
// ── /scrobble/start — play, unpause, seek ──────────────────────────
|
||||
|
||||
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 shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`);
|
||||
return;
|
||||
}
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) return;
|
||||
|
||||
// PREVENT SESSION RESTART: Don't start if session is complete (scrobbled)
|
||||
// After scrobble (>= 80%), send NO more payloads
|
||||
if (isSessionComplete.current) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`);
|
||||
logger.log(`[TraktAutosync] Session complete — skipping /scrobble/start`);
|
||||
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;
|
||||
}
|
||||
if (duration <= 0) 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');
|
||||
// If we're already past 80%, don't send start — it's already scrobbled or will be
|
||||
if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% >= ${TRAKT_SCROBBLE_THRESHOLD}%, skipping start`);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) return;
|
||||
|
||||
if (shouldSyncTrakt) {
|
||||
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})`);
|
||||
lastSyncProgress.current = progressPercent;
|
||||
logger.log(`[TraktAutosync] /scrobble/start sent: ${contentData.title} at ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
} 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) {
|
||||
const simklData = buildSimklContentData();
|
||||
await startSimkl(simklData, progressPercent);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error starting watch:', error);
|
||||
logger.error('[TraktAutosync] Error in handlePlaybackStart:', error);
|
||||
}
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]);
|
||||
|
||||
// Sync progress during playback
|
||||
const handleProgressUpdate = useCallback(async (
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
force: boolean = false
|
||||
) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
// ── /scrobble/stop — pause, close, unmount, video end ──────────────
|
||||
|
||||
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') => {
|
||||
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();
|
||||
|
||||
// Removed excessive logging for handlePlaybackEnd calls
|
||||
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`);
|
||||
return;
|
||||
}
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) return;
|
||||
|
||||
// ENHANCED DEDUPLICATION: Check if session is already complete
|
||||
// After scrobble (>= 80%), send NO more payloads — prevents duplicate entries
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
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})`);
|
||||
// Debounce: prevent duplicate stop calls within 500ms
|
||||
if (now - lastStopCall.current < 500) {
|
||||
logger.log(`[TraktAutosync] Ignoring duplicate stop call within 500ms (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;
|
||||
}
|
||||
// Skip duplicate unmount calls (React strict mode)
|
||||
if (reason === 'unmount' && unmountCount.current > 1) 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
|
||||
// For unmount, use highest known progress
|
||||
if (reason === 'unmount') {
|
||||
let maxProgress = progressPercent;
|
||||
|
||||
// Check last synced progress
|
||||
if (lastSyncProgress.current > maxProgress) {
|
||||
maxProgress = lastSyncProgress.current;
|
||||
if (lastSyncProgress.current > progressPercent) {
|
||||
progressPercent = lastSyncProgress.current;
|
||||
}
|
||||
|
||||
// Also check local storage for the highest recorded progress
|
||||
try {
|
||||
const savedProgress = await storageService.getWatchProgress(
|
||||
options.id,
|
||||
options.type,
|
||||
options.episodeId
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
const savedPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100));
|
||||
if (savedPercent > progressPercent) progressPercent = savedPercent;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error checking saved progress:', error);
|
||||
}
|
||||
|
||||
if (maxProgress !== progressPercent) {
|
||||
// Highest progress logging removed
|
||||
progressPercent = maxProgress;
|
||||
} else {
|
||||
// Current progress logging removed
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// If we have valid progress but no started session, force start one first
|
||||
if (!hasStartedWatching.current && progressPercent > 1) {
|
||||
const contentData = buildContentData();
|
||||
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)}%)`);
|
||||
// Trakt ignores progress < 1% (returns 422)
|
||||
if (progressPercent < 1) {
|
||||
logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% < 1%, skipping stop`);
|
||||
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;
|
||||
|
||||
// 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
|
||||
});
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
let overallSuccess = false;
|
||||
|
||||
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||
let traktStopSuccess = false;
|
||||
// Send /scrobble/stop to Trakt
|
||||
// Trakt API: >= 80% → scrobble (marks watched), 1-79% → pause (saves progress)
|
||||
let traktSuccess = false;
|
||||
if (shouldSyncTrakt) {
|
||||
traktStopSuccess = useImmediate
|
||||
const useImmediate = reason === 'user_close';
|
||||
traktSuccess = useImmediate
|
||||
? await stopWatchingImmediate(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) {
|
||||
// Update local storage sync status for Trakt
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
} else if (shouldSyncTrakt) {
|
||||
// If Trakt stop failed, reset the stop flag so we can try again later
|
||||
hasStopped.current = false;
|
||||
recentlyStoppedSessions.delete(getContentKey(options));
|
||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`);
|
||||
if (traktSuccess) {
|
||||
logger.log(`[TraktAutosync] /scrobble/stop sent: ${contentData.title} at ${progressPercent.toFixed(1)}% (${reason})`);
|
||||
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id, options.type, true, progressPercent, options.episodeId, currentTime
|
||||
);
|
||||
|
||||
// If >= 80%, Trakt has scrobbled it — mark session complete, no more payloads
|
||||
if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
isSessionComplete.current = true;
|
||||
recentlyScrobbledSessions.set(getContentKey(options), {
|
||||
scrobbledAt: now,
|
||||
progress: progressPercent
|
||||
});
|
||||
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
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await stopSimkl(simklData, progressPercent);
|
||||
|
||||
// Update local storage sync status for Simkl
|
||||
await storageService.updateSimklSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId
|
||||
options.id, options.type, true, progressPercent, options.episodeId
|
||||
);
|
||||
logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet)
|
||||
logger.log(`[TraktAutosync] Simkl stop sent: ${simklData.title} at ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.error('[TraktAutosync] Error ending watch:', error);
|
||||
// Reset stop flag on error so we can try again
|
||||
hasStopped.current = false;
|
||||
logger.error('[TraktAutosync] Error in handlePlaybackEnd:', error);
|
||||
}
|
||||
}, [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(() => {
|
||||
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;
|
||||
recentlyStoppedSessions.delete(getContentKey(options));
|
||||
recentlyScrobbledSessions.delete(getContentKey(options));
|
||||
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
|
||||
}, [options.title]);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export interface TraktAutosyncSettings {
|
|||
const DEFAULT_SETTINGS: TraktAutosyncSettings = {
|
||||
enabled: true,
|
||||
syncFrequency: 60000, // 60 seconds
|
||||
completionThreshold: 95, // 95%
|
||||
completionThreshold: 80, // 80% — Trakt API hardcoded threshold
|
||||
};
|
||||
|
||||
export function useTraktAutosyncSettings() {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ if (!TRAKT_CLIENT_ID || !TRAKT_CLIENT_SECRET) {
|
|||
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
|
||||
export interface TraktUser {
|
||||
username: string;
|
||||
|
|
@ -1871,21 +1876,24 @@ export class TraktService {
|
|||
*/
|
||||
public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
|
||||
try {
|
||||
// Validate content data before making API call
|
||||
const validation = this.validateContentData(contentData);
|
||||
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;
|
||||
}
|
||||
|
||||
const payload = await this.buildScrobblePayload(contentData, progress);
|
||||
if (!payload) {
|
||||
console.log('[TraktService] /scrobble/start payload is 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) {
|
||||
logger.error('[TraktService] Failed to start watching:', error);
|
||||
console.log('[TraktService] /scrobble/start ERROR:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1927,21 +1935,24 @@ export class TraktService {
|
|||
*/
|
||||
public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
|
||||
try {
|
||||
// Validate content data before making API call
|
||||
const validation = this.validateContentData(contentData);
|
||||
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;
|
||||
}
|
||||
|
||||
const payload = await this.buildScrobblePayload(contentData, progress);
|
||||
if (!payload) {
|
||||
console.log('[TraktService] /scrobble/stop payload is 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) {
|
||||
logger.error('[TraktService] Failed to stop watching:', error);
|
||||
console.log('[TraktService] /scrobble/stop ERROR:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2239,31 +2250,28 @@ export class TraktService {
|
|||
public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||||
try {
|
||||
if (!await this.isAuthenticated()) {
|
||||
console.log('[TraktService] scrobbleStart: not authenticated');
|
||||
return false;
|
||||
}
|
||||
|
||||
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)) {
|
||||
logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`);
|
||||
console.log(`[TraktService] scrobbleStart BLOCKED: recently scrobbled`);
|
||||
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;
|
||||
if (this.scrobbledItems.has(watchingKey)) {
|
||||
const scrobbledTime = this.scrobbledTimestamps.get(watchingKey);
|
||||
if (scrobbledTime && (Date.now() - scrobbledTime) < 30000) {
|
||||
console.log(`[TraktService] scrobbleStart BLOCKED: scrobbled ${((Date.now() - scrobbledTime) / 1000).toFixed(1)}s ago`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug log removed to reduce terminal noise
|
||||
|
||||
// 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
|
||||
this.currentlyWatching.delete(watchingKey);
|
||||
}
|
||||
|
||||
const result = await this.queueRequest(async () => {
|
||||
|
|
@ -2272,13 +2280,14 @@ export class TraktService {
|
|||
|
||||
if (result) {
|
||||
this.currentlyWatching.add(watchingKey);
|
||||
logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`);
|
||||
console.log(`[TraktService] scrobbleStart SUCCESS: ${contentData.title}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`[TraktService] scrobbleStart FAILED: result was null`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to start scrobbling:', error);
|
||||
console.log('[TraktService] scrobbleStart ERROR:', error);
|
||||
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> {
|
||||
try {
|
||||
|
|
@ -2338,52 +2351,48 @@ export class TraktService {
|
|||
const watchingKey = this.getWatchingKey(contentData);
|
||||
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);
|
||||
if (lastStopTime && (now - lastStopTime) < 1000) {
|
||||
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);
|
||||
|
||||
// Use pause if below user threshold, stop only when ready to scrobble
|
||||
const useStop = progress >= this.completionThreshold;
|
||||
// Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
|
||||
const result = await this.queueRequest(async () => {
|
||||
return useStop
|
||||
? await this.stopWatching(contentData, progress)
|
||||
: await this.pauseWatching(contentData, progress);
|
||||
return await this.stopWatching(contentData, progress);
|
||||
});
|
||||
|
||||
if (result) {
|
||||
this.currentlyWatching.delete(watchingKey);
|
||||
|
||||
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts
|
||||
if (progress >= this.completionThreshold) {
|
||||
// Mark as scrobbled if >= 80% to prevent future duplicates
|
||||
if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
this.scrobbledItems.add(watchingKey);
|
||||
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 >= this.completionThreshold ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||
|
||||
const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] /scrobble/stop sent: ${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;
|
||||
} catch (error) {
|
||||
// Handle rate limiting errors more gracefully
|
||||
if (error instanceof Error && error.message.includes('429')) {
|
||||
logger.warn('[TraktService] Rate limited, will retry later');
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.error('[TraktService] Failed to stop scrobbling:', error);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2429,6 +2438,7 @@ export class TraktService {
|
|||
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
|
|
@ -2438,7 +2448,12 @@ export class TraktService {
|
|||
|
||||
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);
|
||||
if (lastStopTime && (Date.now() - lastStopTime) < 200) {
|
||||
return true;
|
||||
|
|
@ -2446,24 +2461,19 @@ export class TraktService {
|
|||
|
||||
this.lastStopCalls.set(watchingKey, Date.now());
|
||||
|
||||
// BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble
|
||||
const useStop = progress >= this.completionThreshold;
|
||||
const result = useStop
|
||||
? await this.stopWatching(contentData, progress)
|
||||
: await this.pauseWatching(contentData, progress);
|
||||
// Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
|
||||
const result = await this.stopWatching(contentData, progress);
|
||||
|
||||
if (result) {
|
||||
this.currentlyWatching.delete(watchingKey);
|
||||
|
||||
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts
|
||||
if (progress >= this.completionThreshold) {
|
||||
if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
this.scrobbledItems.add(watchingKey);
|
||||
this.scrobbledTimestamps.set(watchingKey, Date.now());
|
||||
}
|
||||
|
||||
// Action reflects actual endpoint used based on user threshold
|
||||
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||
const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] IMMEDIATE /scrobble/stop: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue