diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index ce9a9d97..d8291a74 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')) { 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; 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) { diff --git a/src/services/stremio/catalog-operations.ts b/src/services/stremio/catalog-operations.ts index 8dba5f7e..d118f271 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; @@ -226,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; } @@ -234,7 +258,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 +273,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 +288,7 @@ export async function getMetaDetails( continue; } - if (!addonSupportsMetaResource(addon, type, id)) { + if (!addonCanServeId(addon, id)) { continue; } 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 });