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 [];