mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-28 21:38:46 +00:00
Merge pull request #648 from chrisk325/patch-21
This commit is contained in:
commit
335d6054c1
4 changed files with 119 additions and 39 deletions
|
|
@ -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 }]}>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue