trakt scrobble optimization

This commit is contained in:
tapframe 2025-10-19 13:59:40 +05:30
parent 64981dd110
commit 08f356cfa4
6 changed files with 599 additions and 49 deletions

View file

@ -108,6 +108,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Track recently removed items to prevent immediate re-addition
const recentlyRemovedRef = useRef<Set<string>>(new Set());
const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds
// Track last Trakt sync to prevent excessive API calls
const lastTraktSyncRef = useRef<number>(0);
const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs
// Cache for metadata to avoid redundant API calls
const metadataCache = useRef<Record<string, { metadata: any; basicContent: StreamingContent | null; timestamp: number }>>({});
@ -368,6 +372,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (!isAuthed) return;
// Check Trakt sync cooldown to prevent excessive API calls
const now = Date.now();
if (now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN) {
logger.log(`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`);
return;
}
lastTraktSyncRef.current = now;
const historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
for (const item of historyItems) {
@ -384,18 +397,21 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
}
const perShowPromises = Object.entries(latestWatchedByShow).map(async ([showId, info]) => {
// Collect all valid Trakt items first, then merge as a batch
const traktBatch: ContinueWatchingItem[] = [];
for (const [showId, info] of Object.entries(latestWatchedByShow)) {
try {
// Check if this show was recently removed by the user
const showKey = `series:${showId}`;
if (recentlyRemovedRef.current.has(showKey)) {
logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`);
return;
continue;
}
const nextEpisode = info.episode + 1;
const cachedData = await getCachedMetadata('series', showId);
if (!cachedData?.basicContent) return;
if (!cachedData?.basicContent) continue;
const { metadata, basicContent } = cachedData;
let nextEpisodeVideo = null;
if (metadata?.videos && Array.isArray(metadata.videos)) {
@ -405,18 +421,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
logger.log(` [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`);
await mergeBatchIntoState([
{
...basicContent,
id: showId,
type: 'series',
progress: 0,
lastUpdated: info.watchedAt,
season: info.season,
episode: nextEpisode,
episodeTitle: `Episode ${nextEpisode}`,
} as ContinueWatchingItem,
]);
traktBatch.push({
...basicContent,
id: showId,
type: 'series',
progress: 0,
lastUpdated: info.watchedAt,
season: info.season,
episode: nextEpisode,
episodeTitle: `Episode ${nextEpisode}`,
} as ContinueWatchingItem);
}
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
@ -445,8 +459,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} catch (err) {
// Continue with other shows even if one fails
}
});
await Promise.allSettled(perShowPromises);
}
// Merge all Trakt items as a single batch to ensure proper sorting
if (traktBatch.length > 0) {
await mergeBatchIntoState(traktBatch);
}
} catch (err) {
// Continue even if Trakt history merge fails
}
@ -475,7 +493,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
appState.current.match(/inactive|background/) &&
nextAppState === 'active'
) {
// App has come to the foreground - trigger a background refresh
// App has come to the foreground - force Trakt sync by resetting cooldown
lastTraktSyncRef.current = 0; // Reset cooldown to allow immediate Trakt sync
loadContinueWatching(true);
}
appState.current = nextAppState;
@ -493,9 +512,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
clearTimeout(refreshTimerRef.current);
}
refreshTimerRef.current = setTimeout(() => {
// Trigger a background refresh
// Only trigger background refresh for local progress updates, not Trakt sync
// This prevents the feedback loop where Trakt sync triggers more progress updates
loadContinueWatching(true);
}, 800); // Shorter debounce for snappier UI without battery impact
}, 2000); // Increased debounce to reduce frequency
};
// Try to set up a custom event listener or use a timer as fallback
@ -543,7 +563,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Expose the refresh function via the ref
React.useImperativeHandle(ref, () => ({
refresh: async () => {
// Allow manual refresh to show loading indicator
// Manual refresh bypasses Trakt cooldown to get fresh data
lastTraktSyncRef.current = 0; // Reset cooldown for manual refresh
await loadContinueWatching(false);
return true;
}

View file

@ -1187,6 +1187,12 @@ const AndroidVideoPlayer: React.FC = () => {
if (isMounted.current) {
setSeekTime(null);
isSeeking.current = false;
// IMMEDIATE SYNC: Update Trakt progress immediately after seeking
if (duration > 0 && data?.currentTime !== undefined) {
traktAutosync.handleProgressUpdate(data.currentTime, duration, true); // force=true for immediate sync
}
// Resume playback on iOS if we paused for seeking
if (Platform.OS === 'ios') {
const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false || isDragging;

View file

@ -866,6 +866,9 @@ const KSPlayerCore: React.FC = () => {
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] KSPlayer seek completed to ${timeInSeconds.toFixed(2)}s`);
}
// IMMEDIATE SYNC: Update Trakt progress immediately after seeking
traktAutosync.handleProgressUpdate(timeInSeconds, duration, true); // force=true for immediate sync
}
}, 500);
};

