Merge branch 'NuvioMedia:main' into Mal

This commit is contained in:
paregi12 2026-03-19 14:24:11 +05:30 committed by GitHub
commit 640b45835c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 124 additions and 46 deletions

View file

@ -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')) {

View file

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

View file

@ -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) {

View file

@ -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<MetaDetails | null> {
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<MetaDetails | null> {
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;
}

View file

@ -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<number | null> {
const cacheKey = this.generateCacheKey('find_imdb', { imdbId, language });