diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index d93b52901..a8ee46952 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -399,7 +399,7 @@ export const SeriesContent: React.FC = ({ {/* Episode Number Badge */} - {episodeString} + {episodeString} {/* Episode Title */} @@ -416,9 +416,9 @@ export const SeriesContent: React.FC = ({ {episode.runtime && ( - - {formatRuntime(episode.runtime)} - + + {formatRuntime(episode.runtime)} + )} {episode.vote_average > 0 && ( diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index b1d89e56f..dc3f16d76 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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) => { diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index f4d29ebf1..feeb55989 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -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) => { diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index eef76726e..89f0d6d3d 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -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)(); diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts new file mode 100644 index 000000000..a3893596d --- /dev/null +++ b/src/hooks/useTraktAutosync.ts @@ -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(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 + }; +} \ No newline at end of file diff --git a/src/hooks/useTraktAutosyncSettings.ts b/src/hooks/useTraktAutosyncSettings.ts new file mode 100644 index 000000000..9bcac08ec --- /dev/null +++ b/src/hooks/useTraktAutosyncSettings.ts @@ -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(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 => { + 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 + }; +} \ No newline at end of file diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 692cdaa8e..027205c66 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 }; } \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 2159ed67b..4c63eb470 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -637,7 +637,7 @@ const HomeScreen = () => { handleSaveToLibrary, hasContinueWatching, catalogs, - catalogsLoading, + catalogsLoading, navigation, featuredContentSource ]); diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index b4cef61d5..50eb5aa46 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -592,33 +592,33 @@ const LibraryScreen = () => { if (allItems.length === 0) { return ( - - - Your library is empty - - Add content to your library to keep track of what you're watching - - navigation.navigate('Discover')} - activeOpacity={0.7} - > - Explore Content - - + + + Your library is empty + + Add content to your library to keep track of what you're watching + + navigation.navigate('Discover')} + activeOpacity={0.7} + > + Explore Content + + ); } return ( - { 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'} + /> ); }; diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 712dea132..2d57e6576 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -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)); diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 941500d26..70d74754c 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -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) { diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index ad214ee3b..d9f9e4748 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -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(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 = () => { Trakt Settings @@ -308,18 +328,31 @@ const TraktSettingsScreen: React.FC = () => { Sync Settings - - Auto-sync playback progress - - - Coming soon - + + + + Auto-sync playback progress + + + Automatically sync watch progress to Trakt + + + + { { + const success = await performManualSync(); + Alert.alert( + 'Sync Complete', + success ? 'Successfully synced your watch progress with Trakt.' : 'Sync failed. Please try again.', + [{ text: 'OK' }] + ); + }} + > + {isSyncing ? ( + + ) : ( + + Sync Now + + )} + + + { + 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' }] + ); + }} > - Sync Now (Coming Soon) + Debug Trakt Progress + + + + { + const result = await traktService.debugTraktConnection(); + Alert.alert( + 'Connection Test', + result.authenticated + ? `Connection successful! User: ${result.user?.username || 'Unknown'}` + : `Connection failed: ${result.error}`, + [{ text: 'OK' }] + ); + }} + > + + Test API Connection diff --git a/src/services/storageService.ts b/src/services/storageService.ts index cab688262..3bad14311 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -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 { + 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> { + 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 { + 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(); \ No newline at end of file diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 64a7f6afb..ffccb9a8a 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -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> = []; + private isProcessingQueue: boolean = false; + + // Track items that have been successfully scrobbled to prevent duplicates + private scrobbledItems: Set = new Set(); + private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window) + private scrobbledTimestamps: Map = new Map(); private constructor() { // Initialization happens in initialize method @@ -254,10 +363,20 @@ export class TraktService { private async apiRequest( endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', - body?: any + body?: any, + retryCount: number = 0 ): Promise { 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(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 { + try { + const endpoint = type ? `/sync/playback/${type}` : '/sync/playback'; + return this.apiRequest(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 { + try { + const payload = await this.buildScrobblePayload(contentData, progress); + if (!payload) { + return null; + } + + return this.apiRequest('/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 { + try { + const payload = await this.buildScrobblePayload(contentData, progress); + if (!payload) { + return null; + } + + return this.apiRequest('/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 { + try { + const payload = await this.buildScrobblePayload(contentData, progress); + if (!payload) { + return null; + } + + return this.apiRequest('/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 { + // 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 { + 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 { + 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(requestFn: () => Promise): Promise { + 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 = 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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