diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 436409c1..9d062c29 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -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((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((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((props, re return 280; // Original phone size } }, [deviceType]); - + const computedItemHeight = useMemo(() => { switch (deviceType) { case 'tv': @@ -162,7 +162,7 @@ const ContinueWatchingSection = React.forwardRef((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((props, re return 16; // phone } }, [deviceType]); - + const itemSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -198,11 +198,11 @@ const ContinueWatchingSection = React.forwardRef((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>(new Set()); const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds - + // Track last Trakt sync to prevent excessive API calls const lastTraktSyncRef = useRef(0); const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs @@ -216,18 +216,18 @@ const ContinueWatchingSection = React.forwardRef((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((props, re if (!isAuthed) return new Set(); if (typeof (traktService as any).getWatchedMovies === 'function') { const watched = await (traktService as any).getWatchedMovies(); + const watchedSet = new Set(); + 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(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(); + } catch { + return new Set(); + } + })(); + + // 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(); + + if (typeof (traktService as any).getWatchedShows === 'function') { + const watched = await (traktService as any).getWatchedShows(); + const watchedSet = new Set(); + + 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(); } catch { @@ -365,7 +419,7 @@ const ContinueWatchingSection = React.forwardRef((props, re traktSynced: true, traktProgress: 100, } as any); - } catch (_e) {} + } catch (_e) { } return; } } @@ -422,6 +476,8 @@ const ContinueWatchingSection = React.forwardRef((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((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((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 = {}; @@ -650,7 +761,7 @@ const ContinueWatchingSection = React.forwardRef((props, re useFocusEffect( useCallback(() => { loadContinueWatching(true); - return () => {}; + return () => { }; }, [loadContinueWatching]) ); @@ -667,62 +778,62 @@ const ContinueWatchingSection = React.forwardRef((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((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((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((props, re { label: 'Cancel', style: { color: '#888' }, - onPress: () => {}, + onPress: () => { }, }, { label: 'Remove', @@ -842,7 +953,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( ((props, re } ]}> ((props, re style={styles.continueWatchingPoster} resizeMode={FastImage.resizeMode.cover} /> - + {/* Delete Indicator Overlay */} {deletingItemId === item.id && ( @@ -893,10 +1004,10 @@ const ContinueWatchingSection = React.forwardRef((props, re const isUpNext = item.type === 'series' && item.progress === 0; return ( - ((props, re {item.name} {isUpNext && ( - + Up Next - + )} ); @@ -931,8 +1042,8 @@ const ContinueWatchingSection = React.forwardRef((props, re return ( ((props, re Season {item.season} {item.episodeTitle && ( - ((props, re } else { return ( ((props, re height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4 } ]}> - ((props, re Continue Watching ((props, re ]} /> - + ((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', diff --git a/src/services/traktService.ts b/src/services/traktService.ts index d654855c..bd79d7a3 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -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 = new Set(); private lastSyncTimes: Map = 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 = 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 { 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(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 { 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 { 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 { // For backward compatibility, treat as a pause update @@ -1937,11 +1945,11 @@ export class TraktService { public async debugTraktConnection(): Promise { 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 { 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;