import React, { useState, useEffect, useRef, useCallback } from 'react' import { MovieSite } from './Settings' const Select: React.FC<{ value: string onChange: (v: string) => void options: { value: string; label: string }[] placeholder?: string }> = ({ value, onChange, options, placeholder }) => { const [open, setOpen] = useState(false) const ref = useRef(null) const selected = options.find(o => o.value === value) useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open]) return (
setOpen(o => !o)}>
{selected ? selected.label : (placeholder ?? '')}
{open && (
e.stopPropagation()}> {options.map(o => (
{ onChange(o.value); setOpen(false) }} >{o.label}
))}
)}
) } // Searchable single-select dropdown — для длинных списков (тематик). const SearchableSelect: React.FC<{ value: string onChange: (v: string) => void options: { value: string; label: string }[] placeholder: string maxHeight?: number }> = ({ value, onChange, options, placeholder, maxHeight = 320 }) => { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const ref = useRef(null) const inputRef = useRef(null) const selected = options.find(o => o.value === value) useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); setSearch('') } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open]) useEffect(() => { if (open) requestAnimationFrame(() => inputRef.current?.focus()) }, [open]) const q = search.trim().toLowerCase() const filtered = q ? options.filter(o => o.label.toLowerCase().includes(q)) : options return (
setOpen(o => !o)}> {selected ? selected.label : placeholder} {selected && ( )}
{open && (
e.stopPropagation()}>
setSearch(e.target.value)} />
{filtered.map(o => (
{ onChange(o.value); setOpen(false); setSearch('') }} >{o.label}
))} {!filtered.length &&
Ничего не найдено
}
)}
) } // Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder. const MultiSelect: React.FC<{ values: string[] onChange: (v: string[]) => void options: { value: string; label: string }[] placeholder: string maxHeight?: number }> = ({ values, onChange, options, placeholder, maxHeight = 260 }) => { const [open, setOpen] = useState(false) const ref = useRef(null) useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open]) const toggle = (v: string) => { onChange(values.includes(v) ? values.filter(x => x !== v) : [...values, v]) } const label = values.length === 0 ? placeholder : values.length === 1 ? (options.find(o => o.value === values[0])?.label ?? values[0]) : `${placeholder}: ${values.length}` return (
setOpen(o => !o)}> 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label} {values.length > 0 && ( )}
{open && (
e.stopPropagation()}> {options.map(o => { const on = values.includes(o.value) return (
toggle(o.value)}> {on && ( )} {o.label}
) })}
)}
) } interface TmdbMovie { id: number mediaType: 'movie' | 'tv' title: string originalTitle: string year: string poster: string overview: string rating: string } interface SiteResult { title: string url: string poster?: string year?: string source: string } interface MovieSearchProps { onOpenUrl: (name: string, url: string) => void onBookmark?: (title: string, url: string, poster: string, source: string) => void initialQuery?: string } const MOVIE_GENRES = [ { id: 28, name: 'Боевик' }, { id: 12, name: 'Приключения' }, { id: 16, name: 'Мультфильм' }, { id: 35, name: 'Комедия' }, { id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' }, { id: 18, name: 'Драма' }, { id: 10751, name: 'Семейный' }, { id: 14, name: 'Фэнтези' }, { id: 36, name: 'История' }, { id: 27, name: 'Ужасы' }, { id: 9648, name: 'Детектив' }, { id: 10749, name: 'Мелодрама' }, { id: 878, name: 'Фантастика' }, { id: 53, name: 'Триллер' }, { id: 10752, name: 'Военный' }, { id: 37, name: 'Вестерн' }, ] const TV_GENRES = [ { id: 10759, name: 'Боевик' }, { id: 16, name: 'Мультфильм' }, { id: 35, name: 'Комедия' }, { id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' }, { id: 18, name: 'Драма' }, { id: 10751, name: 'Семейный' }, { id: 10762, name: 'Детское' }, { id: 9648, name: 'Детектив' }, { id: 10765, name: 'Фантастика' }, { id: 10768, name: 'Политика' }, { id: 37, name: 'Вестерн' }, ] // Кураторский список тематик. Каждая — pipe-OR по keyword ID'ам из TMDB. // extraGenre добавляется AND-связкой к выбранным жанрам (только в movie-режиме), // если тематика требует фильтра по жанру для чистоты выдачи (например, "Музыка" // без жанра 10402 даёт хорроры со скрипачами в кадре). // IDs провалидированы probe-скриптом (/search/keyword + /discover/movie): // каждая тема даёт ≥50 результатов на первой странице популярных. const THEMES: { id: string; label: string; keywordIds: number[]; extraGenre?: number }[] = [ { id: 'war', label: 'Война', keywordIds: [1956, 273967] }, { id: 'coldwar', label: 'Холодная война', keywordIds: [2106] }, { id: 'vietnam', label: 'Вьетнамская война', keywordIds: [2957] }, { id: 'zombie', label: 'Зомби', keywordIds: [12377] }, { id: 'vampire', label: 'Вампиры', keywordIds: [3133] }, { id: 'ghost', label: 'Призраки', keywordIds: [162846] }, { id: 'witch', label: 'Ведьмы', keywordIds: [616] }, { id: 'magic', label: 'Маги и волшебство', keywordIds: [2343, 177912] }, { id: 'dragon', label: 'Драконы', keywordIds: [12554] }, { id: 'fairytale', label: 'Сказки', keywordIds: [3205] }, { id: 'cannibal', label: 'Каннибалы', keywordIds: [278235, 14895] }, { id: 'cult', label: 'Секты', keywordIds: [6158] }, { id: 'serialkiller', label: 'Серийные убийцы', keywordIds: [10714] }, { id: 'space', label: 'Космос', keywordIds: [252634, 3801, 4344] }, { id: 'alien', label: 'Инопланетяне', keywordIds: [9951] }, { id: 'timetravel', label: 'Путешествия во времени', keywordIds: [4379] }, { id: 'postapoc', label: 'Постапокалипсис', keywordIds: [4458, 359337] }, { id: 'dystopia', label: 'Антиутопия', keywordIds: [4565, 350338] }, { id: 'epidemic', label: 'Эпидемия и вирус', keywordIds: [188973, 17995, 188957] }, { id: 'disaster', label: 'Стихийные бедствия', keywordIds: [5096, 10617] }, { id: 'dinosaur', label: 'Динозавры', keywordIds: [12616] }, { id: 'robot', label: 'Роботы и ИИ', keywordIds: [14544, 803, 371846] }, { id: 'superhero', label: 'Супергерои', keywordIds: [9715], extraGenre: 28 }, { id: 'spy', label: 'Шпионы', keywordIds: [470, 4289] }, { id: 'mafia', label: 'Мафия', keywordIds: [10391, 10291] }, { id: 'gangster', label: 'Гангстеры', keywordIds: [3149] }, { id: 'heist', label: 'Ограбления', keywordIds: [10051, 642] }, { id: 'prison', label: 'Тюрьма', keywordIds: [378] }, { id: 'escape', label: 'Побег из тюрьмы', keywordIds: [9777] }, { id: 'police', label: 'Полиция и детективы', keywordIds: [703, 6149] }, { id: 'court', label: 'Суд и адвокаты', keywordIds: [33519, 10909] }, { id: 'journalism', label: 'Журналисты', keywordIds: [736, 917] }, { id: 'doctor', label: 'Врачи и медицина', keywordIds: [11612, 13005] }, { id: 'psych', label: 'Психбольница', keywordIds: [11857, 10323] }, { id: 'school', label: 'Школа', keywordIds: [6270] }, { id: 'college', label: 'Колледж и студенты', keywordIds: [3616] }, { id: 'martial', label: 'Боевые искусства', keywordIds: [779, 780], extraGenre: 28 }, { id: 'samurai', label: 'Самураи', keywordIds: [1462] }, { id: 'ninja', label: 'Ниндзя', keywordIds: [10278] }, { id: 'boxing', label: 'Бокс', keywordIds: [209476] }, { id: 'football', label: 'Футбол', keywordIds: [13042, 352822] }, { id: 'sport', label: 'Спорт (общий)', keywordIds: [333328] }, { id: 'olympics', label: 'Олимпиада', keywordIds: [315138] }, { id: 'racing', label: 'Гонки', keywordIds: [191279, 9666] }, { id: 'skiing', label: 'Лыжи и сноуборд', keywordIds: [248915, 3522] }, { id: 'mountains', label: 'Горы и альпинизм', keywordIds: [159212, 160177] }, { id: 'surfing', label: 'Сёрфинг', keywordIds: [5349] }, { id: 'chess', label: 'Шахматы', keywordIds: [316] }, { id: 'submarine', label: 'Подводные лодки', keywordIds: [339] }, { id: 'ship', label: 'Корабли и море', keywordIds: [3799, 191585] }, { id: 'airplane', label: 'Самолёты и пилоты', keywordIds: [3800, 3203] }, { id: 'train', label: 'Поезда', keywordIds: [13008] }, { id: 'rome', label: 'Древний Рим', keywordIds: [5049, 1405] }, { id: 'medieval', label: 'Средневековье', keywordIds: [355987, 161257] }, { id: 'revolution', label: 'Революция', keywordIds: [2020] }, { id: 'biography', label: 'Биография и реальные события', keywordIds: [5565, 9672] }, { id: 'artist', label: 'Художники и писатели', keywordIds: [2679, 13028] }, { id: 'music', label: 'Музыка и группы', keywordIds: [18001, 4048], extraGenre: 10402 }, { id: 'circus', label: 'Цирк', keywordIds: [291] }, { id: 'cooking', label: 'Кулинария', keywordIds: [1918, 18293] }, { id: 'wallstreet', label: 'Уолл-стрит и финансы', keywordIds: [5636, 177493] }, { id: 'religion', label: 'Религия', keywordIds: [11001] }, { id: 'afterlife', label: 'Жизнь после смерти', keywordIds: [6155, 5484] }, { id: 'dream', label: 'Сны', keywordIds: [346773] }, { id: 'love', label: 'О любви', keywordIds: [9673] }, { id: 'wedding', label: 'Свадьба', keywordIds: [13027] }, { id: 'friendship', label: 'Дружба', keywordIds: [6054] }, { id: 'family', label: 'Семья', keywordIds: [10235] }, { id: 'twins', label: 'Близнецы', keywordIds: [15016] }, { id: 'africa', label: 'Африка', keywordIds: [409] }, { id: 'japan', label: 'Япония', keywordIds: [233] }, ] const SORTS = [ { value: 'popularity.desc', label: 'Популярные' }, { value: 'vote_average.desc', label: 'По рейтингу' }, { value: 'release_date.desc', label: 'Новые' }, { value: 'revenue.desc', label: 'По сборам' }, ] const RATINGS = [ { value: '', label: 'Любой' }, { value: '5', label: '5+' }, { value: '6', label: '6+' }, { value: '7', label: '7+' }, { value: '8', label: '8+' }, { value: '9', label: '9+' }, ] const COUNTRIES = [ { value: '', label: 'Страна' }, { value: 'US', label: 'США' }, { value: 'RU', label: 'Россия' }, { value: 'GB', label: 'Великобритания' }, { value: 'FR', label: 'Франция' }, { value: 'DE', label: 'Германия' }, { value: 'IT', label: 'Италия' }, { value: 'ES', label: 'Испания' }, { value: 'JP', label: 'Япония' }, { value: 'KR', label: 'Южная Корея' }, { value: 'CN', label: 'Китай' }, { value: 'IN', label: 'Индия' }, { value: 'SE', label: 'Швеция' }, { value: 'DK', label: 'Дания' }, { value: 'TR', label: 'Турция' }, ] const CURRENT_YEAR = new Date().getFullYear() const YEARS = Array.from({ length: CURRENT_YEAR - 1899 }, (_, i) => CURRENT_YEAR + 1 - i) const StarIcon = () => ( ) const MovieCard: React.FC<{ movie: TmdbMovie; idx: number; baseIdx: number; onSelect: (m: TmdbMovie) => void }> = ({ movie, idx, baseIdx, onSelect }) => (
onSelect(movie)} >
{movie.poster ? {movie.title} { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} /> : null }
{movie.title.charAt(0).toUpperCase()}
{movie.title}
{movie.year && {movie.year}} {movie.rating && {movie.rating}}
{movie.mediaType === 'tv' && Сериал}
) const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initialQuery = '' }) => { const [query, setQuery] = useState(initialQuery) const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie') const [sortBy, setSortBy] = useState('popularity.desc') const [genreIds, setGenreIds] = useState([]) const [themeId, setThemeId] = useState('') // single-select const [years, setYears] = useState([]) const [minRating, setMinRating] = useState('') const [countries, setCountries] = useState([]) const [page, setPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [activeQuery, setActiveQuery] = useState(initialQuery) // committed query (on Enter) const [tmdbResults, setTmdbResults] = useState([]) const [selected, setSelected] = useState(null) const [siteResults, setSiteResults] = useState([]) const [tmdbLoading, setTmdbLoading] = useState(false) const [sitesLoading, setSitesLoading] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [cardBase, setCardBase] = useState(0) const [message, setMessage] = useState('') const [apiKey, setApiKey] = useState('') const [sites, setSites] = useState([]) const [configLoaded, setConfigLoaded] = useState(false) const isSearchMode = activeQuery.trim().length > 0 const genres = mediaType === 'tv' ? TV_GENRES : MOVIE_GENRES useEffect(() => { window.electron?.readConfig().then((cfg: any) => { let enabled: MovieSite[] = (cfg?.movieSites ?? []).filter((s: MovieSite) => s.enabled !== false) const NON_MOVIE = /youtube|rutube|vk\.com|ok\.ru|google|yandex|mail\.ru|twitch|tiktok|instagram|facebook|twitter|telegram/i if (!enabled.length && cfg?.apps?.length) { enabled = cfg.apps .filter((app: any) => { try { return !NON_MOVIE.test(new URL(app.url).hostname) } catch { return false } }) .map((app: any) => { let domain = app.url try { domain = new URL(app.url).hostname } catch (_) {} const type: MovieSite['type'] = /rezka/.test(domain) ? 'hdrezka' : /filmix/.test(domain) ? 'filmix' : 'dle' return { domain, type, enabled: true } }) } const key: string = cfg?.tmdbApiKey ?? '' setSites(enabled) setApiKey(key) setConfigLoaded(true) if (initialQuery) { if (key) doTmdbSearch(initialQuery, key) else doSiteSearch(initialQuery, enabled, undefined, undefined) } }) }, []) // Auto-load discover when config ready or filters change (discover mode only) const discoverRef = useRef(0) const doDiscover = useCallback(async (key: string, pg: number, append: boolean) => { const token = ++discoverRef.current if (append) setLoadingMore(true) else { setTmdbLoading(true); setMessage('') } try { const theme = THEMES.find(t => t.id === themeId) const themeKeywords = theme?.keywordIds ?? [] // extraGenre — только для фильмов (TV-каталог TMDB имеет другие genre ID) const effectiveGenres = (theme?.extraGenre && mediaType === 'movie' && !genreIds.includes(theme.extraGenre)) ? [...genreIds, theme.extraGenre] : genreIds const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds: effectiveGenres, years, minRating, countries, page: pg, themeKeywords }) if (token !== discoverRef.current) return if (res.error) { setMessage(`Ошибка: ${res.error}`); return } setTmdbResults(prev => append ? [...prev, ...res.results] : res.results) setTotalPages(res.totalPages) if (!res.results.length && !append) setMessage('Ничего не найдено') } catch { if (token === discoverRef.current) setMessage('Ошибка загрузки') } finally { if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) } } }, [mediaType, sortBy, genreIds, themeId, years, minRating, countries]) useEffect(() => { if (!configLoaded || !apiKey || isSearchMode) return setPage(1) setTmdbResults([]) doDiscover(apiKey, 1, false) }, [configLoaded, apiKey, mediaType, sortBy, genreIds, themeId, years, minRating, countries, isSearchMode]) const searchRef = useRef(0) const doTmdbSearch = async (q: string, key: string) => { const token = ++searchRef.current setTmdbLoading(true) setMessage('') setTmdbResults([]) setSelected(null) setSiteResults([]) try { const res = await window.electron!.searchTmdb(q, key) if (token !== searchRef.current) return if (res.error) { setMessage(`Ошибка TMDB: ${res.error}`); return } setTmdbResults(res.results) setTotalPages(1) if (!res.results.length) setMessage('Ничего не найдено') } catch { if (token === searchRef.current) setMessage('Ошибка TMDB') } finally { if (token === searchRef.current) setTmdbLoading(false) } } // Стриминг: каждый сайт ответил → результаты добавляются немедленно. Спиннер виден до первого ответа. const siteSearchCancelRef = useRef<(() => void) | null>(null) const doSiteSearch = (q: string, sitesToSearch: MovieSite[], yearHint?: string, mt?: string, append = false): (() => void) | null => { if (!sitesToSearch.length) { if (!append) setMessage('Нет активных сайтов. Добавьте в Настройки → Поиск фильмов.') return null } // Отменить предыдущий стрим (если был) if (!append && siteSearchCancelRef.current) { siteSearchCancelRef.current(); siteSearchCancelRef.current = null } if (!append) { setSitesLoading(true) setMessage('') setSiteResults([]) } const y = yearHint ? parseInt(yearHint) : NaN const isTv = mt === 'tv' const yearDist = (r: SiteResult) => r.year ? Math.abs(parseInt(r.year) - y) : 0.5 const filterBatch = (batch: SiteResult[]): SiteResult[] => { if (!yearHint) return batch // Для фильмов выкидываем результаты с явно неверным годом (>1 год отличия). // Для сериалов год часто не совпадает (разные сезоны) — оставляем все. const filtered = isTv ? batch : batch.filter(r => !r.year || yearDist(r) <= 1) return filtered.sort((a, b) => yearDist(a) - yearDist(b)) } const cancel = window.electron!.searchMoviesStream( q, sitesToSearch, ({ source: _src, results }: { source: string; results: SiteResult[] }) => { const batch = filterBatch(results) if (!batch.length) return setSiteResults(prev => { // Дедупим по URL (если параллельный поиск по originalTitle принесёт те же ссылки). const seen = new Set(prev.map(r => r.url)) const fresh = batch.filter(r => !seen.has(r.url)) if (!fresh.length) return prev const merged = [...prev, ...fresh] return yearHint ? merged.sort((a, b) => yearDist(a) - yearDist(b)) : merged }) }, () => { setSitesLoading(false) setSiteResults(prev => { if (!prev.length && !append) setMessage('Не найдено ни на одном сайте') return prev }) }, ) if (!append) siteSearchCancelRef.current = cancel return cancel } // Чистка слушателей при размонтировании useEffect(() => () => { if (siteSearchCancelRef.current) siteSearchCancelRef.current() }, []) const handleSearch = () => { const q = query.trim() if (!q) return setActiveQuery(q) if (apiKey) doTmdbSearch(q, apiKey) else doSiteSearch(q, sites, undefined, undefined) } const handleSelectMovie = (movie: TmdbMovie) => { setSelected(movie) setSiteResults([]) setMessage('') const searchTitle = movie.title || movie.originalTitle doSiteSearch(searchTitle, sites, movie.year, movie.mediaType) // Параллельный поиск по оригинальному названию — append=true, чтобы не сбросить первичный поиск. // Дедуп по URL уже в doSiteSearch. if (movie.originalTitle && movie.originalTitle !== movie.title) { doSiteSearch(movie.originalTitle, sites, movie.year, movie.mediaType, true) } } const handleBack = () => { setSelected(null); setSiteResults([]); setMessage('') } const handleLoadMore = () => { const nextPage = page + 1 setPage(nextPage) setCardBase(tmdbResults.length) doDiscover(apiKey, nextPage, true) } const handleFilterChange = (fn: () => void) => { setSelected(null) setSiteResults([]) fn() } const clearQuery = () => { setQuery('') setActiveQuery('') setTmdbResults([]) setMessage('') } const loading = tmdbLoading || sitesLoading return (
{/* Search bar */}
setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && !loading && handleSearch()} autoFocus /> {query && ( )}
{/* Filters (only in discover mode with TMDB key) */} {apiKey && !selected && (
{/* Type toggle */}
{/* Sort */} {!isSearchMode && ( handleFilterChange(() => setMinRating(v))} options={RATINGS.map(r => ({ value: r.value, label: r.label === 'Любой' ? 'Рейтинг' : r.label }))} placeholder="Рейтинг" /> )} {/* Years (multi, OR) */} handleFilterChange(() => setYears(v))} options={YEARS.map(y => ({ value: String(y), label: String(y) }))} placeholder="Год" /> {/* Countries (multi, OR) */} {!isSearchMode && ( handleFilterChange(() => setCountries(v))} options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))} placeholder="Страна" /> )}
{/* Themes — searchable dropdown (71 тематика, длинный список) */} {!isSearchMode && (
handleFilterChange(() => setThemeId(v))} options={THEMES.map(t => ({ value: t.id, label: t.label }))} placeholder="Тематика (например: о войне, зомби, шахматы)" />
)} {/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */} {!isSearchMode && (
{genres.map(g => ( ))} {genreIds.length > 1 && ( все выбранные одновременно )}
)}
)} {message &&

{message}

} {/* Detail view */} {selected ? (
{selected.poster && (
)}
{selected.poster ? {selected.title} :
{selected.title.charAt(0)}
}

{selected.title}

{selected.originalTitle !== selected.title && (

{selected.originalTitle}

)}
{selected.year && {selected.year}} {selected.mediaType === 'tv' && Сериал} {selected.rating && {selected.rating}}
{selected.overview &&

{selected.overview}

}
{sitesLoading &&

Ищем на {sites.length} сайтах...

} {!sitesLoading && !siteResults.length && !message &&

Поиск...

} {!sitesLoading && siteResults.length > 0 && (
Найдено на сайтах
)} {!sitesLoading && !siteResults.length && ( )} {siteResults.map((r, i) => (
onOpenUrl(r.title, r.url)}> {r.source} {r.title}
{onBookmark && ( )} Открыть →
))}
) : ( <> {tmdbLoading &&

{isSearchMode ? 'Поиск...' : 'Загрузка...'}

} {tmdbResults.length > 0 && ( <>
{tmdbResults.map((movie, i) => )}
{!isSearchMode && page < totalPages && ( )} )} {/* Direct site results (no TMDB key) */} {siteResults.length > 0 && (
{siteResults.map((r, i) => (
onOpenUrl(r.title, r.url)}>
{r.poster ? {r.title} { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} /> : null }
{r.title.charAt(0).toUpperCase()}
{r.title} {r.year && {r.year}} {r.source}
))}
)} )}
) } export default MovieSearch