trakt continue watching removal fix

This commit is contained in:
tapframe 2025-09-13 13:21:43 +05:30
parent a5d2756854
commit 263da30f17
6 changed files with 525 additions and 108 deletions

View file

@ -98,6 +98,10 @@ 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
// Cache for metadata to avoid redundant API calls
const metadataCache = useRef<Record<string, { metadata: any; basicContent: StreamingContent | null; timestamp: number }>>({});
@ -125,15 +129,17 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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<ContinueWatchingRef>((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<string, ContinueWatchingItem>();
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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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);
}

View file

@ -602,7 +602,6 @@ class NotificationService {
}
if (!metadata) {
logger.warn(`[NotificationService] No metadata found for series: ${seriesId}`);
return;
}

View file

@ -415,22 +415,35 @@ class StorageService {
options?: { addBaseTombstone?: boolean }
): Promise<void> {
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<Promise<void>> = [];
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);
}
}

View file

@ -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}`);
}

View file

@ -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<boolean> {
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<TraktHistoryRemoveResponse | null> {
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<TraktHistoryRemoveResponse>('/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<TraktHistoryItem[]> {
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<TraktHistoryItem[]>(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<TraktHistoryItem[]> {
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<TraktHistoryItem[]> {
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<TraktHistoryItem[]> {
return this.getHistory('shows', undefined, startAt, endAt, page, limit);
}
/**
* Remove a movie from watched history by IMDB ID
*/
public async removeMovieFromHistory(imdbId: string): Promise<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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
*/

View file

@ -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();