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

View file

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