Merge pull request #648 from chrisk325/patch-21

This commit is contained in:
Nayif 2026-03-15 01:53:34 +05:30 committed by GitHub
commit 335d6054c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 119 additions and 39 deletions

View file

@ -68,7 +68,7 @@ export const AddonSection = React.memo(({
{/* Addon Header */}
<View style={styles.addonHeaderContainer}>
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
{addonGroup.addonName}
{(addonGroup as any).sectionName || addonGroup.addonName}
</Text>
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>

View file

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

View file

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

View file

@ -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<string, number> = {};
searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; });
// Global dedupe across emitted results
const globalSeen = new Set<string>();
@ -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<string, StreamingContent>();
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<string, string> = {
'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<string, StreamingContent>();
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<string>();
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<string, StreamingContent>();
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<string>();
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);