diff --git a/src/components/search/AddonSection.tsx b/src/components/search/AddonSection.tsx index d2506cb0..c272b763 100644 --- a/src/components/search/AddonSection.tsx +++ b/src/components/search/AddonSection.tsx @@ -68,7 +68,7 @@ export const AddonSection = React.memo(({ {/* Addon Header */} - {addonGroup.addonName} + {(addonGroup as any).sectionName || addonGroup.addonName} diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index c42d29ac..dd3ba802 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -1924,13 +1924,16 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const cleanEpisodeId = episodeId.replace(/^series:/, ''); const parts = cleanEpisodeId.split(':'); + // Check the episode ID's own namespace, not the show-level id. + // e.g. show id may be "tt12343534" but episodeId may be "kitsu:48363:8" + const episodeIsImdb = parts[0].startsWith('tt'); - if (isImdb && parts.length === 3) { + if (episodeIsImdb && parts.length === 3) { // Format: ttXXX:season:episode showIdStr = parts[0]; seasonNum = parts[1]; episodeNum = parts[2]; - } else if (!isImdb && parts.length === 3) { + } else if (!episodeIsImdb && parts.length === 3) { // Format: prefix:id:episode (no season for MAL/Kitsu/etc) showIdStr = `${parts[0]}:${parts[1]}`; episodeNum = parts[2]; diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index d730abb2..e27337d0 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -515,7 +515,14 @@ const SearchScreen = () => { setResults(prev => { if (!isMounted.current) return prev; - const getRank = (id: string) => addonOrderRankRef.current[id] ?? Number.MAX_SAFE_INTEGER; + // Use catalogIndex from the section for deterministic ordering. + // Falls back to addonOrderRankRef for legacy single-catalog sections. + const getRank = (section: AddonSearchResults) => { + if (section.catalogIndex !== undefined) return section.catalogIndex; + if (addonOrderRankRef.current[section.addonId] !== undefined) return addonOrderRankRef.current[section.addonId] * 1000; + const baseAddonId = section.addonId.includes('||') ? section.addonId.split('||')[0] : section.addonId; + return (addonOrderRankRef.current[baseAddonId] ?? Number.MAX_SAFE_INTEGER - 1) * 1000 + 500; + }; const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId); if (existingIndex >= 0) { @@ -524,10 +531,10 @@ const SearchScreen = () => { return { byAddon: copy, allResults: prev.allResults }; } - const insertRank = getRank(section.addonId); + const insertRank = getRank(section); let insertAt = prev.byAddon.length; for (let i = 0; i < prev.byAddon.length; i++) { - if (getRank(prev.byAddon[i].addonId) > insertRank) { + if (getRank(prev.byAddon[i]) > insertRank) { insertAt = i; break; } diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 50dfd938..5abcd90e 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -52,6 +52,8 @@ export interface StreamingAddon { export interface AddonSearchResults { addonId: string; addonName: string; + sectionName: string; // Display name — catalog name for named catalogs, addon name otherwise + catalogIndex: number; // Position in addon manifest — used for deterministic sort within same addon results: StreamingContent[]; } @@ -1530,6 +1532,10 @@ class CatalogService { return; } + // Build addon order map for deterministic section sorting + const addonOrderRef: Record = {}; + searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; }); + // Global dedupe across emitted results const globalSeen = new Set(); @@ -1537,7 +1543,6 @@ class CatalogService { searchableAddons.map(async (addon) => { if (controller.cancelled) return; try { - // Get the manifest to ensure we have the correct URL const manifest = manifestMap.get(addon.id); if (!manifest) { logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); @@ -1545,7 +1550,6 @@ 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 @@ -1554,48 +1558,114 @@ class CatalogService { ); if (controller.cancelled) return; - const addonResults: StreamingContent[] = []; - for (const s of settled) { - if (s.status === 'fulfilled' && Array.isArray(s.value)) { - addonResults.push(...s.value); + // 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 in ${addon.name}:`, s.reason); + logger.warn(`Search failed for catalog ${searchableCatalogs[i].id} in ${addon.name}:`, s.reason); } } - if (addonResults.length === 0) { + if (catalogResultsList.length === 0) { logger.log(`No results from ${addon.name}`); return; } - // Within this addon's results, if the same ID appears under both a generic - // type (e.g. "series") and a specific type (e.g. "anime.series"), keep only - // the specific one. This handles addons that expose both catalog types. - const bestByIdWithinAddon = new Map(); - for (const item of addonResults) { - const existing = bestByIdWithinAddon.get(item.id); - if (!existing) { - bestByIdWithinAddon.set(item.id, item); - } else if (!existing.type.includes('.') && item.type.includes('.')) { - // Prefer the more specific type - bestByIdWithinAddon.set(item.id, item); + 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', + }; + + // 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; + + // Within-catalog dedup: prefer dot-type over generic for same ID + const bestById = new Map(); + for (const item of results) { + const existing = bestById.get(item.id); + if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { + bestById.set(item.id, item); + } + } + + // 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; + }); + + // 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; + }); + + 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 }); + } } - } - const deduped = Array.from(bestByIdWithinAddon.values()); + } else { + // Single catalog — one section per addon + const allResults = catalogResultsList.flatMap(c => c.results); - // Dedupe against global seen (keyed by type:id to avoid cross-addon ID collisions) - 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; - }); + const bestByIdWithinAddon = new Map(); + for (const item of allResults) { + const existing = bestByIdWithinAddon.get(item.id); + if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { + bestByIdWithinAddon.set(item.id, item); + } + } + const deduped = Array.from(bestByIdWithinAddon.values()); - if (unique.length > 0 && !controller.cancelled) { - logger.log(`Emitting ${unique.length} results from ${addon.name}`); - onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique }); + 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 }); + } } } catch (e) { logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);