Merge pull request #639 from chrisk325/catalogs

remove hardcoded catalog type + seperate search result for anime + fix for tvdb+kitsu
This commit is contained in:
Nayif 2026-03-14 01:32:46 +05:30 committed by GitHub
commit d0bfd3550a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 174 additions and 184 deletions

View file

@ -14,6 +14,24 @@ interface AddonSectionProps {
currentTheme: any;
}
const TYPE_LABELS: Record<string, string> = {
'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<string, StreamingContent[]> = {};
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 (
<View>
@ -50,22 +77,13 @@ export const AddonSection = React.memo(({
</View>
</View>
{/* Movies */}
{movieResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
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
}
]}>
{t('search.movies')} ({movieResults.length})
{groupedByType.map(({ type, items }) => (
<View key={type} style={[styles.carouselContainer, containerStyle]}>
<Text style={[styles.carouselSubtitle, subtitleStyle(currentTheme)]}>
{getLabelForType(type)} ({items.length})
</Text>
<FlatList
data={movieResults}
data={items}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
@ -74,81 +92,16 @@ export const AddonSection = React.memo(({
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
keyExtractor={item => `${addonGroup.addonId}-${type}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{/* TV Shows */}
{seriesResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
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
}
]}>
{t('search.tv_shows')} ({seriesResults.length})
</Text>
<FlatList
data={seriesResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{/* Other types */}
{otherResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
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
}
]}>
{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}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
))}
</View>
);
}, (prev, next) => {
// Only re-render if this section's reference changed
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
});

View file

@ -11,12 +11,13 @@ interface DiscoverBottomSheetsProps {
typeSheetRef: RefObject<BottomSheetModal>;
catalogSheetRef: RefObject<BottomSheetModal>;
genreSheetRef: RefObject<BottomSheetModal>;
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<string, string> = {
'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 */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('movie')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.movies')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_movies')}
</Text>
</View>
{selectedDiscoverType === 'movie' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{/* TV Shows option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'series' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('series')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.tv_shows')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_tv')}
</Text>
</View>
{selectedDiscoverType === 'series' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{availableTypes.map((type) => (
<TouchableOpacity
key={type}
style={[
styles.bottomSheetItem,
selectedDiscoverType === type && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect(type)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{getLabelForType(type)}
</Text>
</View>
{selectedDiscoverType === type && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
</>

View file

@ -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<BottomSheetModal>;
@ -78,7 +78,11 @@ export const DiscoverSection = ({
onPress={() => typeSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{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())}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
@ -112,8 +116,13 @@ export const DiscoverSection = ({
{selectedCatalog && (
<View style={styles.discoverFilterSummary}>
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
{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}` : ''}
</Text>
</View>
)}

View file

@ -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<StreamingContent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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);

View file

@ -84,7 +84,7 @@ const SearchScreen = () => {
// Discover section state
const [discoverCatalogs, setDiscoverCatalogs] = useState<DiscoverCatalog[]>([]);
const [selectedCatalog, setSelectedCatalog] = useState<DiscoverCatalog | null>(null);
const [selectedDiscoverType, setSelectedDiscoverType] = useState<'movie' | 'series'>('movie');
const [selectedDiscoverType, setSelectedDiscoverType] = useState<string>('movie');
const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState<string | null>(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}

View file

@ -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<string, StreamingContent>();
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<string>();
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}`);