From 611f69e7be26570864a61593493ca77da93177be Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 15:52:35 +0530 Subject: [PATCH 01/14] fix special id content trakt scrobble --- src/services/traktService.ts | 206 +++++++++++++++++++++++++++-------- 1 file changed, 162 insertions(+), 44 deletions(-) diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 64c12120..1fa3081f 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1155,17 +1155,19 @@ export class TraktService { } - public async isMovieWatchedAccurate(imdbId: string): Promise { + public async isMovieWatchedAccurate(imdbId: string, fallbackImdbId?: string): Promise { try { - const imdb = imdbId.startsWith('tt') - ? imdbId - : `tt${imdbId}`; + const normalise = (id: string) => id.startsWith('tt') ? id : `tt${id}`; + const imdb = normalise(imdbId); + const fallback = fallbackImdbId && fallbackImdbId !== imdbId && fallbackImdbId.trim() + ? normalise(fallbackImdbId) + : null; const movies = await this.apiRequest('/sync/watched/movies'); const moviesArray = Array.isArray(movies) ? movies : []; return moviesArray.some( - (m: any) => m.movie?.ids?.imdb === imdb + (m: any) => m.movie?.ids?.imdb === imdb || (fallback && m.movie?.ids?.imdb === fallback) ); } catch (err) { logger.warn('[TraktService] Movie watched check failed', err); @@ -1176,14 +1178,20 @@ export class TraktService { public async isEpisodeWatchedAccurate( showImdbId: string, season: number, - episode: number + episode: number, + fallbackImdbId?: string ): Promise { try { if (season === 0) return false; - const imdb = showImdbId.startsWith('tt') - ? showImdbId - : `tt${showImdbId}`; + const normalise = (id: string) => id.startsWith('tt') ? id : `tt${id}`; + const isRealImdbId = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id); + + // Use fallback if primary isn't a real IMDb ID + const resolvedId = isRealImdbId(showImdbId) ? showImdbId + : (fallbackImdbId && isRealImdbId(fallbackImdbId) ? fallbackImdbId : showImdbId); + + const imdb = normalise(resolvedId); const watchedShows = await this.apiRequest( '/sync/watched/shows' @@ -1521,16 +1529,25 @@ export class TraktService { imdbId: string, season: number, episode: number, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + fallbackImdbId?: string ): Promise { try { - const traktId = await this.getTraktIdFromImdbId(imdbId, 'show'); + const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id); + const resolvedId = isImdbFormat(imdbId) ? imdbId + : (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId); + + if (resolvedId !== imdbId) { + logger.log(`[TraktService] addToWatchedEpisodes: falling back from "${imdbId}" to "${resolvedId}"`); + } + + const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show'); if (!traktId) { - logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`); + logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`); return false; } - logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${imdbId} (trakt: ${traktId})`); + logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${resolvedId} (trakt: ${traktId})`); // Use shows array with seasons/episodes structure per Trakt API docs await this.apiRequest('/sync/history', 'POST', { @@ -1570,16 +1587,22 @@ export class TraktService { public async markSeasonAsWatched( imdbId: string, season: number, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + fallbackImdbId?: string ): Promise { try { - const traktId = await this.getTraktIdFromImdbId(imdbId, 'show'); + const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id); + const resolvedId = isImdbFormat(imdbId) ? imdbId + : (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId); + if (resolvedId !== imdbId) logger.log(`[TraktService] markSeasonAsWatched: falling back from "${imdbId}" to "${resolvedId}"`); + + const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show'); if (!traktId) { - logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`); + logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`); return false; } - logger.log(`[TraktService] Marking entire season ${season} as watched for show ${imdbId} (trakt: ${traktId})`); + logger.log(`[TraktService] Marking entire season ${season} as watched for show ${resolvedId} (trakt: ${traktId})`); // Mark entire season - Trakt will mark all episodes in the season await this.apiRequest('/sync/history', 'POST', { @@ -1614,7 +1637,8 @@ export class TraktService { public async markEpisodesAsWatched( imdbId: string, episodes: Array<{ season: number; episode: number }>, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + fallbackImdbId?: string ): Promise { try { if (episodes.length === 0) { @@ -1622,13 +1646,18 @@ export class TraktService { return false; } - const traktId = await this.getTraktIdFromImdbId(imdbId, 'show'); + const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id); + const resolvedId = isImdbFormat(imdbId) ? imdbId + : (fallbackImdbId && isImdbFormat(fallbackImdbId) ? fallbackImdbId : imdbId); + if (resolvedId !== imdbId) logger.log(`[TraktService] markEpisodesAsWatched: falling back from "${imdbId}" to "${resolvedId}"`); + + const traktId = await this.getTraktIdFromImdbId(resolvedId, 'show'); if (!traktId) { - logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`); + logger.warn(`[TraktService] Could not find Trakt ID for show: ${resolvedId}`); return false; } - logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${imdbId}`); + logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${resolvedId}`); // Group episodes by season for the API call const seasonMap = new Map>(); @@ -1709,12 +1738,18 @@ export class TraktService { */ public async removeSeasonFromHistory( imdbId: string, - season: number + season: number, + fallbackImdbId?: string ): Promise { try { logger.log(`[TraktService] Removing season ${season} from history for show: ${imdbId}`); - const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id); + const resolvedId = (!isImdbFormat(imdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId)) + ? fallbackImdbId + : imdbId; + + const fullImdbId = resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}`; const payload: TraktHistoryRemovePayload = { shows: [ @@ -1735,6 +1770,19 @@ export class TraktService { const result = await this.removeFromHistory(payload); + if ((result === null || result.deleted.episodes === 0) && fallbackImdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) { + logger.log(`[TraktService] removeSeasonFromHistory: retrying with fallback ID "${fallbackImdbId}"`); + const fb = fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}`; + const fallbackResult = await this.removeFromHistory({ + shows: [{ ids: { imdb: fb }, seasons: [{ number: season }] }] + }); + if (fallbackResult) { + logger.log(`[TraktService] Season removal success via fallback: ${fallbackResult.deleted.episodes} episodes deleted`); + return fallbackResult.deleted.episodes > 0; + } + return false; + } + if (result) { const success = result.deleted.episodes > 0; logger.log(`[TraktService] Season removal success: ${success} (${result.deleted.episodes} episodes deleted)`); @@ -1902,7 +1950,11 @@ export class TraktService { } /** - * Validate content data before making API calls + * Validate content data before making API calls. + * + * IMDb ID validation is intentionally lenient: a non-IMDb provider ID (e.g. "kitsu:123") + * is allowed through with a warning. Trakt can still scrobble via title + season/episode. + * A truly empty ID is still blocked. */ private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } { const errors: string[] = []; @@ -1915,8 +1967,11 @@ export class TraktService { errors.push('Missing or empty title'); } + // Block only truly empty IDs — non-IMDb provider IDs are allowed (warn, don't fail) if (!contentData.imdbId || contentData.imdbId.trim() === '') { errors.push('Missing or empty IMDb ID'); + } else if (!/^tt\d+$/.test(contentData.imdbId) && !/^\d{7,}$/.test(contentData.imdbId)) { + logger.warn(`[TraktService] imdbId "${contentData.imdbId}" is not a standard IMDb ID — Trakt will match by title/season/episode`); } if (contentData.type === 'episode') { @@ -1929,8 +1984,11 @@ export class TraktService { if (!contentData.showTitle || contentData.showTitle.trim() === '') { errors.push('Missing or empty show title'); } - if (!contentData.showYear || contentData.showYear < 1900) { - errors.push('Invalid show year'); + // showYear is intentionally not required — Trakt can match episodes by + // show title + season + episode number alone. Anime and many non-Western + // shows often have year=0 or missing; blocking scrobble for them is wrong. + if (contentData.showYear !== undefined && contentData.showYear > 0 && contentData.showYear < 1900) { + logger.warn(`[TraktService] showYear ${contentData.showYear} looks invalid, omitting from payload`); } } @@ -1991,19 +2049,25 @@ export class TraktService { return null; } - // Ensure IMDb ID includes the 'tt' prefix for Trakt scrobble payloads - const imdbIdWithPrefix = contentData.imdbId.startsWith('tt') - ? contentData.imdbId - : `tt${contentData.imdbId}`; + const isRealImdbId = /^tt\d+$/.test(contentData.imdbId) || /^\d{7,}$/.test(contentData.imdbId); // Build movie payload - only include year if valid - const movieData: { title: string; year?: number; ids: { imdb: string } } = { + const movieData: { title: string; year?: number; ids: { imdb?: string } } = { title: contentData.title.trim(), - ids: { - imdb: imdbIdWithPrefix - } + ids: {} }; + // Only add IMDb ID to payload when it's a real IMDb format — sending a provider ID + // (e.g. "kitsu:123") causes Trakt to fail the lookup. Without it, Trakt matches by title. + if (isRealImdbId) { + const imdbIdWithPrefix = contentData.imdbId.startsWith('tt') + ? contentData.imdbId + : `tt${contentData.imdbId}`; + (movieData.ids as any).imdb = imdbIdWithPrefix; + } else { + logger.warn(`[TraktService] Movie imdbId "${contentData.imdbId}" is not IMDb format — omitting from scrobble payload, Trakt will match by title`); + } + // Only add year if it's valid (prevents year: 0 or invalid years) if (isValidYear(contentData.year)) { movieData.year = contentData.year; @@ -2067,12 +2131,17 @@ export class TraktService { progress: clampedProgress }; - // Add show IMDB ID if available + // Add show IMDB ID if available and valid IMDb format if (contentData.showImdbId && contentData.showImdbId.trim() !== '') { - const showImdbWithPrefix = contentData.showImdbId.startsWith('tt') - ? contentData.showImdbId - : `tt${contentData.showImdbId}`; - payload.show.ids.imdb = showImdbWithPrefix; + const isRealShowImdbId = /^tt\d+$/.test(contentData.showImdbId) || /^\d{7,}$/.test(contentData.showImdbId); + if (isRealShowImdbId) { + const showImdbWithPrefix = contentData.showImdbId.startsWith('tt') + ? contentData.showImdbId + : `tt${contentData.showImdbId}`; + payload.show.ids.imdb = showImdbWithPrefix; + } else { + logger.warn(`[TraktService] showImdbId "${contentData.showImdbId}" is not IMDb format — omitting from scrobble payload, Trakt will match by title`); + } } // Add episode IMDB ID if available (for specific episode IDs) @@ -2679,21 +2748,43 @@ export class TraktService { } /** - * Remove a movie from watched history by IMDB ID + * Remove a movie from watched history by IMDB ID. + * If the primary imdbId is not a valid IMDb ID (e.g. a provider ID like "kitsu:123"), + * falls back to fallbackImdbId (typically the resolved IMDb ID from metadata). */ - public async removeMovieFromHistory(imdbId: string): Promise { + public async removeMovieFromHistory(imdbId: string, fallbackImdbId?: string): Promise { try { + const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id); + + // Resolve which ID to use: prefer a proper IMDb-format ID + let resolvedId = imdbId; + if (!isImdbFormat(imdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId) && fallbackImdbId !== imdbId) { + logger.log(`[TraktService] removeMovieFromHistory: "${imdbId}" is not IMDb format, falling back to "${fallbackImdbId}"`); + resolvedId = fallbackImdbId; + } + const payload: TraktHistoryRemovePayload = { movies: [ { ids: { - imdb: imdbId.startsWith('tt') ? imdbId : `tt${imdbId}` + imdb: resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}` } } ] }; const result = await this.removeFromHistory(payload); + + // If primary attempt deleted nothing and we haven't tried the fallback yet, try it + if ((result === null || result.deleted.movies === 0) && fallbackImdbId && fallbackImdbId !== imdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) { + logger.log(`[TraktService] removeMovieFromHistory: primary attempt found nothing, retrying with fallback ID "${fallbackImdbId}"`); + const fallbackPayload: TraktHistoryRemovePayload = { + movies: [{ ids: { imdb: fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}` } }] + }; + const fallbackResult = await this.removeFromHistory(fallbackPayload); + return fallbackResult !== null && fallbackResult.deleted.movies > 0; + } + return result !== null && result.deleted.movies > 0; } catch (error) { logger.error('[TraktService] Failed to remove movie from history:', error); @@ -2704,14 +2795,24 @@ export class TraktService { /** * Remove an episode from watched history by IMDB IDs */ - public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number): Promise { + public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number, fallbackImdbId?: string): Promise { try { logger.log(`🔍 [TraktService] removeEpisodeFromHistory called for ${showImdbId} S${season}E${episode}`); + + const isImdbFormat = (id: string) => /^tt\d+$/.test(id) || /^\d{7,}$/.test(id); + const resolvedId = (!isImdbFormat(showImdbId) && fallbackImdbId && isImdbFormat(fallbackImdbId)) + ? fallbackImdbId + : showImdbId; + + if (resolvedId !== showImdbId) { + logger.log(`[TraktService] removeEpisodeFromHistory: "${showImdbId}" is not IMDb format, falling back to "${resolvedId}"`); + } + const payload: TraktHistoryRemovePayload = { shows: [ { ids: { - imdb: showImdbId.startsWith('tt') ? showImdbId : `tt${showImdbId}` + imdb: resolvedId.startsWith('tt') ? resolvedId : `tt${resolvedId}` }, seasons: [ { @@ -2731,6 +2832,23 @@ export class TraktService { const result = await this.removeFromHistory(payload); + // If nothing was deleted and we haven't tried the fallback yet, retry with it + if ((result === null || result.deleted.episodes === 0) && fallbackImdbId && fallbackImdbId !== resolvedId && isImdbFormat(fallbackImdbId)) { + logger.log(`[TraktService] removeEpisodeFromHistory: retrying with fallback ID "${fallbackImdbId}"`); + const fallbackPayload: TraktHistoryRemovePayload = { + shows: [{ + ids: { imdb: fallbackImdbId.startsWith('tt') ? fallbackImdbId : `tt${fallbackImdbId}` }, + seasons: [{ number: season, episodes: [{ number: episode }] }] + }] + }; + const fallbackResult = await this.removeFromHistory(fallbackPayload); + if (fallbackResult) { + logger.log(`✅ [TraktService] Episode removal success via fallback: ${fallbackResult.deleted.episodes} episodes deleted`); + return fallbackResult.deleted.episodes > 0; + } + return false; + } + if (result) { const success = result.deleted.episodes > 0; logger.log(`✅ [TraktService] Episode removal success: ${success} (${result.deleted.episodes} episodes deleted)`); From 13820cdf4737f1387b82d30596e61beffe08c659 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 15:53:56 +0530 Subject: [PATCH 02/14] fix mark as watched for special id on trakt --- src/services/watchedService.ts | 78 +++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 9214a9fd..07aa1ac2 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -316,12 +316,15 @@ class WatchedService { let syncedToTrakt = false; // Sync to Trakt + // showId is the Stremio content ID — pass it as fallback so Trakt can resolve + // anime/provider IDs (e.g. kitsu:123) that aren't valid IMDb IDs if (isTraktAuth) { syncedToTrakt = await this.traktService.addToWatchedEpisodes( showImdbId, season, episode, - watchedAt + watchedAt, + showId !== showImdbId ? showId : undefined ); logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`); } @@ -445,7 +448,8 @@ class WatchedService { syncedToTrakt = await this.traktService.markEpisodesAsWatched( showImdbId, episodes, - watchedAt + watchedAt, + showId !== showImdbId ? showId : undefined ); logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`); } @@ -523,7 +527,8 @@ class WatchedService { syncedToTrakt = await this.traktService.markSeasonAsWatched( showImdbId, season, - watchedAt + watchedAt, + showId !== showImdbId ? showId : undefined ); logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`); } @@ -570,32 +575,40 @@ class WatchedService { } /** - * Unmark a movie as watched (remove from history) + * Unmark a movie as watched (remove from history). + * @param imdbId - The primary content ID (may be a provider ID like "kitsu:123") + * @param fallbackImdbId - The resolved IMDb ID from metadata (used when imdbId isn't IMDb format) */ public async unmarkMovieAsWatched( - imdbId: string + imdbId: string, + fallbackImdbId?: string ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { - logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}`); + logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}${fallbackImdbId && fallbackImdbId !== imdbId ? ` (fallback: ${fallbackImdbId})` : ''}`); const isTraktAuth = await this.traktService.isAuthenticated(); let syncedToTrakt = false; if (isTraktAuth) { - syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId); + syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId, fallbackImdbId); logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`); } - // Simkl Unmark + // Simkl Unmark — try both IDs const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { - await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] }); + const simklId = (fallbackImdbId && fallbackImdbId !== imdbId) ? fallbackImdbId : imdbId; + await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: simklId } }] }); logger.log(`[WatchedService] Simkl remove request sent for movie`); } - // Remove local progress + // Remove local progress — clear both IDs to be safe await storageService.removeWatchProgress(imdbId, 'movie'); await mmkvStorage.removeItem(`watched:movie:${imdbId}`); + if (fallbackImdbId && fallbackImdbId !== imdbId) { + await storageService.removeWatchProgress(fallbackImdbId, 'movie'); + await mmkvStorage.removeItem(`watched:movie:${fallbackImdbId}`); + } await this.removeLocalWatchedItems([ { content_id: imdbId, season: null, episode: null }, ]); @@ -622,21 +635,25 @@ class WatchedService { const isTraktAuth = await this.traktService.isAuthenticated(); let syncedToTrakt = false; + const fallback = showId !== showImdbId ? showId : undefined; + if (isTraktAuth) { syncedToTrakt = await this.traktService.removeEpisodeFromHistory( showImdbId, season, - episode + episode, + fallback ); logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`); } - // Simkl Unmark + // Simkl Unmark — use best available ID const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { + const simklId = showImdbId || showId; await this.simklService.removeFromHistory({ shows: [{ - ids: { imdb: showImdbId }, + ids: { imdb: simklId }, seasons: [{ number: season, episodes: [{ number: episode }] @@ -679,26 +696,27 @@ class WatchedService { const isTraktAuth = await this.traktService.isAuthenticated(); let syncedToTrakt = false; + const fallback = showId !== showImdbId ? showId : undefined; + if (isTraktAuth) { // Remove entire season from Trakt syncedToTrakt = await this.traktService.removeSeasonFromHistory( showImdbId, - season + season, + fallback ); logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); } - // Sync to Simkl + // Sync to Simkl — use best available ID const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { + const simklId = showImdbId || showId; const episodes = episodeNumbers.map(num => ({ number: num })); await this.simklService.removeFromHistory({ shows: [{ - ids: { imdb: showImdbId }, - seasons: [{ - number: season, - episodes: episodes - }] + ids: { imdb: simklId }, + seasons: [{ number: season, episodes: episodes }] }] }); logger.log(`[WatchedService] Simkl season removal request sent`); @@ -728,18 +746,26 @@ class WatchedService { /** * Check if a movie is marked as watched (locally) */ - public async isMovieWatched(imdbId: string): Promise { + public async isMovieWatched(imdbId: string, fallbackImdbId?: string): Promise { try { const isAuthed = await this.traktService.isAuthenticated(); if (isAuthed) { const traktWatched = - await this.traktService.isMovieWatchedAccurate(imdbId); + await this.traktService.isMovieWatchedAccurate(imdbId, fallbackImdbId); if (traktWatched) return true; } const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`); - return local === 'true'; + if (local === 'true') return true; + + // Also check under fallback ID locally + if (fallbackImdbId && fallbackImdbId !== imdbId) { + const localFallback = await mmkvStorage.getItem(`watched:movie:${fallbackImdbId}`); + if (localFallback === 'true') return true; + } + + return false; } catch { return false; } @@ -752,7 +778,8 @@ class WatchedService { public async isEpisodeWatched( showId: string, season: number, - episode: number + episode: number, + fallbackImdbId?: string ): Promise { try { const isAuthed = await this.traktService.isAuthenticated(); @@ -762,7 +789,8 @@ class WatchedService { await this.traktService.isEpisodeWatchedAccurate( showId, season, - episode + episode, + fallbackImdbId ); if (traktWatched) return true; } From 8dc96f4ab3f0518c34345829e750c42e5cb7244f Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 15:55:11 +0530 Subject: [PATCH 03/14] fix for special id scrobble to trakt --- src/hooks/useTraktAutosync.ts | 38 ++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 57bec23a..0966accb 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -53,9 +53,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Generate a unique session key for this content instance useEffect(() => { + const resolvedId = (options.imdbId && options.imdbId.trim()) ? options.imdbId : (options.id || ''); const contentKey = options.type === 'movie' - ? `movie:${options.imdbId}` - : `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`; + ? `movie:${resolvedId}` + : `episode:${options.showImdbId || resolvedId}:${options.season}:${options.episode}`; sessionKey.current = `${contentKey}:${Date.now()}`; // Reset all session state for new content @@ -109,11 +110,22 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return null; } - if (!options.imdbId || options.imdbId.trim() === '') { - logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId'); + // Resolve the best available ID: prefer a proper IMDb ID, fall back to the Stremio content ID. + // This allows scrobbling for content with special IDs (e.g. "kitsu:123", "tmdb:456") where + // the IMDb ID hasn't been resolved yet — Trakt will match by title + season/episode instead. + const imdbIdRaw = options.imdbId && options.imdbId.trim() ? options.imdbId.trim() : ''; + const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : ''; + const resolvedImdbId = imdbIdRaw || stremioIdRaw; + + if (!resolvedImdbId) { + logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId and id'); return null; } + if (!imdbIdRaw && stremioIdRaw) { + logger.warn(`[TraktAutosync] imdbId is empty, falling back to stremio id "${stremioIdRaw}" — Trakt will match by title`); + } + const numericYear = parseYear(options.year); const numericShowYear = parseYear(options.showYear); @@ -125,7 +137,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { if (options.type === 'movie') { return { type: 'movie', - imdbId: options.imdbId.trim(), + imdbId: resolvedImdbId, title: options.title.trim(), year: numericYear // Can be undefined now }; @@ -140,26 +152,34 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return null; } + const resolvedShowImdbId = (options.showImdbId && options.showImdbId.trim()) + ? options.showImdbId.trim() + : resolvedImdbId; + return { type: 'episode', - imdbId: options.imdbId.trim(), + imdbId: resolvedImdbId, title: options.title.trim(), year: numericYear, season: options.season, episode: options.episode, showTitle: (options.showTitle || options.title).trim(), showYear: numericShowYear || numericYear, - showImdbId: (options.showImdbId || options.imdbId).trim() + showImdbId: resolvedShowImdbId }; } }, [options]); const buildSimklContentData = useCallback((): SimklContentData => { + // Use the same fallback logic: prefer imdbId, fall back to stremio id + const resolvedId = (options.imdbId && options.imdbId.trim()) + ? options.imdbId.trim() + : (options.id && options.id.trim()) ? options.id.trim() : ''; return { type: options.type === 'series' ? 'episode' : 'movie', title: options.title, ids: { - imdb: options.imdbId + imdb: resolvedId }, season: options.season, episode: options.episode @@ -623,4 +643,4 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { handlePlaybackEnd, resetState }; -} \ No newline at end of file +} From a33ebfdbcc7780cbbdea61e167fa4234ee21ef0f Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 15:56:13 +0530 Subject: [PATCH 04/14] fix special id mark as watched on trakt --- src/components/home/ContentItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 648ff5ed..643aa0cd 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -206,7 +206,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe } } else { if (item.type === 'movie') { - watchedService.unmarkMovieAsWatched(item.id); + watchedService.unmarkMovieAsWatched(item.id, item.imdb_id ?? undefined); } else { // Unmarking a series from the top level is tricky as we don't know the exact episodes. // For safety and consistency with old behavior, we just clear the legacy flag. @@ -472,4 +472,4 @@ export default React.memo(ContentItem, (prev, next) => { if (prev.item.id !== next.item.id) return false; if (prev.item.poster !== next.item.poster) return false; return true; -}); \ No newline at end of file +}); From 898c1702df5d9831693155ef4db17cc364d91e09 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:19:12 +0530 Subject: [PATCH 05/14] fix tmdbid and imdbid extractor for special ids --- src/hooks/useMetadata.ts | 49 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index dd3ba802..f3a1d4f1 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -939,6 +939,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (finalTmdbId) setTmdbId(finalTmdbId); } + // If the addon returned an imdb_id in its metadata (e.g. Kitsu addon), set it now. + // This ensures imdbId state is populated for Trakt scrobbling even without TMDB enrichment. + if (!imdbId && (finalMetadata as any).imdb_id) { + const resolvedImdb = (finalMetadata as any).imdb_id as string; + setImdbId(resolvedImdb); + // Also resolve tmdbId from the imdb_id if we still don't have it + if (!finalTmdbId) { + const foundTmdbId = await tmdbSvc.findTMDBIdByIMDB(resolvedImdb); + if (foundTmdbId) { + finalTmdbId = foundTmdbId; + setTmdbId(foundTmdbId); + setMetadata(prev => prev ? { ...prev, tmdbId: foundTmdbId } : null); + } + } + } + if (finalTmdbId) { const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; if (normalizedType === 'movie') { @@ -2230,20 +2246,43 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Fetch TMDB ID if needed and then recommendations useEffect(() => { const fetchTmdbIdAndRecommendations = async () => { - if (!settings.enrichMetadataWithTMDB) { + if (!metadata) return; + + const isAnimeId = id.startsWith('kitsu:') || id.startsWith('mal:') || id.startsWith('anilist:'); + + // For anime IDs we always try to resolve tmdbId and imdbId regardless of enrichment setting, + // because they're needed for Trakt scrobbling even when TMDB enrichment is disabled. + if (!settings.enrichMetadataWithTMDB && !isAnimeId) { if (__DEV__) console.log('[useMetadata] enrichment disabled; skip TMDB id extraction (extract path)'); return; } - if (metadata && !tmdbId) { + + if (!tmdbId) { try { - const tmdbService = TMDBService.getInstance(); - const fetchedTmdbId = await tmdbService.extractTMDBIdFromStremioId(id); + const tmdbSvc = TMDBService.getInstance(); + const fetchedTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(id); if (fetchedTmdbId) { if (__DEV__) console.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId }); setTmdbId(fetchedTmdbId); + + // For anime IDs, also resolve the IMDb ID from TMDB external IDs so Trakt can scrobble + if (isAnimeId && !imdbId) { + try { + const externalIds = await tmdbSvc.getShowExternalIds(fetchedTmdbId); + if (externalIds?.imdb_id) { + if (__DEV__) console.log('[useMetadata] resolved imdbId for anime via TMDB', { id, imdbId: externalIds.imdb_id }); + setImdbId(externalIds.imdb_id); + } + } catch (e) { + if (__DEV__) console.warn('[useMetadata] could not resolve imdbId from TMDB for anime ID', { id }); + } + } + + if (!settings.enrichMetadataWithTMDB) return; // Only needed imdbId, skip enrichment + // Fetch certification only if granular setting is enabled if (settings.tmdbEnrichCertification) { - const certification = await tmdbService.getCertification(normalizedType, fetchedTmdbId); + const certification = await tmdbSvc.getCertification(normalizedType, fetchedTmdbId); if (certification) { if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification }); setMetadata(prev => prev ? { From 5864b8ca17a9756439fbad18983c1ab6afdb66fd Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:20:09 +0530 Subject: [PATCH 06/14] fix edge cases From 9c99a48f8a46c3deaf5e4889621fcff8dfb1175f Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:21:01 +0530 Subject: [PATCH 07/14] fix tmdbid extractor for special ids --- src/services/tmdbService.ts | 54 ++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index de7c0de5..679f41f3 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -523,24 +523,58 @@ export class TMDBService { } /** - * Extract TMDB ID from Stremio ID - * Stremio IDs for series are typically in the format: tt1234567:1:1 (imdbId:season:episode) - * or just tt1234567 for the series itself + * Extract TMDB ID from Stremio ID. + * Handles standard IMDb IDs (tt1234567) as well as anime provider IDs: + * - kitsu:12345 → looks up via ARM (arm.haglund.dev) + * - mal:12345 → looks up via ARM + * - anilist:12345 → looks up via ARM */ async extractTMDBIdFromStremioId(stremioId: string): Promise { try { - // Extract the base ID (remove season/episode info if present) - const baseId = stremioId.split(':')[0]; + // Strip season/episode suffix — e.g. "kitsu:7936:5" → "kitsu:7936" + const parts = stremioId.split(':'); + const prefix = parts[0]; + const numericId = parts[1]; - // Only try to convert if it's an IMDb ID (starts with 'tt') - if (!baseId.startsWith('tt')) { + // Standard IMDb ID + if (prefix.startsWith('tt') || /^\d{7,}$/.test(prefix)) { + const baseId = prefix.startsWith('tt') ? prefix : `tt${prefix}`; + return await this.findTMDBIdByIMDB(baseId); + } + + // Anime provider IDs — resolve via ARM (https://arm.haglund.dev/api/v2) + const ARM_SOURCES: Record = { + kitsu: 'kitsu', + mal: 'myanimelist', + anilist: 'anilist', + }; + + const armSource = ARM_SOURCES[prefix]; + if (armSource && numericId && /^\d+$/.test(numericId)) { + const cacheKey = this.generateCacheKey('arm_tmdb', { source: armSource, id: numericId }); + const cached = this.getCachedData(cacheKey); + if (cached !== null) return cached; + + logger.log(`[TMDB] Resolving TMDB ID for ${prefix}:${numericId} via ARM`); + const response = await axios.get('https://arm.haglund.dev/api/v2/ids', { + params: { source: armSource, id: numericId }, + timeout: 8000, + }); + + const tmdbId: number | undefined = response.data?.themoviedb; + if (tmdbId) { + this.setCachedData(cacheKey, tmdbId); + logger.log(`[TMDB] ARM resolved ${prefix}:${numericId} → TMDB ${tmdbId}`); + return tmdbId; + } + + logger.warn(`[TMDB] ARM did not return a TMDB ID for ${prefix}:${numericId}`); return null; } - // Use the existing findTMDBIdByIMDB function to get the TMDB ID - const tmdbId = await this.findTMDBIdByIMDB(baseId); - return tmdbId; + return null; } catch (error) { + logger.warn('[TMDB] extractTMDBIdFromStremioId failed:', error); return null; } } From ee410a5e7535f635d809acef1915eb2615a8b77c Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:21:50 +0530 Subject: [PATCH 08/14] fix trakt sync special id edge cases From d6337d3923ff072130e3814038e830c8e864b83f Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:22:40 +0530 Subject: [PATCH 09/14] fix mark as watched special id edge cases From 68a23fbb3b742b0efede5a04782ccd0f3adc3d66 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:23:33 +0530 Subject: [PATCH 10/14] fix edge cases From 7f4be0bdc8adca06f54051d140d433f1c829f96d Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:24:49 +0530 Subject: [PATCH 11/14] fix From 3ed3dee0e1b79a8d020e7673107221a8a0fc0e15 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 16:29:29 +0530 Subject: [PATCH 12/14] fix --- src/hooks/useMetadata.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index f3a1d4f1..0b5c90c8 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -2278,7 +2278,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } } - if (!settings.enrichMetadataWithTMDB) return; // Only needed imdbId, skip enrichment + if (!settings.enrichMetadataWithTMDB) { + // Enrichment is disabled but we still resolved tmdbId for Trakt scrobbling. + // Set it on the metadata object so the player can read it via metadata.tmdbId. + setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId } : null); + return; + } // Fetch certification only if granular setting is enabled if (settings.tmdbEnrichCertification) { From 7efd21d442baf53d6c10c05c91bd29a041508491 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 17:09:34 +0530 Subject: [PATCH 13/14] fix cinemeta race condition for search catalogs --- src/services/catalogService.ts | 166 ++++++++++++++++----------------- 1 file changed, 79 insertions(+), 87 deletions(-) diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 5abcd90e..f791d761 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1536,8 +1536,29 @@ class CatalogService { const addonOrderRef: Record = {}; searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; }); - // Global dedupe across emitted results - const globalSeen = new Set(); + // Human-readable labels for known content types + const CATALOG_TYPE_LABELS: Record = { + 'movie': 'Movies', + 'series': 'TV Shows', + 'anime.series': 'Anime Series', + 'anime.movie': 'Anime Movies', + 'other': 'Other', + 'tv': 'TV', + 'channel': 'Channels', + }; + const GENERIC_CATALOG_NAMES = new Set(['search', 'Search']); + + // Collect all sections from all addons first, then sort and dedup before emitting. + // This avoids race conditions where concurrent addon workers steal each other's IDs + // from a shared globalSeen set before they get a chance to emit. + type PendingSection = { + addonId: string; + addonName: string; + sectionName: string; + catalogIndex: number; + results: StreamingContent[]; + }; + const allPendingSections: PendingSection[] = []; await Promise.all( searchableAddons.map(async (addon) => { @@ -1552,47 +1573,24 @@ class CatalogService { const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog)); logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`); - // Fetch all catalogs for this addon in parallel const settled = await Promise.allSettled( searchableCatalogs.map(c => this.searchAddonCatalog(manifest, c.type, c.id, trimmedQuery)) ); if (controller.cancelled) return; - // If addon has multiple search catalogs, emit each as its own section. - // If only one, emit as a single addon section (original behaviour). const hasMultipleCatalogs = searchableCatalogs.length > 1; - - const catalogResultsList: { catalog: any; results: StreamingContent[] }[] = []; - for (let i = 0; i < searchableCatalogs.length; i++) { - const s = settled[i]; - if (s.status === 'fulfilled' && Array.isArray(s.value) && s.value.length > 0) { - catalogResultsList.push({ catalog: searchableCatalogs[i], results: s.value }); - } else if (s.status === 'rejected') { - logger.warn(`Search failed for catalog ${searchableCatalogs[i].id} in ${addon.name}:`, s.reason); - } - } - - if (catalogResultsList.length === 0) { - logger.log(`No results from ${addon.name}`); - return; - } + const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER; if (hasMultipleCatalogs) { - // Human-readable labels for known content types used as fallback section names - const CATALOG_TYPE_LABELS: Record = { - 'movie': 'Movies', - 'series': 'TV Shows', - 'anime.series': 'Anime Series', - 'anime.movie': 'Anime Movies', - 'other': 'Other', - 'tv': 'TV', - 'channel': 'Channels', - }; + for (let ci = 0; ci < searchableCatalogs.length; ci++) { + const s = settled[ci]; + const catalog = searchableCatalogs[ci]; + if (s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) { + if (s.status === 'rejected') logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, s.reason); + continue; + } - // Emit each catalog as its own section, in manifest order - for (let ci = 0; ci < catalogResultsList.length; ci++) { - const { catalog, results } = catalogResultsList[ci]; - if (controller.cancelled) return; + const results = (s as PromiseFulfilledResult).value; // Within-catalog dedup: prefer dot-type over generic for same ID const bestById = new Map(); @@ -1604,74 +1602,68 @@ class CatalogService { } // Stamp catalog type onto results - const stamped = Array.from(bestById.values()).map(item => { - if (catalog.type && item.type !== catalog.type) { - return { ...item, type: catalog.type }; - } - return item; - }); + const stamped = Array.from(bestById.values()).map(item => + catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item + ); - // Dedupe against global seen - const unique = stamped.filter(item => { - const key = `${item.type}:${item.id}`; - if (globalSeen.has(key)) return false; - globalSeen.add(key); - return true; - }); + // Build section name — use type label if catalog name is generic + const typeLabel = CATALOG_TYPE_LABELS[catalog.type] + || catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase()); + const catalogLabel = (!catalog.name || GENERIC_CATALOG_NAMES.has(catalog.name) || catalog.name === addon.name) + ? typeLabel + : catalog.name; + const sectionName = `${addon.name} - ${catalogLabel}`; + const catalogIndex = addonRank * 1000 + ci; - if (unique.length > 0 && !controller.cancelled) { - // Build section name: - // - If catalog.name is generic ("Search") or same as addon name, use type label instead - // - Otherwise use catalog.name as-is - const GENERIC_NAMES = new Set(['search', 'Search']); - const typeLabel = CATALOG_TYPE_LABELS[catalog.type] - || catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase()); - const catalogLabel = (!catalog.name || GENERIC_NAMES.has(catalog.name) || catalog.name === addon.name) - ? typeLabel - : catalog.name; - const sectionName = `${addon.name} - ${catalogLabel}`; - - // catalogIndex encodes addon rank + position within addon for deterministic ordering - const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER; - const catalogIndex = addonRank * 1000 + ci; - - logger.log(`Emitting ${unique.length} results from ${sectionName}`); - onAddonResults({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: unique }); - } + allPendingSections.push({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: stamped }); } } else { - // Single catalog — one section per addon - const allResults = catalogResultsList.flatMap(c => c.results); + const s = settled[0]; + const catalog = searchableCatalogs[0]; + if (!s || s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) { + if (s?.status === 'rejected') logger.warn(`Search failed for ${addon.name}:`, s.reason); + return; + } - const bestByIdWithinAddon = new Map(); - for (const item of allResults) { - const existing = bestByIdWithinAddon.get(item.id); + const results = (s as PromiseFulfilledResult).value; + const bestById = new Map(); + for (const item of results) { + const existing = bestById.get(item.id); if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { - bestByIdWithinAddon.set(item.id, item); + bestById.set(item.id, item); } } - const deduped = Array.from(bestByIdWithinAddon.values()); + const stamped = Array.from(bestById.values()).map(item => + catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item + ); - const localSeen = new Set(); - const unique = deduped.filter(item => { - const key = `${item.type}:${item.id}`; - if (localSeen.has(key) || globalSeen.has(key)) return false; - localSeen.add(key); - globalSeen.add(key); - return true; - }); - - if (unique.length > 0 && !controller.cancelled) { - const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER; - logger.log(`Emitting ${unique.length} results from ${addon.name}`); - onAddonResults({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: unique }); - } + allPendingSections.push({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: stamped }); } } catch (e) { logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e); } }) ); + + if (controller.cancelled) return; + + // Sort by catalogIndex (addon manifest order + position within addon), then dedup and emit + allPendingSections.sort((a, b) => a.catalogIndex - b.catalogIndex); + + const globalSeen = new Set(); + for (const section of allPendingSections) { + if (controller.cancelled) return; + const unique = section.results.filter(item => { + const key = `${item.type}:${item.id}`; + if (globalSeen.has(key)) return false; + globalSeen.add(key); + return true; + }); + if (unique.length > 0) { + logger.log(`Emitting ${unique.length} results from ${section.sectionName}`); + onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: unique }); + } + } })(); return { From 6d349353524c1340ef273c253998b68002b6fdcd Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 15 Mar 2026 17:14:55 +0530 Subject: [PATCH 14/14] remove item dedup --- src/services/catalogService.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index f791d761..683e7671 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1647,21 +1647,16 @@ class CatalogService { if (controller.cancelled) return; - // Sort by catalogIndex (addon manifest order + position within addon), then dedup and emit + // Sort by catalogIndex (addon manifest order + position within addon) then emit. + // No cross-section dedup — each section is shown separately so duplicates across + // sections are intentional (e.g. same movie in Cinemeta and People Search). allPendingSections.sort((a, b) => a.catalogIndex - b.catalogIndex); - const globalSeen = new Set(); for (const section of allPendingSections) { if (controller.cancelled) return; - const unique = section.results.filter(item => { - const key = `${item.type}:${item.id}`; - if (globalSeen.has(key)) return false; - globalSeen.add(key); - return true; - }); - if (unique.length > 0) { - logger.log(`Emitting ${unique.length} results from ${section.sectionName}`); - onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: unique }); + if (section.results.length > 0) { + logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`); + onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: section.results }); } } })();