From a30fa604d7747818f7828dfc11cd4c2353fd5991 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 28 Dec 2025 22:10:40 +0530 Subject: [PATCH] discover screen init --- App.tsx | 29 +- src/hooks/useSettings.ts | 2 + src/screens/SearchScreen.tsx | 794 ++++++++++++++++++++++++++++++++- src/screens/SettingsScreen.tsx | 12 + src/services/catalogService.ts | 181 ++++++++ 5 files changed, 985 insertions(+), 33 deletions(-) diff --git a/App.tsx b/App.tsx index ff4b26f..a42d2c8 100644 --- a/App.tsx +++ b/App.tsx @@ -22,6 +22,7 @@ import AppNavigator, { CustomNavigationDarkTheme, CustomDarkTheme } from './src/navigation/AppNavigator'; +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import 'react-native-reanimated'; import { CatalogProvider } from './src/contexts/CatalogContext'; import { GenreProvider } from './src/contexts/GenreContext'; @@ -245,19 +246,21 @@ const ThemedApp = () => { function App(): React.JSX.Element { return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index e76651f..bfc1af7 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -92,6 +92,7 @@ export interface AppSettings { videoPlayerEngine: 'auto' | 'mpv'; // Video player engine: auto (ExoPlayer primary, MPV fallback) or mpv (MPV only) decoderMode: 'auto' | 'sw' | 'hw' | 'hw+'; // Decoder mode: auto (auto-copy), sw (software), hw (mediacodec-copy), hw+ (mediacodec) gpuMode: 'gpu' | 'gpu-next'; // GPU rendering mode: gpu (standard) or gpu-next (advanced HDR/color) + showDiscover: boolean; } export const DEFAULT_SETTINGS: AppSettings = { @@ -157,6 +158,7 @@ export const DEFAULT_SETTINGS: AppSettings = { videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback) decoderMode: 'auto', // Default to auto (best compatibility and performance) gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR) + showDiscover: true, // Show Discover section in SearchScreen }; const SETTINGS_STORAGE_KEY = 'app_settings'; diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index d4db046..81dd419 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -17,6 +17,7 @@ import { Pressable, Platform, Easing, + Modal, } from 'react-native'; import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -45,6 +46,18 @@ import { useTheme } from '../contexts/ThemeContext'; import LoadingSpinner from '../components/common/LoadingSpinner'; import ScreenHeader from '../components/common/ScreenHeader'; import { useScrollToTop } from '../contexts/ScrollToTopContext'; +import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'; +import { useSettings } from '../hooks/useSettings'; + +// Catalog info type for discover +interface DiscoverCatalog { + addonId: string; + addonName: string; + catalogId: string; + catalogName: string; + type: string; + genres: string[]; +} const { width, height } = Dimensions.get('window'); @@ -219,6 +232,7 @@ const SimpleSearchAnimation = () => { }; const SearchScreen = () => { + const { settings } = useSettings(); const navigation = useNavigation>(); const isDarkMode = true; const [query, setQuery] = useState(''); @@ -240,6 +254,28 @@ const SearchScreen = () => { const isMounted = useRef(true); const scrollViewRef = useRef(null); + // Discover section state + const [discoverCatalogs, setDiscoverCatalogs] = useState([]); + const [selectedCatalog, setSelectedCatalog] = useState(null); + const [selectedDiscoverType, setSelectedDiscoverType] = useState<'movie' | 'series'>('movie'); + const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState(null); + // Discover pagination state + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [discoverResults, setDiscoverResults] = useState([]); + + const [discoverLoading, setDiscoverLoading] = useState(false); + const [discoverInitialized, setDiscoverInitialized] = useState(false); + + // Bottom sheet refs and state + const typeSheetRef = useRef(null); + const catalogSheetRef = useRef(null); + const genreSheetRef = useRef(null); + const typeSnapPoints = useMemo(() => ['25%'], []); + const catalogSnapPoints = useMemo(() => ['50%'], []); + const genreSnapPoints = useMemo(() => ['50%'], []); + // Scroll to top handler const scrollToTop = useCallback(() => { scrollViewRef.current?.scrollTo({ y: 0, animated: true }); @@ -253,6 +289,112 @@ const SearchScreen = () => { isMounted.current = false; }; }, []); + + // Load discover catalogs on mount + useEffect(() => { + const loadDiscoverCatalogs = async () => { + try { + const filters = await catalogService.getDiscoverFilters(); + if (isMounted.current) { + // Flatten catalogs from all types into a single array + const allCatalogs: DiscoverCatalog[] = []; + for (const [type, catalogs] of Object.entries(filters.catalogsByType)) { + // Only include movie and series types + if (type === 'movie' || type === 'series') { + for (const catalog of catalogs) { + allCatalogs.push({ + ...catalog, + type, + }); + } + } + } + setDiscoverCatalogs(allCatalogs); + // Auto-select first catalog if available + if (allCatalogs.length > 0) { + setSelectedCatalog(allCatalogs[0]); + } + setDiscoverInitialized(true); + } + } catch (error) { + logger.error('Failed to load discover catalogs:', error); + if (isMounted.current) { + setDiscoverInitialized(true); + } + } + }; + loadDiscoverCatalogs(); + }, []); + + // Fetch discover content when catalog or genre changes + useEffect(() => { + if (!discoverInitialized || !selectedCatalog || query.trim().length > 0) return; + + const fetchDiscoverContent = async () => { + if (!isMounted.current) return; + setDiscoverLoading(true); + setPage(1); // Reset page on new filter + setHasMore(true); + try { + const results = await catalogService.discoverContentFromCatalog( + selectedCatalog.addonId, + selectedCatalog.catalogId, + selectedCatalog.type, + selectedDiscoverGenre || undefined, + 1 // page 1 + ); + if (isMounted.current) { + setDiscoverResults(results); + setHasMore(results.length > 0); + } + } catch (error) { + logger.error('Failed to fetch discover content:', error); + if (isMounted.current) { + setDiscoverResults([]); + } + } finally { + if (isMounted.current) { + setDiscoverLoading(false); + } + } + }; + + fetchDiscoverContent(); + }, [discoverInitialized, selectedCatalog, selectedDiscoverGenre, query]); + + // Load more content for pagination + const loadMoreDiscoverContent = async () => { + if (!hasMore || loadingMore || discoverLoading || !selectedCatalog) return; + + setLoadingMore(true); + const nextPage = page + 1; + + try { + const moreResults = await catalogService.discoverContentFromCatalog( + selectedCatalog.addonId, + selectedCatalog.catalogId, + selectedCatalog.type, + selectedDiscoverGenre || undefined, + nextPage + ); + + if (isMounted.current) { + if (moreResults.length > 0) { + setDiscoverResults(prev => [...prev, ...moreResults]); + setPage(nextPage); + } else { + setHasMore(false); + } + } + } catch (error) { + logger.error('Failed to load more discover content:', error); + } finally { + if (isMounted.current) { + setLoadingMore(false); + } + } + }; + // DropUpMenu state const [menuVisible, setMenuVisible] = useState(false); const [selectedItem, setSelectedItem] = useState(null); @@ -614,13 +756,195 @@ const SearchScreen = () => { ); }; - const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme }: { + // Get available genres for the selected catalog + const availableGenres = useMemo(() => { + if (!selectedCatalog) return []; + return selectedCatalog.genres; + }, [selectedCatalog]); + + // Get catalogs filtered by selected type + const filteredCatalogs = useMemo(() => { + return discoverCatalogs.filter(catalog => catalog.type === selectedDiscoverType); + }, [discoverCatalogs, selectedDiscoverType]); + + // Handle type selection + const handleTypeSelect = (type: 'movie' | 'series') => { + setSelectedDiscoverType(type); + + // Auto-select first catalog for the new type + const catalogsForType = discoverCatalogs.filter(c => c.type === type); + if (catalogsForType.length > 0) { + const firstCatalog = catalogsForType[0]; + setSelectedCatalog(firstCatalog); + + // Auto-select first genre if available + if (firstCatalog.genres.length > 0) { + setSelectedDiscoverGenre(firstCatalog.genres[0]); + } else { + setSelectedDiscoverGenre(null); + } + } else { + setSelectedCatalog(null); + setSelectedDiscoverGenre(null); + } + + typeSheetRef.current?.dismiss(); + }; + + // Handle catalog selection + const handleCatalogSelect = (catalog: DiscoverCatalog) => { + setSelectedCatalog(catalog); + setSelectedDiscoverGenre(null); // Reset genre when catalog changes + catalogSheetRef.current?.dismiss(); + }; + + // Handle genre selection + const handleGenreSelect = (genre: string | null) => { + setSelectedDiscoverGenre(genre); + genreSheetRef.current?.dismiss(); + }; + + // Render backdrop for bottom sheets + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [] + ); + + // Render discover section with catalog and genre selector chips + const renderDiscoverSection = () => { + if (query.trim().length > 0) return null; + + return ( + + {/* Section Header */} + + + Discover + + + + {/* Filter Chips Row */} + {/* Filter Chips Row */} + + {/* Type Selector Chip (Movie/TV Show) */} + typeSheetRef.current?.present()} + > + + {selectedDiscoverType === 'movie' ? 'Movies' : 'TV Shows'} + + + + + {/* Catalog Selector Chip */} + catalogSheetRef.current?.present()} + > + + {selectedCatalog ? selectedCatalog.catalogName : 'Select Catalog'} + + + + + {/* Genre Selector Chip - only show if catalog has genres */} + {availableGenres.length > 0 && ( + genreSheetRef.current?.present()} + > + + {selectedDiscoverGenre || 'All Genres'} + + + + )} + + + {/* Selected filters summary */} + {selectedCatalog && ( + + + {selectedCatalog.addonName} • {selectedCatalog.type === 'movie' ? 'Movies' : 'TV Shows'} + {selectedDiscoverGenre ? ` • ${selectedDiscoverGenre}` : ''} + + + )} + + {/* Discover Results */} + {discoverLoading ? ( + + + + Discovering content... + + + ) : discoverResults.length > 0 ? ( + + {discoverResults.map((item, index) => ( + + ))} + {loadingMore && ( + + + + )} + + ) : discoverInitialized && !discoverLoading && selectedCatalog ? ( + + + + No content found + + + Try a different genre or catalog + + + ) : !selectedCatalog && discoverInitialized ? ( + + + + Select a catalog to discover + + + Tap the catalog chip above to get started + + + ) : null} + + ); + }; + + const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme, isGrid = false }: { item: StreamingContent; index: number; navigation: any; setSelectedItem: (item: StreamingContent) => void; setMenuVisible: (visible: boolean) => void; currentTheme: any; + isGrid?: boolean; }) => { const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary); const [watched, setWatched] = React.useState(false); @@ -633,15 +957,26 @@ const SearchScreen = () => { let w = HORIZONTAL_ITEM_WIDTH; let r = 2 / 3; - if (shape === 'landscape') { - r = 16 / 9; - w = baseHeight * r; - } else if (shape === 'square') { - r = 1; - w = baseHeight; + if (isGrid) { + // Grid Calculation: (Window Width - Padding) / Columns + // Padding: 16 (left) + 16 (right) = 32 + // Gap: 12 (between items) * (columns - 1) + const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3; + const totalPadding = 32; + const totalGap = 12 * (columns - 1); + const availableWidth = width - totalPadding - totalGap; + w = availableWidth / columns; + } else { + if (shape === 'landscape') { + r = 16 / 9; + w = baseHeight * r; + } else if (shape === 'square') { + r = 1; + w = baseHeight; + } } return { itemWidth: w, aspectRatio: r }; - }, [item.posterShape]); + }, [item.posterShape, isGrid]); React.useEffect(() => { const updateWatched = () => { @@ -661,7 +996,11 @@ const SearchScreen = () => { return ( { navigation.navigate('Metadata', { id: item.id, type: item.type }); }} @@ -698,14 +1037,7 @@ const SearchScreen = () => { )} - {item.imdbRating && ( - - - - {item.imdbRating} - - - )} + {/* Rating removed per user request */} { keyboardShouldPersistTaps="handled" onScrollBeginDrag={Keyboard.dismiss} showsVerticalScrollIndicator={false} + scrollEventThrottle={16} + onScroll={({ nativeEvent }) => { + // Only paginate if query is empty (Discover mode) + if (query.trim().length > 0 || !settings.showDiscover) return; + + const { layoutMeasurement, contentOffset, contentSize } = nativeEvent; + const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 500; + + if (isCloseToBottom) { + loadMoreDiscoverContent(); + } + }} > {!query.trim() && renderRecentSearches()} + {!query.trim() && settings.showDiscover && renderDiscoverSection()} + {/* Render results grouped by addon using memoized component */} {results.byAddon.map((addonGroup, addonIndex) => ( { }} /> )} + + {/* Catalog Selection Bottom Sheet */} + + + + Select Catalog + + catalogSheetRef.current?.dismiss()}> + + + + + {filteredCatalogs.map((catalog, index) => ( + handleCatalogSelect(catalog)} + > + + + {catalog.catalogName} + + + {catalog.addonName} • {catalog.type === 'movie' ? 'Movies' : 'TV Shows'} + {catalog.genres.length > 0 ? ` • ${catalog.genres.length} genres` : ''} + + + {selectedCatalog?.catalogId === catalog.catalogId && + selectedCatalog?.addonId === catalog.addonId && ( + + )} + + ))} + + + + {/* Genre Selection Bottom Sheet */} + + + + Select Genre + + genreSheetRef.current?.dismiss()}> + + + + + {/* All Genres option */} + handleGenreSelect(null)} + > + + + All Genres + + + Show all content + + + {!selectedDiscoverGenre && ( + + )} + + + {/* Genre options */} + {availableGenres.map((genre, index) => ( + handleGenreSelect(genre)} + > + + + {genre} + + + {selectedDiscoverGenre === genre && ( + + )} + + ))} + + + + {/* Type Selection Bottom Sheet */} + + + + Select Type + + typeSheetRef.current?.dismiss()}> + + + + + {/* Movies option */} + handleTypeSelect('movie')} + > + + + Movies + + + Browse movie catalogs + + + {selectedDiscoverType === 'movie' && ( + + )} + + + {/* TV Shows option */} + handleTypeSelect('series')} + > + + + TV Shows + + + Browse TV series catalogs + + + {selectedDiscoverType === 'series' && ( + + )} + + + ); }; @@ -1164,12 +1714,11 @@ const styles = StyleSheet.create({ fontWeight: '600', }, horizontalListContent: { - paddingHorizontal: isTablet ? 16 : 12, - paddingRight: isTablet ? 12 : 8, + paddingHorizontal: 16, }, horizontalItem: { width: HORIZONTAL_ITEM_WIDTH, - marginRight: isTablet ? 16 : 12, + marginRight: 16, }, horizontalItemPosterContainer: { width: HORIZONTAL_ITEM_WIDTH, @@ -1360,6 +1909,211 @@ const styles = StyleSheet.create({ zIndex: 2, backgroundColor: 'transparent', }, + // Discover section styles + discoverContainer: { + paddingTop: isTablet ? 16 : 12, + paddingBottom: isTablet ? 24 : 16, + }, + discoverHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + marginBottom: isTablet ? 16 : 12, + gap: 8, + }, + discoverTitle: { + fontSize: isTablet ? 22 : 20, + fontWeight: '700', + }, + discoverTypeContainer: { + flexDirection: 'row', + paddingHorizontal: 16, + marginBottom: isTablet ? 16 : 12, + gap: 12, + }, + discoverTypeButton: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.1)', + gap: 6, + }, + discoverTypeText: { + fontSize: isTablet ? 15 : 14, + fontWeight: '600', + }, + discoverGenreScroll: { + marginBottom: isTablet ? 20 : 16, + }, + discoverGenreContent: { + paddingHorizontal: 16, + gap: 8, + }, + discoverGenreChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 16, + backgroundColor: 'rgba(255,255,255,0.08)', + marginRight: 8, + }, + discoverGenreChipActive: { + backgroundColor: 'rgba(255,255,255,0.2)', + }, + discoverGenreText: { + fontSize: isTablet ? 14 : 13, + fontWeight: '500', + color: 'rgba(255,255,255,0.7)', + }, + discoverGenreTextActive: { + color: '#FFFFFF', + fontWeight: '600', + }, + discoverLoadingContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + discoverLoadingText: { + marginTop: 12, + fontSize: 14, + }, + discoverAddonSection: { + marginBottom: isTablet ? 28 : 20, + }, + discoverAddonHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + marginBottom: isTablet ? 12 : 8, + }, + discoverAddonName: { + fontSize: isTablet ? 16 : 15, + fontWeight: '600', + flex: 1, + }, + discoverAddonBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 10, + }, + discoverAddonBadgeText: { + fontSize: 11, + fontWeight: '600', + }, + discoverEmptyContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + paddingHorizontal: 32, + }, + discoverEmptyText: { + fontSize: 16, + fontWeight: '600', + marginTop: 12, + textAlign: 'center', + }, + discoverEmptySubtext: { + fontSize: 14, + marginTop: 4, + textAlign: 'center', + }, + discoverGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 16, + gap: 12, // vertical and horizontal gap + }, + discoverGridItem: { + marginRight: 0, // Override horizontalItem margin + marginBottom: 0, // Gap handles this now + }, + loadingMoreContainer: { + width: '100%', + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + // New chip-based discover styles + discoverChipsScroll: { + marginBottom: isTablet ? 12 : 10, + flexGrow: 0, + }, + discoverChipsContent: { + paddingHorizontal: 16, + flexDirection: 'row', + gap: 8, + }, + discoverSelectorChip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + gap: 6, + }, + discoverSelectorText: { + fontSize: isTablet ? 14 : 13, + fontWeight: '600', + }, + discoverFilterSummary: { + paddingHorizontal: 16, + marginBottom: isTablet ? 16 : 12, + }, + discoverFilterSummaryText: { + fontSize: 12, + fontWeight: '500', + }, + // Bottom sheet styles + bottomSheetHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.1)', + }, + bottomSheetTitle: { + fontSize: 18, + fontWeight: '700', + }, + bottomSheetContent: { + paddingHorizontal: 12, + paddingBottom: 40, + }, + bottomSheetItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 12, + marginVertical: 2, + }, + bottomSheetItemSelected: { + backgroundColor: 'rgba(255,255,255,0.08)', + }, + bottomSheetItemIcon: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.1)', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + bottomSheetItemContent: { + flex: 1, + }, + bottomSheetItemTitle: { + fontSize: 16, + fontWeight: '600', + }, + bottomSheetItemSubtitle: { + fontSize: 13, + marginTop: 2, + }, }); export default SearchScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3355de3..9564806 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -594,6 +594,18 @@ const SettingsScreen: React.FC = () => { onPress={() => navigation.navigate('HomeScreenSettings')} isTablet={isTablet} /> + ( + updateSetting('showDiscover', value)} + /> + )} + isTablet={isTablet} + /> ; + }> { + const addons = await this.getAllAddons(); + const allGenres = new Set(); + const allTypes = new Set(); + const catalogsByType: Record = {}; + + for (const addon of addons) { + if (!addon.catalogs) continue; + + for (const catalog of addon.catalogs) { + // Track content types + if (catalog.type) { + allTypes.add(catalog.type); + } + + // Get genres from catalog extras + const catalogGenres: string[] = []; + if (catalog.extra && Array.isArray(catalog.extra)) { + for (const extra of catalog.extra) { + if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) { + for (const genre of extra.options) { + allGenres.add(genre); + catalogGenres.push(genre); + } + } + } + } + + // Track catalogs by type for filtering + if (catalog.type) { + if (!catalogsByType[catalog.type]) { + catalogsByType[catalog.type] = []; + } + catalogsByType[catalog.type].push({ + addonId: addon.id, + addonName: addon.name, + catalogId: catalog.id, + catalogName: catalog.name || catalog.id, + genres: catalogGenres + }); + } + } + } + + // Sort genres alphabetically + const sortedGenres = Array.from(allGenres).sort((a, b) => a.localeCompare(b)); + const sortedTypes = Array.from(allTypes); + + return { + genres: sortedGenres, + types: sortedTypes, + catalogsByType + }; + } + + /** + * Discover content by type and optional genre filter + * Fetches from all installed addons that have catalogs matching the criteria + */ + async discoverContent( + type: string, + genre?: string, + limit: number = 20 + ): Promise<{ addonName: string; items: StreamingContent[] }[]> { + const addons = await this.getAllAddons(); + const results: { addonName: string; items: StreamingContent[] }[] = []; + const manifests = await stremioService.getInstalledAddonsAsync(); + + // Find catalogs that match the type + const catalogPromises: Promise<{ addonName: string; items: StreamingContent[] } | null>[] = []; + + for (const addon of addons) { + if (!addon.catalogs) continue; + + // Find catalogs matching the type + const matchingCatalogs = addon.catalogs.filter(catalog => catalog.type === type); + + for (const catalog of matchingCatalogs) { + // Check if this catalog supports the genre filter + const supportsGenre = catalog.extra?.some(e => e.name === 'genre') || + catalog.extraSupported?.includes('genre'); + + // If genre is specified, only use catalogs that support genre OR have no filter restrictions + // If genre is specified but catalog doesn't support genre filter, skip it + if (genre && !supportsGenre) { + continue; + } + + const manifest = manifests.find(m => m.id === addon.id); + if (!manifest) continue; + + const fetchPromise = (async () => { + try { + const filters = genre ? [{ title: 'genre', value: genre }] : []; + const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); + + if (metas && metas.length > 0) { + const items = metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta)); + return { + addonName: addon.name, + items + }; + } + return null; + } catch (error) { + logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error); + return null; + } + })(); + + catalogPromises.push(fetchPromise); + } + } + + const catalogResults = await Promise.all(catalogPromises); + + // Filter out null results and deduplicate by addon + const addonMap = new Map(); + for (const result of catalogResults) { + if (result && result.items.length > 0) { + const existing = addonMap.get(result.addonName) || []; + // Merge items, avoiding duplicates + const existingIds = new Set(existing.map(item => `${item.type}:${item.id}`)); + const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`)); + addonMap.set(result.addonName, [...existing, ...newItems]); + } + } + + // Convert map to array + for (const [addonName, items] of addonMap) { + results.push({ addonName, items: items.slice(0, limit) }); + } + + return results; + } + + /** + * Discover content from a specific catalog with optional genre filter + * @param addonId - The addon ID + * @param catalogId - The catalog ID + * @param type - Content type (movie/series) + * @param genre - Optional genre filter + * @param limit - Maximum items to return + */ + async discoverContentFromCatalog( + addonId: string, + catalogId: string, + type: string, + genre?: string, + page: number = 1 + ): Promise { + try { + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifest = manifests.find(m => m.id === addonId); + + if (!manifest) { + logger.error(`Addon ${addonId} not found`); + return []; + } + + const filters = genre ? [{ title: 'genre', value: genre }] : []; + const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters); + + if (metas && metas.length > 0) { + return metas.map(meta => this.convertMetaToStreamingContent(meta)); + } + return []; + } catch (error) { + logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error); + return []; + } + } + async searchContent(query: string): Promise { if (!query || query.trim().length < 2) { return [];