Ios #14

Merged
tapframe merged 88 commits from ios into main 2025-06-20 13:54:29 +00:00
14 changed files with 1623 additions and 70 deletions
Showing only changes of commit 235a7eff24 - Show all commits

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