diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 0d959f41..fc116c5a 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -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>(); const isDarkMode = true; const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); + const [results, setResults] = useState({ byAddon: [], allResults: [] }); const [searching, setSearching] = useState(false); const [searched, setSearched] = useState(false); const [recentSearches, setRecentSearches] = useState([]); @@ -209,6 +209,10 @@ const SearchScreen = () => { const inputRef = useRef(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); + // Live search handle + const liveSearchHandle = useRef<{ cancel: () => void; done: Promise } | null>(null); + // Addon installation order map for stable section ordering + const addonOrderRankRef = useRef>({}); // DropUpMenu state const [menuVisible, setMenuVisible] = useState(false); const [selectedItem, setSelectedItem] = useState(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 = {}; + 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 && ( - - - Movies ({movieResults.length}) - - ( - { + // 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 ( + + {/* Addon Header */} + + + + {addonGroup.addonName} + + + + {addonGroup.results.length} + + + + + {/* Movies from this addon */} + {movieResults.length > 0 && ( + + + Movies ({movieResults.length}) + + ( + + )} + keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + extraData={refreshFlag} + /> + )} - keyExtractor={item => `movie-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - extraData={refreshFlag} - /> - - )} - {seriesResults.length > 0 && ( - - - TV Shows ({seriesResults.length}) - - ( - + + {/* TV Shows from this addon */} + {seriesResults.length > 0 && ( + + + TV Shows ({seriesResults.length}) + + ( + + )} + keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + extraData={refreshFlag} + /> + )} - keyExtractor={item => `series-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - extraData={refreshFlag} - /> - - )} + + {/* Other content types (anime, etc.) */} + {otherResults.length > 0 && ( + + + {otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length}) + + ( + + )} + keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + extraData={refreshFlag} + /> + + )} + + ); + })} )} @@ -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, diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 083332d4..7adb983e 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -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 { + /** + * 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 - Search results grouped by addon with headers + */ + async searchContentCinemeta(query: string): Promise { 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(); + 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(); + + 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 } { + 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(); + + 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(); + 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 - Search results from this specific addon catalog + */ + private async searchAddonCatalog( + addon: any, + type: string, + catalogId: string, + query: string + ): Promise { + 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 {