trakt test

This commit is contained in:
tapframe 2025-11-25 00:50:47 +05:30
parent 56234daf82
commit a27ee4ac56
2 changed files with 311 additions and 192 deletions

View file

@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Dimensions, Dimensions,
AppState, AppState,
AppStateStatus, AppStateStatus,
@ -55,17 +55,17 @@ const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section
const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins
// Calculate how many posters can fit (fewer items for continue watching) // Calculate how many posters can fit (fewer items for continue watching)
const availableWidth = screenWidth - HORIZONTAL_PADDING; const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
// Limit to reasonable number of columns (2-5 for continue watching) // Limit to reasonable number of columns (2-5 for continue watching)
const numColumns = Math.min(Math.max(maxColumns, 2), 5); const numColumns = Math.min(Math.max(maxColumns, 2), 5);
// Calculate actual poster width // Calculate actual poster width
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
return { return {
numColumns, numColumns,
posterWidth, posterWidth,
@ -85,7 +85,7 @@ const isSupportedId = (id: string): boolean => {
// Function to check if an episode has been released // Function to check if an episode has been released
const isEpisodeReleased = (video: any): boolean => { const isEpisodeReleased = (video: any): boolean => {
if (!video.released) return false; if (!video.released) return false;
try { try {
const releaseDate = new Date(video.released); const releaseDate = new Date(video.released);
const now = new Date(); const now = new Date();
@ -112,16 +112,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const [dimensions, setDimensions] = useState(Dimensions.get('window')); const [dimensions, setDimensions] = useState(Dimensions.get('window'));
const deviceWidth = dimensions.width; const deviceWidth = dimensions.width;
const deviceHeight = dimensions.height; const deviceHeight = dimensions.height;
// Listen for dimension changes (orientation changes) // Listen for dimension changes (orientation changes)
useEffect(() => { useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => { const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions(window); setDimensions(window);
}); });
return () => subscription?.remove(); return () => subscription?.remove();
}, []); }, []);
// Determine device type based on width // Determine device type based on width
const getDeviceType = useCallback(() => { const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
@ -129,13 +129,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone'; return 'phone';
}, [deviceWidth]); }, [deviceWidth]);
const deviceType = getDeviceType(); const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet'; const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet'; const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv'; const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV; const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced responsive sizing for continue watching items // Enhanced responsive sizing for continue watching items
const computedItemWidth = useMemo(() => { const computedItemWidth = useMemo(() => {
switch (deviceType) { switch (deviceType) {
@ -149,7 +149,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
return 280; // Original phone size return 280; // Original phone size
} }
}, [deviceType]); }, [deviceType]);
const computedItemHeight = useMemo(() => { const computedItemHeight = useMemo(() => {
switch (deviceType) { switch (deviceType) {
case 'tv': case 'tv':
@ -162,7 +162,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
return 120; // Original phone height return 120; // Original phone height
} }
}, [deviceType]); }, [deviceType]);
// Enhanced spacing and padding // Enhanced spacing and padding
const horizontalPadding = useMemo(() => { const horizontalPadding = useMemo(() => {
switch (deviceType) { switch (deviceType) {
@ -176,7 +176,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
return 16; // phone return 16; // phone
} }
}, [deviceType]); }, [deviceType]);
const itemSpacing = useMemo(() => { const itemSpacing = useMemo(() => {
switch (deviceType) { switch (deviceType) {
case 'tv': case 'tv':
@ -198,11 +198,11 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Use a ref to track if a background refresh is in progress to avoid state updates // Use a ref to track if a background refresh is in progress to avoid state updates
const isRefreshingRef = useRef(false); const isRefreshingRef = useRef(false);
// Track recently removed items to prevent immediate re-addition // Track recently removed items to prevent immediate re-addition
const recentlyRemovedRef = useRef<Set<string>>(new Set()); const recentlyRemovedRef = useRef<Set<string>>(new Set());
const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds
// Track last Trakt sync to prevent excessive API calls // Track last Trakt sync to prevent excessive API calls
const lastTraktSyncRef = useRef<number>(0); const lastTraktSyncRef = useRef<number>(0);
const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs
@ -216,18 +216,18 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const cacheKey = `${type}:${id}`; const cacheKey = `${type}:${id}`;
const cached = metadataCache.current[cacheKey]; const cached = metadataCache.current[cacheKey];
const now = Date.now(); const now = Date.now();
if (cached && (now - cached.timestamp) < CACHE_DURATION) { if (cached && (now - cached.timestamp) < CACHE_DURATION) {
return cached; return cached;
} }
try { try {
const shouldFetchMeta = await stremioService.isValidContentId(type, id); const shouldFetchMeta = await stremioService.isValidContentId(type, id);
const [metadata, basicContent] = await Promise.all([ const [metadata, basicContent] = await Promise.all([
shouldFetchMeta ? stremioService.getMetaDetails(type, id) : Promise.resolve(null), shouldFetchMeta ? stremioService.getMetaDetails(type, id) : Promise.resolve(null),
catalogService.getBasicContentDetails(type, id) catalogService.getBasicContentDetails(type, id)
]); ]);
if (basicContent) { if (basicContent) {
const result = { metadata, basicContent, timestamp: now }; const result = { metadata, basicContent, timestamp: now };
metadataCache.current[cacheKey] = result; metadataCache.current[cacheKey] = result;
@ -334,13 +334,67 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (!isAuthed) return new Set<string>(); if (!isAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedMovies === 'function') { if (typeof (traktService as any).getWatchedMovies === 'function') {
const watched = await (traktService as any).getWatchedMovies(); const watched = await (traktService as any).getWatchedMovies();
const watchedSet = new Set<string>();
if (Array.isArray(watched)) { if (Array.isArray(watched)) {
const ids = watched watched.forEach((w: any) => {
.map((w: any) => w?.movie?.ids?.imdb) const ids = w?.movie?.ids;
.filter(Boolean) if (!ids) return;
.map((imdb: string) => (imdb.startsWith('tt') ? imdb : `tt${imdb}`));
return new Set<string>(ids); if (ids.imdb) {
const imdb = ids.imdb;
watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`);
}
if (ids.tmdb) {
watchedSet.add(ids.tmdb.toString());
}
});
} }
return watchedSet;
}
return new Set<string>();
} catch {
return new Set<string>();
}
})();
// Fetch Trakt watched shows once and reuse
const traktShowsSetPromise = (async () => {
try {
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (!isAuthed) return new Set<string>();
if (typeof (traktService as any).getWatchedShows === 'function') {
const watched = await (traktService as any).getWatchedShows();
const watchedSet = new Set<string>();
if (Array.isArray(watched)) {
watched.forEach((show: any) => {
const ids = show?.show?.ids;
if (!ids) return;
const imdbId = ids.imdb;
const tmdbId = ids.tmdb;
if (show.seasons && Array.isArray(show.seasons)) {
show.seasons.forEach((season: any) => {
if (season.episodes && Array.isArray(season.episodes)) {
season.episodes.forEach((episode: any) => {
if (imdbId) {
const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`);
}
if (tmdbId) {
watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`);
}
});
}
});
}
});
}
return watchedSet;
} }
return new Set<string>(); return new Set<string>();
} catch { } catch {
@ -365,7 +419,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
traktSynced: true, traktSynced: true,
traktProgress: 100, traktProgress: 100,
} as any); } as any);
} catch (_e) {} } catch (_e) { }
return; return;
} }
} }
@ -422,6 +476,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
let season: number | undefined; let season: number | undefined;
let episodeNumber: number | undefined; let episodeNumber: number | undefined;
let episodeTitle: string | undefined; let episodeTitle: string | undefined;
let isWatchedOnTrakt = false;
if (episodeId && group.type === 'series') { if (episodeId && group.type === 'series') {
let match = episodeId.match(/s(\d+)e(\d+)/i); let match = episodeId.match(/s(\d+)e(\d+)/i);
if (match) { if (match) {
@ -442,6 +498,61 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
} }
} }
// Check if this specific episode is watched on Trakt
if (season !== undefined && episodeNumber !== undefined) {
const watchedEpisodesSet = await traktShowsSetPromise;
// Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string)
const rawId = group.id.replace(/^tt/, '');
const ttId = `tt${rawId}`;
if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) ||
watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) ||
watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) {
isWatchedOnTrakt = true;
// Update local storage to reflect watched status
try {
await storageService.setWatchProgress(
group.id,
'series',
{
currentTime: 1,
duration: 1,
lastUpdated: Date.now(),
traktSynced: true,
traktProgress: 100,
} as any,
episodeId
);
} catch (_e) { }
}
}
}
// If watched on Trakt, treat it as completed (try to find next episode)
if (isWatchedOnTrakt) {
let nextSeason = season;
let nextEpisode = (episodeNumber || 0) + 1;
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) {
const nextEpisodeVideo = metadata.videos.find((video: any) =>
video.season === nextSeason && video.episode === nextEpisode
);
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
batch.push({
...basicContent,
id: group.id,
type: group.type,
progress: 0,
lastUpdated: progress.lastUpdated,
season: nextSeason,
episode: nextEpisode,
episodeTitle: `Episode ${nextEpisode}`,
} as ContinueWatchingItem);
}
}
continue;
} }
batch.push({ batch.push({
@ -466,14 +577,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const traktService = TraktService.getInstance(); const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated(); const isAuthed = await traktService.isAuthenticated();
if (!isAuthed) return; if (!isAuthed) return;
// Check Trakt sync cooldown to prevent excessive API calls // Check Trakt sync cooldown to prevent excessive API calls
const now = Date.now(); const now = Date.now();
if (now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN) { if (now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN) {
logger.log(`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`); logger.log(`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`);
return; return;
} }
lastTraktSyncRef.current = now; lastTraktSyncRef.current = now;
const historyItems = await traktService.getWatchedEpisodesHistory(1, 200); const historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {}; const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
@ -650,7 +761,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadContinueWatching(true); loadContinueWatching(true);
return () => {}; return () => { };
}, [loadContinueWatching]) }, [loadContinueWatching])
); );
@ -667,62 +778,62 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const handleContentPress = useCallback(async (item: ContinueWatchingItem) => { const handleContentPress = useCallback(async (item: ContinueWatchingItem) => {
try { try {
logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`); logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`);
// Check if cached streams are enabled in settings // Check if cached streams are enabled in settings
if (!settings.useCachedStreams) { if (!settings.useCachedStreams) {
logger.log(`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`); logger.log(`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`);
// Navigate based on the second setting // Navigate based on the second setting
if (settings.openMetadataScreenWhenCacheDisabled) { if (settings.openMetadataScreenWhenCacheDisabled) {
// Navigate to MetadataScreen // Navigate to MetadataScreen
if (item.type === 'series' && item.season && item.episode) { if (item.type === 'series' && item.season && item.episode) {
const episodeId = `${item.id}:${item.season}:${item.episode}`; const episodeId = `${item.id}:${item.season}:${item.episode}`;
navigation.navigate('Metadata', { navigation.navigate('Metadata', {
id: item.id, id: item.id,
type: item.type, type: item.type,
episodeId: episodeId episodeId: episodeId
}); });
} else { } else {
navigation.navigate('Metadata', { navigation.navigate('Metadata', {
id: item.id, id: item.id,
type: item.type type: item.type
}); });
} }
} else { } else {
// Navigate to StreamsScreen // Navigate to StreamsScreen
if (item.type === 'series' && item.season && item.episode) { if (item.type === 'series' && item.season && item.episode) {
const episodeId = `${item.id}:${item.season}:${item.episode}`; const episodeId = `${item.id}:${item.season}:${item.episode}`;
navigation.navigate('Streams', { navigation.navigate('Streams', {
id: item.id, id: item.id,
type: item.type, type: item.type,
episodeId: episodeId episodeId: episodeId
}); });
} else { } else {
navigation.navigate('Streams', { navigation.navigate('Streams', {
id: item.id, id: item.id,
type: item.type type: item.type
}); });
} }
} }
return; return;
} }
// Check if we have a cached stream for this content // Check if we have a cached stream for this content
const episodeId = item.type === 'series' && item.season && item.episode const episodeId = item.type === 'series' && item.season && item.episode
? `${item.id}:${item.season}:${item.episode}` ? `${item.id}:${item.season}:${item.episode}`
: undefined; : undefined;
logger.log(`🔍 [ContinueWatching] Looking for cached stream with episodeId: ${episodeId || 'none'}`); logger.log(`🔍 [ContinueWatching] Looking for cached stream with episodeId: ${episodeId || 'none'}`);
const cachedStream = await streamCacheService.getCachedStream(item.id, item.type, episodeId); const cachedStream = await streamCacheService.getCachedStream(item.id, item.type, episodeId);
if (cachedStream) { if (cachedStream) {
// We have a valid cached stream, navigate directly to player // We have a valid cached stream, navigate directly to player
logger.log(`🚀 [ContinueWatching] Using cached stream for ${item.name}`); logger.log(`🚀 [ContinueWatching] Using cached stream for ${item.name}`);
// Determine the player route based on platform // Determine the player route based on platform
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
// Navigate directly to player with cached stream data // Navigate directly to player with cached stream data
navigation.navigate(playerRoute as any, { navigation.navigate(playerRoute as any, {
uri: cachedStream.stream.url, uri: cachedStream.stream.url,
@ -743,25 +854,25 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
backdrop: cachedStream.metadata?.backdrop || item.banner, backdrop: cachedStream.metadata?.backdrop || item.banner,
videoType: undefined, // Let player auto-detect videoType: undefined, // Let player auto-detect
} as any); } as any);
return; return;
} }
// No cached stream or cache failed, navigate to StreamsScreen // No cached stream or cache failed, navigate to StreamsScreen
logger.log(`📺 [ContinueWatching] No cached stream, navigating to StreamsScreen for ${item.name}`); logger.log(`📺 [ContinueWatching] No cached stream, navigating to StreamsScreen for ${item.name}`);
if (item.type === 'series' && item.season && item.episode) { if (item.type === 'series' && item.season && item.episode) {
// For series, navigate to the specific episode // For series, navigate to the specific episode
navigation.navigate('Streams', { navigation.navigate('Streams', {
id: item.id, id: item.id,
type: item.type, type: item.type,
episodeId: episodeId episodeId: episodeId
}); });
} else { } else {
// For movies or series without specific episode, navigate to main content // For movies or series without specific episode, navigate to main content
navigation.navigate('Streams', { navigation.navigate('Streams', {
id: item.id, id: item.id,
type: item.type type: item.type
}); });
} }
} catch (error) { } catch (error) {
@ -769,15 +880,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Fallback to StreamsScreen on any error // Fallback to StreamsScreen on any error
if (item.type === 'series' && item.season && item.episode) { if (item.type === 'series' && item.season && item.episode) {
const episodeId = `${item.id}:${item.season}:${item.episode}`; const episodeId = `${item.id}:${item.season}:${item.episode}`;
navigation.navigate('Streams', { navigation.navigate('Streams', {
id: item.id, id: item.id,
type: item.type, type: item.type,
episodeId: episodeId episodeId: episodeId
}); });
} else { } else {
navigation.navigate('Streams', { navigation.navigate('Streams', {
id: item.id, id: item.id,
type: item.type type: item.type
}); });
} }
} }
@ -798,7 +909,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{ {
label: 'Cancel', label: 'Cancel',
style: { color: '#888' }, style: { color: '#888' },
onPress: () => {}, onPress: () => { },
}, },
{ {
label: 'Remove', label: 'Remove',
@ -842,7 +953,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.wideContentItem, styles.wideContentItem,
{ {
backgroundColor: currentTheme.colors.elevation1, backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border, borderColor: currentTheme.colors.border,
@ -864,7 +975,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
]}> ]}>
<FastImage <FastImage
source={{ source={{
uri: item.poster || 'https://via.placeholder.com/300x450', uri: item.poster || 'https://via.placeholder.com/300x450',
priority: FastImage.priority.high, priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable cache: FastImage.cacheControl.immutable
@ -872,7 +983,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
style={styles.continueWatchingPoster} style={styles.continueWatchingPoster}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
{/* Delete Indicator Overlay */} {/* Delete Indicator Overlay */}
{deletingItemId === item.id && ( {deletingItemId === item.id && (
<View style={styles.deletingOverlay}> <View style={styles.deletingOverlay}>
@ -893,10 +1004,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const isUpNext = item.type === 'series' && item.progress === 0; const isUpNext = item.type === 'series' && item.progress === 0;
return ( return (
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text <Text
style={[ style={[
styles.contentTitle, styles.contentTitle,
{ {
color: currentTheme.colors.highEmphasis, color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16 fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16
} }
@ -906,19 +1017,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{item.name} {item.name}
</Text> </Text>
{isUpNext && ( {isUpNext && (
<View style={[ <View style={[
styles.progressBadge, styles.progressBadge,
{ {
backgroundColor: currentTheme.colors.primary, backgroundColor: currentTheme.colors.primary,
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8, paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3 paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3
} }
]}> ]}>
<Text style={[ <Text style={[
styles.progressText, styles.progressText,
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 } { fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 }
]}>Up Next</Text> ]}>Up Next</Text>
</View> </View>
)} )}
</View> </View>
); );
@ -931,8 +1042,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
return ( return (
<View style={styles.episodeRow}> <View style={styles.episodeRow}>
<Text style={[ <Text style={[
styles.episodeText, styles.episodeText,
{ {
color: currentTheme.colors.mediumEmphasis, color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13 fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
} }
@ -940,10 +1051,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
Season {item.season} Season {item.season}
</Text> </Text>
{item.episodeTitle && ( {item.episodeTitle && (
<Text <Text
style={[ style={[
styles.episodeTitle, styles.episodeTitle,
{ {
color: currentTheme.colors.mediumEmphasis, color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12 fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
} }
@ -958,8 +1069,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} else { } else {
return ( return (
<Text style={[ <Text style={[
styles.yearText, styles.yearText,
{ {
color: currentTheme.colors.mediumEmphasis, color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13 fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
} }
@ -979,19 +1090,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4 height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
} }
]}> ]}>
<View <View
style={[ style={[
styles.wideProgressBar, styles.wideProgressBar,
{ {
width: `${item.progress}%`, width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary backgroundColor: currentTheme.colors.primary
} }
]} ]}
/> />
</View> </View>
<Text style={[ <Text style={[
styles.progressLabel, styles.progressLabel,
{ {
color: currentTheme.colors.textMuted, color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11 fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11
} }
@ -1023,15 +1134,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}> <View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[ <Text style={[
styles.title, styles.title,
{ {
color: currentTheme.colors.text, color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24 fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
} }
]}>Continue Watching</Text> ]}>Continue Watching</Text>
<View style={[ <View style={[
styles.titleUnderline, styles.titleUnderline,
{ {
backgroundColor: currentTheme.colors.primary, backgroundColor: currentTheme.colors.primary,
width: isTV ? 50 : isLargeTablet ? 45 : isTablet ? 40 : 40, width: isTV ? 50 : isLargeTablet ? 45 : isTablet ? 40 : 40,
height: isTV ? 4 : isLargeTablet ? 3.5 : isTablet ? 3 : 3 height: isTV ? 4 : isLargeTablet ? 3.5 : isTablet ? 3 : 3
@ -1039,7 +1150,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
]} /> ]} />
</View> </View>
</View> </View>
<FlashList <FlashList
data={continueWatchingItems} data={continueWatchingItems}
renderItem={renderContinueWatchingItem} renderItem={renderContinueWatchingItem}
@ -1048,14 +1159,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={[ contentContainerStyle={[
styles.wideList, styles.wideList,
{ {
paddingLeft: horizontalPadding, paddingLeft: horizontalPadding,
paddingRight: horizontalPadding paddingRight: horizontalPadding
} }
]} ]}
ItemSeparatorComponent={ItemSeparator} ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7} onEndReachedThreshold={0.7}
onEndReached={() => {}} onEndReached={() => { }}
removeClippedSubviews={true} removeClippedSubviews={true}
/> />
@ -1209,7 +1320,7 @@ const styles = StyleSheet.create({
}, },
contentItem: { contentItem: {
width: POSTER_WIDTH, width: POSTER_WIDTH,
aspectRatio: 2/3, aspectRatio: 2 / 3,
margin: 0, margin: 0,
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',

View file

@ -52,6 +52,14 @@ export interface TraktWatchedItem {
}; };
plays: number; plays: number;
last_watched_at: string; last_watched_at: string;
seasons?: {
number: number;
episodes: {
number: number;
plays: number;
last_watched_at: string;
}[];
}[];
} }
export interface TraktWatchlistItem { export interface TraktWatchlistItem {
@ -559,7 +567,7 @@ export class TraktService {
private refreshToken: string | null = null; private refreshToken: string | null = null;
private tokenExpiry: number = 0; private tokenExpiry: number = 0;
private isInitialized: boolean = false; private isInitialized: boolean = false;
// Rate limiting - Optimized for real-time scrobbling // Rate limiting - Optimized for real-time scrobbling
private lastApiCall: number = 0; private lastApiCall: number = 0;
private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates
@ -575,21 +583,21 @@ export class TraktService {
private currentlyWatching: Set<string> = new Set(); private currentlyWatching: Set<string> = new Set();
private lastSyncTimes: Map<string, number> = new Map(); private lastSyncTimes: Map<string, number> = new Map();
private readonly SYNC_DEBOUNCE_MS = 5000; // Reduced from 20000ms to 5000ms for real-time updates private readonly SYNC_DEBOUNCE_MS = 5000; // Reduced from 20000ms to 5000ms for real-time updates
// Debounce for stop calls - Optimized for responsiveness // Debounce for stop calls - Optimized for responsiveness
private lastStopCalls: Map<string, number> = new Map(); private lastStopCalls: Map<string, number> = new Map();
private readonly STOP_DEBOUNCE_MS = 1000; // Reduced from 3000ms to 1000ms for better responsiveness private readonly STOP_DEBOUNCE_MS = 1000; // Reduced from 3000ms to 1000ms for better responsiveness
// Default completion threshold (overridden by user settings) // Default completion threshold (overridden by user settings)
private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80% private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80%
private constructor() { private constructor() {
// Increased cleanup interval from 5 minutes to 15 minutes to reduce heating // Increased cleanup interval from 5 minutes to 15 minutes to reduce heating
setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes
// Add AppState cleanup to reduce memory pressure // Add AppState cleanup to reduce memory pressure
AppState.addEventListener('change', this.handleAppStateChange); AppState.addEventListener('change', this.handleAppStateChange);
// Load user settings // Load user settings
this.loadCompletionThreshold(); this.loadCompletionThreshold();
} }
@ -611,21 +619,21 @@ export class TraktService {
logger.error('[TraktService] Error loading completion threshold:', error); logger.error('[TraktService] Error loading completion threshold:', error);
} }
} }
/** /**
* Get the current completion threshold (user-configured or default) * Get the current completion threshold (user-configured or default)
*/ */
private get completionThreshold(): number { private get completionThreshold(): number {
return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD; return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD;
} }
/** /**
* Set the completion threshold * Set the completion threshold
*/ */
private set completionThreshold(value: number) { private set completionThreshold(value: number) {
this._completionThreshold = value; this._completionThreshold = value;
} }
// Backing field for completion threshold // Backing field for completion threshold
private _completionThreshold: number | null = null; private _completionThreshold: number | null = null;
@ -635,7 +643,7 @@ export class TraktService {
private cleanupOldStopCalls(): void { private cleanupOldStopCalls(): void {
const now = Date.now(); const now = Date.now();
let cleanupCount = 0; let cleanupCount = 0;
// Remove stop calls older than the debounce window // Remove stop calls older than the debounce window
for (const [key, timestamp] of this.lastStopCalls.entries()) { for (const [key, timestamp] of this.lastStopCalls.entries()) {
if (now - timestamp > this.STOP_DEBOUNCE_MS) { if (now - timestamp > this.STOP_DEBOUNCE_MS) {
@ -643,7 +651,7 @@ export class TraktService {
cleanupCount++; cleanupCount++;
} }
} }
// Also clean up old scrobbled timestamps // Also clean up old scrobbled timestamps
for (const [key, timestamp] of this.scrobbledTimestamps.entries()) { for (const [key, timestamp] of this.scrobbledTimestamps.entries()) {
if (now - timestamp > this.SCROBBLE_EXPIRY_MS) { if (now - timestamp > this.SCROBBLE_EXPIRY_MS) {
@ -652,7 +660,7 @@ export class TraktService {
cleanupCount++; cleanupCount++;
} }
} }
// Clean up old sync times that haven't been updated in a while // Clean up old sync times that haven't been updated in a while
for (const [key, timestamp] of this.lastSyncTimes.entries()) { for (const [key, timestamp] of this.lastSyncTimes.entries()) {
if (now - timestamp > 24 * 60 * 60 * 1000) { // 24 hours if (now - timestamp > 24 * 60 * 60 * 1000) { // 24 hours
@ -660,7 +668,7 @@ export class TraktService {
cleanupCount++; cleanupCount++;
} }
} }
// Skip verbose cleanup logging to reduce CPU load // Skip verbose cleanup logging to reduce CPU load
} }
@ -703,7 +711,7 @@ export class TraktService {
*/ */
public async isAuthenticated(): Promise<boolean> { public async isAuthenticated(): Promise<boolean> {
await this.ensureInitialized(); await this.ensureInitialized();
if (!this.accessToken) { if (!this.accessToken) {
return false; return false;
} }
@ -908,12 +916,12 @@ export class TraktService {
const maxRetries = 3; const maxRetries = 3;
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
const retryAfter = response.headers.get('Retry-After'); const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter const delay = retryAfter
? parseInt(retryAfter) * 1000 ? parseInt(retryAfter) * 1000
: Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s : 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})`); logger.log(`[TraktService] Rate limited (429), retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
return this.apiRequest<T>(endpoint, method, body, retryCount + 1); return this.apiRequest<T>(endpoint, method, body, retryCount + 1);
} else { } else {
@ -926,13 +934,13 @@ export class TraktService {
if (response.status === 409) { if (response.status === 409) {
const errorText = await response.text(); const errorText = await response.text();
logger.log(`[TraktService] Content already scrobbled (409) for ${endpoint}:`, errorText); logger.log(`[TraktService] Content already scrobbled (409) for ${endpoint}:`, errorText);
// Parse the error response to get expiry info // Parse the error response to get expiry info
try { try {
const errorData = JSON.parse(errorText); const errorData = JSON.parse(errorText);
if (errorData.watched_at && errorData.expires_at) { if (errorData.watched_at && errorData.expires_at) {
logger.log(`[TraktService] Item was already watched at ${errorData.watched_at}, expires 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 this is a scrobble endpoint, mark the item as already scrobbled
if (endpoint.includes('/scrobble/') && body) { if (endpoint.includes('/scrobble/') && body) {
const contentKey = this.getContentKeyFromPayload(body); const contentKey = this.getContentKeyFromPayload(body);
@ -942,7 +950,7 @@ export class TraktService {
logger.log(`[TraktService] Marked content as already scrobbled: ${contentKey}`); logger.log(`[TraktService] Marked content as already scrobbled: ${contentKey}`);
} }
} }
// Return a success-like response for 409 conflicts // Return a success-like response for 409 conflicts
// This prevents the error from bubbling up and causing retry loops // This prevents the error from bubbling up and causing retry loops
return { return {
@ -955,7 +963,7 @@ export class TraktService {
} catch (parseError) { } catch (parseError) {
logger.warn(`[TraktService] Could not parse 409 error response: ${parseError}`); logger.warn(`[TraktService] Could not parse 409 error response: ${parseError}`);
} }
// Return a graceful response even if we can't parse the error // Return a graceful response even if we can't parse the error
return { return {
id: 0, id: 0,
@ -967,7 +975,7 @@ export class TraktService {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
// Enhanced error logging for debugging // Enhanced error logging for debugging
logger.error(`[TraktService] API Error ${response.status} for ${endpoint}:`, { logger.error(`[TraktService] API Error ${response.status} for ${endpoint}:`, {
status: response.status, status: response.status,
@ -976,14 +984,14 @@ export class TraktService {
requestBody: body ? JSON.stringify(body, null, 2) : 'No body', requestBody: body ? JSON.stringify(body, null, 2) : 'No body',
headers: Object.fromEntries(response.headers.entries()) headers: Object.fromEntries(response.headers.entries())
}); });
// Handle 404 errors more gracefully - they might indicate content not found in Trakt // Handle 404 errors more gracefully - they might indicate content not found in Trakt
if (response.status === 404) { if (response.status === 404) {
logger.warn(`[TraktService] Content not found in Trakt database (404) for ${endpoint}. This might indicate:`); logger.warn(`[TraktService] Content not found in Trakt database (404) for ${endpoint}. This might indicate:`);
logger.warn(`[TraktService] 1. Invalid IMDb ID: ${body?.movie?.ids?.imdb || body?.show?.ids?.imdb || 'N/A'}`); logger.warn(`[TraktService] 1. Invalid IMDb ID: ${body?.movie?.ids?.imdb || body?.show?.ids?.imdb || 'N/A'}`);
logger.warn(`[TraktService] 2. Content not in Trakt database: ${body?.movie?.title || body?.show?.title || 'N/A'}`); logger.warn(`[TraktService] 2. Content not in Trakt database: ${body?.movie?.title || body?.show?.title || 'N/A'}`);
logger.warn(`[TraktService] 3. Authentication issues with token`); logger.warn(`[TraktService] 3. Authentication issues with token`);
// Return a graceful response for 404s instead of throwing // Return a graceful response for 404s instead of throwing
return { return {
id: 0, id: 0,
@ -992,7 +1000,7 @@ export class TraktService {
error: 'Content not found in Trakt database' error: 'Content not found in Trakt database'
} as any; } as any;
} }
throw new Error(`API request failed: ${response.status}`); throw new Error(`API request failed: ${response.status}`);
} }
@ -1016,7 +1024,7 @@ export class TraktService {
if (endpoint.includes('/scrobble/')) { if (endpoint.includes('/scrobble/')) {
// API success logging removed // API success logging removed
} }
return responseData; return responseData;
} }
@ -1041,7 +1049,7 @@ export class TraktService {
*/ */
private isRecentlyScrobbled(contentData: TraktContentData): boolean { private isRecentlyScrobbled(contentData: TraktContentData): boolean {
const contentKey = this.getWatchingKey(contentData); const contentKey = this.getWatchingKey(contentData);
// Clean up expired entries // Clean up expired entries
const now = Date.now(); const now = Date.now();
for (const [key, timestamp] of this.scrobbledTimestamps.entries()) { for (const [key, timestamp] of this.scrobbledTimestamps.entries()) {
@ -1050,7 +1058,7 @@ export class TraktService {
this.scrobbledTimestamps.delete(key); this.scrobbledTimestamps.delete(key);
} }
} }
return this.scrobbledItems.has(contentKey); return this.scrobbledItems.has(contentKey);
} }
@ -1181,7 +1189,7 @@ export class TraktService {
if (!images || !images.poster || images.poster.length === 0) { if (!images || !images.poster || images.poster.length === 0) {
return null; return null;
} }
// Get the first poster and add https prefix // Get the first poster and add https prefix
const posterPath = images.poster[0]; const posterPath = images.poster[0];
return posterPath.startsWith('http') ? posterPath : `https://${posterPath}`; return posterPath.startsWith('http') ? posterPath : `https://${posterPath}`;
@ -1194,7 +1202,7 @@ export class TraktService {
if (!images || !images.fanart || images.fanart.length === 0) { if (!images || !images.fanart || images.fanart.length === 0) {
return null; return null;
} }
// Get the first fanart and add https prefix // Get the first fanart and add https prefix
const fanartPath = images.fanart[0]; const fanartPath = images.fanart[0];
return fanartPath.startsWith('http') ? fanartPath : `https://${fanartPath}`; return fanartPath.startsWith('http') ? fanartPath : `https://${fanartPath}`;
@ -1291,9 +1299,9 @@ export class TraktService {
* Add a show episode to user's watched history * Add a show episode to user's watched history
*/ */
public async addToWatchedEpisodes( public async addToWatchedEpisodes(
imdbId: string, imdbId: string,
season: number, season: number,
episode: number, episode: number,
watchedAt: Date = new Date() watchedAt: Date = new Date()
): Promise<boolean> { ): Promise<boolean> {
try { try {
@ -1355,8 +1363,8 @@ export class TraktService {
* Check if a show episode is in user's watched history * Check if a show episode is in user's watched history
*/ */
public async isEpisodeWatched( public async isEpisodeWatched(
imdbId: string, imdbId: string,
season: number, season: number,
episode: number episode: number
): Promise<boolean> { ): Promise<boolean> {
try { try {
@ -1478,19 +1486,19 @@ export class TraktService {
*/ */
private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } { private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } {
const errors: string[] = []; const errors: string[] = [];
if (!contentData.type || !['movie', 'episode'].includes(contentData.type)) { if (!contentData.type || !['movie', 'episode'].includes(contentData.type)) {
errors.push('Invalid content type'); errors.push('Invalid content type');
} }
if (!contentData.title || contentData.title.trim() === '') { if (!contentData.title || contentData.title.trim() === '') {
errors.push('Missing or empty title'); errors.push('Missing or empty title');
} }
if (!contentData.imdbId || contentData.imdbId.trim() === '') { if (!contentData.imdbId || contentData.imdbId.trim() === '') {
errors.push('Missing or empty IMDb ID'); errors.push('Missing or empty IMDb ID');
} }
if (contentData.type === 'episode') { if (contentData.type === 'episode') {
if (!contentData.season || contentData.season < 1) { if (!contentData.season || contentData.season < 1) {
errors.push('Invalid season number'); errors.push('Invalid season number');
@ -1505,7 +1513,7 @@ export class TraktService {
errors.push('Invalid show year'); errors.push('Invalid show year');
} }
} }
return { return {
isValid: errors.length === 0, isValid: errors.length === 0,
errors errors
@ -1547,7 +1555,7 @@ export class TraktService {
const imdbIdWithPrefix = contentData.imdbId.startsWith('tt') const imdbIdWithPrefix = contentData.imdbId.startsWith('tt')
? contentData.imdbId ? contentData.imdbId
: `tt${contentData.imdbId}`; : `tt${contentData.imdbId}`;
const payload = { const payload = {
movie: { movie: {
title: contentData.title, title: contentData.title,
@ -1558,7 +1566,7 @@ export class TraktService {
}, },
progress: clampedProgress progress: clampedProgress
}; };
logger.log('[TraktService] Movie payload built:', payload); logger.log('[TraktService] Movie payload built:', payload);
return payload; return payload;
} else if (contentData.type === 'episode') { } else if (contentData.type === 'episode') {
@ -1598,11 +1606,11 @@ export class TraktService {
const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt') const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt')
? contentData.imdbId ? contentData.imdbId
: `tt${contentData.imdbId}`; : `tt${contentData.imdbId}`;
if (!payload.episode.ids) { if (!payload.episode.ids) {
payload.episode.ids = {}; payload.episode.ids = {};
} }
payload.episode.ids.imdb = episodeImdbWithPrefix; payload.episode.ids.imdb = episodeImdbWithPrefix;
} }
@ -1635,7 +1643,7 @@ export class TraktService {
} catch (error) { } catch (error) {
logger.error('[TraktService] Queue request failed:', error); logger.error('[TraktService] Queue request failed:', error);
} }
// Wait minimum interval before next request // Wait minimum interval before next request
if (this.requestQueue.length > 0) { if (this.requestQueue.length > 0) {
await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL)); await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL));
@ -1659,7 +1667,7 @@ export class TraktService {
reject(error); reject(error);
} }
}); });
// Start processing if not already running // Start processing if not already running
this.processQueue(); this.processQueue();
}); });
@ -1702,7 +1710,7 @@ export class TraktService {
} }
// Debug log removed to reduce terminal noise // Debug log removed to reduce terminal noise
// Only start if not already watching this content // Only start if not already watching this content
if (this.currentlyWatching.has(watchingKey)) { if (this.currentlyWatching.has(watchingKey)) {
logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`); logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`);
@ -1736,10 +1744,10 @@ export class TraktService {
} }
const now = Date.now(); const now = Date.now();
const watchingKey = this.getWatchingKey(contentData); const watchingKey = this.getWatchingKey(contentData);
const lastSync = this.lastSyncTimes.get(watchingKey) || 0; const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms) // IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms)
if (!force && (now - lastSync) < 100) { if (!force && (now - lastSync) < 100) {
return true; // Skip this sync, but return success return true; // Skip this sync, but return success
@ -1763,7 +1771,7 @@ export class TraktService {
logger.warn('[TraktService] Rate limited, will retry later'); logger.warn('[TraktService] Rate limited, will retry later');
return true; // Return success to avoid error spam return true; // Return success to avoid error spam
} }
logger.error('[TraktService] Failed to update progress:', error); logger.error('[TraktService] Failed to update progress:', error);
return false; return false;
} }
@ -1794,7 +1802,7 @@ export class TraktService {
// Use pause if below user threshold, stop only when ready to scrobble // Use pause if below user threshold, stop only when ready to scrobble
const useStop = progress >= this.completionThreshold; const useStop = progress >= this.completionThreshold;
const result = await this.queueRequest(async () => { const result = await this.queueRequest(async () => {
return useStop return useStop
? await this.stopWatching(contentData, progress) ? await this.stopWatching(contentData, progress)
: await this.pauseWatching(contentData, progress); : await this.pauseWatching(contentData, progress);
}); });
@ -1923,8 +1931,8 @@ export class TraktService {
* @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead * @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead
*/ */
public async syncProgressToTrakt( public async syncProgressToTrakt(
contentData: TraktContentData, contentData: TraktContentData,
progress: number, progress: number,
force: boolean = false force: boolean = false
): Promise<boolean> { ): Promise<boolean> {
// For backward compatibility, treat as a pause update // For backward compatibility, treat as a pause update
@ -1937,11 +1945,11 @@ export class TraktService {
public async debugTraktConnection(): Promise<any> { public async debugTraktConnection(): Promise<any> {
try { try {
logger.log('[TraktService] Testing Trakt API connection...'); logger.log('[TraktService] Testing Trakt API connection...');
// Test basic API access // Test basic API access
const userResponse = await this.apiRequest('/users/me', 'GET'); const userResponse = await this.apiRequest('/users/me', 'GET');
logger.log('[TraktService] User info:', userResponse); logger.log('[TraktService] User info:', userResponse);
// Test a minimal scrobble start to verify API works // Test a minimal scrobble start to verify API works
const testPayload = { const testPayload = {
movie: { movie: {
@ -1953,19 +1961,19 @@ export class TraktService {
}, },
progress: 1.0 progress: 1.0
}; };
logger.log('[TraktService] Testing scrobble/start endpoint with test payload...'); logger.log('[TraktService] Testing scrobble/start endpoint with test payload...');
const scrobbleResponse = await this.apiRequest('/scrobble/start', 'POST', testPayload); const scrobbleResponse = await this.apiRequest('/scrobble/start', 'POST', testPayload);
logger.log('[TraktService] Scrobble test response:', scrobbleResponse); logger.log('[TraktService] Scrobble test response:', scrobbleResponse);
return { return {
authenticated: true, authenticated: true,
user: userResponse, user: userResponse,
scrobbleTest: scrobbleResponse scrobbleTest: scrobbleResponse
}; };
} catch (error) { } catch (error) {
logger.error('[TraktService] Debug connection failed:', error); logger.error('[TraktService] Debug connection failed:', error);
return { return {
authenticated: false, authenticated: false,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
}; };
@ -1984,7 +1992,7 @@ export class TraktService {
const progress = await this.getPlaybackProgress(); const progress = await this.getPlaybackProgress();
// Progress logging removed // Progress logging removed
progress.forEach((item, index) => { progress.forEach((item, index) => {
if (item.type === 'movie' && item.movie) { if (item.type === 'movie' && item.movie) {
// Movie progress logging removed // Movie progress logging removed
@ -1992,7 +2000,7 @@ export class TraktService {
// Episode progress logging removed // Episode progress logging removed
} }
}); });
if (progress.length === 0) { if (progress.length === 0) {
// No progress logging removed // No progress logging removed
} }
@ -2022,16 +2030,16 @@ export class TraktService {
public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise<boolean> { public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise<boolean> {
try { try {
logger.log(`🔍 [TraktService] deletePlaybackForContent called for ${type}:${imdbId} (season:${season}, episode:${episode})`); logger.log(`🔍 [TraktService] deletePlaybackForContent called for ${type}:${imdbId} (season:${season}, episode:${episode})`);
if (!this.accessToken) { if (!this.accessToken) {
logger.log(`❌ [TraktService] No access token - cannot delete playback`); logger.log(`❌ [TraktService] No access token - cannot delete playback`);
return false; return false;
} }
logger.log(`🔍 [TraktService] Fetching current playback progress...`); logger.log(`🔍 [TraktService] Fetching current playback progress...`);
const progressItems = await this.getPlaybackProgress(); const progressItems = await this.getPlaybackProgress();
logger.log(`📊 [TraktService] Found ${progressItems.length} playback items`); logger.log(`📊 [TraktService] Found ${progressItems.length} playback items`);
const target = progressItems.find(item => { const target = progressItems.find(item => {
if (type === 'movie' && item.type === 'movie' && item.movie?.ids.imdb === imdbId) { if (type === 'movie' && item.type === 'movie' && item.movie?.ids.imdb === imdbId) {
logger.log(`🎯 [TraktService] Found matching movie: ${item.movie?.title}`); logger.log(`🎯 [TraktService] Found matching movie: ${item.movie?.title}`);
@ -2050,7 +2058,7 @@ export class TraktService {
} }
return false; return false;
}); });
if (target) { if (target) {
logger.log(`🗑️ [TraktService] Deleting playback item with ID: ${target.id}`); logger.log(`🗑️ [TraktService] Deleting playback item with ID: ${target.id}`);
const result = await this.deletePlaybackItem(target.id); const result = await this.deletePlaybackItem(target.id);
@ -2475,7 +2483,7 @@ export class TraktService {
// Ensure IMDb ID includes the 'tt' prefix // Ensure IMDb ID includes the 'tt' prefix
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const payload = type === 'movie' const payload = type === 'movie'
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
@ -2500,7 +2508,7 @@ export class TraktService {
// Ensure IMDb ID includes the 'tt' prefix // Ensure IMDb ID includes the 'tt' prefix
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const payload = type === 'movie' const payload = type === 'movie'
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
@ -2525,7 +2533,7 @@ export class TraktService {
// Ensure IMDb ID includes the 'tt' prefix // Ensure IMDb ID includes the 'tt' prefix
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const payload = type === 'movie' const payload = type === 'movie'
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
@ -2550,7 +2558,7 @@ export class TraktService {
// Ensure IMDb ID includes the 'tt' prefix // Ensure IMDb ID includes the 'tt' prefix
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const payload = type === 'movie' const payload = type === 'movie'
? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] }
: { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] };
@ -2575,13 +2583,13 @@ export class TraktService {
// Ensure IMDb ID includes the 'tt' prefix // Ensure IMDb ID includes the 'tt' prefix
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const watchlistItems = type === 'movie' const watchlistItems = type === 'movie'
? await this.getWatchlistMovies() ? await this.getWatchlistMovies()
: await this.getWatchlistShows(); : await this.getWatchlistShows();
return watchlistItems.some(item => { return watchlistItems.some(item => {
const itemImdbId = type === 'movie' const itemImdbId = type === 'movie'
? item.movie?.ids?.imdb ? item.movie?.ids?.imdb
: item.show?.ids?.imdb; : item.show?.ids?.imdb;
return itemImdbId === imdbIdWithPrefix; return itemImdbId === imdbIdWithPrefix;
}); });
@ -2603,13 +2611,13 @@ export class TraktService {
// Ensure IMDb ID includes the 'tt' prefix // Ensure IMDb ID includes the 'tt' prefix
const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const collectionItems = type === 'movie' const collectionItems = type === 'movie'
? await this.getCollectionMovies() ? await this.getCollectionMovies()
: await this.getCollectionShows(); : await this.getCollectionShows();
return collectionItems.some(item => { return collectionItems.some(item => {
const itemImdbId = type === 'movie' const itemImdbId = type === 'movie'
? item.movie?.ids?.imdb ? item.movie?.ids?.imdb
: item.show?.ids?.imdb; : item.show?.ids?.imdb;
return itemImdbId === imdbIdWithPrefix; return itemImdbId === imdbIdWithPrefix;
}); });
@ -2630,7 +2638,7 @@ export class TraktService {
this.currentlyWatching.clear(); this.currentlyWatching.clear();
this.lastSyncTimes.clear(); this.lastSyncTimes.clear();
this.lastStopCalls.clear(); this.lastStopCalls.clear();
// Clear request queue to prevent background processing // Clear request queue to prevent background processing
this.requestQueue = []; this.requestQueue = [];
this.isProcessingQueue = false; this.isProcessingQueue = false;