multiple addon seach func

This commit is contained in:
tapframe 2025-10-08 16:31:07 +05:30
parent f31942efdf
commit 581e912d4c
2 changed files with 476 additions and 120 deletions

View file

@ -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,

View file

@ -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> {