mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Implement Trakt autosync functionality across video player components
This update integrates Trakt autosync capabilities into the AndroidVideoPlayer and VideoPlayer components, allowing for automatic syncing of watch progress and playback events. Key features include starting a watching session, updating progress during playback, and handling playback end events to ensure accurate tracking. Additionally, the useTraktIntegration and useTraktAutosync hooks have been enhanced to support these functionalities, improving the overall user experience by maintaining consistent watch history across devices.
This commit is contained in:
parent
6acf84677f
commit
235a7eff24
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