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 { useNavigation } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
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 { Image } from 'expo-image';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { DropUpMenu } from '../components/home/DropUpMenu';
|
import { DropUpMenu } from '../components/home/DropUpMenu';
|
||||||
|
|
@ -201,7 +201,7 @@ const SearchScreen = () => {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const isDarkMode = true;
|
const isDarkMode = true;
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<StreamingContent[]>([]);
|
const [results, setResults] = useState<GroupedSearchResults>({ byAddon: [], allResults: [] });
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
|
|
@ -209,6 +209,10 @@ const SearchScreen = () => {
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { currentTheme } = useTheme();
|
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
|
// DropUpMenu state
|
||||||
const [menuVisible, setMenuVisible] = useState(false);
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
|
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
|
||||||
|
|
@ -306,7 +310,7 @@ const SearchScreen = () => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
if (query) {
|
if (query) {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setResults([]);
|
setResults({ byAddon: [], allResults: [] });
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
setShowRecent(true);
|
setShowRecent(true);
|
||||||
loadRecentSearches();
|
loadRecentSearches();
|
||||||
|
|
@ -354,25 +358,59 @@ const SearchScreen = () => {
|
||||||
const debouncedSearch = useCallback(
|
const debouncedSearch = useCallback(
|
||||||
debounce(async (searchQuery: string) => {
|
debounce(async (searchQuery: string) => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
setResults([]);
|
// Cancel any in-flight live search
|
||||||
|
liveSearchHandle.current?.cancel();
|
||||||
|
liveSearchHandle.current = null;
|
||||||
|
setResults({ byAddon: [], allResults: [] });
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
return;
|
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 {
|
try {
|
||||||
logger.info('Performing search for:', searchQuery);
|
const addons = await catalogService.getAllAddons();
|
||||||
const searchResults = await catalogService.searchContentCinemeta(searchQuery);
|
const rank: Record<string, number> = {};
|
||||||
setResults(searchResults);
|
addons.forEach((a, idx) => { rank[a.id] = idx; });
|
||||||
if (searchResults.length > 0) {
|
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);
|
await saveRecentSearch(searchQuery);
|
||||||
}
|
} catch {}
|
||||||
logger.info('Search completed, found', searchResults.length, 'results');
|
});
|
||||||
} catch (error) {
|
liveSearchHandle.current = handle;
|
||||||
logger.error('Search failed:', error);
|
|
||||||
setResults([]);
|
|
||||||
} finally {
|
|
||||||
setSearching(false);
|
|
||||||
}
|
|
||||||
}, 800),
|
}, 800),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
@ -388,11 +426,13 @@ const SearchScreen = () => {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
setShowRecent(false);
|
setShowRecent(false);
|
||||||
setResults([]);
|
setResults({ byAddon: [], allResults: [] });
|
||||||
} else {
|
} else {
|
||||||
// Cancel any pending search when query is cleared
|
// Cancel any pending search when query is cleared
|
||||||
debouncedSearch.cancel();
|
debouncedSearch.cancel();
|
||||||
setResults([]);
|
liveSearchHandle.current?.cancel();
|
||||||
|
liveSearchHandle.current = null;
|
||||||
|
setResults({ byAddon: [], allResults: [] });
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
setShowRecent(true);
|
setShowRecent(true);
|
||||||
|
|
@ -407,7 +447,9 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setResults([]);
|
liveSearchHandle.current?.cancel();
|
||||||
|
liveSearchHandle.current = null;
|
||||||
|
setResults({ byAddon: [], allResults: [] });
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
setShowRecent(true);
|
setShowRecent(true);
|
||||||
loadRecentSearches();
|
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(() => {
|
const hasResultsToShow = useMemo(() => {
|
||||||
return movieResults.length > 0 || seriesResults.length > 0;
|
return results.byAddon.length > 0;
|
||||||
}, [movieResults, seriesResults]);
|
}, [results]);
|
||||||
|
|
||||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||||
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
|
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
|
||||||
|
|
@ -707,64 +741,131 @@ const SearchScreen = () => {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{!query.trim() && renderRecentSearches()}
|
{!query.trim() && renderRecentSearches()}
|
||||||
{movieResults.length > 0 && (
|
{/* Render results grouped by addon */}
|
||||||
<Animated.View
|
{results.byAddon.map((addonGroup, addonIndex) => {
|
||||||
style={styles.carouselContainer}
|
// Group by type within each addon
|
||||||
entering={FadeIn.duration(300)}
|
const movieResults = addonGroup.results.filter(item => item.type === 'movie');
|
||||||
>
|
const seriesResults = addonGroup.results.filter(item => item.type === 'series');
|
||||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
const otherResults = addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series');
|
||||||
Movies ({movieResults.length})
|
|
||||||
</Text>
|
return (
|
||||||
<FlatList
|
<Animated.View
|
||||||
data={movieResults}
|
key={addonGroup.addonId}
|
||||||
renderItem={({ item, index }) => (
|
entering={FadeIn.duration(300).delay(addonIndex * 100)}
|
||||||
<SearchResultItem
|
>
|
||||||
item={item}
|
{/* Addon Header */}
|
||||||
index={index}
|
<View style={styles.addonHeaderContainer}>
|
||||||
navigation={navigation}
|
<MaterialIcons
|
||||||
setSelectedItem={setSelectedItem}
|
name="extension"
|
||||||
setMenuVisible={setMenuVisible}
|
size={20}
|
||||||
currentTheme={currentTheme}
|
color={currentTheme.colors.primary}
|
||||||
refreshFlag={refreshFlag}
|
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
|
{/* TV Shows from this addon */}
|
||||||
showsHorizontalScrollIndicator={false}
|
{seriesResults.length > 0 && (
|
||||||
contentContainerStyle={styles.horizontalListContent}
|
<Animated.View
|
||||||
extraData={refreshFlag}
|
style={styles.carouselContainer}
|
||||||
/>
|
entering={FadeIn.duration(300)}
|
||||||
</Animated.View>
|
>
|
||||||
)}
|
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||||
{seriesResults.length > 0 && (
|
TV Shows ({seriesResults.length})
|
||||||
<Animated.View
|
</Text>
|
||||||
style={styles.carouselContainer}
|
<FlatList
|
||||||
entering={FadeIn.duration(300).delay(50)}
|
data={seriesResults}
|
||||||
>
|
renderItem={({ item, index }) => (
|
||||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
<SearchResultItem
|
||||||
TV Shows ({seriesResults.length})
|
item={item}
|
||||||
</Text>
|
index={index}
|
||||||
<FlatList
|
navigation={navigation}
|
||||||
data={seriesResults}
|
setSelectedItem={setSelectedItem}
|
||||||
renderItem={({ item, index }) => (
|
setMenuVisible={setMenuVisible}
|
||||||
<SearchResultItem
|
currentTheme={currentTheme}
|
||||||
item={item}
|
refreshFlag={refreshFlag}
|
||||||
index={index}
|
/>
|
||||||
navigation={navigation}
|
)}
|
||||||
setSelectedItem={setSelectedItem}
|
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
|
||||||
setMenuVisible={setMenuVisible}
|
horizontal
|
||||||
currentTheme={currentTheme}
|
showsHorizontalScrollIndicator={false}
|
||||||
refreshFlag={refreshFlag}
|
contentContainerStyle={styles.horizontalListContent}
|
||||||
/>
|
extraData={refreshFlag}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
keyExtractor={item => `series-${item.id}`}
|
|
||||||
horizontal
|
{/* Other content types (anime, etc.) */}
|
||||||
showsHorizontalScrollIndicator={false}
|
{otherResults.length > 0 && (
|
||||||
contentContainerStyle={styles.horizontalListContent}
|
<Animated.View
|
||||||
extraData={refreshFlag}
|
style={styles.carouselContainer}
|
||||||
/>
|
entering={FadeIn.duration(300)}
|
||||||
</Animated.View>
|
>
|
||||||
)}
|
<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>
|
</Animated.ScrollView>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -897,6 +998,39 @@ const styles = StyleSheet.create({
|
||||||
marginBottom: isTablet ? 16 : 12,
|
marginBottom: isTablet ? 16 : 12,
|
||||||
paddingHorizontal: 16,
|
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: {
|
horizontalListContent: {
|
||||||
paddingHorizontal: isTablet ? 16 : 12,
|
paddingHorizontal: isTablet ? 16 : 12,
|
||||||
paddingRight: isTablet ? 12 : 8,
|
paddingRight: isTablet ? 12 : 8,
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,31 @@ export interface StreamingAddon {
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
extraSupported?: string[];
|
||||||
|
extra?: Array<{ name: string; options?: string[] }>;
|
||||||
}[];
|
}[];
|
||||||
resources: {
|
resources: {
|
||||||
name: string;
|
name: string;
|
||||||
types: string[];
|
types: string[];
|
||||||
idPrefixes?: string[];
|
idPrefixes?: string[];
|
||||||
}[];
|
}[];
|
||||||
|
url?: string; // preferred base URL (manifest or original)
|
||||||
|
originalUrl?: string; // original addon URL if provided
|
||||||
transportUrl?: string;
|
transportUrl?: string;
|
||||||
transportName?: 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 {
|
export interface StreamingContent {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -172,6 +187,8 @@ class CatalogService {
|
||||||
types: manifest.types || [],
|
types: manifest.types || [],
|
||||||
catalogs: manifest.catalogs || [],
|
catalogs: manifest.catalogs || [],
|
||||||
resources: manifest.resources || [],
|
resources: manifest.resources || [],
|
||||||
|
url: (manifest.url || manifest.originalUrl) as any,
|
||||||
|
originalUrl: (manifest.originalUrl || manifest.url) as any,
|
||||||
transportUrl: manifest.url,
|
transportUrl: manifest.url,
|
||||||
transportName: manifest.name
|
transportName: manifest.name
|
||||||
};
|
};
|
||||||
|
|
@ -812,54 +829,259 @@ class CatalogService {
|
||||||
return uniqueResults;
|
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) {
|
if (!query) {
|
||||||
return [];
|
return { byAddon: [], allResults: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedQuery = query.trim().toLowerCase();
|
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 addons = await this.getAllAddons();
|
||||||
const results: StreamingContent[] = [];
|
const byAddon: AddonSearchResults[] = [];
|
||||||
|
|
||||||
// Find Cinemeta addon by its ID
|
// Find all addons that support search
|
||||||
const cinemeta = addons.find(addon => addon.id === 'com.linvo.cinemeta');
|
const searchableAddons = addons.filter(addon => {
|
||||||
|
if (!addon.catalogs) return false;
|
||||||
if (!cinemeta || !cinemeta.catalogs) {
|
|
||||||
logger.error('Cinemeta addon not found');
|
// Check if any catalog supports search
|
||||||
return [];
|
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
|
// Create deduplicated flat list for backwards compatibility
|
||||||
const searchPromises = ['movie', 'series'].map(async (type) => {
|
const allResults: StreamingContent[] = [];
|
||||||
try {
|
const globalSeen = new Set<string>();
|
||||||
// Direct API call to Cinemeta
|
|
||||||
const url = `https://v3-cinemeta.strem.io/catalog/${type}/top/search=${encodeURIComponent(trimmedQuery)}.json`;
|
byAddon.forEach(addonGroup => {
|
||||||
logger.log('Request URL:', url);
|
addonGroup.results.forEach(item => {
|
||||||
|
const key = `${item.type}:${item.id}`;
|
||||||
const response = await axios.get<{ metas: any[] }>(url);
|
if (!globalSeen.has(key)) {
|
||||||
const metas = response.data.metas || [];
|
globalSeen.add(key);
|
||||||
|
allResults.push(item);
|
||||||
if (metas && metas.length > 0) {
|
}
|
||||||
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
|
});
|
||||||
results.push(...items);
|
});
|
||||||
|
|
||||||
|
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);
|
logger.log(`Searching ${addon.name} (${type}/${catalogId}):`, url);
|
||||||
|
|
||||||
// Remove duplicates while preserving order
|
const response = await axios.get<{ metas: any[] }>(url, {
|
||||||
const seen = new Set();
|
timeout: 10000, // 10 second timeout per addon
|
||||||
return results.filter(item => {
|
});
|
||||||
const key = `${item.type}:${item.id}`;
|
|
||||||
if (seen.has(key)) return false;
|
const metas = response.data?.metas || [];
|
||||||
seen.add(key);
|
|
||||||
return true;
|
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> {
|
async getStremioId(type: string, tmdbId: string): Promise<string | null> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue