import React, { useEffect, useState } from 'react'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { useConfig } from '@/contexts/ConfigContext'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Edit2, GripVertical, Star, Sparkles, AlertTriangle } from 'lucide-react'; import { allSearchProviders } from '@/data/catalogs'; import { GEMINI_MODELS, DEFAULT_GEMINI_MODEL, DEFAULT_OPENROUTER_MODEL } from '@/data/ai-models'; import type { AIModel } from '@/data/ai-models'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; const DEFAULT_SEARCH_ORDER = [ 'movie', 'series', 'tvdb.collections.search', 'gemini.search', 'anime_series', 'anime_movie', 'people_search_movie', 'people_search_series', ]; // Sortable Search Provider Item Component function SortableSearchProviderItem({ provider, onEditSearchName, onEngineEnabledChange, onengineRatingPostersChange, getSearchDisplayName, getProviderBaseLabel, getSearchCustomName, getSearchDisplayType, hasRPDBKey, engineRatingPostersEnabled }: { provider: { id: string; type: string; provider: string }; onEditSearchName: (searchId: string) => void; onEngineEnabledChange: (engine: string, checked: boolean) => void; onengineRatingPostersChange: (engine: string, checked: boolean) => void; getSearchDisplayName: (searchId: string, providerId: string) => string; getProviderBaseLabel: (providerId: string) => string; getSearchCustomName: (searchId: string) => string; getSearchDisplayType: (searchId: string) => string; hasRPDBKey: boolean; engineRatingPostersEnabled: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: provider.id }); const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 10 : 'auto', }; const searchName = getSearchDisplayName(provider.id, provider.provider); const providerLabel = getProviderBaseLabel(provider.provider); const displayType = getSearchDisplayType(provider.id); return (
{searchName}
{providerLabel} {displayType}
onEngineEnabledChange(provider.provider, checked)} aria-label="Enable this engine" />
{hasRPDBKey && provider.provider !== 'tvdb.collections.search' && ( )} onEngineEnabledChange(provider.provider, checked)} aria-label="Enable this engine" />
); } export function SearchSettings() { const { config, setConfig, hasBuiltInTvdb, traktSearchEnabled } = useConfig(); const [editingProvider, setEditingProvider] = useState(null); const [editName, setEditName] = useState(''); const [editType, setEditType] = useState(''); const hasGeminiKey = !!config.apiKeys?.gemini; const hasOpenRouterKey = !!config.apiKeys?.openrouter; const hasAnyAiKey = hasGeminiKey || hasOpenRouterKey; const [openRouterModels, setOpenRouterModels] = useState([]); const [openRouterModelsLoading, setOpenRouterModelsLoading] = useState(false); const openRouterKeyRef = React.useRef(config.apiKeys?.openrouter); // Fetch OpenRouter model list when key is available useEffect(() => { const key = config.apiKeys?.openrouter; if (!key || key === openRouterKeyRef.current && openRouterModels.length > 0) { if (!key) setOpenRouterModels([]); openRouterKeyRef.current = key; return; } openRouterKeyRef.current = key; let cancelled = false; setOpenRouterModelsLoading(true); fetch('https://openrouter.ai/api/v1/models', { headers: { 'Authorization': `Bearer ${key}` }, }) .then(res => res.json()) .then(data => { if (cancelled) return; const models: AIModel[] = (data?.data || []) .filter((m: any) => m.id && m.name) .map((m: any) => ({ id: m.id, name: m.name, grounding: false })) .sort((a: AIModel, b: AIModel) => a.name.localeCompare(b.name)); setOpenRouterModels(models); }) .catch(() => { if (!cancelled) setOpenRouterModels([]); }) .finally(() => { if (!cancelled) setOpenRouterModelsLoading(false); }); return () => { cancelled = true; }; }, [config.apiKeys?.openrouter]); // eslint-disable-line react-hooks/exhaustive-deps const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); // Get enabled search providers in order const getEnabledSearchProviders = () => { const rawSearchOrder = Array.isArray(config.search.searchOrder) ? config.search.searchOrder : []; const searchOrder = Array.from(new Set([...rawSearchOrder, ...DEFAULT_SEARCH_ORDER])); const enabledProviders = []; // Add movie search if enabled if (config.search.engineEnabled?.[config.search.providers.movie] !== false) { enabledProviders.push({ id: 'movie', type: 'movie', provider: config.search.providers.movie }); } // Add series search if enabled if (config.search.engineEnabled?.[config.search.providers.series] !== false) { enabledProviders.push({ id: 'series', type: 'series', provider: config.search.providers.series }); } // Add TVDB collections if enabled if (config.search.engineEnabled?.['tvdb.collections.search'] !== false && hasTvdbKey) { enabledProviders.push({ id: 'tvdb.collections.search', type: 'collection', provider: 'tvdb.collections.search' }); } // Add Gemini AI search if enabled (AI or explicit engine enable and key present) if (config.search.engineEnabled?.['gemini.search'] !== false && config.search.ai_enabled && hasAnyAiKey) { enabledProviders.push({ id: 'gemini.search', type: 'ai', provider: 'gemini.search' }); } // Add anime series search if enabled if (config.search.engineEnabled?.[config.search.providers.anime_series] !== false) { enabledProviders.push({ id: 'anime_series', type: 'anime.series', provider: config.search.providers.anime_series }); } // Add anime movie search if enabled if (config.search.engineEnabled?.[config.search.providers.anime_movie] !== false) { enabledProviders.push({ id: 'anime_movie', type: 'anime.movie', provider: config.search.providers.anime_movie }); } const peopleSearchMovieProvider = config.search.providers.people_search_movie || 'tmdb.people.search'; if (config.search.engineEnabled?.['people_search_movie'] !== false) { enabledProviders.push({ id: 'people_search_movie', type: 'movie', provider: peopleSearchMovieProvider }); } const peopleSearchSeriesProvider = config.search.providers.people_search_series || 'tmdb.people.search'; if (config.search.engineEnabled?.['people_search_series'] !== false) { enabledProviders.push({ id: 'people_search_series', type: 'series', provider: peopleSearchSeriesProvider }); } // Sort by the searchOrder array return enabledProviders.sort((a, b) => { const aIndex = searchOrder.indexOf(a.id); const bIndex = searchOrder.indexOf(b.id); const aPos = aIndex === -1 ? Number.POSITIVE_INFINITY : aIndex; const bPos = bIndex === -1 ? Number.POSITIVE_INFINITY : bIndex; return aPos - bPos; }); }; const handleDragEnd = (event: any) => { const { active, over } = event; if (!over || active.id === over.id) return; const enabledProviders = getEnabledSearchProviders(); const oldIndex = enabledProviders.findIndex(item => item.id === active.id); const newIndex = enabledProviders.findIndex(item => item.id === over.id); if (oldIndex !== -1 && newIndex !== -1) { const reorderedProviders = arrayMove(enabledProviders, oldIndex, newIndex); const reorderedEnabledIds = reorderedProviders.map(item => item.id); const currentOrder = Array.isArray(config.search.searchOrder) ? config.search.searchOrder : []; const normalizedCurrentOrder = Array.from(new Set([...currentOrder, ...DEFAULT_SEARCH_ORDER])); const remainingIds = normalizedCurrentOrder.filter(id => !reorderedEnabledIds.includes(id)); const newSearchOrder = [...reorderedEnabledIds, ...remainingIds]; setConfig(prev => ({ ...prev, search: { ...prev.search, searchOrder: newSearchOrder } })); } }; // Helper function to get display name for a provider const getProviderBaseLabel = (providerId: string) => { if (providerId === 'tvdb.collections.search') { return 'TVDB Collections'; } if (providerId === 'gemini.search') { return 'AI Search'; } const provider = allSearchProviders.find(p => p.value === providerId); return provider?.label || providerId; }; // Helper function to get default search name const getDefaultSearchName = (searchId: string) => { const searchNameMap: { [key: string]: string } = { 'movie': 'Movies Search', 'series': 'Series Search', 'anime_series': 'Anime Series Search', 'anime_movie': 'Anime Movies Search', 'tvdb.collections.search': 'TVDB Collections', 'gemini.search': 'AI Search', 'people_search_movie': 'People Search (Movies)', 'people_search_series': 'People Search (Series)', }; return searchNameMap[searchId] || searchId; }; const getSearchCustomName = (searchId: string) => config.search.searchNames?.[searchId]?.trim() || ''; const getSearchDisplayName = (searchId: string, providerId: string) => { const customName = getSearchCustomName(searchId); if (customName) { return customName; } return getDefaultSearchName(searchId); }; // Helper function to get default type for a search catalog const getDefaultSearchType = (searchId: string) => { const searchTypeMap: { [key: string]: string } = { 'movie': 'movie', 'series': 'series', 'anime_series': 'anime.series', 'anime_movie': 'anime.movie', 'tvdb.collections.search': 'collection', 'gemini.search': 'other', 'people_search_movie': 'movie', 'people_search_series': 'series', }; return searchTypeMap[searchId] || 'movie'; }; const getSearchCustomType = (searchId: string) => config.search.searchDisplayTypes?.[searchId]?.trim() || ''; const getSearchDisplayType = (searchId: string) => { const customType = getSearchCustomType(searchId); if (customType) { return customType; } return getDefaultSearchType(searchId); }; const handleEditSearchName = (searchId: string) => { setEditingProvider(searchId); setEditName(getSearchCustomName(searchId) || getDefaultSearchName(searchId)); setEditType(getSearchCustomType(searchId) || getDefaultSearchType(searchId)); }; const handleSaveSearchName = () => { if (editingProvider && editName.trim() && editType.trim()) { setConfig(prev => ({ ...prev, search: { ...prev.search, searchNames: { ...prev.search.searchNames, [editingProvider]: editName.trim() }, searchDisplayTypes: { ...prev.search.searchDisplayTypes, [editingProvider]: editType.trim() } } })); } setEditingProvider(null); setEditName(''); setEditType(''); }; const handleCancelEdit = () => { setEditingProvider(null); setEditName(''); setEditType(''); }; // Legacy function for backward compatibility with Primary Keyword Engines section const getProviderDisplayName = (providerId: string) => { const baseLabel = getProviderBaseLabel(providerId); return baseLabel; }; const handleSearchEnabledChange = (checked: boolean) => { setConfig(prev => ({ ...prev, search: { ...prev.search, enabled: checked } })); }; const handleAiToggle = (checked: boolean) => { setConfig(prev => ({ ...prev, search: { ...prev.search, ai_enabled: checked, // Set default provider/model if not already set ai_provider: prev.search.ai_provider || 'gemini', ai_model: prev.search.ai_model || DEFAULT_GEMINI_MODEL, engineEnabled: { ...prev.search.engineEnabled, 'gemini.search': checked, }, searchOrder: (() => { const currentOrder = Array.isArray(prev.search.searchOrder) ? prev.search.searchOrder : []; return Array.from(new Set([...currentOrder, ...DEFAULT_SEARCH_ORDER])); })(), }, })); }; const handleAiProviderChange = (provider: 'gemini' | 'openrouter') => { const defaultModel = provider === 'openrouter' ? DEFAULT_OPENROUTER_MODEL : DEFAULT_GEMINI_MODEL; setConfig(prev => ({ ...prev, search: { ...prev.search, ai_provider: provider, ai_model: defaultModel, }, })); }; const handleAiModelChange = (model: string) => { setConfig(prev => ({ ...prev, search: { ...prev.search, ai_model: model, }, })); }; const handleProviderChange = ( type: 'movie' | 'series' | 'anime_movie' | 'anime_series' | 'people_search_movie' | 'people_search_series', value: string ) => { setConfig(prev => ({ ...prev, search: { ...prev.search, providers: { ...prev.search.providers, [type]: value } } })); }; const handleEngineEnabledChange = (engine: string, checked: boolean) => { setConfig(prev => ({ ...prev, search: { ...prev.search, engineEnabled: { ...prev.search.engineEnabled, [engine]: checked, }, ...(engine === 'gemini.search' && { ai_enabled: checked }), }, })); }; const handleengineRatingPostersChange = (engine: string, checked: boolean) => { setConfig(prev => ({ ...prev, search: { ...prev.search, engineRatingPosters: { ...prev.search.engineRatingPosters, [engine]: checked, }, }, })); }; // Check if TVDB key and rating poster keys are available const hasTvdbKey = !!config.apiKeys?.tvdb?.trim() || hasBuiltInTvdb; const hasRPDBKey = !!config.apiKeys?.rpdb || !!config.apiKeys?.topPoster || !!config.customPosterUrlPattern; const isTraktSearchEnabled = traktSearchEnabled; const movieSearchProviders = allSearchProviders.filter(p => { if ((p.value === 'trakt.search' || p.value === 'trakt.people.search') && !isTraktSearchEnabled) { return false; } return p.mediaType.includes('movie') && !p.value.includes('people.search') && p.value !== 'mal.search.movie' && p.value !== 'kitsu.search.movie'; }); const seriesSearchProviders = allSearchProviders.filter(p => { if ((p.value === 'trakt.search' || p.value === 'trakt.people.search') && !isTraktSearchEnabled) { return false; } return p.mediaType.includes('series') && !p.value.includes('people.search') && p.value !== 'mal.search.series' && p.value !== 'kitsu.search.series'; }); const animeSearchProviders = allSearchProviders.filter( p => p.mediaType.includes('anime_movie') || p.mediaType.includes('anime_series') ); const peopleSearchProviders = allSearchProviders.filter( p => { if (p.value === 'trakt.people.search' && !isTraktSearchEnabled) { return false; } return p.value.includes('people.search') } ); useEffect(() => { if (!traktSearchEnabled) { const updates: Partial> = {}; if (config.search.providers.movie === 'trakt.search') updates.movie = 'tmdb.search'; if (config.search.providers.series === 'trakt.search') updates.series = 'tvdb.search'; if (config.search.providers.people_search_movie === 'trakt.people.search') updates.people_search_movie = 'tmdb.people.search'; if (config.search.providers.people_search_series === 'trakt.people.search') updates.people_search_series = 'tmdb.people.search'; if (Object.keys(updates).length > 0) { setConfig(prev => ({ ...prev, search: { ...prev.search, providers: { ...prev.search.providers, ...updates, }, }, })); } } }, [traktSearchEnabled]); return (

Search Settings

Configure your addon's search functionality.

{config.search.enabled && (
Primary Keyword Engines Choose the default engine for basic keyword searches. The AI search uses this engine to find items based on its suggestions.
handleEngineEnabledChange(config.search.providers.movie, checked)} aria-label="Enable this engine" />
handleEngineEnabledChange(config.search.providers.series, checked)} aria-label="Enable this engine" />
handleEngineEnabledChange(config.search.providers.anime_series, checked)} aria-label="Enable this engine" />
handleEngineEnabledChange(config.search.providers.anime_movie, checked)} aria-label="Enable this engine" />
{/* People Search */} People Search Search for movies and series by person names (actors, directors, writers). Only searches people's credits, not titles.
handleEngineEnabledChange('people_search_movie', checked)} aria-label="Enable people search for movies" />
handleEngineEnabledChange('people_search_series', checked)} aria-label="Enable people search for series" />
{/* TVDB Collections Search - only show if TVDB key is available */} {hasTvdbKey && ( TVDB Collections Search Search for curated TVDB lists and collections
handleEngineEnabledChange('tvdb.collections.search', checked)} aria-label="Enable TVDB Collections search" />
)} {/* AI Search Toggle */}
AI-Powered Search
Use AI to interpret natural language queries and find media using descriptive phrases instead of exact titles.
{!hasAnyAiKey && (

A Gemini or OpenRouter API key is required to enable AI search. Add your key in the Integrations settings.

)}
{!hasAnyAiKey && (

Add a Gemini or OpenRouter API key in Integrations to enable AI search

)}
{config.search.ai_enabled && (
{/* Provider Selection */}

Choose your AI provider

{/* Model Selection */}

{(config.search.ai_provider || 'gemini') === 'openrouter' ? 'Any OpenRouter model ID' : (() => { const selected = GEMINI_MODELS.find(m => m.id === config.search.ai_model); return (selected?.grounding || config.search.ai_web_search) ? 'with Web Search' : 'without Web Search'; })() }

{(config.search.ai_provider || 'gemini') === 'openrouter' ? ( <> handleAiModelChange(e.target.value)} placeholder={openRouterModelsLoading ? 'Loading models...' : 'e.g. google/gemini-2.5-flash'} className="w-full sm:w-[280px]" /> {openRouterModels.map(model => ( ))} ) : ( )}
{/* Web Search toggle (Gemini non-free-grounding models only) */} {/* OpenRouter always uses :online — no toggle needed */} {(() => { const provider = config.search.ai_provider || 'gemini'; if (provider !== 'gemini') return null; const selected = GEMINI_MODELS.find(m => m.id === config.search.ai_model); if (selected?.grounding) return null; // already has free grounding return ( <>

Requires a paid Gemini API key. Free keys will get 429 errors.

setConfig(prev => ({ ...prev, search: { ...prev.search, ai_web_search: checked }, }))} />
{!config.search.ai_web_search && (

This model cannot search the web on free tier — results may be less accurate for recent or niche content. If you have a paid Gemini key, enable "Web Search" above.

)} ); })()}
)}
{/* Search Ordering */} Search Catalog Order Drag and drop to reorder search catalogs in Stremio p.id)} strategy={verticalListSortingStrategy} >
{getEnabledSearchProviders().map(provider => ( ))}
)} {/* Edit Search Name Dialog */} Edit Search Catalog
setEditName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveSearchName(); } else if (e.key === 'Escape') { handleCancelEdit(); } }} autoFocus placeholder="Enter custom name for this search" />
setEditType(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSaveSearchName(); } else if (e.key === 'Escape') { handleCancelEdit(); } }} placeholder="Enter custom type for this search" />
); }