Implement Trakt autosync functionality across video player components

This update integrates Trakt autosync capabilities into the AndroidVideoPlayer and VideoPlayer components, allowing for automatic syncing of watch progress and playback events. Key features include starting a watching session, updating progress during playback, and handling playback end events to ensure accurate tracking. Additionally, the useTraktIntegration and useTraktAutosync hooks have been enhanced to support these functionalities, improving the overall user experience by maintaining consistent watch history across devices.
This commit is contained in:
tapframe 2025-06-19 21:39:47 +05:30
parent 6acf84677f
commit 235a7eff24
14 changed files with 1623 additions and 70 deletions

View file

@ -399,7 +399,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<View style={styles.episodeContent}>
{/* Episode Number Badge */}
<View style={styles.episodeNumberBadgeHorizontal}>
<Text style={styles.episodeNumberHorizontal}>{episodeString}</Text>
<Text style={styles.episodeNumberHorizontal}>{episodeString}</Text>
</View>
{/* Episode Title */}
@ -416,9 +416,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<View style={styles.episodeMetadataRowHorizontal}>
{episode.runtime && (
<View style={styles.runtimeContainerHorizontal}>
<Text style={styles.runtimeTextHorizontal}>
{formatRuntime(episode.runtime)}
</Text>
<Text style={styles.runtimeTextHorizontal}>
{formatRuntime(episode.runtime)}
</Text>
</View>
)}
{episode.vote_average > 0 && (

View file

@ -10,6 +10,7 @@ import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { MaterialIcons } from '@expo/vector-icons';
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import {
DEFAULT_SUBTITLE_SIZE,
@ -63,6 +64,21 @@ const AndroidVideoPlayer: React.FC = () => {
availableStreams: passedAvailableStreams
} = route.params;
// Initialize Trakt autosync
const traktAutosync = useTraktAutosync({
id: id || '',
type: type === 'series' ? 'series' : 'movie',
title: episodeTitle || title,
year: year || 0,
imdbId: imdbId || '',
season: season,
episode: episode,
showTitle: title,
showYear: year,
showImdbId: imdbId,
episodeId: episodeId
});
safeDebugLog("Android Component mounted with props", {
uri, title, season, episode, episodeTitle, quality, year,
streamProvider, id, type, episodeId, imdbId
@ -276,6 +292,9 @@ const AndroidVideoPlayer: React.FC = () => {
};
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
// Sync to Trakt if authenticated
await traktAutosync.handleProgressUpdate(currentTime, duration);
} catch (error) {
logger.error('[AndroidVideoPlayer] Error saving watch progress:', error);
}
@ -302,6 +321,8 @@ const AndroidVideoPlayer: React.FC = () => {
return () => {
if (id && type && duration > 0) {
saveWatchProgress();
// Final Trakt sync on component unmount
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
}
};
}, [id, type, currentTime, duration]);
@ -431,6 +452,9 @@ const AndroidVideoPlayer: React.FC = () => {
setIsVideoLoaded(true);
setIsPlayerReady(true);
// Start Trakt watching session when video loads
traktAutosync.handlePlaybackStart(currentTime, data.duration || duration);
if (initialPosition && !isInitialSeekComplete) {
setTimeout(() => {
if (videoRef.current && duration > 0 && isMounted.current) {
@ -484,6 +508,12 @@ const AndroidVideoPlayer: React.FC = () => {
};
const handleClose = () => {
logger.log('[AndroidVideoPlayer] Close button pressed - syncing to Trakt before closing');
logger.log(`[AndroidVideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`);
// Sync progress to Trakt before closing
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
@ -597,7 +627,8 @@ const AndroidVideoPlayer: React.FC = () => {
};
const onEnd = () => {
// End logic here
// Sync final progress to Trakt
traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended');
};
const selectAudioTrack = (trackId: number) => {

View file

@ -11,6 +11,7 @@ import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { MaterialIcons } from '@expo/vector-icons';
import AndroidVideoPlayer from './AndroidVideoPlayer';
import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import {
DEFAULT_SUBTITLE_SIZE,
@ -58,6 +59,21 @@ const VideoPlayer: React.FC = () => {
availableStreams: passedAvailableStreams
} = route.params;
// Initialize Trakt autosync
const traktAutosync = useTraktAutosync({
id: id || '',
type: type === 'series' ? 'series' : 'movie',
title: episodeTitle || title,
year: year || 0,
imdbId: imdbId || '',
season: season,
episode: episode,
showTitle: title,
showYear: year,
showImdbId: imdbId,
episodeId: episodeId
});
safeDebugLog("Component mounted with props", {
uri, title, season, episode, episodeTitle, quality, year,
streamProvider, id, type, episodeId, imdbId
@ -271,6 +287,9 @@ const VideoPlayer: React.FC = () => {
};
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
// Sync to Trakt if authenticated
await traktAutosync.handleProgressUpdate(currentTime, duration);
} catch (error) {
logger.error('[VideoPlayer] Error saving watch progress:', error);
}
@ -297,6 +316,8 @@ const VideoPlayer: React.FC = () => {
return () => {
if (id && type && duration > 0) {
saveWatchProgress();
// Final Trakt sync on component unmount
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
}
};
}, [id, type, currentTime, duration]);
@ -304,6 +325,11 @@ const VideoPlayer: React.FC = () => {
const onPlaying = () => {
if (isMounted.current && !isSeeking.current) {
setPaused(false);
// Start Trakt watching session only if duration is loaded
if (duration > 0) {
traktAutosync.handlePlaybackStart(currentTime, duration);
}
}
};
@ -490,6 +516,12 @@ const VideoPlayer: React.FC = () => {
};
const handleClose = () => {
logger.log('[VideoPlayer] Close button pressed - syncing to Trakt before closing');
logger.log(`[VideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`);
// Sync progress to Trakt before closing
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
@ -603,7 +635,8 @@ const VideoPlayer: React.FC = () => {
};
const onEnd = () => {
// End logic here
// Sync final progress to Trakt
traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended');
};
const selectAudioTrack = (trackId: number) => {

View file

@ -97,10 +97,10 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) =
const updateProgress = () => {
'worklet';
progressOpacity.value = withTiming(hasProgress ? 1 : 0, {
duration: hasProgress ? 200 : 150,
easing: easings.fast
});
progressOpacity.value = withTiming(hasProgress ? 1 : 0, {
duration: hasProgress ? 200 : 150,
easing: easings.fast
});
};
runOnUI(updateProgress)();

View file

@ -0,0 +1,258 @@
import { useCallback, useRef, useEffect } from 'react';
import { useTraktIntegration } from './useTraktIntegration';
import { useTraktAutosyncSettings } from './useTraktAutosyncSettings';
import { TraktContentData } from '../services/traktService';
import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
interface TraktAutosyncOptions {
id: string;
type: 'movie' | 'series';
title: string;
year: number | string; // Allow both for compatibility
imdbId: string;
// For episodes
season?: number;
episode?: number;
showTitle?: string;
showYear?: number | string; // Allow both for compatibility
showImdbId?: string;
episodeId?: string;
}
export function useTraktAutosync(options: TraktAutosyncOptions) {
const {
isAuthenticated,
startWatching,
updateProgress,
stopWatching
} = useTraktIntegration();
const { settings: autosyncSettings } = useTraktAutosyncSettings();
const hasStartedWatching = useRef(false);
const lastSyncTime = useRef(0);
const lastSyncProgress = useRef(0);
const sessionKey = useRef<string | null>(null);
const unmountCount = useRef(0);
// Generate a unique session key for this content instance
useEffect(() => {
const contentKey = options.type === 'movie'
? `movie:${options.imdbId}`
: `episode:${options.imdbId}:${options.season}:${options.episode}`;
sessionKey.current = `${contentKey}:${Date.now()}`;
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
return () => {
unmountCount.current++;
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
};
}, [options.imdbId, options.season, options.episode, options.type]);
// Build Trakt content data from options
const buildContentData = useCallback((): TraktContentData => {
// Ensure year is a number and valid
const parseYear = (year: number | string | undefined): number => {
if (!year) return 0;
if (typeof year === 'number') return year;
const parsed = parseInt(year.toString(), 10);
return isNaN(parsed) ? 0 : parsed;
};
const numericYear = parseYear(options.year);
const numericShowYear = parseYear(options.showYear);
// Validate required fields
if (!options.title || !options.imdbId) {
logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId });
}
if (options.type === 'movie') {
return {
type: 'movie',
imdbId: options.imdbId,
title: options.title,
year: numericYear
};
} else {
return {
type: 'episode',
imdbId: options.imdbId,
title: options.title,
year: numericYear,
season: options.season,
episode: options.episode,
showTitle: options.showTitle || options.title,
showYear: numericShowYear || numericYear,
showImdbId: options.showImdbId || options.imdbId
};
}
}, [options]);
// Start watching (scrobble start)
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, session=${sessionKey.current}`);
if (!isAuthenticated || !autosyncSettings.enabled || hasStartedWatching.current) {
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}`);
return;
}
if (duration <= 0) {
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`);
return;
}
try {
const progressPercent = (currentTime / duration) * 100;
const contentData = buildContentData();
const success = await startWatching(contentData, progressPercent);
if (success) {
hasStartedWatching.current = true;
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
}
} catch (error) {
logger.error('[TraktAutosync] Error starting watch:', error);
}
}, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]);
// Sync progress during playback
const handleProgressUpdate = useCallback(async (
currentTime: number,
duration: number,
force: boolean = false
) => {
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
return;
}
try {
const progressPercent = (currentTime / duration) * 100;
const now = Date.now();
// Use the user's configured sync frequency
const timeSinceLastSync = now - lastSyncTime.current;
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
if (!force && timeSinceLastSync < autosyncSettings.syncFrequency && progressDiff < 5) {
return;
}
const contentData = buildContentData();
const success = await updateProgress(contentData, progressPercent, force);
if (success) {
lastSyncTime.current = now;
lastSyncProgress.current = progressPercent;
// Update local storage sync status
await storageService.updateTraktSyncStatus(
options.id,
options.type,
true,
progressPercent,
options.episodeId
);
logger.log(`[TraktAutosync] Synced progress ${progressPercent.toFixed(1)}%: ${contentData.title}`);
}
} catch (error) {
logger.error('[TraktAutosync] Error syncing progress:', error);
}
}, [isAuthenticated, autosyncSettings.enabled, autosyncSettings.syncFrequency, updateProgress, buildContentData, options]);
// Handle playback end/pause
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => {
logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`);
if (!isAuthenticated || !autosyncSettings.enabled) {
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
return;
}
// Skip rapid unmount calls (likely from React strict mode or component remounts)
if (reason === 'unmount' && unmountCount.current > 1) {
logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
return;
}
try {
let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
logger.log(`[TraktAutosync] Initial progress calculation: ${progressPercent.toFixed(1)}%`);
// If progress is 0 during unmount, use the last synced progress instead
// This happens when video player state is reset before component unmount
if (reason === 'unmount' && progressPercent < 1 && lastSyncProgress.current > 0) {
progressPercent = lastSyncProgress.current;
logger.log(`[TraktAutosync] Using last synced progress for unmount: ${progressPercent.toFixed(1)}%`);
}
// If we have valid progress but no started session, force start one first
if (!hasStartedWatching.current && progressPercent > 1) {
logger.log(`[TraktAutosync] Force starting session for progress: ${progressPercent.toFixed(1)}%`);
const contentData = buildContentData();
const success = await startWatching(contentData, progressPercent);
if (success) {
hasStartedWatching.current = true;
logger.log(`[TraktAutosync] Force started watching: ${contentData.title}`);
}
}
// Only stop if we have meaningful progress (>= 1%) or it's a natural video end
// Skip unmount calls with very low progress unless video actually ended
if (reason === 'unmount' && progressPercent < 1) {
logger.log(`[TraktAutosync] Skipping unmount stop for ${options.title} - too early (${progressPercent.toFixed(1)}%)`);
return;
}
const contentData = buildContentData();
// Use stopWatching for proper scrobble stop
const success = await stopWatching(contentData, progressPercent);
if (success) {
// Update local storage sync status
await storageService.updateTraktSyncStatus(
options.id,
options.type,
true,
progressPercent,
options.episodeId
);
}
// 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)}%`);
}
logger.log(`[TraktAutosync] Ended watching: ${options.title} (${reason})`);
} catch (error) {
logger.error('[TraktAutosync] Error ending watch:', error);
}
}, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]);
// Reset state (useful when switching content)
const resetState = useCallback(() => {
hasStartedWatching.current = false;
lastSyncTime.current = 0;
lastSyncProgress.current = 0;
unmountCount.current = 0;
sessionKey.current = null;
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
}, [options.title]);
return {
isAuthenticated,
handlePlaybackStart,
handleProgressUpdate,
handlePlaybackEnd,
resetState
};
}

View file

@ -0,0 +1,159 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useTraktIntegration } from './useTraktIntegration';
import { logger } from '../utils/logger';
const TRAKT_AUTOSYNC_ENABLED_KEY = '@trakt_autosync_enabled';
const TRAKT_SYNC_FREQUENCY_KEY = '@trakt_sync_frequency';
const TRAKT_COMPLETION_THRESHOLD_KEY = '@trakt_completion_threshold';
export interface TraktAutosyncSettings {
enabled: boolean;
syncFrequency: number; // in milliseconds
completionThreshold: number; // percentage (80-95)
}
const DEFAULT_SETTINGS: TraktAutosyncSettings = {
enabled: true,
syncFrequency: 60000, // 60 seconds
completionThreshold: 95, // 95%
};
export function useTraktAutosyncSettings() {
const {
isAuthenticated,
syncAllProgress,
fetchAndMergeTraktProgress
} = useTraktIntegration();
const [settings, setSettings] = useState<TraktAutosyncSettings>(DEFAULT_SETTINGS);
const [isLoading, setIsLoading] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
// Load settings from storage
const loadSettings = useCallback(async () => {
try {
setIsLoading(true);
const [enabled, frequency, threshold] = await Promise.all([
AsyncStorage.getItem(TRAKT_AUTOSYNC_ENABLED_KEY),
AsyncStorage.getItem(TRAKT_SYNC_FREQUENCY_KEY),
AsyncStorage.getItem(TRAKT_COMPLETION_THRESHOLD_KEY)
]);
setSettings({
enabled: enabled !== null ? JSON.parse(enabled) : DEFAULT_SETTINGS.enabled,
syncFrequency: frequency ? parseInt(frequency, 10) : DEFAULT_SETTINGS.syncFrequency,
completionThreshold: threshold ? parseInt(threshold, 10) : DEFAULT_SETTINGS.completionThreshold,
});
} catch (error) {
logger.error('[useTraktAutosyncSettings] Error loading settings:', error);
setSettings(DEFAULT_SETTINGS);
} finally {
setIsLoading(false);
}
}, []);
// Save individual setting
const saveSetting = useCallback(async (key: string, value: any) => {
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch (error) {
logger.error('[useTraktAutosyncSettings] Error saving setting:', error);
}
}, []);
// Update autosync enabled status
const setAutosyncEnabled = useCallback(async (enabled: boolean) => {
try {
await saveSetting(TRAKT_AUTOSYNC_ENABLED_KEY, enabled);
setSettings(prev => ({ ...prev, enabled }));
logger.log(`[useTraktAutosyncSettings] Autosync ${enabled ? 'enabled' : 'disabled'}`);
} catch (error) {
logger.error('[useTraktAutosyncSettings] Error updating autosync enabled:', error);
}
}, [saveSetting]);
// Update sync frequency
const setSyncFrequency = useCallback(async (frequency: number) => {
try {
await saveSetting(TRAKT_SYNC_FREQUENCY_KEY, frequency);
setSettings(prev => ({ ...prev, syncFrequency: frequency }));
logger.log(`[useTraktAutosyncSettings] Sync frequency updated to ${frequency}ms`);
} catch (error) {
logger.error('[useTraktAutosyncSettings] Error updating sync frequency:', error);
}
}, [saveSetting]);
// Update completion threshold
const setCompletionThreshold = useCallback(async (threshold: number) => {
try {
await saveSetting(TRAKT_COMPLETION_THRESHOLD_KEY, threshold);
setSettings(prev => ({ ...prev, completionThreshold: threshold }));
logger.log(`[useTraktAutosyncSettings] Completion threshold updated to ${threshold}%`);
} catch (error) {
logger.error('[useTraktAutosyncSettings] Error updating completion threshold:', error);
}
}, [saveSetting]);
// Manual sync all progress
const performManualSync = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) {
logger.warn('[useTraktAutosyncSettings] Cannot sync: not authenticated');
return false;
}
try {
setIsSyncing(true);
logger.log('[useTraktAutosyncSettings] Starting manual sync...');
// First, fetch and merge Trakt progress with local
await fetchAndMergeTraktProgress();
// Then, sync any unsynced local progress to Trakt
const success = await syncAllProgress();
logger.log(`[useTraktAutosyncSettings] Manual sync ${success ? 'completed' : 'failed'}`);
return success;
} catch (error) {
logger.error('[useTraktAutosyncSettings] Error during manual sync:', error);
return false;
} finally {
setIsSyncing(false);
}
}, [isAuthenticated, syncAllProgress, fetchAndMergeTraktProgress]);
// Get formatted sync frequency options
const getSyncFrequencyOptions = useCallback(() => [
{ label: 'Every 30 seconds', value: 30000 },
{ label: 'Every minute', value: 60000 },
{ label: 'Every 2 minutes', value: 120000 },
{ label: 'Every 5 minutes', value: 300000 },
], []);
// Get formatted completion threshold options
const getCompletionThresholdOptions = useCallback(() => [
{ label: '80% complete', value: 80 },
{ label: '85% complete', value: 85 },
{ label: '90% complete', value: 90 },
{ label: '95% complete', value: 95 },
], []);
// Load settings on mount
useEffect(() => {
loadSettings();
}, [loadSettings]);
return {
settings,
isLoading,
isSyncing,
isAuthenticated,
setAutosyncEnabled,
setSyncFrequency,
setCompletionThreshold,
performManualSync,
getSyncFrequencyOptions,
getCompletionThresholdOptions,
loadSettings
};
}

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { traktService, TraktUser, TraktWatchedItem } from '../services/traktService';
import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService';
import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
export function useTraktIntegration() {
@ -128,6 +129,179 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, loadWatchedItems]);
// Start watching content (scrobble start)
const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.scrobbleStart(contentData, progress);
} catch (error) {
logger.error('[useTraktIntegration] Error starting watch:', error);
return false;
}
}, [isAuthenticated]);
// Update progress while watching (scrobble pause)
const updateProgress = useCallback(async (
contentData: TraktContentData,
progress: number,
force: boolean = false
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.scrobblePause(contentData, progress, force);
} catch (error) {
logger.error('[useTraktIntegration] Error updating progress:', error);
return false;
}
}, [isAuthenticated]);
// Stop watching content (scrobble stop)
const stopWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.scrobbleStop(contentData, progress);
} catch (error) {
logger.error('[useTraktIntegration] Error stopping watch:', error);
return false;
}
}, [isAuthenticated]);
// Sync progress to Trakt (legacy method)
const syncProgress = useCallback(async (
contentData: TraktContentData,
progress: number,
force: boolean = false
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
return await traktService.syncProgressToTrakt(contentData, progress, force);
} catch (error) {
logger.error('[useTraktIntegration] Error syncing progress:', error);
return false;
}
}, [isAuthenticated]);
// Get playback progress from Trakt
const getTraktPlaybackProgress = useCallback(async (type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> => {
if (!isAuthenticated) return [];
try {
return await traktService.getPlaybackProgress(type);
} catch (error) {
logger.error('[useTraktIntegration] Error getting playback progress:', error);
return [];
}
}, [isAuthenticated]);
// Sync all local progress to Trakt
const syncAllProgress = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const unsyncedProgress = await storageService.getUnsyncedProgress();
logger.log(`[useTraktIntegration] Found ${unsyncedProgress.length} unsynced progress entries`);
let syncedCount = 0;
const batchSize = 5; // Process in smaller batches
const delayBetweenBatches = 2000; // 2 seconds between batches
// Process items in batches to avoid overwhelming the API
for (let i = 0; i < unsyncedProgress.length; i += batchSize) {
const batch = unsyncedProgress.slice(i, i + batchSize);
// Process batch items with individual error handling
const batchPromises = batch.map(async (item) => {
try {
// Build content data from stored progress
const contentData: TraktContentData = {
type: item.type as 'movie' | 'episode',
imdbId: item.id,
title: 'Unknown', // We don't store title in progress, this would need metadata lookup
year: 0,
season: item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined,
episode: item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined
};
const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
const success = await traktService.syncProgressToTrakt(contentData, progressPercent, true);
if (success) {
await storageService.updateTraktSyncStatus(item.id, item.type, true, progressPercent, item.episodeId);
return true;
}
return false;
} catch (error) {
logger.error('[useTraktIntegration] Error syncing individual progress:', error);
return false;
}
});
// Wait for batch to complete
const batchResults = await Promise.all(batchPromises);
syncedCount += batchResults.filter(result => result).length;
// Delay between batches to avoid rate limiting
if (i + batchSize < unsyncedProgress.length) {
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
}
}
logger.log(`[useTraktIntegration] Synced ${syncedCount}/${unsyncedProgress.length} progress entries`);
return syncedCount > 0;
} catch (error) {
logger.error('[useTraktIntegration] Error syncing all progress:', error);
return false;
}
}, [isAuthenticated]);
// Fetch and merge Trakt progress with local progress
const fetchAndMergeTraktProgress = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const traktProgress = await getTraktPlaybackProgress();
for (const item of traktProgress) {
try {
let id: string;
let type: string;
let episodeId: string | undefined;
if (item.type === 'movie' && item.movie) {
id = item.movie.ids.imdb;
type = 'movie';
} else if (item.type === 'episode' && item.show && item.episode) {
id = item.show.ids.imdb;
type = 'series';
episodeId = `S${item.episode.season}E${item.episode.number}`;
} else {
continue;
}
await storageService.mergeWithTraktProgress(
id,
type,
item.progress,
item.paused_at,
episodeId
);
} catch (error) {
logger.error('[useTraktIntegration] Error merging individual Trakt progress:', error);
}
}
logger.log(`[useTraktIntegration] Merged ${traktProgress.length} Trakt progress entries`);
return true;
} catch (error) {
logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error);
return false;
}
}, [isAuthenticated, getTraktPlaybackProgress]);
// Initialize and check auth status
useEffect(() => {
checkAuthStatus();
@ -140,6 +314,14 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, loadWatchedItems]);
// Auto-sync when authenticated changes
useEffect(() => {
if (isAuthenticated) {
// Fetch Trakt progress and merge with local
fetchAndMergeTraktProgress();
}
}, [isAuthenticated, fetchAndMergeTraktProgress]);
return {
isAuthenticated,
isLoading,
@ -152,6 +334,13 @@ export function useTraktIntegration() {
isEpisodeWatched,
markMovieAsWatched,
markEpisodeAsWatched,
refreshAuthStatus
refreshAuthStatus,
startWatching,
updateProgress,
stopWatching,
syncProgress, // legacy
getTraktPlaybackProgress,
syncAllProgress,
fetchAndMergeTraktProgress
};
}

