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"
/>
onEditSearchName(provider.id)}
className="shrink-0 px-2"
>
{hasRPDBKey && provider.provider !== 'tvdb.collections.search' && (
onengineRatingPostersChange(provider.id, !engineRatingPostersEnabled)}
className="shrink-0 px-2"
title={engineRatingPostersEnabled ? 'Rating posters enabled' : 'Rating posters disabled'}
>
)}
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.
Enable 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.
Movies Search Engine:
handleProviderChange('movie', val)}>
{movieSearchProviders.map(p => (
{getProviderDisplayName(p.value)}
{p.value === 'tvdb.search' && !hasTvdbKey && ' (API key required)'}
))}
handleEngineEnabledChange(config.search.providers.movie, checked)}
aria-label="Enable this engine"
/>
Series Search Engine:
handleProviderChange('series', val)}>
{seriesSearchProviders.map(p => (
{getProviderDisplayName(p.value)}
{p.value === 'tvdb.search' && !hasTvdbKey && ' (API key required)'}
))}
handleEngineEnabledChange(config.search.providers.series, checked)}
aria-label="Enable this engine"
/>
Anime (Series) Search Engine:
handleProviderChange('anime_series', val)}>
{animeSearchProviders
.filter(p => p.mediaType.includes('anime_series'))
.map(p => (
{getProviderDisplayName(p.value)}
))}
handleEngineEnabledChange(config.search.providers.anime_series, checked)}
aria-label="Enable this engine"
/>
Anime (Movies) Search Engine:
handleProviderChange('anime_movie', val)}>
{animeSearchProviders
.filter(p => p.mediaType.includes('anime_movie'))
.map(p => (
{getProviderDisplayName(p.value)}
))}
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.
People Search (Movies) Engine:
handleProviderChange('people_search_movie', val)}
>
{peopleSearchProviders
.filter(p => p.mediaType.includes('movie'))
.map(p => (
{getProviderDisplayName(p.value)}
{p.value === 'tvdb.people.search' && !hasTvdbKey && ' (API key required)'}
))}
handleEngineEnabledChange('people_search_movie', checked)}
aria-label="Enable people search for movies"
/>
People Search (Series) Engine:
handleProviderChange('people_search_series', val)}
>
{peopleSearchProviders
.filter(p => p.mediaType.includes('series'))
.map(p => (
{getProviderDisplayName(p.value)}
{p.value === 'tvdb.people.search' && !hasTvdbKey && ' (API key required)'}
))}
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
Enable TVDB Collections Search:
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 */}
Provider
Choose your AI provider
handleAiProviderChange(value as 'gemini' | 'openrouter')}
>
Google Gemini{!hasGeminiKey ? ' (no key)' : ''}
OpenRouter{!hasOpenRouterKey ? ' (no key)' : ''}
{/* Model Selection */}
Model
{(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 => (
{model.name}
))}
>
) : (
{GEMINI_MODELS.map(model => (
{model.name}{model.grounding ? ' (with Web Search)' : ''}
))}
)}
{/* 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 (
<>
Web Search
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
Cancel
Save
);
}