multiple addon seach func

This commit is contained in:
tapframe 2025-10-08 16:31:07 +05:30
parent f31942efdf
commit 581e912d4c
2 changed files with 476 additions and 120 deletions

View file

@ -21,7 +21,7 @@ import {
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { catalogService, StreamingContent } from '../services/catalogService'; import { catalogService, StreamingContent, GroupedSearchResults, AddonSearchResults } from '../services/catalogService';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { DropUpMenu } from '../components/home/DropUpMenu'; import { DropUpMenu } from '../components/home/DropUpMenu';
@ -201,7 +201,7 @@ const SearchScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = true; const isDarkMode = true;
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<StreamingContent[]>([]); const [results, setResults] = useState<GroupedSearchResults>({ byAddon: [], allResults: [] });
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>([]); const [recentSearches, setRecentSearches] = useState<string[]>([]);
@ -209,6 +209,10 @@ const SearchScreen = () => {
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Live search handle
const liveSearchHandle = useRef<{ cancel: () => void; done: Promise<void> } | null>(null);
// Addon installation order map for stable section ordering
const addonOrderRankRef = useRef<Record<string, number>>({});
// DropUpMenu state // DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false); const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null); const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
@ -306,7 +310,7 @@ const SearchScreen = () => {
Keyboard.dismiss(); Keyboard.dismiss();
if (query) { if (query) {
setQuery(''); setQuery('');
setResults([]); setResults({ byAddon: [], allResults: [] });
setSearched(false); setSearched(false);
setShowRecent(true); setShowRecent(true);
loadRecentSearches(); loadRecentSearches();
@ -354,25 +358,59 @@ const SearchScreen = () => {
const debouncedSearch = useCallback( const debouncedSearch = useCallback(
debounce(async (searchQuery: string) => { debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
setResults([]); // Cancel any in-flight live search
liveSearchHandle.current?.cancel();
liveSearchHandle.current = null;
setResults({ byAddon: [], allResults: [] });
setSearching(false); setSearching(false);
return; return;
} }
// Cancel prior live search
liveSearchHandle.current?.cancel();
setResults({ byAddon: [], allResults: [] });
setSearching(true);
logger.info('Starting live search for:', searchQuery);
// Preload addon order to keep sections sorted by installation order
try { try {
logger.info('Performing search for:', searchQuery); const addons = await catalogService.getAllAddons();
const searchResults = await catalogService.searchContentCinemeta(searchQuery); const rank: Record<string, number> = {};
setResults(searchResults); addons.forEach((a, idx) => { rank[a.id] = idx; });
if (searchResults.length > 0) { addonOrderRankRef.current = rank;
} catch {}
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
// Append/update this addon section immediately
setResults(prev => {
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
let nextByAddon: AddonSearchResults[];
if (existingIndex >= 0) {
nextByAddon = prev.byAddon.slice();
nextByAddon[existingIndex] = section;
} else {
nextByAddon = [...prev.byAddon, section];
}
// Sort by installation order
const rank = addonOrderRankRef.current;
nextByAddon = nextByAddon.slice().sort((a, b) => {
const ra = rank[a.addonId] ?? Number.MAX_SAFE_INTEGER;
const rb = rank[b.addonId] ?? Number.MAX_SAFE_INTEGER;
return ra - rb;
});
// Hide loading overlay once first section arrives
if (nextByAddon.length === 1) {
setSearching(false);
}
return { byAddon: nextByAddon, allResults: prev.allResults };
});
// Save to recents after first result batch
try {
await saveRecentSearch(searchQuery); await saveRecentSearch(searchQuery);
} } catch {}
logger.info('Search completed, found', searchResults.length, 'results'); });
} catch (error) { liveSearchHandle.current = handle;
logger.error('Search failed:', error);
setResults([]);
} finally {
setSearching(false);
}
}, 800), }, 800),
[] []
); );
@ -388,11 +426,13 @@ const SearchScreen = () => {
setSearching(false); setSearching(false);
setSearched(false); setSearched(false);
setShowRecent(false); setShowRecent(false);
setResults([]); setResults({ byAddon: [], allResults: [] });
} else { } else {
// Cancel any pending search when query is cleared // Cancel any pending search when query is cleared
debouncedSearch.cancel(); debouncedSearch.cancel();
setResults([]); liveSearchHandle.current?.cancel();
liveSearchHandle.current = null;
setResults({ byAddon: [], allResults: [] });
setSearched(false); setSearched(false);
setSearching(false); setSearching(false);
setShowRecent(true); setShowRecent(true);
@ -407,7 +447,9 @@ const SearchScreen = () => {
const handleClearSearch = () => { const handleClearSearch = () => {
setQuery(''); setQuery('');
setResults([]); liveSearchHandle.current?.cancel();
liveSearchHandle.current = null;
setResults({ byAddon: [], allResults: [] });
setSearched(false); setSearched(false);
setShowRecent(true); setShowRecent(true);
loadRecentSearches(); loadRecentSearches();
@ -555,17 +597,9 @@ const SearchScreen = () => {
); );
}; };
const movieResults = useMemo(() => {
return results.filter(item => item.type === 'movie');
}, [results]);
const seriesResults = useMemo(() => {
return results.filter(item => item.type === 'series');
}, [results]);
const hasResultsToShow = useMemo(() => { const hasResultsToShow = useMemo(() => {
return movieResults.length > 0 || seriesResults.length > 0; return results.byAddon.length > 0;
}, [movieResults, seriesResults]); }, [results]);
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
@ -707,64 +741,131 @@ const SearchScreen = () => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{!query.trim() && renderRecentSearches()} {!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && ( {/* Render results grouped by addon */}
<Animated.View {results.byAddon.map((addonGroup, addonIndex) => {
style={styles.carouselContainer} // Group by type within each addon
entering={FadeIn.duration(300)} const movieResults = addonGroup.results.filter(item => item.type === 'movie');
> const seriesResults = addonGroup.results.filter(item => item.type === 'series');
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}> const otherResults = addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series');
Movies ({movieResults.length})
</Text> return (
<FlatList <Animated.View
data={movieResults} key={addonGroup.addonId}
renderItem={({ item, index }) => ( entering={FadeIn.duration(300).delay(addonIndex * 100)}
<SearchResultItem >
item={item} {/* Addon Header */}
index={index} <View style={styles.addonHeaderContainer}>
navigation={navigation} <MaterialIcons
setSelectedItem={setSelectedItem} name="extension"
setMenuVisible={setMenuVisible} size={20}
currentTheme={currentTheme} color={currentTheme.colors.primary}
refreshFlag={refreshFlag} style={styles.addonHeaderIcon}
/> />
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
{addonGroup.addonName}
</Text>
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
{addonGroup.results.length}
</Text>
</View>
</View>
{/* Movies from this addon */}
{movieResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300)}
>
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
Movies ({movieResults.length})
</Text>
<FlatList
data={movieResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
navigation={navigation}
setSelectedItem={setSelectedItem}
setMenuVisible={setMenuVisible}
currentTheme={currentTheme}
refreshFlag={refreshFlag}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
extraData={refreshFlag}
/>
</Animated.View>
)} )}
keyExtractor={item => `movie-${item.id}`}
horizontal {/* TV Shows from this addon */}
showsHorizontalScrollIndicator={false} {seriesResults.length > 0 && (
contentContainerStyle={styles.horizontalListContent} <Animated.View
extraData={refreshFlag} style={styles.carouselContainer}
/> entering={FadeIn.duration(300)}
</Animated.View> >
)} <Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
{seriesResults.length > 0 && ( TV Shows ({seriesResults.length})
<Animated.View </Text>
style={styles.carouselContainer} <FlatList
entering={FadeIn.duration(300).delay(50)} data={seriesResults}
> renderItem={({ item, index }) => (
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}> <SearchResultItem
TV Shows ({seriesResults.length}) item={item}
</Text> index={index}
<FlatList navigation={navigation}
data={seriesResults} setSelectedItem={setSelectedItem}
renderItem={({ item, index }) => ( setMenuVisible={setMenuVisible}
<SearchResultItem currentTheme={currentTheme}
item={item} refreshFlag={refreshFlag}
index={index} />
navigation={navigation} )}
setSelectedItem={setSelectedItem} keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
setMenuVisible={setMenuVisible} horizontal
currentTheme={currentTheme} showsHorizontalScrollIndicator={false}
refreshFlag={refreshFlag} contentContainerStyle={styles.horizontalListContent}
/> extraData={refreshFlag}
/>
</Animated.View>
)} )}
keyExtractor={item => `series-${item.id}`}
horizontal {/* Other content types (anime, etc.) */}
showsHorizontalScrollIndicator={false} {otherResults.length > 0 && (
contentContainerStyle={styles.horizontalListContent} <Animated.View
extraData={refreshFlag} style={styles.carouselContainer}
/> entering={FadeIn.duration(300)}
</Animated.View> >
)} <Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
</Text>
<FlatList
data={otherResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
navigation={navigation}
setSelectedItem={setSelectedItem}
setMenuVisible={setMenuVisible}
currentTheme={currentTheme}
refreshFlag={refreshFlag}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
extraData={refreshFlag}
/>
</Animated.View>
)}
</Animated.View>
);
})}
</Animated.ScrollView> </Animated.ScrollView>
)} )}
</View> </View>
@ -897,6 +998,39 @@ const styles = StyleSheet.create({
marginBottom: isTablet ? 16 : 12, marginBottom: isTablet ? 16 : 12,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
carouselSubtitle: {
fontSize: isTablet ? 16 : 14,
fontWeight: '600',
marginBottom: isTablet ? 12 : 8,
paddingHorizontal: 16,
},
addonHeaderContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: isTablet ? 16 : 12,
marginTop: isTablet ? 24 : 16,
marginBottom: isTablet ? 8 : 4,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
addonHeaderIcon: {
marginRight: 8,
},
addonHeaderText: {
fontSize: isTablet ? 18 : 16,
fontWeight: '700',
flex: 1,
},
addonHeaderBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
addonHeaderBadgeText: {
fontSize: isTablet ? 12 : 11,
fontWeight: '600',
},
horizontalListContent: { horizontalListContent: {
paddingHorizontal: isTablet ? 16 : 12, paddingHorizontal: isTablet ? 16 : 12,
paddingRight: isTablet ? 12 : 8, paddingRight: isTablet ? 12 : 8,

View file

@ -25,16 +25,31 @@ export interface StreamingAddon {
type: string; type: string;
id: string; id: string;
name: string; name: string;
extraSupported?: string[];
extra?: Array<{ name: string; options?: string[] }>;
}[]; }[];
resources: { resources: {
name: string; name: string;
types: string[]; types: string[];
idPrefixes?: string[]; idPrefixes?: string[];
}[]; }[];
url?: string; // preferred base URL (manifest or original)
originalUrl?: string; // original addon URL if provided
transportUrl?: string; transportUrl?: string;
transportName?: string; transportName?: string;
} }
export interface AddonSearchResults {
addonId: string;
addonName: string;
results: StreamingContent[];
}
export interface GroupedSearchResults {
byAddon: AddonSearchResults[];
allResults: StreamingContent[]; // Deduplicated flat list for backwards compatibility
}
export interface StreamingContent { export interface StreamingContent {
id: string; id: string;
type: string; type: string;
@ -172,6 +187,8 @@ class CatalogService {
types: manifest.types || [], types: manifest.types || [],
catalogs: manifest.catalogs || [], catalogs: manifest.catalogs || [],
resources: manifest.resources || [], resources: manifest.resources || [],
url: (manifest.url || manifest.originalUrl) as any,
originalUrl: (manifest.originalUrl || manifest.url) as any,
transportUrl: manifest.url, transportUrl: manifest.url,
transportName: manifest.name transportName: manifest.name
}; };
@ -812,54 +829,259 @@ class CatalogService {
return uniqueResults; return uniqueResults;
} }
async searchContentCinemeta(query: string): Promise<StreamingContent[]> { /**
* Search across all installed addons that support search functionality.
* This dynamically queries any addon with catalogs that have 'search' in their extraSupported or extra fields.
* Results are grouped by addon source with headers.
*
* @param query - The search query string
* @returns Promise<GroupedSearchResults> - Search results grouped by addon with headers
*/
async searchContentCinemeta(query: string): Promise<GroupedSearchResults> {
if (!query) { if (!query) {
return []; return { byAddon: [], allResults: [] };
} }
const trimmedQuery = query.trim().toLowerCase(); const trimmedQuery = query.trim().toLowerCase();
logger.log('Searching Cinemeta for:', trimmedQuery); logger.log('Searching across all addons for:', trimmedQuery);
const addons = await this.getAllAddons(); const addons = await this.getAllAddons();
const results: StreamingContent[] = []; const byAddon: AddonSearchResults[] = [];
// Find Cinemeta addon by its ID // Find all addons that support search
const cinemeta = addons.find(addon => addon.id === 'com.linvo.cinemeta'); const searchableAddons = addons.filter(addon => {
if (!addon.catalogs) return false;
if (!cinemeta || !cinemeta.catalogs) {
logger.error('Cinemeta addon not found'); // Check if any catalog supports search
return []; return addon.catalogs.some(catalog => {
const extraSupported = catalog.extraSupported || [];
const extra = catalog.extra || [];
// Check if 'search' is in extraSupported or extra
return extraSupported.includes('search') ||
extra.some((e: any) => e.name === 'search');
});
});
logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', '));
// Search each addon and keep results grouped
for (const addon of searchableAddons) {
const searchableCatalogs = (addon.catalogs || []).filter(catalog => {
const extraSupported = catalog.extraSupported || [];
const extra = catalog.extra || [];
return extraSupported.includes('search') ||
extra.some((e: any) => e.name === 'search');
});
// Search all catalogs for this addon in parallel
const catalogPromises = searchableCatalogs.map(catalog =>
this.searchAddonCatalog(addon, catalog.type, catalog.id, trimmedQuery)
);
const catalogResults = await Promise.allSettled(catalogPromises);
// Collect all results for this addon
const addonResults: StreamingContent[] = [];
catalogResults.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
addonResults.push(...result.value);
} else if (result.status === 'rejected') {
logger.error(`Search failed for ${addon.name}:`, result.reason);
}
});
// Only add addon section if it has results
if (addonResults.length > 0) {
// Deduplicate within this addon's results
const seen = new Set<string>();
const uniqueAddonResults = addonResults.filter(item => {
const key = `${item.type}:${item.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
byAddon.push({
addonId: addon.id,
addonName: addon.name,
results: uniqueAddonResults,
});
}
} }
// Search in both movie and series catalogs simultaneously // Create deduplicated flat list for backwards compatibility
const searchPromises = ['movie', 'series'].map(async (type) => { const allResults: StreamingContent[] = [];
try { const globalSeen = new Set<string>();
// Direct API call to Cinemeta
const url = `https://v3-cinemeta.strem.io/catalog/${type}/top/search=${encodeURIComponent(trimmedQuery)}.json`; byAddon.forEach(addonGroup => {
logger.log('Request URL:', url); addonGroup.results.forEach(item => {
const key = `${item.type}:${item.id}`;
const response = await axios.get<{ metas: any[] }>(url); if (!globalSeen.has(key)) {
const metas = response.data.metas || []; globalSeen.add(key);
allResults.push(item);
if (metas && metas.length > 0) { }
const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); });
results.push(...items); });
logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`);
return { byAddon, allResults };
}
/**
* Live search that emits results per-addon as they arrive.
* Returns a handle with cancel() and a done promise.
*/
startLiveSearch(
query: string,
onAddonResults: (section: AddonSearchResults) => void
): { cancel: () => void; done: Promise<void> } {
const controller = { cancelled: false } as { cancelled: boolean };
const done = (async () => {
if (!query || !query.trim()) return;
const trimmedQuery = query.trim().toLowerCase();
logger.log('Live search across addons for:', trimmedQuery);
const addons = await this.getAllAddons();
// Determine searchable addons
const searchableAddons = addons.filter(addon =>
(addon.catalogs || []).some(c =>
(c.extraSupported && c.extraSupported.includes('search')) ||
(c.extra && c.extra.some(e => e.name === 'search'))
)
);
// Global dedupe across emitted results
const globalSeen = new Set<string>();
await Promise.all(
searchableAddons.map(async (addon) => {
if (controller.cancelled) return;
try {
const searchableCatalogs = (addon.catalogs || []).filter(c =>
(c.extraSupported && c.extraSupported.includes('search')) ||
(c.extra && c.extra.some(e => e.name === 'search'))
);
// Fetch all catalogs for this addon in parallel
const settled = await Promise.allSettled(
searchableCatalogs.map(c => this.searchAddonCatalog(addon, c.type, c.id, trimmedQuery))
);
if (controller.cancelled) return;
const addonResults: StreamingContent[] = [];
for (const s of settled) {
if (s.status === 'fulfilled' && Array.isArray(s.value)) {
addonResults.push(...s.value);
}
}
if (addonResults.length === 0) return;
// Dedupe within addon and against global
const localSeen = new Set<string>();
const unique = addonResults.filter(item => {
const key = `${item.type}:${item.id}`;
if (localSeen.has(key) || globalSeen.has(key)) return false;
localSeen.add(key);
globalSeen.add(key);
return true;
});
if (unique.length > 0 && !controller.cancelled) {
onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique });
}
} catch (e) {
// ignore individual addon errors
}
})
);
})();
return {
cancel: () => { controller.cancelled = true; },
done,
};
}
/**
* Search a specific catalog from a specific addon.
* Handles URL construction for both Cinemeta (hardcoded) and other addons (dynamic).
*
* @param addon - The addon manifest containing id, name, and url
* @param type - Content type (movie, series, anime, etc.)
* @param catalogId - The catalog ID to search within
* @param query - The search query string
* @returns Promise<StreamingContent[]> - Search results from this specific addon catalog
*/
private async searchAddonCatalog(
addon: any,
type: string,
catalogId: string,
query: string
): Promise<StreamingContent[]> {
try {
let url: string;
// Special handling for Cinemeta (hardcoded URL)
if (addon.id === 'com.linvo.cinemeta') {
const encodedCatalogId = encodeURIComponent(catalogId);
const encodedQuery = encodeURIComponent(query);
url = `https://v3-cinemeta.strem.io/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
}
// Handle other addons
else {
// Choose best available URL
const chosenUrl: string | undefined = addon.url || addon.originalUrl || addon.transportUrl;
if (!chosenUrl) {
logger.warn(`Addon ${addon.name} has no URL, skipping search`);
return [];
}
// Extract base URL and preserve query params
const [baseUrlPart, queryParams] = chosenUrl.split('?');
let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, '');
// Ensure URL has protocol
if (!cleanBaseUrl.startsWith('http')) {
cleanBaseUrl = `https://${cleanBaseUrl}`;
}
const encodedCatalogId = encodeURIComponent(catalogId);
const encodedQuery = encodeURIComponent(query);
url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
// Append original query params if they existed
if (queryParams) {
url += `?${queryParams}`;
} }
} catch (error) {
logger.error(`Cinemeta search failed for ${type}:`, error);
} }
});
await Promise.all(searchPromises); logger.log(`Searching ${addon.name} (${type}/${catalogId}):`, url);
// Remove duplicates while preserving order const response = await axios.get<{ metas: any[] }>(url, {
const seen = new Set(); timeout: 10000, // 10 second timeout per addon
return results.filter(item => { });
const key = `${item.type}:${item.id}`;
if (seen.has(key)) return false; const metas = response.data?.metas || [];
seen.add(key);
return true; if (metas.length > 0) {
}); const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
logger.log(`Found ${items.length} results from ${addon.name}`);
return items;
}
return [];
} catch (error: any) {
// Don't throw, just log and return empty
const errorMsg = error?.response?.status
? `HTTP ${error.response.status}`
: error?.message || 'Unknown error';
logger.error(`Search failed for ${addon.name} (${type}/${catalogId}): ${errorMsg}`);
return [];
}
} }
async getStremioId(type: string, tmdbId: string): Promise<string | null> { async getStremioId(type: string, tmdbId: string): Promise<string | null> {