View file

@ -637,7 +637,7 @@ const HomeScreen = () => {
handleSaveToLibrary,
hasContinueWatching,
catalogs,
catalogsLoading,
catalogsLoading,
navigation,
featuredContentSource
]);

View file

@ -592,33 +592,33 @@ const LibraryScreen = () => {
if (allItems.length === 0) {
return (
<View style={styles.emptyContainer}>
<MaterialIcons
name="video-library"
size={80}
color={currentTheme.colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text>
</TouchableOpacity>
</View>
<View style={styles.emptyContainer}>
<MaterialIcons
name="video-library"
size={80}
color={currentTheme.colors.mediumGray}
style={{ opacity: 0.7 }}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your library is empty</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Add content to your library to keep track of what you're watching
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => navigation.navigate('Discover')}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlatList
<FlatList
data={allItems}
renderItem={({ item }) => {
if (item.type === 'trakt-folder') {
@ -626,16 +626,16 @@ const LibraryScreen = () => {
}
return renderItem({ item: item as LibraryItem });
}}
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
);
};

View file

@ -305,11 +305,11 @@ const SearchScreen = () => {
const saveRecentSearch = async (searchQuery: string) => {
try {
setRecentSearches(prevSearches => {
const newRecentSearches = [
searchQuery,
const newRecentSearches = [
searchQuery,
...prevSearches.filter(s => s !== searchQuery)
].slice(0, MAX_RECENT_SEARCHES);
].slice(0, MAX_RECENT_SEARCHES);
// Save to AsyncStorage
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));

View file

@ -689,7 +689,7 @@ export const StreamsScreen = () => {
if (!success) {
console.log('VideoPlayerService failed, falling back to built-in player');
navigateToPlayer(stream);
navigateToPlayer(stream);
}
}
} catch (error) {

View file

@ -11,6 +11,8 @@ import {
ScrollView,
StatusBar,
Platform,
Linking,
Switch,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
@ -20,6 +22,9 @@ import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger';
import TraktIcon from '../../assets/rating-icons/trakt.svg';
import { useTheme } from '../contexts/ThemeContext';
import { useTraktIntegration } from '../hooks/useTraktIntegration';
import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings';
import { colors } from '../styles';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -44,6 +49,21 @@ const TraktSettingsScreen: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const { currentTheme } = useTheme();
const {
settings: autosyncSettings,
isSyncing,
setAutosyncEnabled,
performManualSync
} = useTraktAutosyncSettings();
const {
isLoading: traktLoading,
refreshAuthStatus
} = useTraktIntegration();
const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false);
const [showThresholdModal, setShowThresholdModal] = useState(false);
const checkAuthStatus = useCallback(async () => {
setIsLoading(true);
@ -180,7 +200,7 @@ const TraktSettingsScreen: React.FC = () => {
</TouchableOpacity>
<Text style={[
styles.headerTitle,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
]}>
Trakt Settings
</Text>
@ -308,18 +328,31 @@ const TraktSettingsScreen: React.FC = () => {
Sync Settings
</Text>
<View style={styles.settingItem}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Auto-sync playback progress
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Coming soon
</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}>
Auto-sync playback progress
</Text>
<Text style={[
styles.settingDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Automatically sync watch progress to Trakt
</Text>
</View>
<Switch
value={autosyncSettings.enabled}
onValueChange={setAutosyncEnabled}
trackColor={{
false: isDarkMode ? 'rgba(120,120,128,0.3)' : 'rgba(120,120,128,0.2)',
true: currentTheme.colors.primary + '80'
}}
thumbColor={autosyncSettings.enabled ? currentTheme.colors.primary : (isDarkMode ? '#ffffff' : '#f4f3f4')}
/>
</View>
</View>
<View style={styles.settingItem}>
<Text style={[
@ -338,15 +371,85 @@ const TraktSettingsScreen: React.FC = () => {
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? 'rgba(120,120,128,0.2)' : 'rgba(120,120,128,0.1)' }
{
backgroundColor: isDarkMode ? currentTheme.colors.primary + '40' : currentTheme.colors.primary + '20',
opacity: isSyncing ? 0.6 : 1
}
]}
disabled={true}
disabled={isSyncing}
onPress={async () => {
const success = await performManualSync();
Alert.alert(
'Sync Complete',
success ? 'Successfully synced your watch progress with Trakt.' : 'Sync failed. Please try again.',
[{ text: 'OK' }]
);
}}
>
{isSyncing ? (
<ActivityIndicator
size="small"
color={isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary}
/>
) : (
<Text style={[
styles.buttonText,
{ color: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary }
]}>
Sync Now
</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
{
backgroundColor: isDarkMode ? '#FF6B35' + '40' : '#FF6B35' + '20',
marginTop: 8
}
]}
onPress={async () => {
await traktService.debugPlaybackProgress();
Alert.alert(
'Debug Complete',
'Check the app logs for current Trakt playback progress. Look for lines starting with "[TraktService] DEBUG".',
[{ text: 'OK' }]
);
}}
>
<Text style={[
styles.buttonText,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
{ color: '#FF6B35' }
]}>
Sync Now (Coming Soon)
Debug Trakt Progress
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
{
backgroundColor: isDarkMode ? '#9B59B6' + '40' : '#9B59B6' + '20',
marginTop: 8
}
]}
onPress={async () => {
const result = await traktService.debugTraktConnection();
Alert.alert(
'Connection Test',
result.authenticated
? `Connection successful! User: ${result.user?.username || 'Unknown'}`
: `Connection failed: ${result.error}`,
[{ text: 'OK' }]
);
}}
>
<Text style={[
styles.buttonText,
{ color: '#9B59B6' }
]}>
Test API Connection
</Text>
</TouchableOpacity>
</View>

View file

@ -5,6 +5,9 @@ interface WatchProgress {
currentTime: number;
duration: number;
lastUpdated: number;
traktSynced?: boolean;
traktLastSynced?: number;
traktProgress?: number;
}
class StorageService {
@ -103,6 +106,130 @@ class StorageService {
return {};
}
}
/**
* Update Trakt sync status for a watch progress entry
*/
public async updateTraktSyncStatus(
id: string,
type: string,
traktSynced: boolean,
traktProgress?: number,
episodeId?: string
): Promise<void> {
try {
const existingProgress = await this.getWatchProgress(id, type, episodeId);
if (existingProgress) {
const updatedProgress: WatchProgress = {
...existingProgress,
traktSynced,
traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced,
traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
}
} catch (error) {
logger.error('Error updating Trakt sync status:', error);
}
}
/**
* Get all watch progress entries that need Trakt sync
*/
public async getUnsyncedProgress(): Promise<Array<{
key: string;
id: string;
type: string;
episodeId?: string;
progress: WatchProgress;
}>> {
try {
const allProgress = await this.getAllWatchProgress();
const unsynced: Array<{
key: string;
id: string;
type: string;
episodeId?: string;
progress: WatchProgress;
}> = [];
for (const [key, progress] of Object.entries(allProgress)) {
// Check if needs sync (either never synced or local progress is newer)
const needsSync = !progress.traktSynced ||
(progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced);
if (needsSync) {
const parts = key.split(':');
const type = parts[0];
const id = parts[1];
const episodeId = parts[2] || undefined;
unsynced.push({
key,
id,
type,
episodeId,
progress
});
}
}
return unsynced;
} catch (error) {
logger.error('Error getting unsynced progress:', error);
return [];
}
}
/**
* Merge Trakt progress with local progress
*/
public async mergeWithTraktProgress(
id: string,
type: string,
traktProgress: number,
traktPausedAt: string,
episodeId?: string
): Promise<void> {
try {
const localProgress = await this.getWatchProgress(id, type, episodeId);
const traktTimestamp = new Date(traktPausedAt).getTime();
if (!localProgress) {
// No local progress, use Trakt data (estimate duration)
const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour
const newProgress: WatchProgress = {
currentTime: (traktProgress / 100) * estimatedDuration,
duration: estimatedDuration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, newProgress, episodeId);
} else {
// Merge with existing local progress
const shouldUseTraktProgress = traktTimestamp > localProgress.lastUpdated;
if (shouldUseTraktProgress && localProgress.duration > 0) {
const updatedProgress: WatchProgress = {
...localProgress,
currentTime: (traktProgress / 100) * localProgress.duration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
} else {
// Local is newer, just mark as needing sync
await this.updateTraktSyncStatus(id, type, false, undefined, episodeId);
}
}
} catch (error) {
logger.error('Error merging with Trakt progress:', error);
}
}
}
export const storageService = StorageService.getInstance();

View file

@ -47,12 +47,121 @@ export interface TraktWatchedItem {
last_watched_at: string;
}
// New types for scrobbling
export interface TraktPlaybackItem {
progress: number;
paused_at: string;
id: number;
type: 'movie' | 'episode';
movie?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
episode?: {
season: number;
number: number;
title: string;
ids: {
trakt: number;
tvdb?: number;
imdb?: string;
tmdb?: number;
};
};
show?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
tvdb?: number;
imdb: string;
tmdb: number;
};
};
}
export interface TraktScrobbleResponse {
id: number;
action: 'start' | 'pause' | 'scrobble' | 'conflict';
progress: number;
sharing?: {
twitter?: boolean;
mastodon?: boolean;
tumblr?: boolean;
facebook?: boolean;
};
movie?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
episode?: {
season: number;
number: number;
title: string;
ids: {
trakt: number;
tvdb?: number;
imdb?: string;
tmdb?: number;
};
};
show?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
tvdb?: number;
imdb: string;
tmdb: number;
};
};
// Additional field for 409 handling
alreadyScrobbled?: boolean;
}
export interface TraktContentData {
type: 'movie' | 'episode';
imdbId: string;
title: string;
year: number;
season?: number;
episode?: number;
showTitle?: string;
showYear?: number;
showImdbId?: string;
}
export class TraktService {
private static instance: TraktService;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private tokenExpiry: number = 0;
private isInitialized: boolean = false;
// Rate limiting
private lastApiCall: number = 0;
private readonly MIN_API_INTERVAL = 1000; // Minimum 1 second between API calls
private requestQueue: Array<() => Promise<any>> = [];
private isProcessingQueue: boolean = false;
// Track items that have been successfully scrobbled to prevent duplicates
private scrobbledItems: Set<string> = new Set();
private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window)
private scrobbledTimestamps: Map<string, number> = new Map();
private constructor() {
// Initialization happens in initialize method
@ -254,10 +363,20 @@ export class TraktService {
private async apiRequest<T>(
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
body?: any
body?: any,
retryCount: number = 0
): Promise<T> {
await this.ensureInitialized();
// Rate limiting: ensure minimum interval between API calls
const now = Date.now();
const timeSinceLastCall = now - this.lastApiCall;
if (timeSinceLastCall < this.MIN_API_INTERVAL) {
const delay = this.MIN_API_INTERVAL - timeSinceLastCall;
await new Promise(resolve => setTimeout(resolve, delay));
}
this.lastApiCall = Date.now();
// Ensure we have a valid token
if (this.tokenExpiry && this.tokenExpiry < Date.now() && this.refreshToken) {
await this.refreshAccessToken();
@ -285,11 +404,125 @@ export class TraktService {
const response = await fetch(`${TRAKT_API_URL}${endpoint}`, options);
// Debug log API responses for scrobble endpoints
if (endpoint.includes('/scrobble/')) {
logger.log(`[TraktService] DEBUG API Response for ${endpoint}:`, {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
});
}
// Handle rate limiting with exponential backoff
if (response.status === 429) {
const maxRetries = 3;
if (retryCount < maxRetries) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter
? parseInt(retryAfter) * 1000
: Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
logger.log(`[TraktService] Rate limited (429), retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.apiRequest<T>(endpoint, method, body, retryCount + 1);
} else {
logger.error(`[TraktService] Rate limited (429), max retries exceeded for ${endpoint}`);
throw new Error(`API request failed: 429 (Rate Limited)`);
}
}
// Handle 409 conflicts gracefully (already watched/scrobbled)
if (response.status === 409) {
const errorText = await response.text();
logger.log(`[TraktService] Content already scrobbled (409) for ${endpoint}:`, errorText);
// Parse the error response to get expiry info
try {
const errorData = JSON.parse(errorText);
if (errorData.watched_at && errorData.expires_at) {
logger.log(`[TraktService] Item was already watched at ${errorData.watched_at}, expires at ${errorData.expires_at}`);
// If this is a scrobble endpoint, mark the item as already scrobbled
if (endpoint.includes('/scrobble/') && body) {
const contentKey = this.getContentKeyFromPayload(body);
if (contentKey) {
this.scrobbledItems.add(contentKey);
this.scrobbledTimestamps.set(contentKey, Date.now());
logger.log(`[TraktService] Marked content as already scrobbled: ${contentKey}`);
}
}
// Return a success-like response for 409 conflicts
// This prevents the error from bubbling up and causing retry loops
return {
id: 0,
action: endpoint.includes('/stop') ? 'scrobble' : 'start',
progress: body?.progress || 0,
alreadyScrobbled: true
} as any;
}
} catch (parseError) {
logger.warn(`[TraktService] Could not parse 409 error response: ${parseError}`);
}
// Return a graceful response even if we can't parse the error
return {
id: 0,
action: 'conflict',
progress: 0,
alreadyScrobbled: true
} as any;
}
if (!response.ok) {
const errorText = await response.text();
logger.error(`[TraktService] API Error ${response.status} for ${endpoint}:`, errorText);
throw new Error(`API request failed: ${response.status}`);
}
return await response.json() as T;
const responseData = await response.json() as T;
// Debug log successful scrobble responses
if (endpoint.includes('/scrobble/')) {
logger.log(`[TraktService] DEBUG API Success for ${endpoint}:`, responseData);
}
return responseData;
}
/**
* Helper method to extract content key from scrobble payload for deduplication
*/
private getContentKeyFromPayload(payload: any): string | null {
try {
if (payload.movie && payload.movie.ids && payload.movie.ids.imdb) {
return `movie:${payload.movie.ids.imdb}`;
} else if (payload.episode && payload.show && payload.show.ids && payload.show.ids.imdb) {
return `episode:${payload.show.ids.imdb}:${payload.episode.season}:${payload.episode.number}`;
}
} catch (error) {
logger.warn('[TraktService] Could not extract content key from payload:', error);
}
return null;
}
/**
* Check if content was recently scrobbled to prevent duplicates
*/
private isRecentlyScrobbled(contentData: TraktContentData): boolean {
const contentKey = this.getWatchingKey(contentData);
// Clean up expired entries
const now = Date.now();
for (const [key, timestamp] of this.scrobbledTimestamps.entries()) {
if (now - timestamp > this.SCROBBLE_EXPIRY_MS) {
this.scrobbledItems.delete(key);
this.scrobbledTimestamps.delete(key);
}
}
return this.scrobbledItems.has(contentKey);
}
/**
@ -459,6 +692,426 @@ export class TraktService {
return false;
}
}
/**
* Get current playback progress from Trakt
*/
public async getPlaybackProgress(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> {
try {
const endpoint = type ? `/sync/playback/${type}` : '/sync/playback';
return this.apiRequest<TraktPlaybackItem[]>(endpoint);
} catch (error) {
logger.error('[TraktService] Failed to get playback progress:', error);
return [];
}
}
/**
* Start watching content (scrobble start)
*/
public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try {
const payload = await this.buildScrobblePayload(contentData, progress);
if (!payload) {
return null;
}
return this.apiRequest<TraktScrobbleResponse>('/scrobble/start', 'POST', payload);
} catch (error) {
logger.error('[TraktService] Failed to start watching:', error);
return null;
}
}
/**
* Pause watching content (scrobble pause)
*/
public async pauseWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try {
const payload = await this.buildScrobblePayload(contentData, progress);
if (!payload) {
return null;
}
return this.apiRequest<TraktScrobbleResponse>('/scrobble/pause', 'POST', payload);
} catch (error) {
logger.error('[TraktService] Failed to pause watching:', error);
return null;
}
}
/**
* Stop watching content (scrobble stop) - handles completion logic
*/
public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try {
const payload = await this.buildScrobblePayload(contentData, progress);
if (!payload) {
return null;
}
return this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload);
} catch (error) {
logger.error('[TraktService] Failed to stop watching:', error);
return null;
}
}
/**
* Update watching progress or mark as complete (legacy method)
* @deprecated Use specific methods: startWatching, pauseWatching, stopWatching
*/
public async updateProgress(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
// For backwards compatibility, use stop for now
return this.stopWatching(contentData, progress);
}
/**
* Build scrobble payload for API requests
*/
private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise<any | null> {
try {
if (contentData.type === 'movie') {
// Clean IMDB ID - some APIs want it without 'tt' prefix
const cleanImdbId = contentData.imdbId.startsWith('tt')
? contentData.imdbId.substring(2)
: contentData.imdbId;
const payload = {
movie: {
title: contentData.title,
year: contentData.year,
ids: {
imdb: cleanImdbId
}
},
progress: Math.round(progress * 100) / 100 // Round to 2 decimal places
};
logger.log('[TraktService] DEBUG movie payload:', JSON.stringify(payload, null, 2));
return payload;
} else if (contentData.type === 'episode') {
if (!contentData.season || !contentData.episode || !contentData.showTitle || !contentData.showYear) {
logger.error('[TraktService] Missing episode data for scrobbling');
return null;
}
const payload: any = {
show: {
title: contentData.showTitle,
year: contentData.showYear,
ids: {}
},
episode: {
season: contentData.season,
number: contentData.episode
},
progress: Math.round(progress * 100) / 100
};
// Add show IMDB ID if available
if (contentData.showImdbId) {
const cleanShowImdbId = contentData.showImdbId.startsWith('tt')
? contentData.showImdbId.substring(2)
: contentData.showImdbId;
payload.show.ids.imdb = cleanShowImdbId;
}
logger.log('[TraktService] DEBUG episode payload:', JSON.stringify(payload, null, 2));
return payload;
}
return null;
} catch (error) {
logger.error('[TraktService] Failed to build scrobble payload:', error);
return null;
}
}
/**
* Process the request queue with proper rate limiting
*/
private async processQueue(): Promise<void> {
if (this.isProcessingQueue || this.requestQueue.length === 0) {
return;
}
this.isProcessingQueue = true;
while (this.requestQueue.length > 0) {
const request = this.requestQueue.shift();
if (request) {
try {
await request();
} catch (error) {
logger.error('[TraktService] Queue request failed:', error);
}
// Wait minimum interval before next request
if (this.requestQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL));
}
}
}
this.isProcessingQueue = false;
}
/**
* Add request to queue for rate-limited processing
*/
private queueRequest<T>(requestFn: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.requestQueue.push(async () => {
try {
const result = await requestFn();
resolve(result);
} catch (error) {
reject(error);
}
});
// Start processing if not already running
this.processQueue();
});
}
/**
* Track currently watching sessions to avoid duplicate starts
*/
private currentlyWatching: Set<string> = new Set();
private lastSyncTime: number = 0;
private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds
/**
* Generate a unique key for content being watched
*/
private getWatchingKey(contentData: TraktContentData): string {
if (contentData.type === 'movie') {
return `movie:${contentData.imdbId}`;
} else {
return `episode:${contentData.showImdbId || contentData.imdbId}:S${contentData.season}E${contentData.episode}`;
}
}
/**
* Start watching content (use when playback begins)
*/
public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> {
try {
if (!await this.isAuthenticated()) {
return false;
}
// 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}`);
return true;
}
// Debug log the content data being sent
logger.log(`[TraktService] DEBUG scrobbleStart payload:`, {
type: contentData.type,
title: contentData.title,
year: contentData.year,
imdbId: contentData.imdbId,
season: contentData.season,
episode: contentData.episode,
showTitle: contentData.showTitle,
progress: progress
});
const watchingKey = this.getWatchingKey(contentData);
// Only start if not already watching this content
if (this.currentlyWatching.has(watchingKey)) {
return true; // Already started
}
const result = await this.queueRequest(async () => {
return await this.startWatching(contentData, progress);
});
if (result) {
this.currentlyWatching.add(watchingKey);
logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`);
return true;
}
return false;
} catch (error) {
logger.error('[TraktService] Failed to start scrobbling:', error);
return false;
}
}
/**
* Update progress while watching (use for periodic progress updates)
*/
public async scrobblePause(contentData: TraktContentData, progress: number, force: boolean = false): Promise<boolean> {
try {
if (!await this.isAuthenticated()) {
return false;
}
const now = Date.now();
// Debounce API calls unless forced
if (!force && (now - this.lastSyncTime) < this.SYNC_DEBOUNCE_MS) {
return true; // Skip this sync, but return success
}
this.lastSyncTime = now;
const result = await this.queueRequest(async () => {
return await this.pauseWatching(contentData, progress);
});
if (result) {
logger.log(`[TraktService] Updated progress ${progress.toFixed(1)}% for ${contentData.type}: ${contentData.title}`);
return true;
}
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; // Return success to avoid error spam
}
logger.error('[TraktService] Failed to update progress:', error);
return false;
}
}
/**
* Stop watching content (use when playback ends or stops)
*/
public async scrobbleStop(contentData: TraktContentData, progress: number): Promise<boolean> {
try {
if (!await this.isAuthenticated()) {
return false;
}
const watchingKey = this.getWatchingKey(contentData);
const result = await this.queueRequest(async () => {
return await this.stopWatching(contentData, progress);
});
if (result) {
this.currentlyWatching.delete(watchingKey);
// Mark as scrobbled if >= 80% to prevent future duplicates
if (progress >= 80) {
this.scrobbledItems.add(watchingKey);
this.scrobbledTimestamps.set(watchingKey, Date.now());
}
// The stop endpoint automatically handles the 80%+ completion logic
// and will mark as scrobbled if >= 80%, or pause if < 80%
const action = progress >= 80 ? 'scrobbled' : 'paused';
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true;
}
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;
}
}
/**
* Legacy sync method - now delegates to proper scrobble methods
* @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead
*/
public async syncProgressToTrakt(
contentData: TraktContentData,
progress: number,
force: boolean = false
): Promise<boolean> {
// For backward compatibility, treat as a pause update
return this.scrobblePause(contentData, progress, force);
}
/**
* Debug method to test Trakt API connection and scrobble functionality
*/
public async debugTraktConnection(): Promise<any> {
try {
logger.log('[TraktService] Testing Trakt API connection...');
// Test basic API access
const userResponse = await this.apiRequest('/users/me', 'GET');
logger.log('[TraktService] User info:', userResponse);
// Test a minimal scrobble start to verify API works
const testPayload = {
movie: {
title: "Test Movie",
year: 2023,
ids: {
imdb: "1234567" // Fake IMDB ID for testing
}
},
progress: 1.0
};
logger.log('[TraktService] Testing scrobble/start endpoint with test payload...');
const scrobbleResponse = await this.apiRequest('/scrobble/start', 'POST', testPayload);
logger.log('[TraktService] Scrobble test response:', scrobbleResponse);
return {
authenticated: true,
user: userResponse,
scrobbleTest: scrobbleResponse
};
} catch (error) {
logger.error('[TraktService] Debug connection failed:', error);
return {
authenticated: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Debug method to check current playback progress on Trakt
*/
public async debugPlaybackProgress(): Promise<void> {
try {
if (!await this.isAuthenticated()) {
logger.log('[TraktService] DEBUG: Not authenticated');
return;
}
const progress = await this.getPlaybackProgress();
logger.log(`[TraktService] DEBUG: Found ${progress.length} items in Trakt playback progress:`);
progress.forEach((item, index) => {
if (item.type === 'movie' && item.movie) {
logger.log(`[TraktService] DEBUG ${index + 1}: Movie "${item.movie.title}" (${item.movie.year}) - ${item.progress.toFixed(1)}% - Paused: ${item.paused_at}`);
} else if (item.type === 'episode' && item.episode && item.show) {
logger.log(`[TraktService] DEBUG ${index + 1}: Episode "${item.show.title}" S${item.episode.season}E${item.episode.number} - ${item.progress.toFixed(1)}% - Paused: ${item.paused_at}`);
}
});
if (progress.length === 0) {
logger.log('[TraktService] DEBUG: No items found in Trakt playback progress');
}
} catch (error) {
logger.error('[TraktService] DEBUG: Error fetching playback progress:', error);
}
}
}
// Export a singleton instance