diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index b63bc154..4f326767 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -98,6 +98,10 @@ 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 // Cache for metadata to avoid redundant API calls const metadataCache = useRef>({}); @@ -125,15 +129,17 @@ const ContinueWatchingSection = React.forwardRef((props, re return result; } return null; - } catch (error) { - logger.error(`Failed to fetch metadata for ${type}:${id}:`, error); + } catch (error: any) { + // Skip logging 404 errors to reduce noise return null; } }, []); // Modified loadContinueWatching to render incrementally const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { - if (isRefreshingRef.current) return; + if (isRefreshingRef.current) { + return; + } if (!isBackgroundRefresh) { setLoading(true); @@ -143,20 +149,30 @@ const ContinueWatchingSection = React.forwardRef((props, re // Helper to merge a batch of items into state (dedupe by type:id, keep newest) const mergeBatchIntoState = (batch: ContinueWatchingItem[]) => { if (!batch || batch.length === 0) return; + setContinueWatchingItems((prev) => { const map = new Map(); for (const it of prev) { map.set(`${it.type}:${it.id}`, it); } + for (const it of batch) { const key = `${it.type}:${it.id}`; + + // Skip recently removed items to prevent immediate re-addition + if (recentlyRemovedRef.current.has(key)) { + continue; + } + const existing = map.get(key); if (!existing || (it.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { map.set(key, it); } } + const merged = Array.from(map.values()); merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + return merged; }); }; @@ -274,7 +290,7 @@ const ContinueWatchingSection = React.forwardRef((props, re if (batch.length > 0) mergeBatchIntoState(batch); } catch (error) { - logger.error(`Failed to process content group ${group.type}:${group.id}:`, error); + // Continue processing other groups even if one fails } }); @@ -302,6 +318,13 @@ const ContinueWatchingSection = React.forwardRef((props, re const perShowPromises = Object.entries(latestWatchedByShow).map(async ([showId, info]) => { try { + // Check if this show was recently removed by the user + const showKey = `series:${showId}`; + if (recentlyRemovedRef.current.has(showKey)) { + logger.log(`ðŸšŦ [TraktSync] Skipping recently removed show: ${showKey}`); + return; + } + const nextEpisode = info.episode + 1; const cachedData = await getCachedMetadata('series', showId); if (!cachedData?.basicContent) return; @@ -313,6 +336,7 @@ const ContinueWatchingSection = React.forwardRef((props, re ); } if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { + logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`); mergeBatchIntoState([ { ...basicContent, @@ -327,38 +351,43 @@ const ContinueWatchingSection = React.forwardRef((props, re ]); } - // Persist "watched" progress for the episode that Trakt reported - const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`; - const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`]; - const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0; - if (!existingProgress || existingPercent < 85) { - await storageService.setWatchProgress( - showId, - 'series', - { - currentTime: 1, - duration: 1, - lastUpdated: info.watchedAt, - traktSynced: true, - traktProgress: 100, - } as any, - `${info.season}:${info.episode}` - ); + // Persist "watched" progress for the episode that Trakt reported (only if not recently removed) + if (!recentlyRemovedRef.current.has(showKey)) { + const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`; + const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`]; + const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0; + if (!existingProgress || existingPercent < 85) { + logger.log(`ðŸ’ū [TraktSync] Adding local progress for ${showId}: S${info.season}E${info.episode}`); + await storageService.setWatchProgress( + showId, + 'series', + { + currentTime: 1, + duration: 1, + lastUpdated: info.watchedAt, + traktSynced: true, + traktProgress: 100, + } as any, + `${info.season}:${info.episode}` + ); + } + } else { + logger.log(`ðŸšŦ [TraktSync] Skipping local progress for recently removed show: ${showKey}`); } } catch (err) { - logger.error('Failed to build placeholder from history:', err); + // Continue with other shows even if one fails } }); await Promise.allSettled(perShowPromises); } catch (err) { - logger.error('Error merging Trakt history:', err); + // Continue even if Trakt history merge fails } })(); // Wait for all groups and trakt merge to settle, then finalize loading state await Promise.allSettled([...groupPromises, traktMergePromise]); } catch (error) { - logger.error('Failed to load continue watching items:', error); + // Continue even if loading fails } finally { setLoading(false); isRefreshingRef.current = false; @@ -474,26 +503,47 @@ const ContinueWatchingSection = React.forwardRef((props, re try { // Trigger haptic feedback for confirmation Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - + // Remove all watch progress for this content (all episodes if series) await storageService.removeAllWatchProgressForContent(item.id, item.type, { addBaseTombstone: true }); - + // Also remove from Trakt playback queue if authenticated const traktService = TraktService.getInstance(); const isAuthed = await traktService.isAuthenticated(); + logger.log(`🔍 [ContinueWatching] Trakt authentication status: ${isAuthed}`); + if (isAuthed) { - await traktService.deletePlaybackForContent( - item.id, - item.type as 'movie' | 'series', - undefined, - undefined - ); + logger.log(`🗑ïļ [ContinueWatching] Removing Trakt history for ${item.id}`); + let traktResult = false; + + if (item.type === 'movie') { + traktResult = await traktService.removeMovieFromHistory(item.id); + } else { + traktResult = await traktService.removeShowFromHistory(item.id); + } + + logger.log(`✅ [ContinueWatching] Trakt removal result: ${traktResult}`); + } else { + logger.log(`â„đïļ [ContinueWatching] Skipping Trakt removal - not authenticated`); } - + + // Track this item as recently removed to prevent immediate re-addition + const itemKey = `${item.type}:${item.id}`; + recentlyRemovedRef.current.add(itemKey); + + // Clear from recently removed after the ignore duration + setTimeout(() => { + recentlyRemovedRef.current.delete(itemKey); + }, REMOVAL_IGNORE_DURATION); + // Update the list by filtering out the deleted item - setContinueWatchingItems(prev => prev.filter(i => i.id !== item.id)); + setContinueWatchingItems(prev => { + const newList = prev.filter(i => i.id !== item.id); + return newList; + }); + } catch (error) { - logger.error('Failed to remove watch progress:', error); + // Continue even if removal fails } finally { setDeletingItemId(null); } diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 62dee2bf..79dc9b81 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -602,7 +602,6 @@ class NotificationService { } if (!metadata) { - logger.warn(`[NotificationService] No metadata found for series: ${seriesId}`); return; } diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 229de7db..7c66ba51 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -415,22 +415,35 @@ class StorageService { options?: { addBaseTombstone?: boolean } ): Promise { try { + logger.log(`🗑ïļ [StorageService] removeAllWatchProgressForContent called for ${type}:${id}`); + const all = await this.getAllWatchProgress(); const prefix = `${type}:${id}`; + logger.log(`🔍 [StorageService] Looking for keys with prefix: ${prefix}`); + + const matchingKeys = Object.keys(all).filter(key => key === prefix || key.startsWith(`${prefix}:`)); + logger.log(`📊 [StorageService] Found ${matchingKeys.length} matching keys:`, matchingKeys); + const removals: Array> = []; - for (const key of Object.keys(all)) { - if (key === prefix || key.startsWith(`${prefix}:`)) { - // Compute episodeId if present - const episodeId = key.length > prefix.length + 1 ? key.slice(prefix.length + 1) : undefined; - removals.push(this.removeWatchProgress(id, type, episodeId)); - } + for (const key of matchingKeys) { + // Compute episodeId if present + const episodeId = key.length > prefix.length + 1 ? key.slice(prefix.length + 1) : undefined; + logger.log(`🗑ïļ [StorageService] Removing progress for key: ${key} (episodeId: ${episodeId})`); + removals.push(this.removeWatchProgress(id, type, episodeId)); } + await Promise.allSettled(removals); + logger.log(`✅ [StorageService] All watch progress removals completed`); + if (options?.addBaseTombstone) { + logger.log(`ðŸŠĶ [StorageService] Adding tombstone for ${type}:${id}`); await this.addWatchProgressTombstone(id, type); + logger.log(`✅ [StorageService] Tombstone added successfully`); } + + logger.log(`✅ [StorageService] removeAllWatchProgressForContent completed for ${type}:${id}`); } catch (error) { - logger.error('Error removing all watch progress for content:', error); + logger.error(`❌ [StorageService] Error removing all watch progress for content ${type}:${id}:`, error); } } diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index c4ba56c0..9df97d50 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -220,9 +220,7 @@ class StremioService { try { const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json'); this.installedAddons.set(cinemetaId, cinemetaManifest); - logger.log('✅ Cinemeta pre-installed as default addon with full manifest'); } catch (error) { - logger.error('Failed to fetch Cinemeta manifest, using fallback:', error); // Fallback to minimal manifest if fetch fails const fallbackManifest: Manifest = { id: cinemetaId, @@ -263,7 +261,6 @@ class StremioService { } }; this.installedAddons.set(cinemetaId, fallbackManifest); - logger.log('✅ Cinemeta pre-installed with fallback manifest'); } } @@ -273,9 +270,7 @@ class StremioService { try { const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json'); this.installedAddons.set(opensubsId, opensubsManifest); - logger.log('✅ OpenSubtitles v3 pre-installed as default subtitles addon'); } catch (error) { - logger.error('Failed to fetch OpenSubtitles manifest, using fallback:', error); const fallbackManifest: Manifest = { id: opensubsId, name: 'OpenSubtitles v3', @@ -297,7 +292,6 @@ class StremioService { } }; this.installedAddons.set(opensubsId, fallbackManifest); - logger.log('✅ OpenSubtitles v3 pre-installed with fallback manifest'); } } @@ -330,7 +324,6 @@ class StremioService { this.initialized = true; } catch (error) { - logger.error('Failed to initialize addons:', error); // Initialize with empty state on error this.installedAddons = new Map(); this.addonOrder = []; @@ -352,12 +345,21 @@ class StremioService { return await request(); } catch (error: any) { lastError = error; - logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, { - message: error.message, - code: error.code, - isAxiosError: error.isAxiosError, - status: error.response?.status, - }); + + // Don't retry on 404 errors (content not found) - these are expected for some content + if (error.response?.status === 404) { + throw error; + } + + // Only log warnings for non-404 errors to reduce noise + if (error.response?.status !== 404) { + logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, { + message: error.message, + code: error.code, + isAxiosError: error.isAxiosError, + status: error.response?.status, + }); + } if (attempt < retries) { const backoffDelay = delay * Math.pow(2, attempt); @@ -375,7 +377,7 @@ class StremioService { const scope = (await AsyncStorage.getItem('@user:current')) || 'local'; await AsyncStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray)); } catch (error) { - logger.error('Failed to save addons:', error); + // Continue even if save fails } } @@ -388,7 +390,7 @@ class StremioService { AsyncStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder)), ]); } catch (error) { - logger.error('Failed to save addon order:', error); + // Continue even if save fails } } @@ -444,7 +446,6 @@ class StremioService { removeAddon(id: string): void { // Prevent removal of Cinemeta as it's a pre-installed addon if (id === 'com.linvo.cinemeta') { - logger.warn('❌ Cannot remove Cinemeta - it is a pre-installed addon'); return; } @@ -548,10 +549,8 @@ class StremioService { // Add filters if (filters.length > 0) { - logger.log(`Adding ${filters.length} filters to Cinemeta request`); filters.forEach(filter => { if (filter.value) { - logger.log(`Adding filter ${filter.title}=${filter.value}`); url += `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`; } }); @@ -592,6 +591,8 @@ class StremioService { }); } + logger.log(`🔗 [${manifest.name}] Requesting catalog: ${url}`); + const response = await this.retryRequest(async () => { return await axios.get(url); }); @@ -612,18 +613,13 @@ class StremioService { // If a preferred addon is specified, try it first if (preferredAddonId) { - logger.log(`ðŸŽŊ Trying preferred addon first: ${preferredAddonId}`); const preferredAddon = addons.find(addon => addon.id === preferredAddonId); - + if (preferredAddon && preferredAddon.resources) { - // Log what URL would be used for debugging + // Build URL for metadata request const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || ''); - const wouldBeUrl = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; - logger.log(`🔍 Would check URL: ${wouldBeUrl} (addon: ${preferredAddon.name})`); - - // Log addon resources for debugging - logger.log(`🔍 Addon resources:`, JSON.stringify(preferredAddon.resources, null, 2)); - + const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; + // Check if addon supports meta resource for this type let hasMetaSupport = false; @@ -653,20 +649,16 @@ class StremioService { try { const response = await this.retryRequest(async () => { - return await axios.get(wouldBeUrl, { timeout: 10000 }); + return await axios.get(url, { timeout: 10000 }); }); if (response.data && response.data.meta) { return response.data.meta; } } catch (error) { - logger.warn(`❌ Failed to fetch meta from preferred addon ${preferredAddon.name}:`, error); + // Continue trying other addons } - } else { - logger.warn(`⚠ïļ Preferred addon ${preferredAddonId} does not support meta for type ${type}`); } - } else { - logger.warn(`⚠ïļ Preferred addon ${preferredAddonId} not found or has no resources`); } } @@ -679,7 +671,7 @@ class StremioService { for (const baseUrl of cinemetaUrls) { try { const url = `${baseUrl}/meta/${type}/${id}.json`; - + const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); @@ -687,8 +679,7 @@ class StremioService { if (response.data && response.data.meta) { return response.data.meta; } - } catch (error) { - logger.warn(`❌ Failed to fetch meta from ${baseUrl}:`, error); + } catch (error: any) { continue; // Try next URL } } @@ -727,7 +718,7 @@ class StremioService { const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`; - logger.log(`HTTP GET: ${url}`); + logger.log(`🔗 [${addon.name}] Requesting metadata: ${url}`); const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); @@ -741,7 +732,10 @@ class StremioService { } } - logger.warn('No metadata found from any addon'); + // Only log this warning in debug mode to reduce noise + if (__DEV__) { + logger.warn('No metadata found from any addon'); + } return null; } catch (error) { logger.error('Error in getMetaDetails:', error); @@ -796,8 +790,6 @@ class StremioService { .sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime()) .slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow - logger.log(`[StremioService] Filtered ${metadata.videos.length} episodes down to ${filteredEpisodes.length} upcoming episodes for ${metadata.name}`); - return { seriesName: metadata.name, poster: metadata.poster || '', @@ -872,25 +864,20 @@ class StremioService { if (tmdbIdNumber) { tmdbId = tmdbIdNumber.toString(); - logger.log(`🔄 [getStreams] Converted IMDb ID ${baseImdbId} to TMDB ID ${tmdbId}${scraperType === 'tv' ? ` (S${season}E${episode})` : ''}`); } else { - logger.warn(`⚠ïļ [getStreams] Could not convert IMDb ID ${baseImdbId} to TMDB ID`); return; // Skip local scrapers if we can't convert the ID } } catch (error) { - logger.error(`❌ [getStreams] Failed to parse Stremio ID or convert to TMDB ID:`, error); return; // Skip local scrapers if ID parsing fails } // Execute local scrapers asynchronously with TMDB ID localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => { if (error) { - logger.error(`❌ [getStreams] Local scraper ${scraperName} failed:`, error); if (callback) { callback(null, scraperId, scraperName, error); } } else if (streams && streams.length > 0) { - logger.log(`✅ [getStreams] Local scraper ${scraperName} returned ${streams.length} streams`); if (callback) { callback(streams, scraperId, scraperName, null); } @@ -899,21 +886,13 @@ class StremioService { } } } catch (error) { - logger.error('❌ [getStreams] Failed to execute local scrapers:', error); + // Continue even if local scrapers fail } // Check specifically for TMDB Embed addon const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi'); - if (tmdbEmbed) { - logger.log('🔍 [getStreams] Found TMDB Embed Streams addon:', { - id: tmdbEmbed.id, - name: tmdbEmbed.name, - url: tmdbEmbed.url, - resources: tmdbEmbed.resources, - types: tmdbEmbed.types - }); - } else { - logger.log('⚠ïļ [getStreams] TMDB Embed Streams addon not found among installed addons'); + if (!tmdbEmbed) { + // TMDB Embed addon not found } // Find addons that provide streams and sort them by installation order @@ -1002,7 +981,6 @@ class StremioService { callback(processedStreams, addon.id, addon.name, null); } } catch (error) { - logger.error(`❌ [getStreams] Failed to get streams from ${addon.name} (${addon.id}):`, error); if (callback) { // Call callback with error callback(null, addon.id, addon.name, error as Error); @@ -1067,8 +1045,6 @@ class StremioService { status: error.response?.status, responseData: error.response?.data }; - logger.error('Failed to fetch streams from addon:', errorDetails); - // Re-throw the error with more context throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`); } diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 5a6d1a4e..b4e5c3b2 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -252,6 +252,133 @@ export interface TraktContentData { showImdbId?: string; } +export interface TraktHistoryItem { + id: number; + watched_at: string; + action: 'scrobble' | 'checkin' | 'watch'; + type: 'movie' | 'episode'; + movie?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + imdb: string; + tmdb: number; + }; + }; + episode?: { + season: number; + number: number; + title: string; + ids: { + trakt: number; + tvdb?: number; + imdb?: string; + tmdb?: number; + }; + }; + show?: { + title: string; + year: number; + ids: { + trakt: number; + slug: string; + tvdb?: number; + imdb: string; + tmdb: number; + }; + }; +} + +export interface TraktHistoryRemovePayload { + movies?: Array<{ + title?: string; + year?: number; + ids: { + trakt?: number; + slug?: string; + imdb?: string; + tmdb?: number; + }; + }>; + shows?: Array<{ + title?: string; + year?: number; + ids: { + trakt?: number; + slug?: string; + tvdb?: number; + imdb?: string; + tmdb?: number; + }; + seasons?: Array<{ + number: number; + episodes?: Array<{ + number: number; + }>; + }>; + }>; + seasons?: Array<{ + ids: { + trakt?: number; + tvdb?: number; + tmdb?: number; + }; + }>; + episodes?: Array<{ + ids: { + trakt?: number; + tvdb?: number; + imdb?: string; + tmdb?: number; + }; + }>; + ids?: number[]; +} + +export interface TraktHistoryRemoveResponse { + deleted: { + movies: number; + episodes: number; + shows?: number; + seasons?: number; + }; + not_found: { + movies: Array<{ + ids: { + imdb?: string; + trakt?: number; + tmdb?: number; + }; + }>; + shows: Array<{ + ids: { + imdb?: string; + trakt?: number; + tvdb?: number; + tmdb?: number; + }; + }>; + seasons: Array<{ + ids: { + trakt?: number; + tvdb?: number; + tmdb?: number; + }; + }>; + episodes: Array<{ + ids: { + trakt?: number; + tvdb?: number; + imdb?: string; + tmdb?: number; + }; + }>; + ids: number[]; + }; +} + export class TraktService { private static instance: TraktService; private accessToken: string | null = null; @@ -1524,26 +1651,47 @@ export class TraktService { */ public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise { try { - if (!this.accessToken) return false; + 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}`); return true; } if (type === 'series' && item.type === 'episode' && item.show?.ids.imdb === imdbId) { if (season !== undefined && episode !== undefined) { - return item.episode?.season === season && item.episode?.number === episode; + const matches = item.episode?.season === season && item.episode?.number === episode; + if (matches) { + logger.log(`ðŸŽŊ [TraktService] Found matching episode: ${item.show?.title} S${season}E${episode}`); + } + return matches; } + logger.log(`ðŸŽŊ [TraktService] Found matching series episode: ${item.show?.title} S${item.episode?.season}E${item.episode?.number}`); return true; // match any episode of the show if specific not provided } return false; }); + if (target) { - return await this.deletePlaybackItem(target.id); + logger.log(`🗑ïļ [TraktService] Deleting playback item with ID: ${target.id}`); + const result = await this.deletePlaybackItem(target.id); + logger.log(`✅ [TraktService] Delete result: ${result}`); + return result; + } else { + logger.log(`â„đïļ [TraktService] No matching playback item found for ${type}:${imdbId}`); + return false; } - return false; } catch (error) { - logger.error('[TraktService] Error deleting playback for content:', error); + logger.error(`❌ [TraktService] Error deleting playback for content ${type}:${imdbId}:`, error); return false; } } @@ -1571,6 +1719,238 @@ export class TraktService { } } + /** + * Remove items from user's watched history + */ + public async removeFromHistory(payload: TraktHistoryRemovePayload): Promise { + try { + logger.log(`🔍 [TraktService] removeFromHistory called with payload:`, JSON.stringify(payload, null, 2)); + + if (!await this.isAuthenticated()) { + logger.log(`❌ [TraktService] Not authenticated for removeFromHistory`); + return null; + } + + const result = await this.apiRequest('/sync/history/remove', 'POST', payload); + + logger.log(`ðŸ“Ĩ [TraktService] removeFromHistory API response:`, JSON.stringify(result, null, 2)); + + return result; + } catch (error) { + logger.error('[TraktService] Failed to remove from history:', error); + return null; + } + } + + /** + * Get user's watch history with optional filtering + */ + public async getHistory( + type?: 'movies' | 'shows' | 'episodes', + id?: number, + startAt?: Date, + endAt?: Date, + page: number = 1, + limit: number = 100 + ): Promise { + try { + if (!await this.isAuthenticated()) { + return []; + } + + let endpoint = '/sync/history'; + if (type) { + endpoint += `/${type}`; + if (id) { + endpoint += `/${id}`; + } + } + + const params = new URLSearchParams(); + params.append('page', page.toString()); + params.append('limit', limit.toString()); + + if (startAt) { + params.append('start_at', startAt.toISOString()); + } + + if (endAt) { + params.append('end_at', endAt.toISOString()); + } + + const queryString = params.toString(); + if (queryString) { + endpoint += `?${queryString}`; + } + + return this.apiRequest(endpoint, 'GET'); + } catch (error) { + logger.error('[TraktService] Failed to get history:', error); + return []; + } + } + + /** + * Get user's movie watch history + */ + public async getHistoryMovies( + startAt?: Date, + endAt?: Date, + page: number = 1, + limit: number = 100 + ): Promise { + return this.getHistory('movies', undefined, startAt, endAt, page, limit); + } + + /** + * Get user's episode watch history + */ + public async getHistoryEpisodes( + startAt?: Date, + endAt?: Date, + page: number = 1, + limit: number = 100 + ): Promise { + return this.getHistory('episodes', undefined, startAt, endAt, page, limit); + } + + /** + * Get user's show watch history + */ + public async getHistoryShows( + startAt?: Date, + endAt?: Date, + page: number = 1, + limit: number = 100 + ): Promise { + return this.getHistory('shows', undefined, startAt, endAt, page, limit); + } + + /** + * Remove a movie from watched history by IMDB ID + */ + public async removeMovieFromHistory(imdbId: string): Promise { + try { + const payload: TraktHistoryRemovePayload = { + movies: [ + { + ids: { + imdb: imdbId.startsWith('tt') ? imdbId : `tt${imdbId}` + } + } + ] + }; + + const result = await this.removeFromHistory(payload); + return result !== null && result.deleted.movies > 0; + } catch (error) { + logger.error('[TraktService] Failed to remove movie from history:', error); + return false; + } + } + + /** + * Remove an episode from watched history by IMDB IDs + */ + public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number): Promise { + try { + const payload: TraktHistoryRemovePayload = { + shows: [ + { + ids: { + imdb: showImdbId.startsWith('tt') ? showImdbId : `tt${showImdbId}` + }, + seasons: [ + { + number: season, + episodes: [ + { + number: episode + } + ] + } + ] + } + ] + }; + + const result = await this.removeFromHistory(payload); + return result !== null && result.deleted.episodes > 0; + } catch (error) { + logger.error('[TraktService] Failed to remove episode from history:', error); + return false; + } + } + + /** + * Remove entire show from watched history by IMDB ID + */ + public async removeShowFromHistory(imdbId: string): Promise { + try { + logger.log(`🔍 [TraktService] removeShowFromHistory called for ${imdbId}`); + + // First, let's check if this show exists in history + logger.log(`🔍 [TraktService] Checking if ${imdbId} exists in watch history...`); + const history = await this.getHistoryEpisodes(undefined, undefined, 1, 200); // Get recent episode history + const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + const showInHistory = history.some(item => + item.show?.ids?.imdb === fullImdbId + ); + + logger.log(`📊 [TraktService] Show ${imdbId} found in history: ${showInHistory}`); + + if (!showInHistory) { + logger.log(`â„đïļ [TraktService] Show ${imdbId} not found in watch history - nothing to remove`); + return true; // Consider this a success since there's nothing to remove + } + + const payload: TraktHistoryRemovePayload = { + shows: [ + { + ids: { + imdb: imdbId.startsWith('tt') ? imdbId : `tt${imdbId}` + } + } + ] + }; + + logger.log(`ðŸ“Ī [TraktService] Sending removeFromHistory payload:`, JSON.stringify(payload, null, 2)); + + const result = await this.removeFromHistory(payload); + + logger.log(`ðŸ“Ĩ [TraktService] removeFromHistory response:`, JSON.stringify(result, null, 2)); + + if (result) { + const success = result.deleted.episodes > 0; + logger.log(`✅ [TraktService] Show removal success: ${success} (${result.deleted.episodes} episodes deleted)`); + return success; + } + + logger.log(`❌ [TraktService] No response from removeFromHistory API`); + return false; + } catch (error) { + logger.error('[TraktService] Failed to remove show from history:', error); + return false; + } + } + + /** + * Remove items from history by history IDs + */ + public async removeHistoryByIds(historyIds: number[]): Promise { + try { + const payload: TraktHistoryRemovePayload = { + ids: historyIds + }; + + const result = await this.removeFromHistory(payload); + return result !== null && (result.deleted.movies > 0 || result.deleted.episodes > 0); + } catch (error) { + logger.error('[TraktService] Failed to remove history by IDs:', error); + return false; + } + } + /** * Handle app state changes to reduce memory pressure */ diff --git a/src/utils/memoryManager.ts b/src/utils/memoryManager.ts index e04dcd9c..50c9e1ba 100644 --- a/src/utils/memoryManager.ts +++ b/src/utils/memoryManager.ts @@ -28,7 +28,6 @@ export class MemoryManager { // Request garbage collection if available (development builds) if (global && typeof global.gc === 'function') { global.gc(); - logger.log('[MemoryManager] Forced garbage collection'); } else if (__DEV__) { // In development, we can try to trigger GC by creating and releasing large objects this.triggerGCInDev();