Ios #14
14 changed files with 1623 additions and 70 deletions
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)();
|
||||
|
|
|
|||
258
src/hooks/useTraktAutosync.ts
Normal file
258
src/hooks/useTraktAutosync.ts
Normal 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
|
||||
};
|
||||
}
|
||||
159
src/hooks/useTraktAutosyncSettings.ts
Normal file
159
src/hooks/useTraktAutosyncSettings.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -637,7 +637,7 @@ const HomeScreen = () => {
|
|||
handleSaveToLibrary,
|
||||
hasContinueWatching,
|
||||
catalogs,
|
||||
catalogsLoading,
|
||||
catalogsLoading,
|
||||
navigation,
|
||||
featuredContentSource
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue