mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
multiple addon seach func
This commit is contained in:
parent
f31942efdf
commit
581e912d4c
2 changed files with 476 additions and 120 deletions
|
|
@ -21,7 +21,7 @@ import {
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { catalogService, StreamingContent } from '../services/catalogService';
|
||||
import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService';
|
||||
import { Image } from 'expo-image';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { DropUpMenu } from '../components/home/DropUpMenu';
|
||||
|
|
@ -201,7 +201,7 @@ const SearchScreen = () => {
|
|||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const isDarkMode = true;
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<StreamingContent[]>([]);
|
||||
const [results, setResults] = useState<GroupedSearchResults>({ byAddon: [], allResults: [] });
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
|
|
@ -209,6 +209,10 @@ const SearchScreen = () => {
|
|||
const inputRef = useRef<TextInput>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { currentTheme } = useTheme();
|
||||
// Live search handle
|
||||
const liveSearchHandle = useRef<{ cancel: () => void; done: Promise<void> } | null>(null);
|
||||
// Addon installation order map for stable section ordering
|
||||
const addonOrderRankRef = useRef<Record<string, number>>({});
|
||||
// DropUpMenu state
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
|
||||
|
|
@ -306,7 +310,7 @@ const SearchScreen = () => {
|
|||
Keyboard.dismiss();
|
||||
if (query) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearched(false);
|
||||
setShowRecent(true);
|
||||
loadRecentSearches();
|
||||
|
|
@ -354,25 +358,59 @@ const SearchScreen = () => {
|
|||
const debouncedSearch = useCallback(
|
||||
debounce(async (searchQuery: string) => {
|
||||
if (!searchQuery.trim()) {
|
||||
setResults([]);
|
||||
// Cancel any in-flight live search
|
||||
liveSearchHandle.current?.cancel();
|
||||
liveSearchHandle.current = null;
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel prior live search
|
||||
liveSearchHandle.current?.cancel();
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearching(true);
|
||||
|
||||
logger.info('Starting live search for:', searchQuery);
|
||||
// Preload addon order to keep sections sorted by installation order
|
||||
try {
|
||||
logger.info('Performing search for:', searchQuery);
|
||||
const searchResults = await catalogService.searchContentCinemeta(searchQuery);
|
||||
setResults(searchResults);
|
||||
if (searchResults.length > 0) {
|
||||
const addons = await catalogService.getAllAddons();
|
||||
const rank: Record<string, number> = {};
|
||||
addons.forEach((a, idx) => { rank[a.id] = idx; });
|
||||
addonOrderRankRef.current = rank;
|
||||
} catch {}
|
||||
|
||||
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
|
||||
// Append/update this addon section immediately
|
||||
setResults(prev => {
|
||||
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
|
||||
let nextByAddon: AddonSearchResults[];
|
||||
if (existingIndex >= 0) {
|
||||
nextByAddon = prev.byAddon.slice();
|
||||
nextByAddon[existingIndex] = section;
|
||||
} else {
|
||||
nextByAddon = [...prev.byAddon, section];
|
||||
}
|
||||
// Sort by installation order
|
||||
const rank = addonOrderRankRef.current;
|
||||
nextByAddon = nextByAddon.slice().sort((a, b) => {
|
||||
const ra = rank[a.addonId] ?? Number.MAX_SAFE_INTEGER;
|
||||
const rb = rank[b.addonId] ?? Number.MAX_SAFE_INTEGER;
|
||||
return ra - rb;
|
||||
});
|
||||
// Hide loading overlay once first section arrives
|
||||
if (nextByAddon.length === 1) {
|
||||
setSearching(false);
|
||||
}
|
||||
return { byAddon: nextByAddon, allResults: prev.allResults };
|
||||
});
|
||||
|
||||
// Save to recents after first result batch
|
||||
try {
|
||||
await saveRecentSearch(searchQuery);
|
||||
}
|
||||
logger.info('Search completed, found', searchResults.length, 'results');
|
||||
} catch (error) {
|
||||
logger.error('Search failed:', error);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
liveSearchHandle.current = handle;
|
||||
}, 800),
|
||||
[]
|
||||
);
|
||||
|
|
@ -388,11 +426,13 @@ const SearchScreen = () => {
|
|||
setSearching(false);
|
||||
setSearched(false);
|
||||
setShowRecent(false);
|
||||
setResults([]);
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
} else {
|
||||
// Cancel any pending search when query is cleared
|
||||
debouncedSearch.cancel();
|
||||
setResults([]);
|
||||
liveSearchHandle.current?.cancel();
|
||||
liveSearchHandle.current = null;
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearched(false);
|
||||
setSearching(false);
|
||||
setShowRecent(true);
|
||||
|
|
@ -407,7 +447,9 @@ const SearchScreen = () => {
|
|||
|
||||
const handleClearSearch = () => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
liveSearchHandle.current?.cancel();
|
||||
liveSearchHandle.current = null;
|
||||
setResults({ byAddon: [], allResults: [] });
|
||||
setSearched(false);
|
||||
setShowRecent(true);
|
||||
loadRecentSearches();
|
||||
|
|
@ -555,17 +597,9 @@ const SearchScreen = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const movieResults = useMemo(() => {
|
||||
return results.filter(item => item.type === 'movie');
|
||||
}, [results]);
|
||||
|
||||
const seriesResults = useMemo(() => {
|
||||
return results.filter(item => item.type === 'series');
|
||||
}, [results]);
|
||||
|
||||
const hasResultsToShow = useMemo(() => {
|
||||
return movieResults.length > 0 || seriesResults.length > 0;
|
||||
}, [movieResults, seriesResults]);
|
||||
return results.byAddon.length > 0;
|
||||
}, [results]);
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
|
||||
|
|
@ -707,64 +741,131 @@ const SearchScreen = () => {
|
|||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{!query.trim() && renderRecentSearches()}
|
||||
{movieResults.length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.carouselContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
||||
Movies ({movieResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={movieResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
refreshFlag={refreshFlag}
|
||||
{/* Render results grouped by addon */}
|
||||
{results.byAddon.map((addonGroup, addonIndex) => {
|
||||
// Group by type within each addon
|
||||
const movieResults = addonGroup.results.filter(item => item.type === 'movie');
|
||||
const seriesResults = addonGroup.results.filter(item => item.type === 'series');
|
||||
const otherResults = addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series');
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={addonGroup.addonId}
|
||||
entering={FadeIn.duration(300).delay(addonIndex * 100)}
|
||||
>
|
||||
{/* Addon Header */}
|
||||
<View style={styles.addonHeaderContainer}>
|
||||
<MaterialIcons
|
||||
name="extension"
|
||||
size={20}
|
||||
color={currentTheme.colors.primary}
|
||||
style={styles.addonHeaderIcon}
|
||||
/>
|
||||
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
|
||||
{addonGroup.addonName}
|
||||
</Text>
|
||||
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
|
||||
{addonGroup.results.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Movies from this addon */}
|
||||
{movieResults.length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.carouselContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
Movies ({movieResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={movieResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
refreshFlag={refreshFlag}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
extraData={refreshFlag}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
keyExtractor={item => `movie-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
extraData={refreshFlag}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
{seriesResults.length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.carouselContainer}
|
||||
entering={FadeIn.duration(300).delay(50)}
|
||||
>
|
||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
||||
TV Shows ({seriesResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={seriesResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
refreshFlag={refreshFlag}
|
||||
/>
|
||||
|
||||
{/* TV Shows from this addon */}
|
||||
{seriesResults.length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.carouselContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
TV Shows ({seriesResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={seriesResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
refreshFlag={refreshFlag}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
extraData={refreshFlag}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
keyExtractor={item => `series-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
extraData={refreshFlag}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Other content types (anime, etc.) */}
|
||||
{otherResults.length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.carouselContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={otherResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
refreshFlag={refreshFlag}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
extraData={refreshFlag}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
})}
|
||||
</Animated.ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -897,6 +998,39 @@ const styles = StyleSheet.create({
|
|||
marginBottom: isTablet ? 16 : 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
carouselSubtitle: {
|
||||
fontSize: isTablet ? 16 : 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: isTablet ? 12 : 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
addonHeaderContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: isTablet ? 16 : 12,
|
||||
marginTop: isTablet ? 24 : 16,
|
||||
marginBottom: isTablet ? 8 : 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
addonHeaderIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
addonHeaderText: {
|
||||
fontSize: isTablet ? 18 : 16,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
},
|
||||
addonHeaderBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
addonHeaderBadgeText: {
|
||||
fontSize: isTablet ? 12 : 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
horizontalListContent: {
|
||||
paddingHorizontal: isTablet ? 16 : 12,
|
||||
paddingRight: isTablet ? 12 : 8,
|
||||
|
|
|
|||
|
|
@ -25,16 +25,31 @@ export interface StreamingAddon {
|
|||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
extraSupported?: string[];
|
||||
extra?: Array<{ name: string; options?: string[] }>;
|
||||
}[];
|
||||
resources: {
|
||||
name: string;
|
||||
types: string[];
|
||||
idPrefixes?: string[];
|
||||
}[];
|
||||
url?: string; // preferred base URL (manifest or original)
|
||||
originalUrl?: string; // original addon URL if provided
|
||||
transportUrl?: string;
|
||||
transportName?: string;
|
||||
}
|
||||
|
||||
export interface AddonSearchResults {
|
||||
addonId: string;
|
||||
addonName: string;
|
||||
results: StreamingContent[];
|
||||
}
|
||||
|
||||
export interface GroupedSearchResults {
|
||||
byAddon: AddonSearchResults[];
|
||||
allResults: StreamingContent[]; // Deduplicated flat list for backwards compatibility
|
||||
}
|
||||
|
||||
export interface StreamingContent {
|
||||
id: string;
|
||||
type: string;
|
||||
|
|
@ -172,6 +187,8 @@ class CatalogService {
|
|||
types: manifest.types || [],
|
||||
catalogs: manifest.catalogs || [],
|
||||
resources: manifest.resources || [],
|
||||
url: (manifest.url || manifest.originalUrl) as any,
|
||||
originalUrl: (manifest.originalUrl || manifest.url) as any,
|
||||
transportUrl: manifest.url,
|
||||
transportName: manifest.name
|
||||
};
|
||||
|
|
@ -812,54 +829,259 @@ class CatalogService {
|
|||
return uniqueResults;
|
||||
}
|
||||
|
||||
async searchContentCinemeta(query: string): Promise<StreamingContent[]> {
|
||||
/**
|
||||
* Search across all installed addons that support search functionality.
|
||||
* This dynamically queries any addon with catalogs that have 'search' in their extraSupported or extra fields.
|
||||
* Results are grouped by addon source with headers.
|
||||
*
|
||||
* @param query - The search query string
|
||||
* @returns Promise<GroupedSearchResults> - Search results grouped by addon with headers
|
||||
*/
|
||||
async searchContentCinemeta(query: string): Promise<GroupedSearchResults> {
|
||||
if (!query) {
|
||||
return [];
|
||||
return { byAddon: [], allResults: [] };
|
||||
}
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
logger.log('Searching Cinemeta for:', trimmedQuery);
|
||||
logger.log('Searching across all addons for:', trimmedQuery);
|
||||
|
||||
const addons = await this.getAllAddons();
|
||||
const results: StreamingContent[] = [];
|
||||
const byAddon: AddonSearchResults[] = [];
|
||||
|
||||
// Find Cinemeta addon by its ID
|
||||
const cinemeta = addons.find(addon => addon.id === 'com.linvo.cinemeta');
|
||||
|
||||
if (!cinemeta || !cinemeta.catalogs) {
|
||||
logger.error('Cinemeta addon not found');
|
||||
return [];
|
||||
// Find all addons that support search
|
||||
const searchableAddons = addons.filter(addon => {
|
||||
if (!addon.catalogs) return false;
|
||||
|
||||
// Check if any catalog supports search
|
||||
return addon.catalogs.some(catalog => {
|
||||
const extraSupported = catalog.extraSupported || [];
|
||||
const extra = catalog.extra || [];
|
||||
|
||||
// Check if 'search' is in extraSupported or extra
|
||||
return extraSupported.includes('search') ||
|
||||
extra.some((e: any) => e.name === 'search');
|
||||
});
|
||||
});
|
||||
|
||||
logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', '));
|
||||
|
||||
// Search each addon and keep results grouped
|
||||
for (const addon of searchableAddons) {
|
||||
const searchableCatalogs = (addon.catalogs || []).filter(catalog => {
|
||||
const extraSupported = catalog.extraSupported || [];
|
||||
const extra = catalog.extra || [];
|
||||
return extraSupported.includes('search') ||
|
||||
extra.some((e: any) => e.name === 'search');
|
||||
});
|
||||
|
||||
// Search all catalogs for this addon in parallel
|
||||
const catalogPromises = searchableCatalogs.map(catalog =>
|
||||
this.searchAddonCatalog(addon, catalog.type, catalog.id, trimmedQuery)
|
||||
);
|
||||
|
||||
const catalogResults = await Promise.allSettled(catalogPromises);
|
||||
|
||||
// Collect all results for this addon
|
||||
const addonResults: StreamingContent[] = [];
|
||||
catalogResults.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
addonResults.push(...result.value);
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error(`Search failed for ${addon.name}:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
// Only add addon section if it has results
|
||||
if (addonResults.length > 0) {
|
||||
// Deduplicate within this addon's results
|
||||
const seen = new Set<string>();
|
||||
const uniqueAddonResults = addonResults.filter(item => {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
byAddon.push({
|
||||
addonId: addon.id,
|
||||
addonName: addon.name,
|
||||
results: uniqueAddonResults,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Search in both movie and series catalogs simultaneously
|
||||
const searchPromises = ['movie', 'series'].map(async (type) => {
|
||||
try {
|
||||
// Direct API call to Cinemeta
|
||||
const url = `https://v3-cinemeta.strem.io/catalog/${type}/top/search=${encodeURIComponent(trimmedQuery)}.json`;
|
||||
logger.log('Request URL:', url);
|
||||
|
||||
const response = await axios.get<{ metas: any[] }>(url);
|
||||
const metas = response.data.metas || [];
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
|
||||
results.push(...items);
|
||||
// Create deduplicated flat list for backwards compatibility
|
||||
const allResults: StreamingContent[] = [];
|
||||
const globalSeen = new Set<string>();
|
||||
|
||||
byAddon.forEach(addonGroup => {
|
||||
addonGroup.results.forEach(item => {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
if (!globalSeen.has(key)) {
|
||||
globalSeen.add(key);
|
||||
allResults.push(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`);
|
||||
|
||||
return { byAddon, allResults };
|
||||
}
|
||||
|
||||
/**
|
||||
* Live search that emits results per-addon as they arrive.
|
||||
* Returns a handle with cancel() and a done promise.
|
||||
*/
|
||||
startLiveSearch(
|
||||
query: string,
|
||||
onAddonResults: (section: AddonSearchResults) => void
|
||||
): { cancel: () => void; done: Promise<void> } {
|
||||
const controller = { cancelled: false } as { cancelled: boolean };
|
||||
|
||||
const done = (async () => {
|
||||
if (!query || !query.trim()) return;
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
logger.log('Live search across addons for:', trimmedQuery);
|
||||
|
||||
const addons = await this.getAllAddons();
|
||||
|
||||
// Determine searchable addons
|
||||
const searchableAddons = addons.filter(addon =>
|
||||
(addon.catalogs || []).some(c =>
|
||||
(c.extraSupported && c.extraSupported.includes('search')) ||
|
||||
(c.extra && c.extra.some(e => e.name === 'search'))
|
||||
)
|
||||
);
|
||||
|
||||
// Global dedupe across emitted results
|
||||
const globalSeen = new Set<string>();
|
||||
|
||||
await Promise.all(
|
||||
searchableAddons.map(async (addon) => {
|
||||
if (controller.cancelled) return;
|
||||
try {
|
||||
const searchableCatalogs = (addon.catalogs || []).filter(c =>
|
||||
(c.extraSupported && c.extraSupported.includes('search')) ||
|
||||
(c.extra && c.extra.some(e => e.name === 'search'))
|
||||
);
|
||||
|
||||
// Fetch all catalogs for this addon in parallel
|
||||
const settled = await Promise.allSettled(
|
||||
searchableCatalogs.map(c => this.searchAddonCatalog(addon, c.type, c.id, trimmedQuery))
|
||||
);
|
||||
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 (addonResults.length === 0) return;
|
||||
|
||||
// Dedupe within addon and against global
|
||||
const localSeen = new Set<string>();
|
||||
const unique = addonResults.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) {
|
||||
onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique });
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore individual addon errors
|
||||
}
|
||||
})
|
||||
);
|
||||
})();
|
||||
|
||||
return {
|
||||
cancel: () => { controller.cancelled = true; },
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a specific catalog from a specific addon.
|
||||
* Handles URL construction for both Cinemeta (hardcoded) and other addons (dynamic).
|
||||
*
|
||||
* @param addon - The addon manifest containing id, name, and url
|
||||
* @param type - Content type (movie, series, anime, etc.)
|
||||
* @param catalogId - The catalog ID to search within
|
||||
* @param query - The search query string
|
||||
* @returns Promise<StreamingContent[]> - Search results from this specific addon catalog
|
||||
*/
|
||||
private async searchAddonCatalog(
|
||||
addon: any,
|
||||
type: string,
|
||||
catalogId: string,
|
||||
query: string
|
||||
): Promise<StreamingContent[]> {
|
||||
try {
|
||||
let url: string;
|
||||
|
||||
// Special handling for Cinemeta (hardcoded URL)
|
||||
if (addon.id === 'com.linvo.cinemeta') {
|
||||
const encodedCatalogId = encodeURIComponent(catalogId);
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
url = `https://v3-cinemeta.strem.io/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
|
||||
}
|
||||
// Handle other addons
|
||||
else {
|
||||
// Choose best available URL
|
||||
const chosenUrl: string | undefined = addon.url || addon.originalUrl || addon.transportUrl;
|
||||
if (!chosenUrl) {
|
||||
logger.warn(`Addon ${addon.name} has no URL, skipping search`);
|
||||
return [];
|
||||
}
|
||||
// Extract base URL and preserve query params
|
||||
const [baseUrlPart, queryParams] = chosenUrl.split('?');
|
||||
let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, '');
|
||||
|
||||
// Ensure URL has protocol
|
||||
if (!cleanBaseUrl.startsWith('http')) {
|
||||
cleanBaseUrl = `https://${cleanBaseUrl}`;
|
||||
}
|
||||
|
||||
const encodedCatalogId = encodeURIComponent(catalogId);
|
||||
const encodedQuery = encodeURIComponent(query);
|
||||
url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
|
||||
|
||||
// Append original query params if they existed
|
||||
if (queryParams) {
|
||||
url += `?${queryParams}`;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Cinemeta search failed for ${type}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
// Remove duplicates while preserving order
|
||||
const seen = new Set();
|
||||
return results.filter(item => {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
logger.log(`Searching ${addon.name} (${type}/${catalogId}):`, url);
|
||||
|
||||
const response = await axios.get<{ metas: any[] }>(url, {
|
||||
timeout: 10000, // 10 second timeout per addon
|
||||
});
|
||||
|
||||
const metas = response.data?.metas || [];
|
||||
|
||||
if (metas.length > 0) {
|
||||
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
|
||||
logger.log(`Found ${items.length} results from ${addon.name}`);
|
||||
return items;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
// Don't throw, just log and return empty
|
||||
const errorMsg = error?.response?.status
|
||||
? `HTTP ${error.response.status}`
|
||||
: error?.message || 'Unknown error';
|
||||
logger.error(`Search failed for ${addon.name} (${type}/${catalogId}): ${errorMsg}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getStremioId(type: string, tmdbId: string): Promise<string | null> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue