diff --git a/src/components/search/AddonSection.tsx b/src/components/search/AddonSection.tsx index 7d7c3b7a..d2506cb0 100644 --- a/src/components/search/AddonSection.tsx +++ b/src/components/search/AddonSection.tsx @@ -14,6 +14,24 @@ interface AddonSectionProps { currentTheme: any; } +const TYPE_LABELS: Record = { + 'movie': 'search.movies', + 'series': 'search.tv_shows', + 'anime.movie': 'search.anime_movies', + 'anime.series': 'search.anime_series', +}; + +const subtitleStyle = (currentTheme: any) => ({ + color: currentTheme.colors.lightGray, + fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14, + marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8, + paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16, +}); + +const containerStyle = { + marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24, +}; + export const AddonSection = React.memo(({ addonGroup, addonIndex, @@ -23,18 +41,27 @@ export const AddonSection = React.memo(({ }: AddonSectionProps) => { const { t } = useTranslation(); - const movieResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'movie'), - [addonGroup.results] - ); - const seriesResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'series'), - [addonGroup.results] - ); - const otherResults = useMemo(() => - addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), - [addonGroup.results] - ); + // Group results by their exact type, preserving order of first appearance + const groupedByType = useMemo(() => { + const order: string[] = []; + const groups: Record = {}; + + for (const item of addonGroup.results) { + if (!groups[item.type]) { + order.push(item.type); + groups[item.type] = []; + } + groups[item.type].push(item); + } + + return order.map(type => ({ type, items: groups[type] })); + }, [addonGroup.results]); + + const getLabelForType = (type: string): string => { + if (TYPE_LABELS[type]) return t(TYPE_LABELS[type]); + // Fallback: capitalise and replace dots/underscores for unknown types + return type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + }; return ( @@ -50,22 +77,13 @@ export const AddonSection = React.memo(({ - {/* Movies */} - {movieResults.length > 0 && ( - - - {t('search.movies')} ({movieResults.length}) + {groupedByType.map(({ type, items }) => ( + + + {getLabelForType(type)} ({items.length}) ( )} - keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`} + keyExtractor={item => `${addonGroup.addonId}-${type}-${item.id}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} /> - )} - - {/* TV Shows */} - {seriesResults.length > 0 && ( - - - {t('search.tv_shows')} ({seriesResults.length}) - - ( - - )} - keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - {/* Other types */} - {otherResults.length > 0 && ( - - - {otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length}) - - ( - - )} - keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} + ))} ); }, (prev, next) => { - // Only re-render if this section's reference changed return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex; }); diff --git a/src/components/search/DiscoverBottomSheets.tsx b/src/components/search/DiscoverBottomSheets.tsx index 87d17d0d..52719f91 100644 --- a/src/components/search/DiscoverBottomSheets.tsx +++ b/src/components/search/DiscoverBottomSheets.tsx @@ -11,12 +11,13 @@ interface DiscoverBottomSheetsProps { typeSheetRef: RefObject; catalogSheetRef: RefObject; genreSheetRef: RefObject; - selectedDiscoverType: 'movie' | 'series'; + selectedDiscoverType: string; selectedCatalog: DiscoverCatalog | null; selectedDiscoverGenre: string | null; filteredCatalogs: DiscoverCatalog[]; availableGenres: string[]; - onTypeSelect: (type: 'movie' | 'series') => void; + availableTypes: string[]; + onTypeSelect: (type: string) => void; onCatalogSelect: (catalog: DiscoverCatalog) => void; onGenreSelect: (genre: string | null) => void; currentTheme: any; @@ -31,6 +32,7 @@ export const DiscoverBottomSheets = ({ selectedDiscoverGenre, filteredCatalogs, availableGenres, + availableTypes, onTypeSelect, onCatalogSelect, onGenreSelect, @@ -38,7 +40,20 @@ export const DiscoverBottomSheets = ({ }: DiscoverBottomSheetsProps) => { const { t } = useTranslation(); - const typeSnapPoints = useMemo(() => ['25%'], []); + const TYPE_LABELS: Record = { + 'movie': t('search.movies'), + 'series': t('search.tv_shows'), + 'anime.movie': t('search.anime_movies'), + 'anime.series': t('search.anime_series'), + }; + const getLabelForType = (type: string) => + TYPE_LABELS[type] ?? type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + + const typeSnapPoints = useMemo(() => { + const itemCount = availableTypes.length; + const snapPct = Math.min(20 + itemCount * 10, 60); + return [`${snapPct}%`]; + }, [availableTypes]); const catalogSnapPoints = useMemo(() => ['50%'], []); const genreSnapPoints = useMemo(() => ['50%'], []); const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null); @@ -225,47 +240,25 @@ export const DiscoverBottomSheets = ({ style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }} contentContainerStyle={styles.bottomSheetContent} > - {/* Movies option */} - onTypeSelect('movie')} - > - - - {t('search.movies')} - - - {t('search.browse_movies')} - - - {selectedDiscoverType === 'movie' && ( - - )} - - - {/* TV Shows option */} - onTypeSelect('series')} - > - - - {t('search.tv_shows')} - - - {t('search.browse_tv')} - - - {selectedDiscoverType === 'series' && ( - - )} - + {availableTypes.map((type) => ( + onTypeSelect(type)} + > + + + {getLabelForType(type)} + + + {selectedDiscoverType === type && ( + + )} + + ))} diff --git a/src/components/search/DiscoverSection.tsx b/src/components/search/DiscoverSection.tsx index db83a562..ba6dfe05 100644 --- a/src/components/search/DiscoverSection.tsx +++ b/src/components/search/DiscoverSection.tsx @@ -22,7 +22,7 @@ interface DiscoverSectionProps { pendingDiscoverResults: StreamingContent[]; loadingMore: boolean; selectedCatalog: DiscoverCatalog | null; - selectedDiscoverType: 'movie' | 'series'; + selectedDiscoverType: string; selectedDiscoverGenre: string | null; availableGenres: string[]; typeSheetRef: React.RefObject; @@ -78,7 +78,11 @@ export const DiscoverSection = ({ onPress={() => typeSheetRef.current?.present()} > - {selectedDiscoverType === 'movie' ? t('search.movies') : t('search.tv_shows')} + {selectedDiscoverType === 'movie' ? t('search.movies') + : selectedDiscoverType === 'series' ? t('search.tv_shows') + : selectedDiscoverType === 'anime.movie' ? t('search.anime_movies') + : selectedDiscoverType === 'anime.series' ? t('search.anime_series') + : selectedDiscoverType.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} @@ -112,8 +116,13 @@ export const DiscoverSection = ({ {selectedCatalog && ( - {selectedCatalog.addonName} â€ĸ {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')} - {selectedDiscoverGenre ? ` â€ĸ ${selectedDiscoverGenre}` : ''} + {selectedCatalog.addonName} â€ĸ { + selectedCatalog.type === 'movie' ? t('search.movies') + : selectedCatalog.type === 'series' ? t('search.tv_shows') + : selectedCatalog.type === 'anime.movie' ? t('search.anime_movies') + : selectedCatalog.type === 'anime.series' ? t('search.anime_series') + : selectedCatalog.type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + }{selectedDiscoverGenre ? ` â€ĸ ${selectedDiscoverGenre}` : ''} )} diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index fc41f662..34e4c60f 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -114,6 +114,13 @@ interface UseMetadataReturn { export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => { const { settings, isLoaded: settingsLoaded } = useSettings(); + + // Normalize anime subtypes to their base types for all internal logic. + // anime.series behaves like series; anime.movie behaves like movie. + const normalizedType = type === 'anime.series' ? 'series' + : type === 'anime.movie' ? 'movie' + : type; + const [metadata, setMetadata] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -427,7 +434,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat return; } // Check cache first - const cachedCast = cacheService.getCast(id, type); + const cachedCast = cacheService.getCast(id, normalizedType); if (cachedCast) { if (__DEV__) logger.log('[loadCast] Using cached cast data'); setCast(cachedCast); @@ -439,7 +446,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (id.startsWith('tmdb:')) { const tmdbId = id.split(':')[1]; if (__DEV__) logger.log('[loadCast] Using TMDB ID directly:', tmdbId); - const castData = await tmdbService.getCredits(parseInt(tmdbId), type); + const castData = await tmdbService.getCredits(parseInt(tmdbId), normalizedType); if (castData && castData.cast) { const formattedCast = castData.cast.map((actor: any) => ({ id: actor.id, @@ -464,7 +471,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (tmdbId) { if (__DEV__) logger.log('[loadCast] Fetching cast using TMDB ID:', tmdbId); - const castData = await tmdbService.getCredits(tmdbId, type); + const castData = await tmdbService.getCredits(tmdbId, normalizedType); if (castData && castData.cast) { const formattedCast = castData.cast.map((actor: any) => ({ id: actor.id, @@ -511,7 +518,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setLoadAttempts(prev => prev + 1); // Check metadata screen cache - const cachedScreen = cacheService.getMetadataScreen(id, type); + const cachedScreen = cacheService.getMetadataScreen(id, normalizedType); if (cachedScreen) { console.log('🔍 [useMetadata] Using cached metadata:', { id, @@ -523,7 +530,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat }); setMetadata(cachedScreen.metadata); setCast(cachedScreen.cast); - if (type === 'series' && cachedScreen.episodes) { + if (normalizedType === 'series' && cachedScreen.episodes) { setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes); setEpisodes(cachedScreen.episodes.currentEpisodes); setSelectedSeason(cachedScreen.episodes.selectedSeason); @@ -567,7 +574,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } else { const tmdbId = id.split(':')[1]; // For TMDB IDs, we need to handle metadata differently - if (type === 'movie') { + if (normalizedType === 'movie') { if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId); const movieDetails = await tmdbService.getMovieDetails( tmdbId, @@ -639,7 +646,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } setMetadata(formattedMovie); - cacheService.setMetadata(id, type, formattedMovie); + cacheService.setMetadata(id, normalizedType, formattedMovie); (async () => { const items = await catalogService.getLibraryItems(); const isInLib = items.some(item => item.id === id); @@ -649,7 +656,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat return; } } - } else if (type === 'series') { + } else if (normalizedType === 'series') { // Handle TV shows with TMDB IDs if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId); try { @@ -719,7 +726,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } setMetadata(formattedShow); - cacheService.setMetadata(id, type, formattedShow); + cacheService.setMetadata(id, normalizedType, formattedShow); // Load series data (episodes) setTmdbId(parseInt(tmdbId)); @@ -779,7 +786,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat for (const addon of externalMetaAddons) { try { const result = await withTimeout( - stremioService.getMetaDetails(type, actualId, addon.id), + stremioService.getMetaDetails(normalizedType, actualId, addon.id), API_TIMEOUT ); @@ -799,7 +806,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // If no external addon worked, fall back to catalog addon console.log('🔍 [useMetadata] No external meta addon worked, falling back to catalog addon'); const result = await withTimeout( - catalogService.getEnhancedContentDetails(type, actualId, addonId), + catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), API_TIMEOUT ); if (actualId.startsWith('tt')) { @@ -831,7 +838,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...'); console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId }); const result = await withTimeout( - catalogService.getEnhancedContentDetails(type, actualId, addonId), + catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), API_TIMEOUT ); console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL'); @@ -871,13 +878,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback'); const tmdbRaw = id.split(':')[1]; try { - const stremioId = await catalogService.getStremioId(type === 'series' ? 'tv' : 'movie', tmdbRaw); + const stremioId = await catalogService.getStremioId(normalizedType === 'series' ? 'tv' : 'movie', tmdbRaw); if (stremioId && stremioId !== id) { console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId }); const [content, castData] = await Promise.allSettled([ withRetry(async () => { const result = await withTimeout( - catalogService.getEnhancedContentDetails(type, stremioId, addonId), + catalogService.getEnhancedContentDetails(normalizedType, stremioId, addonId), API_TIMEOUT ); if (stremioId.startsWith('tt')) { @@ -934,7 +941,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (finalTmdbId) { const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; - if (type === 'movie') { + if (normalizedType === 'movie') { const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang); if (localized) { const movieDetailsObj = { @@ -1011,7 +1018,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) { const tmdbService = TMDBService.getInstance(); const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - const contentType = type === 'series' ? 'tv' : 'movie'; + const contentType = normalizedType === 'series' ? 'tv' : 'movie'; // Get TMDB ID let tmdbIdForLogo = null; @@ -1080,7 +1087,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } return updated; }); - cacheService.setMetadata(id, type, finalMetadata); + cacheService.setMetadata(id, normalizedType, finalMetadata); (async () => { const items = await catalogService.getLibraryItems(); const isInLib = items.some(item => item.id === id); @@ -1597,10 +1604,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format) try { let externalIds = null; - if (type === 'movie') { + if (normalizedType === 'movie') { const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT); externalIds = movieDetails?.external_ids; - } else if (type === 'series') { + } else if (normalizedType === 'series') { externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT); } @@ -1829,7 +1836,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat return false; }); - const requestedEpisodeType = type; + const requestedEpisodeType = normalizedType; let streamAddons = pickStreamCapableAddons(requestedEpisodeType); if (streamAddons.length === 0) { @@ -2029,12 +2036,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Remove 'series:' prefix if present to be safe, though parsing logic above usually handles it stremioEpisodeId = episodeId.replace(/^series:/, ''); } else if (!seasonNum) { - // No season (e.g., mal:57658:1) - use id:episode format - stremioEpisodeId = `${id}:${episodeNum}`; + // No season (e.g., kitsu:12345:1, mal:57658:1) - use showIdStr:episode format. + // Use showIdStr (parsed from episodeId) rather than outer `id` so that when the + // show has multiple IDs (e.g. tvdb+kitsu), we preserve the namespace that the + // episode actually belongs to (e.g. kitsu:animeId:epNum, not tvdb:showId:epNum). + const baseId = showIdStr && showIdStr !== id ? showIdStr : id; + stremioEpisodeId = `${baseId}:${episodeNum}`; } else { - stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; + const baseId = showIdStr && showIdStr !== id ? showIdStr : id; + stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`; } - if (__DEV__) console.log('â„šī¸ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId); + if (__DEV__) console.log('â„šī¸ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId, '| stremioEpisodeId:', stremioEpisodeId); } // Extract episode info from the episodeId for logging @@ -2111,7 +2123,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (!metadata) return; if (inLibrary) { - catalogService.removeFromLibrary(type, id); + catalogService.removeFromLibrary(normalizedType, id); } else { catalogService.addToLibrary(metadata); } @@ -2190,12 +2202,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat try { const tmdbService = TMDBService.getInstance(); const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; - const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId), lang); + const results = await tmdbService.getRecommendations(normalizedType === 'movie' ? 'movie' : 'tv', String(tmdbId), lang); // Convert TMDB results to StreamingContent format (simplified) const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({ id: `tmdb:${item.id}`, - type: type === 'movie' ? 'movie' : 'series', + type: normalizedType === 'movie' ? 'movie' : 'series', name: item.title || item.name || 'Untitled', poster: tmdbService.getImageUrl(item.poster_path) || 'https://via.placeholder.com/300x450', // Provide fallback year: (item.release_date || item.first_air_date)?.substring(0, 4) || 'N/A', // Ensure string and provide fallback @@ -2226,7 +2238,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setTmdbId(fetchedTmdbId); // Fetch certification only if granular setting is enabled if (settings.tmdbEnrichCertification) { - const certification = await tmdbService.getCertification(type, fetchedTmdbId); + const certification = await tmdbService.getCertification(normalizedType, fetchedTmdbId); if (certification) { if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification }); setMetadata(prev => prev ? { @@ -2299,7 +2311,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat return; } const tmdbSvc = TMDBService.getInstance(); - const cert = await tmdbSvc.getCertification(type, tmdbId); + const cert = await tmdbSvc.getCertification(normalizedType, tmdbId); if (cert) { if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert }); setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev); @@ -2326,7 +2338,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat return; } - const contentKey = `${type}-${tmdbId}`; + const contentKey = `${normalizedType}-${tmdbId}`; if (productionInfoFetchedRef.current === contentKey) { return; } @@ -2334,7 +2346,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Only skip if networks are set AND collection is already set (for movies) const hasNetworks = !!(metadata as any).networks; const hasCollection = !!(metadata as any).collection; - if (hasNetworks && (type !== 'movie' || hasCollection)) { + if (hasNetworks && (normalizedType !== 'movie' || hasCollection)) { return; } @@ -2357,7 +2369,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat collectionsEnabled: settings.tmdbEnrichCollections }); - if (type === 'series') { + if (normalizedType === 'series') { // Fetch networks and additional details for TV shows const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang); @@ -2406,7 +2418,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat })); } } - } else if (type === 'movie') { + } else if (normalizedType === 'movie') { // Fetch production companies and additional details for movies const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang); diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index c9eb4c39..d730abb2 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -84,7 +84,7 @@ const SearchScreen = () => { // Discover section state const [discoverCatalogs, setDiscoverCatalogs] = useState([]); const [selectedCatalog, setSelectedCatalog] = useState(null); - const [selectedDiscoverType, setSelectedDiscoverType] = useState<'movie' | 'series'>('movie'); + const [selectedDiscoverType, setSelectedDiscoverType] = useState('movie'); const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState(null); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); @@ -127,7 +127,7 @@ const SearchScreen = () => { try { // Load saved type const savedType = await mmkvStorage.getItem(DISCOVER_TYPE_KEY); - if (savedType && (savedType === 'movie' || savedType === 'series')) { + if (savedType) { setSelectedDiscoverType(savedType); } @@ -141,7 +141,7 @@ const SearchScreen = () => { }, []); // Save discover settings when they change - const saveDiscoverSettings = useCallback(async (type: 'movie' | 'series', catalog: DiscoverCatalog | null, genre: string | null) => { + const saveDiscoverSettings = useCallback(async (type: string, catalog: DiscoverCatalog | null, genre: string | null) => { try { // Save type await mmkvStorage.setItem(DISCOVER_TYPE_KEY, type); @@ -267,10 +267,8 @@ const SearchScreen = () => { if (isMounted.current) { const allCatalogs: DiscoverCatalog[] = []; for (const [type, catalogs] of Object.entries(filters.catalogsByType)) { - if (type === 'movie' || type === 'series') { - for (const catalog of catalogs) { - allCatalogs.push({ ...catalog, type }); - } + for (const catalog of catalogs) { + allCatalogs.push({ ...catalog, type }); } } setDiscoverCatalogs(allCatalogs); @@ -636,9 +634,10 @@ const SearchScreen = () => { }; const availableGenres = useMemo(() => selectedCatalog?.genres || [], [selectedCatalog]); + const availableTypes = useMemo(() => [...new Set(discoverCatalogs.map(c => c.type))], [discoverCatalogs]); const filteredCatalogs = useMemo(() => discoverCatalogs.filter(c => c.type === selectedDiscoverType), [discoverCatalogs, selectedDiscoverType]); - const handleTypeSelect = (type: 'movie' | 'series') => { + const handleTypeSelect = (type: string) => { setSelectedDiscoverType(type); // Save type setting @@ -893,6 +892,7 @@ const SearchScreen = () => { selectedDiscoverGenre={selectedDiscoverGenre} filteredCatalogs={filteredCatalogs} availableGenres={availableGenres} + availableTypes={availableTypes} onTypeSelect={handleTypeSelect} onCatalogSelect={handleCatalogSelect} onGenreSelect={handleGenreSelect} diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index cce738c8..71eb680d 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -363,6 +363,8 @@ class CatalogService { } private canBrowseCatalog(catalog: StreamingCatalog): boolean { + // Exclude non-standard types like anime.series, anime.movie from discover browsing + if (catalog.type && catalog.type.includes('.')) return false; const requiredExtras = this.getRequiredCatalogExtras(catalog); return requiredExtras.every(extraName => extraName === 'genre'); } @@ -1534,9 +1536,24 @@ class CatalogService { return; } - // Dedupe within addon and against global + // Within this addon's results, if the same ID appears under both a generic + // type (e.g. "series") and a specific type (e.g. "anime.series"), keep only + // the specific one. This handles addons that expose both catalog types. + const bestByIdWithinAddon = new Map(); + for (const item of addonResults) { + const existing = bestByIdWithinAddon.get(item.id); + if (!existing) { + bestByIdWithinAddon.set(item.id, item); + } else if (!existing.type.includes('.') && item.type.includes('.')) { + // Prefer the more specific type + bestByIdWithinAddon.set(item.id, item); + } + } + const deduped = Array.from(bestByIdWithinAddon.values()); + + // Dedupe against global seen (keyed by type:id to avoid cross-addon ID collisions) const localSeen = new Set(); - const unique = addonResults.filter(item => { + const unique = deduped.filter(item => { const key = `${item.type}:${item.id}`; if (localSeen.has(key) || globalSeen.has(key)) return false; localSeen.add(key); @@ -1626,6 +1643,12 @@ class CatalogService { const items = metas.map(meta => { const content = this.convertMetaToStreamingContent(meta); content.addonId = manifest.id; + // The meta's own type field may be generic (e.g. "series") even when + // the catalog it came from is more specific (e.g. "anime.series"). + // Stamp the catalog type so grouping in the UI is correct. + if (type && content.type !== type) { + content.type = type; + } return content; }); logger.log(`Found ${items.length} results from ${manifest.name}`);