View file

@ -35,6 +35,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const hasStartedWatching = useRef(false);
const hasStopped = useRef(false); // New: Track if we've already stopped for this session
const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled)
const isUnmounted = useRef(false); // New: Track if component has unmounted
const lastSyncTime = useRef(0);
const lastSyncProgress = useRef(0);
const sessionKey = useRef<string | null>(null);
@ -43,21 +44,23 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Generate a unique session key for this content instance
useEffect(() => {
const contentKey = options.type === 'movie'
const contentKey = options.type === 'movie'
? `movie:${options.imdbId}`
: `episode:${options.imdbId}:${options.season}:${options.episode}`;
: `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`;
sessionKey.current = `${contentKey}:${Date.now()}`;
// Reset all session state for new content
hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false;
isUnmounted.current = false; // Reset unmount flag for new mount
lastStopCall.current = 0;
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
return () => {
unmountCount.current++;
isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
};
}, [options.imdbId, options.season, options.episode, options.type]);
@ -104,8 +107,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Start watching (scrobble start)
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
if (isUnmounted.current) return; // Prevent execution after component unmount
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`);
if (!isAuthenticated || !autosyncSettings.enabled) {
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
return;
@ -156,6 +161,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
duration: number,
force: boolean = false
) => {
if (isUnmounted.current) return; // Prevent execution after component unmount
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
return;
}
@ -231,6 +238,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Handle playback end/pause
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => {
if (isUnmounted.current) return; // Prevent execution after component unmount
const now = Date.now();
// Removed excessive logging for handlePlaybackEnd calls
@ -339,12 +348,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
return;
}
// For natural end events, ensure we cross Trakt's 80% scrobble threshold reliably.
// If close to the end, boost to 95% to avoid rounding issues.
if (reason === 'ended' && progressPercent < 95) {
logger.log(`[TraktAutosync] Natural end detected at ${progressPercent.toFixed(1)}%, boosting to 95% for scrobble`);
progressPercent = 95;
}
// Note: No longer boosting progress since Trakt API handles 80% threshold correctly
// Mark stop attempt and update timestamp
lastStopCall.current = now;
@ -368,8 +372,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
currentTime
);
// Mark session as complete if high progress (scrobbled)
if (progressPercent >= 80) {
// Mark session as complete if >= user completion threshold
if (progressPercent >= autosyncSettings.completionThreshold) {
isSessionComplete.current = true;
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
@ -420,6 +424,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false;
isUnmounted.current = false;
lastSyncTime.current = 0;
lastSyncProgress.current = 0;
unmountCount.current = 0;

View file

@ -562,7 +562,7 @@ export class TraktService {
// Rate limiting - Optimized for real-time scrobbling
private lastApiCall: number = 0;
private readonly MIN_API_INTERVAL = 1000; // Reduced from 3000ms to 1000ms for real-time updates
private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates
private requestQueue: Array<() => Promise<any>> = [];
private isProcessingQueue: boolean = false;
@ -1740,8 +1740,8 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData);
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 300ms)
if (!force && (now - lastSync) < 300) {
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms)
if (!force && (now - lastSync) < 100) {
return true; // Skip this sync, but return success
}
@ -1791,13 +1791,12 @@ export class TraktService {
// Record this stop attempt
this.lastStopCalls.set(watchingKey, now);
// Respect higher user threshold by pausing below effective threshold
const effectiveThreshold = Math.max(80, this.completionThreshold);
// Use pause if below user threshold, stop only when ready to scrobble
const useStop = progress >= this.completionThreshold;
const result = await this.queueRequest(async () => {
if (progress < effectiveThreshold) {
return await this.pauseWatching(contentData, progress);
}
return await this.stopWatching(contentData, progress);
return useStop
? await this.stopWatching(contentData, progress)
: await this.pauseWatching(contentData, progress);
});
if (result) {
@ -1810,7 +1809,8 @@ export class TraktService {
logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`);
}
const action = progress >= effectiveThreshold ? 'scrobbled' : 'paused';
// 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})`);
return true;
@ -1889,11 +1889,11 @@ export class TraktService {
this.lastStopCalls.set(watchingKey, Date.now());
// BYPASS QUEUE: Respect higher user threshold by pausing below effective threshold
const effectiveThreshold = Math.max(80, this.completionThreshold);
const result = progress < effectiveThreshold
? await this.pauseWatching(contentData, progress)
: await this.stopWatching(contentData, progress);
// 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);
if (result) {
this.currentlyWatching.delete(watchingKey);
@ -1904,7 +1904,8 @@ export class TraktService {
this.scrobbledTimestamps.set(watchingKey, Date.now());
}
const action = progress >= effectiveThreshold ? 'scrobbled' : 'paused';
// 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})`);
return true;

514
trakt/docs.md Normal file
View file

@ -0,0 +1,514 @@
Scrobble / Start / Start watching in a media center POSThttps://api.trakt.tv/scrobble/startRequestStart watching a movie by sending a standard movie object.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
},
"progress": 1.25
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 1.25,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
RequestStart watching an episode by sending a standard episode object.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"episode": {
"ids": {
"trakt": 16
}
},
"progress": 10
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 10,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestStart watching an episode if you don't have episode ids, but have show info. Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"tvdb": 81189
}
},
"episode": {
"season": 1,
"number": 1
},
"progress": 10
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 10,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestStart watching an episode using absolute numbering (useful for Anime and Donghua). Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696
}
},
"episode": {
"number_abs": 164
},
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"progress": 10
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 10,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 9,
"number": 21,
"title": "Light the Fire of Shandia! Wiper the Warrior",
"ids": {
"trakt": 856373,
"tvdb": 362082,
"imdb": null,
"tmdb": null
}
},
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696,
"slug": "one-piece",
"tvdb": 81797,
"imdb": "tt0388629",
"tmdb": 37854
}
}
}
Scrobble / Pause / Pause watching in a media center POSThttps://api.trakt.tv/scrobble/pauseRequest
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
},
"progress": 75
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 1337,
"action": "pause",
"progress": 75,
"sharing": {
"twitter": false,
"mastodon": false,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
BODY
{
"id": 3373536622,
"action": "scrobble",
"progress": 99.9,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
RequestScrobble an episode by sending a standard episode object.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"episode": {
"ids": {
"trakt": 16
}
},
"progress": 85
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 3373536623,
"action": "scrobble",
"progress": 85,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestScrobble an episode if you don't have episode ids, but have show info. Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"tvdb": 81189
}
},
"episode": {
"season": 1,
"number": 1
},
"progress": 85
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 3373536623,
"action": "scrobble",
"progress": 85,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestScrobble an episode using absolute numbering (useful for Anime and Donghua). Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696
}
},
"episode": {
"number_abs": 164
},
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"progress": 90
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 3373536624,
"action": "scrobble",
"progress": 90,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 9,
"number": 21,
"title": "Light the Fire of Shandia! Wiper the Warrior",
"ids": {
"trakt": 856373,
"tvdb": 362082,
"imdb": null,
"tmdb": null
}
},
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696,
"slug": "one-piece",
"tvdb": 81797,
"imdb": "tt0388629",
"tmdb": 37854
}
}
}
RequestIf the progress is < 80%, the video will be treated a a pause and the playback position will be saved.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
},
"progress": 75
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 1337,
"action": "pause",
"progress": 75,
"sharing": {
"twitter": false,
"mastodon": true,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
ResponseThe same item was recently scrobbled.
409
HEADERS
Content-Type:application/json
BODY
{
"watched_at": "2014-10-15T22:21:29.000Z",
"expires_at": "2014-10-15T23:21:29.000Z"
}