feat(1.0.13): тематики, стриминг поиска, авто-логотипы

- Тематики: 14 курированных категорий (Зомби, Космос, Вампиры,
  Постапокалипсис, Шпионы и т.д.) поверх жанров TMDB. discover-tmdb
  принимает themeKeywords (pipe-OR по with_keywords); для musical/
  superhero/martial добавлен extraGenre AND-связкой в movie-режиме.
- Стриминг поиска по сайтам: search-movies переведён с invoke→return
  на event-emit — карточки появляются по мере ответа каждого сайта,
  не ждут самого медленного. Спиннер виден до первого результата.
- Авто-детект логотипа сайта: поле "URL иконки" убрано из формы.
  Бэк IPC detect-logo пробует manifest.json → apple-touch-icon →
  link rel=icon ≥48px → JSON-LD → og:image → msapplication-TileImage
  → /favicon.ico (с проверкой content-type=image/*). Легаси-приложения
  без иконки догоняются тихо при открытии Settings.
This commit is contained in:
2026-05-17 11:15:42 +03:00
parent 1030622e19
commit 8684eb7b67
7 changed files with 338 additions and 60 deletions

155
main.js
View File

@@ -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. <link rel="apple-touch-icon"|"apple-touch-icon-precomposed"> (180×180 standard)
// 3. <link rel="icon" sizes="..."> с максимальным размером ≥48
// 4. JSON-LD Organization.logo
// 5. <meta property="og:image"> (часто hero, но всё лучше favicon)
// 6. <meta name="msapplication-TileImage">
// 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', () => {

View File

@@ -1,6 +1,6 @@
{
"name": "ESH-Media",
"version": "1.0.12",
"version": "1.0.13",
"private": true,
"main": "main.js",
"scripts": {

View File

@@ -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'),

View File

@@ -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<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
const [sortBy, setSortBy] = useState('popularity.desc')
const [genreIds, setGenreIds] = useState<number[]>([])
const [themeId, setThemeId] = useState<string>('') // single-select
const [years, setYears] = useState<string[]>([])
const [minRating, setMinRating] = useState('')
const [countries, setCountries] = useState<string[]>([])
@@ -280,7 +301,13 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ 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<MovieSearchProps> = ({ 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,42 +349,62 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
}
}
const doSiteSearch = async (q: string, sitesToSearch: MovieSite[], yearHint?: string, mt?: string) => {
if (!sitesToSearch.length) { setMessage('Нет активных сайтов. Добавьте в Настройки → Поиск фильмов.'); return }
// Стриминг: каждый сайт ответил → результаты добавляются немедленно. Спиннер виден до первого ответа.
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([])
try {
const data = await window.electron!.searchMovies(q, sitesToSearch)
let filtered = data
if (yearHint) {
const y = parseInt(yearHint)
}
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 normalizeTitle = (t: string) => t.toLowerCase().replace(/[^а-яёa-z0-9]/gi, ' ').replace(/\s+/g, ' ').trim()
const groups = new Map<string, SiteResult[]>()
for (const r of data) {
const key = normalizeTitle(r.title)
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(r)
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 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 {
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()
@@ -373,13 +420,10 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ 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<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
<div className="ms-type-toggle">
<button
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })}
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]); setThemeId('') })}
>Фильмы</button>
<button
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })}
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]); setThemeId('') })}
>Сериалы</button>
</div>
@@ -485,6 +529,24 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
)}
</div>
{/* Themes (single-select — одна тематика за раз) */}
{!isSearchMode && (
<div className="ms-genres ms-themes">
<button
className={`ms-genre-chip ms-theme-chip${themeId === '' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setThemeId(''))}
title="Без тематики"
>Без темы</button>
{THEMES.map(t => (
<button
key={t.id}
className={`ms-genre-chip ms-theme-chip${themeId === t.id ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setThemeId(prev => prev === t.id ? '' : t.id))}
>{t.label}</button>
))}
</div>
)}
{/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
{!isSearchMode && (
<div className="ms-genres">

View File

@@ -66,9 +66,33 @@ const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
const [settings, setSettings] = useState<SettingsData>(DEFAULT_SETTINGS)
const [newApp, setNewApp] = useState<AppEntry>({ 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<SettingsProps> = ({ 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<SettingsProps> = ({ onClose, onAppsChange }) => {
value={newApp.url}
onChange={e => setNewApp({ ...newApp, url: e.target.value })}
/>
<input
className="settings-input"
placeholder="URL иконки (необязательно)"
value={newApp.imageUrl}
onChange={e => setNewApp({ ...newApp, imageUrl: e.target.value })}
/>
<label className="proxy-switch-label add-proxy-label">
<span>Использовать прокси</span>
<div

View File

@@ -22,9 +22,15 @@ declare global {
refreshPage: () => void
readConfig: () => Promise<any>
writeConfig: (data: any) => void
searchMovies: (query: string, sites: any[]) => Promise<any[]>
searchMoviesStream: (
query: string,
sites: any[],
onResult: (batch: { source: string; results: any[] }) => void,
onDone: () => void,
) => () => void
searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }>
discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }>
detectLogo: (siteUrl: string) => Promise<string>
getPageMeta: () => Promise<{ poster: string; title: string; url: string } | null>
installUpdate: () => Promise<boolean>
checkUpdateNow: () => Promise<boolean>

View File

@@ -788,6 +788,15 @@ body {
.ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; }
.ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Темы — фиолетовая заливка в активном состоянии, чтобы не сливались с жанрами */
.ms-themes { margin-bottom: 6px; }
.ms-theme-chip { font-size: 11.5px; padding: 5px 13px; }
.ms-theme-chip.active {
background: linear-gradient(135deg, #b66dff 0%, #6e4dff 100%);
border-color: #8a5cff;
color: #fff;
}
.ms-genres-hint {
font-size: 10px;
color: #666;