diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index f6b0a387..ce213824 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -108,6 +108,10 @@ const ContinueWatchingSection = React.forwardRef((props, re // Track recently removed items to prevent immediate re-addition const recentlyRemovedRef = useRef>(new Set()); const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds + + // Track last Trakt sync to prevent excessive API calls + const lastTraktSyncRef = useRef(0); + const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs // Cache for metadata to avoid redundant API calls const metadataCache = useRef>({}); @@ -368,6 +372,15 @@ const ContinueWatchingSection = React.forwardRef((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 = {}; for (const item of historyItems) { @@ -384,18 +397,21 @@ const ContinueWatchingSection = React.forwardRef((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((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((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((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((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((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; } diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 2354da8e..40d1d1fb 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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; diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 688af143..ae9db995 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -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); }; diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 5a6edeac..fa181e1d 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -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(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; diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 3fdb1b35..2f420401 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -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> = []; 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; diff --git a/trakt/docs.md b/trakt/docs.md new file mode 100644 index 00000000..b9f7d3c6 --- /dev/null +++ b/trakt/docs.md @@ -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" +} \ No newline at end of file