diff --git a/main.js b/main.js index f891e9d..38a5bec 100644 --- a/main.js +++ b/main.js @@ -1230,18 +1230,20 @@ ipcMain.handle('search-tmdb', async (_event, query, apiKey) => { } }); -ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreIds, years, minRating, countries, page }) => { +ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreIds, years, minRating, countries, page, themeKeywords }) => { // Multi-filter semantics (per user request): // genres → AND (movie must match ALL selected genres) — TMDB comma-join in with_genres // countries→ OR (movie matches ANY selected country/lang) — TMDB pipe-join // years → OR (movie released in ANY selected year) — fan-out: one request per year, merge // rating → min threshold (single) — vote_average.gte + // themeKeywords → OR within theme (curated keyword IDs) — TMDB pipe-join in with_keywords try { const isBearer = apiKey.startsWith('eyJ'); const type = mediaType === 'tv' ? 'tv' : 'movie'; const genreArr = Array.isArray(genreIds) ? genreIds.filter(Boolean) : []; const countryArr = Array.isArray(countries) ? countries.filter(Boolean) : []; const yearArr = Array.isArray(years) ? years.filter(Boolean) : []; + const keywordArr = Array.isArray(themeKeywords) ? themeKeywords.filter(Boolean) : []; const buildParams = (yearOverride) => { const params = new URLSearchParams({ @@ -1252,6 +1254,7 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr }); if (!isBearer) params.set('api_key', apiKey); if (genreArr.length) params.set('with_genres', genreArr.join(',')); // AND + if (keywordArr.length) params.set('with_keywords', keywordArr.join('|')); // OR within theme if (minRating) params.set('vote_average.gte', minRating); if (countryArr.length) { // Mix: codes that map to original_language go to with_original_language (pipe-OR), @@ -1324,9 +1327,153 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr } }); -ipcMain.handle('search-movies', async (_event, query, sites) => { - const settled = await Promise.allSettled(sites.map(s => searchOneSite(s, query))); - return settled.flatMap(r => r.status === 'fulfilled' ? r.value : []); +// Streaming search: один сайт ответил → шлём результаты немедленно, не ждём остальных. +// Клиент передаёт searchId, чтобы можно было отменить устаревший поиск (token). +ipcMain.on('search-movies-start', (event, searchId, query, sites) => { + const wc = event.sender; + const send = (channel, payload) => { + if (wc.isDestroyed()) return; + wc.send(channel, { searchId, ...payload }); + }; + + let pending = sites.length; + if (pending === 0) { + send('search-movies-done', {}); + return; + } + + sites.forEach(site => { + searchOneSite(site, query) + .then(results => send('search-movies-result', { source: site.domain, results: results || [] })) + .catch(err => { + console.warn(`[search] ${site.domain} stream error:`, err.message); + send('search-movies-result', { source: site.domain, results: [] }); + }) + .finally(() => { + pending -= 1; + if (pending === 0) send('search-movies-done', {}); + }); + }); +}); + +// Авто-детект логотипа сайта. Возвращает абсолютный URL картинки или '' (если ничего не нашлось). +// Fallback chain (от лучшего к худшему): +// 1. /manifest.json → icons[] (PWA, обычно 192×192 / 512×512) +// 2. (180×180 standard) +// 3. с максимальным размером ≥48 +// 4. JSON-LD Organization.logo +// 5. (часто hero, но всё лучше favicon) +// 6. +// 7. /favicon.ico (last resort, низкое качество) +async function detectSiteLogo(siteUrl) { + let url; + try { + if (!/^https?:\/\//i.test(siteUrl)) siteUrl = 'https://' + siteUrl; + url = new URL(siteUrl); + } catch (_) { + return ''; + } + const origin = url.origin; + const fetchOpts = { headers: { 'User-Agent': SEARCH_HEADERS['User-Agent'] }, signal: AbortSignal.timeout(6000) }; + const abs = (href) => { try { return new URL(href, origin + '/').toString(); } catch { return ''; } }; + const parseSizes = (s) => { + if (!s) return 0; + const m = String(s).match(/(\d+)\s*x\s*(\d+)/i); + return m ? parseInt(m[1], 10) : 0; + }; + + // 1. manifest.json + try { + const r = await getProxySession().fetch(origin + '/manifest.json', fetchOpts); + if (r.ok) { + const j = await r.json(); + const icons = Array.isArray(j.icons) ? j.icons : []; + if (icons.length) { + icons.sort((a, b) => parseSizes(b.sizes) - parseSizes(a.sizes)); + const best = icons[0]; + if (best?.src) return abs(best.src); + } + } + } catch (_) {} + + // 2-6. HTML head + let html = ''; + try { + const r = await getProxySession().fetch(origin + '/', fetchOpts); + if (r.ok) html = await r.text(); + } catch (_) {} + + if (html) { + try { + const $ = cheerio.load(html); + + // apple-touch-icon + const apple = $('link[rel~="apple-touch-icon"], link[rel~="apple-touch-icon-precomposed"]') + .toArray() + .map(el => ({ href: $(el).attr('href'), size: parseSizes($(el).attr('sizes')) || 180 })) + .filter(x => x.href) + .sort((a, b) => b.size - a.size); + if (apple.length) return abs(apple[0].href); + + // link rel=icon с явным размером ≥48 + const icons = $('link[rel~="icon"], link[rel="shortcut icon"]') + .toArray() + .map(el => ({ href: $(el).attr('href'), size: parseSizes($(el).attr('sizes')) })) + .filter(x => x.href && x.size >= 48) + .sort((a, b) => b.size - a.size); + if (icons.length) return abs(icons[0].href); + + // JSON-LD Organization.logo + const jsonLd = $('script[type="application/ld+json"]').toArray(); + for (const el of jsonLd) { + try { + const data = JSON.parse($(el).contents().text()); + const items = Array.isArray(data) ? data : [data]; + for (const item of items) { + const logo = item?.logo || item?.publisher?.logo; + const logoUrl = typeof logo === 'string' ? logo : logo?.url; + if (logoUrl) return abs(logoUrl); + } + } catch (_) {} + } + + // og:image (может быть hero, но обычно крупная и фирменная) + const og = $('meta[property="og:image:secure_url"]').attr('content') + || $('meta[property="og:image"]').attr('content'); + if (og) return abs(og); + + // msapplication-TileImage + const tile = $('meta[name="msapplication-TileImage"]').attr('content'); + if (tile) return abs(tile); + + // link rel=icon без указанного размера (обычно низкое качество — но всё ещё лучше /favicon.ico) + const anyIcon = $('link[rel~="icon"], link[rel="shortcut icon"]').first().attr('href'); + if (anyIcon) return abs(anyIcon); + } catch (e) { + console.warn('[detect-logo] cheerio parse error:', e.message); + } + } + + // 7. favicon.ico fallback — но только если реально картинка (часть сайтов отдаёт 403/HTML за бот-стеной). + try { + const r = await getProxySession().fetch(origin + '/favicon.ico', { method: 'HEAD', ...fetchOpts }); + if (r.ok && (r.headers.get('content-type') || '').startsWith('image/')) { + return origin + '/favicon.ico'; + } + } catch (_) {} + + return ''; // ничего нормального не нашлось — UI покажет букву-заглушку +} + +ipcMain.handle('detect-logo', async (_event, siteUrl) => { + try { + const logo = await detectSiteLogo(siteUrl); + console.log(`[detect-logo] ${siteUrl} → ${logo}`); + return logo; + } catch (e) { + console.warn('[detect-logo] error:', e.message); + return ''; + } }); ipcMain.handle('get-current-page', () => { diff --git a/package.json b/package.json index a98ba42..8db2785 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ESH-Media", - "version": "1.0.12", + "version": "1.0.13", "private": true, "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index e9eb08f..827b1ac 100644 --- a/preload.js +++ b/preload.js @@ -26,9 +26,34 @@ contextBridge.exposeInMainWorld('electron', { checkUpdateNow: () => ipcRenderer.invoke('check-update-now'), readConfig: () => ipcRenderer.invoke('read-config'), writeConfig: (data) => ipcRenderer.send('write-config', data), - searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites), + // Streaming: возвращает unsubscribe(). onResult({ source, results }) вызывается по мере прихода + // ответов от каждого сайта; onDone() — когда все сайты ответили. + // Слушатели снимаются автоматически после onDone (или вручную через возвращённую функцию). + searchMoviesStream: (query, sites, onResult, onDone) => { + const searchId = `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + let active = true; + const cleanup = () => { + if (!active) return; + active = false; + ipcRenderer.removeListener('search-movies-result', resultListener); + ipcRenderer.removeListener('search-movies-done', doneListener); + }; + const resultListener = (_event, payload) => { + if (active && payload.searchId === searchId) onResult({ source: payload.source, results: payload.results }); + }; + const doneListener = (_event, payload) => { + if (active && payload.searchId === searchId) { + try { onDone(); } finally { cleanup(); } + } + }; + ipcRenderer.on('search-movies-result', resultListener); + ipcRenderer.on('search-movies-done', doneListener); + ipcRenderer.send('search-movies-start', searchId, query, sites); + return cleanup; + }, searchTmdb: (query, apiKey) => ipcRenderer.invoke('search-tmdb', query, apiKey), discoverTmdb: (params) => ipcRenderer.invoke('discover-tmdb', params), + detectLogo: (siteUrl) => ipcRenderer.invoke('detect-logo', siteUrl), toggleKiosk: () => ipcRenderer.invoke('toggle-kiosk'), isKiosk: () => ipcRenderer.invoke('is-kiosk'), diff --git a/src/components/MovieSearch.tsx b/src/components/MovieSearch.tsx index aa97cb6..6a7b63f 100644 --- a/src/components/MovieSearch.tsx +++ b/src/components/MovieSearch.tsx @@ -152,6 +152,26 @@ const TV_GENRES = [ { id: 10765, name: 'Фантастика' }, { id: 10768, name: 'Политика' }, { id: 37, name: 'Вестерн' }, ] +// Кураторский список тематик. Каждая — pipe-OR по keyword ID'ам из TMDB. +// extraGenre добавляется AND-связкой к выбранным жанрам, если тематика этого требует +// (например, "Музыка" без жанра 10402 даёт хорроры со скрипачами в кадре). +const THEMES: { id: string; label: string; keywordIds: number[]; extraGenre?: number }[] = [ + { id: 'zombie', label: 'Зомби', keywordIds: [12377] }, + { id: 'space', label: 'Космос', keywordIds: [252634, 3801, 4344] }, + { id: 'vampire', label: 'Вампиры', keywordIds: [3133] }, + { id: 'superhero', label: 'Супергерои', keywordIds: [9715], extraGenre: 28 }, + { id: 'timetravel', label: 'Путешествия во времени', keywordIds: [4379] }, + { id: 'postapoc', label: 'Постапокалипсис', keywordIds: [4458, 359337] }, + { id: 'spy', label: 'Шпионы', keywordIds: [470, 4289] }, + { id: 'serialkiller', label: 'Серийные убийцы', keywordIds: [10714] }, + { id: 'dinosaur', label: 'Динозавры', keywordIds: [12616] }, + { id: 'alien', label: 'Инопланетяне', keywordIds: [9951] }, + { id: 'fairytale', label: 'Сказки', keywordIds: [3205] }, + { id: 'mafia', label: 'Мафия', keywordIds: [10391, 10291] }, + { id: 'martial', label: 'Боевые искусства', keywordIds: [779, 780], extraGenre: 28 }, + { id: 'music', label: 'Музыка и группы', keywordIds: [18001, 4048], extraGenre: 10402 }, +] + const SORTS = [ { value: 'popularity.desc', label: 'Популярные' }, { value: 'vote_average.desc', label: 'По рейтингу' }, @@ -226,6 +246,7 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia 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([]) @@ -280,7 +301,13 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia if (append) setLoadingMore(true) else { setTmdbLoading(true); setMessage('') } try { - const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds, years, minRating, countries, page: pg }) + 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) @@ -291,14 +318,14 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia } finally { if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) } } - }, [mediaType, sortBy, genreIds, years, minRating, countries]) + }, [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, years, minRating, countries, isSearchMode]) + }, [configLoaded, apiKey, mediaType, sortBy, genreIds, themeId, years, minRating, countries, isSearchMode]) const searchRef = useRef(0) const doTmdbSearch = async (q: string, key: string) => { @@ -322,43 +349,63 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia } } - const doSiteSearch = async (q: string, sitesToSearch: MovieSite[], yearHint?: string, mt?: string) => { - if (!sitesToSearch.length) { setMessage('Нет активных сайтов. Добавьте в Настройки → Поиск фильмов.'); return } - setSitesLoading(true) - setMessage('') - setSiteResults([]) - try { - const data = await window.electron!.searchMovies(q, sitesToSearch) - let filtered = data - if (yearHint) { - const y = parseInt(yearHint) - const isTv = mt === 'tv' - const yearDist = (r: SiteResult) => r.year ? Math.abs(parseInt(r.year) - y) : 0.5 - const normalizeTitle = (t: string) => t.toLowerCase().replace(/[^а-яёa-z0-9]/gi, ' ').replace(/\s+/g, ' ').trim() - const groups = new Map() - for (const r of data) { - const key = normalizeTitle(r.title) - if (!groups.has(key)) groups.set(key, []) - groups.get(key)!.push(r) - } - const deduped: SiteResult[] = [] - for (const group of groups.values()) { - const minDist = Math.min(...group.map(yearDist)) - deduped.push(...group.filter(r => yearDist(r) === minDist)) - } - filtered = isTv - ? deduped.sort((a, b) => yearDist(a) - yearDist(b)) - : deduped.filter(r => !r.year || yearDist(r) <= 1).sort((a, b) => yearDist(a) - yearDist(b)) - } - setSiteResults(filtered) - if (!filtered.length) setMessage('Не найдено ни на одном сайте') - } catch { - setMessage('Ошибка поиска по сайтам') - } finally { - setSitesLoading(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 @@ -373,13 +420,10 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia 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) { - window.electron!.searchMovies(movie.originalTitle, sites).then(extra => { - setSiteResults(prev => { - const existing = new Set(prev.map(r => r.url)) - return [...prev, ...extra.filter(r => !existing.has(r.url))] - }) - }).catch(() => {}) + doSiteSearch(movie.originalTitle, sites, movie.year, movie.mediaType, true) } } @@ -443,11 +487,11 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia
@@ -485,6 +529,24 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia )} + {/* Themes (single-select — одна тематика за раз) */} + {!isSearchMode && ( +
+ + {THEMES.map(t => ( + + ))} +
+ )} + {/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */} {!isSearchMode && (
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a9ddd02..eb07f22 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -66,9 +66,33 @@ const Settings: React.FC = ({ onClose, onAppsChange }) => { const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [newApp, setNewApp] = useState({ name: '', imageUrl: '', url: '', useProxy: false }) + // Тихая авто-детекция логотипа: обновляет imageUrl в state+config когда сервер вернёт URL. + // Если детект ничего не нашёл — оставляем пустой imageUrl (тогда отобразится буква-заглушка). + const autoDetectLogo = (appUrl: string, onDetected: (logoUrl: string) => void) => { + if (!window.electron?.detectLogo) return + window.electron.detectLogo(appUrl).then(logoUrl => { + if (logoUrl) onDetected(logoUrl) + }).catch(() => {}) + } + useEffect(() => { window.electron?.readConfig().then((cfg: SettingsData | null) => { - if (cfg?.apps) setSettings(cfg) + if (!cfg?.apps) return + setSettings(cfg) + // Догоняем легаси-приложения без иконки: тихо парсим и обновляем конфиг. + const missing = cfg.apps.filter(a => !a.imageUrl && a.url) + if (!missing.length) return + missing.forEach(target => { + autoDetectLogo(target.url, logoUrl => { + setSettings(prev => { + const apps = prev.apps.map(a => a.url === target.url && !a.imageUrl ? { ...a, imageUrl: logoUrl } : a) + const updated = { ...prev, apps } + saveSettings(updated) + onAppsChange(apps) + return updated + }) + }) + }) }) }, []) @@ -91,12 +115,23 @@ const Settings: React.FC = ({ onClose, onAppsChange }) => { const addApp = () => { if (!newApp.name || !newApp.url) return - const apps = [...settings.apps, newApp] + const fresh: AppEntry = { ...newApp, imageUrl: '' } + const apps = [...settings.apps, fresh] const updated = { ...settings, apps } setSettings(updated) saveSettings(updated) onAppsChange(apps) setNewApp({ name: '', imageUrl: '', url: '', useProxy: false }) + // Тихо детектим логотип в фоне; UI получит иконку, когда сервер ответит. + autoDetectLogo(fresh.url, logoUrl => { + setSettings(prev => { + const nextApps = prev.apps.map(a => a.url === fresh.url && !a.imageUrl ? { ...a, imageUrl: logoUrl } : a) + const next = { ...prev, apps: nextApps } + saveSettings(next) + onAppsChange(nextApps) + return next + }) + }) } const removeApp = (index: number) => { @@ -268,12 +303,6 @@ const Settings: React.FC = ({ onClose, onAppsChange }) => { value={newApp.url} onChange={e => setNewApp({ ...newApp, url: e.target.value })} /> - setNewApp({ ...newApp, imageUrl: e.target.value })} - />