From ed11bf8cfe463313689777c5c3c9e3b309e6f3af Mon Sep 17 00:00:00 2001 From: cyberalby2 Date: Mon, 16 Mar 2026 20:13:33 +0100 Subject: [PATCH 01/10] MDBList settings screen visual bug fix --- src/screens/MDBListSettingsScreen.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/screens/MDBListSettingsScreen.tsx b/src/screens/MDBListSettingsScreen.tsx index 93e9391a..9ce9ed90 100644 --- a/src/screens/MDBListSettingsScreen.tsx +++ b/src/screens/MDBListSettingsScreen.tsx @@ -700,7 +700,8 @@ const MDBListSettingsScreen: React.FC = () => { /> - + {isMdbListEnabled && + <> {t('mdblist.api_section')} @@ -885,6 +886,7 @@ const MDBListSettingsScreen: React.FC = () => { + } Date: Wed, 18 Mar 2026 14:51:58 +0530 Subject: [PATCH 02/10] fix search catalogs edge cases --- src/hooks/useMetadata.ts | 47 +++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index fa6c0707..e9e4dcea 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -722,8 +722,45 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } } + // If normalizedType is not a known type (e.g. "other" from Gemini/AI search), + // resolve the correct type via TMDB before fetching addon metadata. + let effectiveType = normalizedType; + if (normalizedType !== 'movie' && normalizedType !== 'series') { + try { + if (actualId.startsWith('tt')) { + // Use TMDB /find endpoint which returns tv_results + movie_results simultaneously + // — gives definitive type in one call with no sequential guessing + const tmdbSvc = TMDBService.getInstance(); + const resolved = await tmdbSvc.findTypeAndIdByIMDB(actualId); + if (resolved) { + effectiveType = resolved.type; + setTmdbId(resolved.tmdbId); + if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB /find`); + } + } else if (actualId.startsWith('tmdb:')) { + // For tmdb: IDs try both in parallel, prefer series + const tmdbSvc = TMDBService.getInstance(); + const tmdbRaw = parseInt(actualId.split(':')[1]); + if (!isNaN(tmdbRaw)) { + const [movieResult, seriesResult] = await Promise.allSettled([ + tmdbSvc.getMovieDetails(String(tmdbRaw)).catch(() => null), + tmdbSvc.getTVShowDetails(tmdbRaw).catch(() => null), + ]); + const hasMovie = movieResult.status === 'fulfilled' && !!movieResult.value; + const hasSeries = seriesResult.status === 'fulfilled' && !!seriesResult.value; + // Prefer series when both exist (anime/TV tagged as "other" is usually a series) + if (hasSeries) effectiveType = 'series'; + else if (hasMovie) effectiveType = 'movie'; + if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB parallel check`); + } + } + } catch (e) { + if (__DEV__) console.log('🔍 [useMetadata] Failed to resolve type via TMDB, using fallback:', e); + } + } + // Load all data in parallel - if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId }); + if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type: effectiveType, actualId, addonId }); let contentResult: any = null; let lastError = null; @@ -758,7 +795,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat for (const addon of externalMetaAddons) { try { const result = await withTimeout( - stremioService.getMetaDetails(normalizedType, actualId, addon.id), + stremioService.getMetaDetails(effectiveType, actualId, addon.id), API_TIMEOUT ); @@ -775,7 +812,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // If no external addon worked, fall back to catalog addon const result = await withTimeout( - catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), + catalogService.getEnhancedContentDetails(effectiveType, actualId, addonId), API_TIMEOUT ); if (actualId.startsWith('tt')) { @@ -804,7 +841,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Load content with timeout and retry withRetry(async () => { const result = await withTimeout( - catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), + catalogService.getEnhancedContentDetails(effectiveType, actualId, addonId), API_TIMEOUT ); // Store the actual ID used (could be IMDB) @@ -841,7 +878,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const [content, castData] = await Promise.allSettled([ withRetry(async () => { const result = await withTimeout( - catalogService.getEnhancedContentDetails(normalizedType, stremioId, addonId), + catalogService.getEnhancedContentDetails(effectiveType, stremioId, addonId), API_TIMEOUT ); if (stremioId.startsWith('tt')) { From 31f1577019d40a737678374e517eeb749aeddb68 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 18 Mar 2026 14:53:46 +0530 Subject: [PATCH 03/10] add item type parser through tmdb lookup --- src/services/tmdbService.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 679f41f3..3dbb7bff 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -582,6 +582,32 @@ export class TMDBService { /** * Find TMDB ID by IMDB ID */ + /** + * Resolve both the TMDB ID and the correct content type ('movie' | 'series') for an IMDb ID. + * Uses TMDB's /find endpoint which returns tv_results and movie_results simultaneously, + * giving a definitive type without sequential guessing. + * TV results take priority since "other"-typed search results are usually series/anime. + */ + async findTypeAndIdByIMDB(imdbId: string): Promise<{ tmdbId: number; type: 'movie' | 'series' } | null> { + try { + const baseImdbId = imdbId.split(':')[0]; + const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, { + headers: await this.getHeaders(), + params: await this.getParams({ external_source: 'imdb_id' }), + }); + + if (response.data.tv_results?.length > 0) { + return { tmdbId: response.data.tv_results[0].id, type: 'series' }; + } + if (response.data.movie_results?.length > 0) { + return { tmdbId: response.data.movie_results[0].id, type: 'movie' }; + } + return null; + } catch { + return null; + } + } + async findTMDBIdByIMDB(imdbId: string, language: string = 'en-US'): Promise { const cacheKey = this.generateCacheKey('find_imdb', { imdbId, language }); From f7ce36080c453c0d2da493c4a776eea8411a531d Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 18 Mar 2026 14:55:29 +0530 Subject: [PATCH 04/10] fix search catalogs normalisation logic --- src/services/stremio/catalog-operations.ts | 68 ++++++++++++++-------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/src/services/stremio/catalog-operations.ts b/src/services/stremio/catalog-operations.ts index 8dba5f7e..5a6e4b77 100644 --- a/src/services/stremio/catalog-operations.ts +++ b/src/services/stremio/catalog-operations.ts @@ -177,30 +177,49 @@ export function getCatalogHasMore( return ctx.catalogHasMore.get(`${manifestId}|${type}|${id}`); } -function addonSupportsMetaResource(addon: Manifest, type: string, id: string): boolean { - let hasMetaSupport = false; - let supportsIdPrefix = false; - +/** + * Check if an addon can serve metadata for this ID by matching ID prefix. + * Does NOT require a type match — type is resolved separately via resolveTypeForAddon. + */ +function addonCanServeId(addon: Manifest, id: string): boolean { for (const resource of addon.resources || []) { if (typeof resource === 'object' && resource !== null && 'name' in resource) { - const typedResource = resource as ResourceObject; - if (typedResource.name === 'meta' && typedResource.types?.includes(type)) { - hasMetaSupport = true; - supportsIdPrefix = - !typedResource.idPrefixes?.length || - typedResource.idPrefixes.some(prefix => id.startsWith(prefix)); - break; - } - } else if (resource === 'meta' && addon.types?.includes(type)) { - hasMetaSupport = true; - supportsIdPrefix = - !addon.idPrefixes?.length || addon.idPrefixes.some(prefix => id.startsWith(prefix)); - break; + const r = resource as ResourceObject; + if (r.name !== 'meta') continue; + if (!r.idPrefixes?.length) return true; + if (r.idPrefixes.some(p => id.startsWith(p))) return true; + } else if (resource === 'meta') { + if (!addon.idPrefixes?.length) return true; + if (addon.idPrefixes.some(p => id.startsWith(p))) return true; } } + return false; +} - const requiresIdPrefix = !!addon.idPrefixes?.length; - return hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix); +/** + * Resolve the correct type to use in the metadata URL for a given addon. + * Looks at what types the addon declares for its meta resource matching this ID prefix, + * rather than blindly trusting the passed-in type (which may be "other", "Movie", etc.). + * Falls back to lowercased passed-in type if no better match found. + */ +function resolveTypeForAddon(addon: Manifest, type: string, id: string): string { + const lowerFallback = type ? type.toLowerCase() : type; + for (const resource of addon.resources || []) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const r = resource as ResourceObject; + if (r.name !== 'meta' || !r.types?.length) continue; + const prefixMatch = !r.idPrefixes?.length || r.idPrefixes.some(p => id.startsWith(p)); + if (prefixMatch) { + const exact = r.types.find(t => t.toLowerCase() === lowerFallback); + return exact ?? r.types[0]; + } + } + } + if (addon.types?.length) { + const exact = addon.types.find(t => t.toLowerCase() === lowerFallback); + return exact ?? addon.types[0]; + } + return lowerFallback; } async function fetchMetaFromAddon( @@ -209,11 +228,12 @@ async function fetchMetaFromAddon( type: string, id: string ): Promise { + const resolvedType = resolveTypeForAddon(addon, type, id); const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url || ''); const encodedId = encodeURIComponent(id); const url = queryParams - ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` - : `${baseUrl}/meta/${type}/${encodedId}.json`; + ? `${baseUrl}/meta/${resolvedType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/meta/${resolvedType}/${encodedId}.json`; const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000))); return response.data?.meta?.id ? response.data.meta : null; @@ -234,7 +254,7 @@ export async function getMetaDetails( if (preferredAddonId) { const preferredAddon = addons.find(addon => addon.id === preferredAddonId); - if (preferredAddon?.resources && addonSupportsMetaResource(preferredAddon, type, id)) { + if (preferredAddon?.resources && addonCanServeId(preferredAddon, id)) { try { const meta = await fetchMetaFromAddon(ctx, preferredAddon, type, id); if (meta) { @@ -249,7 +269,7 @@ export async function getMetaDetails( for (const baseUrl of ['https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io']) { try { const encodedId = encodeURIComponent(id); - const url = `${baseUrl}/meta/${type}/${encodedId}.json`; + const url = `${baseUrl}/meta/${type ? type.toLowerCase() : type}/${encodedId}.json`; const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000))); if (response.data?.meta?.id) { return response.data.meta; @@ -264,7 +284,7 @@ export async function getMetaDetails( continue; } - if (!addonSupportsMetaResource(addon, type, id)) { + if (!addonCanServeId(addon, id)) { continue; } From 5458dfbdd863da25b5d144acdc885f946a740b52 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 18 Mar 2026 14:56:53 +0530 Subject: [PATCH 05/10] adapting to refactored chunk --- src/services/catalog/search.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/services/catalog/search.ts b/src/services/catalog/search.ts index 08ca0cac..95407c8e 100644 --- a/src/services/catalog/search.ts +++ b/src/services/catalog/search.ts @@ -321,13 +321,9 @@ async function searchAddonCatalog( const items = metas.map(meta => { const content = convertMetaToStreamingContent(meta, library); - const addonSupportsMeta = Array.isArray(manifest.resources) && manifest.resources.some((resource: any) => - resource === 'meta' || (typeof resource === 'object' && resource?.name === 'meta') - ); - - if (addonSupportsMeta) { - content.addonId = manifest.id; - } + // Do NOT set addonId from search results — let getMetaDetails resolve the correct + // meta addon by ID prefix matching. Setting it here causes 404s when two addons + // are installed and one returns IDs the other can't serve metadata for. const normalizedCatalogType = type ? type.toLowerCase() : type; if (normalizedCatalogType && content.type !== normalizedCatalogType) { From c7198f919dcabdf938e1848fc89b49fb9de8d407 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 18 Mar 2026 19:05:20 +0530 Subject: [PATCH 06/10] better error handling for slower addons --- src/services/stremio/catalog-operations.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/services/stremio/catalog-operations.ts b/src/services/stremio/catalog-operations.ts index 5a6e4b77..d118f271 100644 --- a/src/services/stremio/catalog-operations.ts +++ b/src/services/stremio/catalog-operations.ts @@ -246,7 +246,11 @@ export async function getMetaDetails( preferredAddonId?: string ): Promise { try { - if (!(await ctx.isValidContentId(type, id))) { + // isValidContentId gate removed — addonCanServeId() handles per-addon ID prefix + // filtering correctly. The gate caused false negatives when type was non-standard + // or prefixes weren't indexed yet, silently returning null before any addon was tried. + const lowerId = (id || '').toLowerCase(); + if (!id || lowerId === 'null' || lowerId === 'undefined' || lowerId === 'moviebox' || lowerId === 'torbox') { return null; } From f9df89abcf9b1c17583e6e5bd39ad218e7b31b71 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 18 Mar 2026 19:14:04 +0530 Subject: [PATCH 07/10] remove isvalidid hardgate --- src/services/catalog/content-details.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/services/catalog/content-details.ts b/src/services/catalog/content-details.ts index c30fb6e7..df3c5840 100644 --- a/src/services/catalog/content-details.ts +++ b/src/services/catalog/content-details.ts @@ -38,11 +38,8 @@ export async function getContentDetails( for (let attempt = 0; attempt < 2; attempt += 1) { try { - const isValidId = await stremioService.isValidContentId(type, id); - - if (!isValidId) { - break; - } + // isValidContentId gate removed — getMetaDetails uses addonCanServeId() + // for per-addon prefix matching, avoiding false negatives for custom ID types. meta = await stremioService.getMetaDetails(type, id, preferredAddonId); if (meta) { @@ -102,10 +99,8 @@ export async function getBasicContentDetails( for (let attempt = 0; attempt < 3; attempt += 1) { try { - if (!(await stremioService.isValidContentId(type, id))) { - break; - } - + // isValidContentId gate removed — getMetaDetails uses addonCanServeId() + // for per-addon prefix matching, avoiding false negatives for custom ID types. meta = await stremioService.getMetaDetails(type, id, preferredAddonId); if (meta) { break; From 6f208027449da09f0e468ffa6442148e6496c814 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Wed, 18 Mar 2026 20:12:39 +0530 Subject: [PATCH 08/10] feat(mal): robust unmarking logic and enhanced skip-intro accuracy with TMDB integration --- src/components/home/ContentItem.tsx | 2 +- src/components/metadata/SeriesContent.tsx | 41 +++++- src/components/player/AndroidVideoPlayer.tsx | 3 + src/components/player/KSPlayerCore.tsx | 4 +- .../player/hooks/useSkipSegments.ts | 10 +- src/hooks/useMetadata.ts | 2 + src/services/introService.ts | 19 ++- src/services/mal/MalSync.ts | 65 +++++++++ src/services/watchedService.ts | 124 +++++++++++++++++- 9 files changed, 254 insertions(+), 16 deletions(-) diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 643aa0cd..08844610 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, item.imdb_id ?? undefined); + watchedService.unmarkMovieAsWatched(item.id, undefined, undefined, item.name, 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. diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index b2eb5c78..bdc5984b 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -650,12 +650,30 @@ const SeriesContentComponent: React.FC = ({ // 3. Background Async Operation const showImdbId = imdbId || metadata.id; + const malId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const tmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + // Calculate dayIndex for same-day releases + let dayIndex = 0; + if (episode.air_date) { + const sameDayEpisodes = episodes + .filter(ep => ep.air_date === episode.air_date) + .sort((a, b) => a.episode_number - b.episode_number); + dayIndex = sameDayEpisodes.findIndex(ep => ep.episode_number === episode.episode_number); + if (dayIndex < 0) dayIndex = 0; + } + try { const result = await watchedService.unmarkEpisodeAsWatched( - showImdbId, - metadata.id, + showImdbId || '', + metadata.id || '', episode.season_number, - episode.episode_number + episode.episode_number, + episode.air_date, + metadata?.name, + malId, + dayIndex, + tmdbId ); loadEpisodesProgress(); // Sync with source of truth @@ -768,12 +786,23 @@ const SeriesContentComponent: React.FC = ({ // 3. Background Async Operation const showImdbId = imdbId || metadata.id; + const malId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const tmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + const lastEp = Math.max(...episodeNumbers); + const lastEpisodeData = seasonEpisodes.find(e => e.episode_number === lastEp); + try { const result = await watchedService.unmarkSeasonAsWatched( - showImdbId, - metadata.id, + showImdbId || '', + metadata.id || '', currentSeason, - episodeNumbers + episodeNumbers, + lastEpisodeData?.air_date, + metadata?.name, + malId, + 0, // dayIndex (assuming 0 for season batch unmarking) + tmdbId ); // Re-sync diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 99a346c6..5933a1ef 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -271,6 +271,8 @@ const AndroidVideoPlayer: React.FC = () => { const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + const { segments: skipIntervals, outroSegment } = useSkipSegments({ imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined), type, @@ -278,6 +280,7 @@ const AndroidVideoPlayer: React.FC = () => { episode, malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + tmdbId: currentTmdbId, enabled: settings.skipIntroEnabled }); diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 4fc0a9e2..dc745f46 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -214,6 +214,8 @@ const KSPlayerCore: React.FC = () => { episodeId }); + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + const { segments: skipIntervals, outroSegment } = useSkipSegments({ imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), type, @@ -221,6 +223,7 @@ const KSPlayerCore: React.FC = () => { episode, malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + tmdbId: currentTmdbId, enabled: settings.skipIntroEnabled }); @@ -243,7 +246,6 @@ const KSPlayerCore: React.FC = () => { }); const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; - const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; // Calculate dayIndex for same-day releases const currentDayIndex = useMemo(() => { diff --git a/src/components/player/hooks/useSkipSegments.ts b/src/components/player/hooks/useSkipSegments.ts index a5e868fe..61f6fb23 100644 --- a/src/components/player/hooks/useSkipSegments.ts +++ b/src/components/player/hooks/useSkipSegments.ts @@ -10,6 +10,7 @@ interface UseSkipSegmentsProps { malId?: string; kitsuId?: string; releaseDate?: string; + tmdbId?: number; enabled: boolean; } @@ -21,6 +22,7 @@ export const useSkipSegments = ({ malId, kitsuId, releaseDate, + tmdbId, enabled }: UseSkipSegmentsProps) => { const [segments, setSegments] = useState([]); @@ -29,9 +31,9 @@ export const useSkipSegments = ({ const lastKeyRef = useRef(''); useEffect(() => { - const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}`; + const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}-${tmdbId}`; - if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { + if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId && !tmdbId) || !season || !episode) { setSegments([]); setIsLoading(false); fetchedRef.current = false; @@ -55,7 +57,7 @@ export const useSkipSegments = ({ const fetchSegments = async () => { try { - const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate); + const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate, tmdbId); // Ignore stale responses from old requests. if (cancelled || lastKeyRef.current !== key) return; @@ -78,7 +80,7 @@ export const useSkipSegments = ({ return () => { cancelled = true; }; - }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, enabled]); + }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, tmdbId, enabled]); const getActiveSegment = (currentTime: number) => { return segments.find( diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index fa6c0707..ce9a9d97 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -925,6 +925,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat name: localized.title || finalMetadata.name, description: localized.overview || finalMetadata.description, movieDetails: movieDetailsObj, + tmdbId: finalTmdbId, ...(productionInfo.length > 0 && { networks: productionInfo }), }; } @@ -962,6 +963,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat name: localized.name || finalMetadata.name, description: localized.overview || finalMetadata.description, tvDetails, + tmdbId: finalTmdbId, ...(productionInfo.length > 0 && { networks: productionInfo }), }; } diff --git a/src/services/introService.ts b/src/services/introService.ts index db6c949d..cf71eef6 100644 --- a/src/services/introService.ts +++ b/src/services/introService.ts @@ -306,7 +306,8 @@ export async function getSkipTimes( episode: number, malId?: string, kitsuId?: string, - releaseDate?: string + releaseDate?: string, + tmdbId?: number ): Promise { // 1. Try IntroDB (TV Shows) first if (imdbId) { @@ -320,7 +321,21 @@ export async function getSkipTimes( let finalMalId = malId; let finalEpisode = episode; - // If we have IMDb ID and Release Date, try ArmSyncService to resolve exact MAL ID and Episode + // Priority 1: TMDB-based Resolution (Highest Accuracy) + if (!finalMalId && tmdbId && releaseDate) { + try { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate); + if (tmdbResult) { + finalMalId = tmdbResult.malId.toString(); + finalEpisode = tmdbResult.episode; + logger.log(`[IntroService] TMDB resolved: MAL ${finalMalId} Ep ${finalEpisode}`); + } + } catch (e) { + logger.warn('[IntroService] TMDB resolve failed', e); + } + } + + // Priority 2: IMDb-based ARM Sync (Fallback) if (!finalMalId && imdbId && releaseDate) { try { const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate); diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts index d89873fd..6dbeba8e 100644 --- a/src/services/mal/MalSync.ts +++ b/src/services/mal/MalSync.ts @@ -282,6 +282,71 @@ export const MalSync = { } }, + unscrobbleEpisode: async ( + animeTitle: string, + episodeNumber: number, + type: 'movie' | 'series' = 'series', + season?: number, + imdbId?: string, + releaseDate?: string, + providedMalId?: number, + dayIndex?: number, + tmdbId?: number + ) => { + try { + if (!MalAuth.isAuthenticated()) return; + + let malId: number | null = providedMalId || null; + let finalEpisodeNumber = episodeNumber; + + // Resolve ID using same strategies as scrobbling + if (!malId && tmdbId && releaseDate) { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + malId = tmdbResult.malId; + finalEpisodeNumber = tmdbResult.episode; + } + } + + if (!malId && imdbId && type === 'series' && releaseDate) { + const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex); + if (armResult) { + malId = armResult.malId; + finalEpisodeNumber = armResult.episode; + } + } + + if (!malId) { + malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate, dayIndex, tmdbId); + } + + if (!malId) return; + + // Get current count + const currentInfo = await MalApiService.getMyListStatus(malId); + if (!currentInfo.my_list_status) return; + + // Decrement logic: Only if the episode we are unmarking is the LAST one watched or current + const currentlyWatched = currentInfo.my_list_status.num_episodes_watched; + if (finalEpisodeNumber === currentlyWatched) { + const newCount = Math.max(0, finalEpisodeNumber - 1); + let newStatus = currentInfo.my_list_status.status; + + // If we unmark everything, maybe move back to 'plan_to_watch' or keep 'watching' + if (newCount === 0 && newStatus === 'watching') { + // Optional: Move back to plan to watch if desired + } else if (newStatus === 'completed') { + newStatus = 'watching'; + } + + await MalApiService.updateStatus(malId, newStatus, newCount); + console.log(`[MalSync] Unscrobbled MAL ID ${malId} to Ep ${newCount}`); + } + } catch (e) { + console.error('[MalSync] Unscrobble failed:', e); + } + }, + /** * Direct scrobble with known MAL ID and Episode * Used when ArmSync has already resolved the exact details. diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 07aa1ac2..43608f64 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -6,6 +6,7 @@ import { logger } from '../utils/logger'; import { MalSync } from './mal/MalSync'; import { MalAuth } from './mal/MalAuth'; import { ArmSyncService } from './mal/ArmSyncService'; +import { MalApiService } from './mal/MalApi'; export interface LocalWatchedItem { content_id: string; @@ -577,10 +578,16 @@ class WatchedService { /** * Unmark a movie as watched (remove from history). * @param imdbId - The primary content ID (may be a provider ID like "kitsu:123") + * @param malId - Optional MAL ID + * @param tmdbId - Optional TMDB ID + * @param title - Optional title * @param fallbackImdbId - The resolved IMDb ID from metadata (used when imdbId isn't IMDb format) */ public async unmarkMovieAsWatched( imdbId: string, + malId?: number, + tmdbId?: number, + title?: string, fallbackImdbId?: string ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { @@ -594,6 +601,21 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`); } + // Sync to MAL + if (MalAuth.isAuthenticated()) { + MalSync.unscrobbleEpisode( + title || 'Movie', + 1, + 'movie', + undefined, + imdbId, + undefined, + malId, + undefined, + tmdbId + ).catch(err => logger.error('[WatchedService] MAL movie unsync failed:', err)); + } + // Simkl Unmark — try both IDs const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { @@ -627,7 +649,12 @@ class WatchedService { showImdbId: string, showId: string, season: number, - episode: number + episode: number, + releaseDate?: string, + showTitle?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`); @@ -647,6 +674,21 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`); } + // Sync to MAL + if (MalAuth.isAuthenticated()) { + MalSync.unscrobbleEpisode( + showTitle || 'Anime', + episode, + 'series', + season, + showImdbId, + releaseDate, + malId, + dayIndex, + tmdbId + ).catch(err => logger.error('[WatchedService] MAL unsync failed:', err)); + } + // Simkl Unmark — use best available ID const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { @@ -688,7 +730,12 @@ class WatchedService { showImdbId: string, showId: string, season: number, - episodeNumbers: number[] + episodeNumbers: number[], + releaseDate?: string, + showTitle?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number ): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> { try { logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`); @@ -708,6 +755,79 @@ class WatchedService { logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); } + // Sync to MAL (Unscrobble the latest episode in this season ONLY if it's the one we're currently on) + if (MalAuth.isAuthenticated() && episodeNumbers.length > 0) { + const maxEpisodeInSeason = Math.max(...episodeNumbers); + + const resolveAndUnscrobble = async () => { + try { + // Use the robust resolution logic from MalSync.unscrobbleEpisode + // to find the ACTUAL malId and absolute episode number + let finalMalId = malId; + let resolvedEpisode = maxEpisodeInSeason; + + // 1. Try TMDB Resolution + if (!finalMalId && tmdbId && releaseDate) { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + finalMalId = tmdbResult.malId; + resolvedEpisode = tmdbResult.episode; + } + } + + // 2. Try IMDb/ARM Fallback + if (!finalMalId && showImdbId && releaseDate) { + const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate, dayIndex); + if (armResult) { + finalMalId = armResult.malId; + resolvedEpisode = armResult.episode; + } + } + + // 3. Last resort: Standard lookup + if (!finalMalId) { + finalMalId = (await MalSync.getMalId( + showTitle || 'Anime', + 'series', + undefined, + season, + showImdbId, + maxEpisodeInSeason, + releaseDate, + dayIndex, + tmdbId + )) || undefined; + } + + if (finalMalId) { + const currentInfo = await MalApiService.getMyListStatus(finalMalId); + const currentlyWatched = currentInfo.my_list_status?.num_episodes_watched || 0; + + // Only unscrobble if the season's end matches our current progress + if (currentlyWatched === resolvedEpisode) { + // Calculate the episode count BEFORE this season started + const minEpisodeInSeason = Math.min(...episodeNumbers); + const newCount = Math.max(0, minEpisodeInSeason - 1); + + let newStatus: any = currentInfo.my_list_status?.status || 'watching'; + if (newCount === 0 && newStatus === 'watching') { + // Optional: could move to plan_to_watch + } else if (newStatus === 'completed') { + newStatus = 'watching'; + } + + await MalApiService.updateStatus(finalMalId, newStatus, newCount); + logger.log(`[WatchedService] Unmarked season: MAL ID ${finalMalId} reverted to Ep ${newCount}`); + } + } + } catch (e) { + logger.error('[WatchedService] MAL season unsync resolution failed:', e); + } + }; + + resolveAndUnscrobble(); + } + // Sync to Simkl — use best available ID const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { From 38890942a1db26d9eceb079072e769b6d2727542 Mon Sep 17 00:00:00 2001 From: Ramon <20274410+ram130@users.noreply.github.com> Date: Thu, 19 Mar 2026 04:52:03 -0400 Subject: [PATCH 09/10] fix: robust Trakt continue-watching sort + new episode priority boost --- .../mergeTraktContinueWatching.ts | 27 +++++++++++++++++-- src/services/traktService.ts | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/home/continueWatching/mergeTraktContinueWatching.ts b/src/components/home/continueWatching/mergeTraktContinueWatching.ts index b4b8e304..07e548d6 100644 --- a/src/components/home/continueWatching/mergeTraktContinueWatching.ts +++ b/src/components/home/continueWatching/mergeTraktContinueWatching.ts @@ -91,8 +91,31 @@ export async function mergeTraktContinueWatching({ const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); const sortedPlaybackItems = [...playbackItems] - .sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()) - .slice(0, 30); + .sort((a, b) => { + const getBaseTime = (item: any) => + new Date( + item.paused_at || + item.updated_at || + item.last_watched_at || + 0 + ).getTime(); + + const getPriorityTime = (item: any) => { + const base = getBaseTime(item); + // NEW EPISODE PRIORITY BOOST + if (item.episode && (item.progress ?? 0) < 1) { + const aired = new Date(item.episode.first_aired || 0).getTime(); + const daysSinceAired = (Date.now() - aired) / (1000 * 60 * 60 * 24); + if (daysSinceAired >= 0 && daysSinceAired < 60) { + return base + 1000000000; // boost to top on aired ep + } + } + return base; + }; + + return getPriorityTime(b) - getPriorityTime(a); + }) + .slice(0, 30); for (const item of sortedPlaybackItems) { try { diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 1fa3081f..1ff45ab6 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1396,7 +1396,7 @@ export class TraktService { */ public async getPlaybackProgressWithImages(type?: 'movies' | 'shows'): Promise { try { - const endpoint = type ? `/sync/playback/${type}?extended=images` : '/sync/playback?extended=images'; + const endpoint = type ? `/sync/playback/${type}?extended=full,images` : '/sync/playback?extended=full,images'; return this.apiRequest(endpoint); } catch (error) { logger.error('[TraktService] Failed to get playback progress with images:', error); From 75301a1d27f46a80ff595c7db3fae9a3f66541f4 Mon Sep 17 00:00:00 2001 From: Ramon <20274410+ram130@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:05:11 -0400 Subject: [PATCH 10/10] fix: robust Trakt continue-watching sort + new episode priority boost --- src/services/traktService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 1ff45ab6..1c7f7406 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1396,6 +1396,8 @@ export class TraktService { */ public async getPlaybackProgressWithImages(type?: 'movies' | 'shows'): Promise { try { + // extended=full,images so we receive episode.first_aired (needed for the new-episode priority boost + // in mergeTraktContinueWatching.ts — brand-new/recently-aired episodes now jump to the top of Continue Watching). const endpoint = type ? `/sync/playback/${type}?extended=full,images` : '/sync/playback?extended=full,images'; return this.apiRequest(endpoint); } catch (error) {