3 Commits

Author SHA1 Message Date
747b0f4c18 feat(1.0.14): тематики searchable, фикс лага confirm-диалога
- Confirm dialog: предзагрузка WebContentsView при старте приложения.
  Раньше каждое нажатие "Закрыть" создавало новый view с холодной
  загрузкой HTML+React → ~2с лаг и дубликаты от повторных кликов.
  Теперь view кэшируется, текст обновляется через IPC, повторные
  клики игнорируются пока диалог открыт.
- Темы: 14 → 71 (Война, Холодная война, Вьетнам, Призраки, Драконы,
  Шахматы, Самолёты, Поезда, Сёрфинг, Япония, ...). Все ID
  провалидированы probe-скриптом (≥50 фильмов на тематику).
- Chip-row заменён на SearchableSelect с поиском по подстроке —
  длинный список не помещается в чипы, а dropdown с фильтром
  гораздо удобнее. Заодно ушёл фиолетовый цвет чипа, плохо
  сочетавшийся с темой сайта.
2026-05-17 11:29:39 +03:00
8684eb7b67 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.
2026-05-17 11:15:42 +03:00
1030622e19 fix(1.0.12): generalize OAuth popup for all providers, skip passive flows
Two issues in 1.0.11:

  1. YouTube auto-opens window.open on accounts.google.com/...?passive=true
     at page load to silently pick up an existing Google session via
     postMessage. Our setWindowOpenHandler routed these to a top-level popup
     where the postMessage parent context is missing → Google falls back to
     "JavaScript отключен". Active "Войти" clicks don't carry passive=true,
     so they still need the popup.

  2. Only accounts.google.com was intercepted. Yandex, Mail.ru, Microsoft,
     VK, Apple and GitHub login pages run similar embedded-browser checks;
     in-place WebContentsView navigation to them would likely trip the same
     detectors.

Replaced isGoogleLoginUrl/migrateGoogleCookies with a provider table
(OAUTH_PROVIDERS by host, OAUTH_PATH_HOSTS by host+path-prefix) so any
known login domain routes through the same clean popup + cookie-migration
flow. passive=true URLs are filtered out so window.open auto-launches
silently fail instead of popping a broken popup.

LOGIN_PARTITION renamed persist:google-login → persist:oauth-login since
it now holds login state for all providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:11:29 +03:00
8 changed files with 654 additions and 105 deletions

324
main.js
View File

@@ -453,6 +453,10 @@ async function createWindow() {
} else { } else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
} }
// Прогрев confirm-диалога: создаём WebContentsView один раз, чтобы убрать ~2с лаг
// при первом нажатии кнопки закрытия (и кнопки навигации с подтверждением).
preloadConfirmView();
} }
// --- View helpers --- // --- View helpers ---
@@ -544,22 +548,56 @@ function removeError() {
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} }); dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} });
} }
function setConfirm(text, actionOnYes) { // Confirm dialog: один кэшированный WebContentsView, переиспользуем для всех confirm'ов.
const view = makeDialogView(); // Холодный старт WebContentsView с React-загрузкой занимает ~1-2с, отсюда был лаг
confirmViews.push({ view, actionOnYes }); // при нажатии на кнопку закрытия. Теперь view создаётся при старте приложения и хранится готовым.
const query = new URLSearchParams({ text: text || '' }).toString(); let confirmCachedView = null;
view.webContents.once('did-finish-load', () => { addChild(view); }); let confirmReady = false;
let activeConfirm = null; // { actionOnYes } если диалог открыт, иначе null
let pendingConfirm = null; // если showConfirm вызвали до того как view загрузился
function preloadConfirmView() {
if (confirmCachedView) return;
confirmCachedView = makeDialogView();
confirmCachedView.webContents.once('did-finish-load', () => {
confirmReady = true;
if (pendingConfirm) {
const p = pendingConfirm;
pendingConfirm = null;
setConfirm(p.text, p.actionOnYes);
}
});
if (isDev) { if (isDev) {
view.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html?${query}`); confirmCachedView.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html`);
} else { } else {
view.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-confirm.html'), { query: { text: text || '' } }); confirmCachedView.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-confirm.html'));
} }
} }
function setConfirm(text, actionOnYes) {
// Гард: если уже открыт диалог — игнорируем повторные клики (никаких дубликатов).
if (activeConfirm) return;
if (!confirmReady) {
pendingConfirm = { text, actionOnYes };
return;
}
activeConfirm = { actionOnYes };
// confirmViews — для backwards compat с обработчиком action; держим в синхроне.
confirmViews.push({ view: confirmCachedView, actionOnYes });
confirmCachedView.webContents.send('dialog-confirm-set', { text, visible: true });
addChild(confirmCachedView);
}
function removeConfirm() { function removeConfirm() {
if (!confirmViews.length) return; if (!activeConfirm) return;
const { view } = confirmViews.pop(); activeConfirm = null;
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} }); confirmViews.pop();
confirmCachedView.webContents.send('dialog-confirm-set', { visible: false });
// Жду fade-out (≈220ms по dialogs.css), потом снимаю с DOM. View остаётся живой для следующего раза.
setTimeout(() => {
if (activeConfirm) return; // если за это время открыли новый — оставить
try { removeChild(confirmCachedView); } catch (_) {}
}, 250);
} }
@@ -629,29 +667,75 @@ async function restoreSession() {
// Google's anti-abuse JS at /v3/signin/_/AccountsSignInUi/browserinfo rejects // Google's anti-abuse JS at /v3/signin/_/AccountsSignInUi/browserinfo rejects
// any session that has an *explicit* setProxy({ proxyRules }) applied — the way // any session that has an *explicit* setProxy({ proxyRules }) applied — the way
// Chromium routes through such a proxy (explicit CONNECT etc.) is fingerprintable. // Chromium routes through such a proxy (explicit CONNECT etc.) is fingerprintable.
// Sessions inheriting Windows' system proxy pass cleanly. So we open the OAuth // Sessions inheriting Windows' system proxy pass cleanly. So we open OAuth flows
// flow in a dedicated partition that we never call setProxy() on, then copy the // in a dedicated partition that we never call setProxy() on, then copy the
// resulting .google.com / .youtube.com cookies into the parent view's session // resulting auth cookies into the parent view's session so the user appears
// so the user appears logged-in there too. // logged-in there too.
// //
// The popup is a top-level BrowserWindow (not WebContentsView) — embedded view // The popup is a top-level BrowserWindow (not WebContentsView) — embedded view
// shape is also a signal Google reads. No preload, no chrome.* spoof, no fade // shape is also a signal anti-abuse engines read. No preload, no chrome.* spoof,
// overlay: anything we touch on the DOM/globals could trip the detector. // no fade overlay: anything we touch on the DOM/globals could trip the detector.
const LOGIN_PARTITION = 'persist:google-login'; //
// Yandex, Mail.ru, Microsoft Live, VK, Facebook, Apple, GitHub all run similar
// embedded-browser checks on their login pages (some more aggressive than
// Google's), so the popup is opened for every known OAuth provider host.
const LOGIN_PARTITION = 'persist:oauth-login';
function isGoogleLoginUrl(u) { // Hostname → list of cookie-domain suffixes to migrate back to the parent
// session after a successful login. Migrating extra domains is harmless, so
// each provider lists everything the auth handshake might set cookies on.
const OAUTH_PROVIDERS = {
'accounts.google.com': ['.google.com', '.youtube.com', '.googleusercontent.com'],
'passport.yandex.ru': ['.yandex.ru', '.yandex.com', '.passport.yandex.ru'],
'passport.yandex.com': ['.yandex.ru', '.yandex.com', '.passport.yandex.com'],
'oauth.yandex.ru': ['.yandex.ru', '.yandex.com'],
'login.live.com': ['.live.com', '.microsoft.com', '.microsoftonline.com'],
'login.microsoftonline.com': ['.microsoftonline.com', '.microsoft.com', '.live.com'],
'login.microsoft.com': ['.microsoft.com', '.live.com'],
'auth.mail.ru': ['.mail.ru'],
'login.mail.ru': ['.mail.ru'],
'oauth.mail.ru': ['.mail.ru'],
'oauth.vk.com': ['.vk.com', '.vk.ru'],
'login.vk.com': ['.vk.com', '.vk.ru'],
'oauth.vk.ru': ['.vk.ru', '.vk.com'],
'login.vk.ru': ['.vk.ru', '.vk.com'],
'appleid.apple.com': ['.apple.com', '.icloud.com'],
'idmsa.apple.com': ['.apple.com', '.icloud.com'],
};
// Hosts where the login subpath signals an OAuth flow (the rest of the host
// is regular browsing). Listed separately because the bare host is not a
// login page, only specific paths are.
const OAUTH_PATH_HOSTS = {
'github.com': { prefix: '/login', cookies: ['.github.com', '.githubusercontent.com'] },
'www.facebook.com': { prefix: '/login', cookies: ['.facebook.com'] },
'm.facebook.com': { prefix: '/login', cookies: ['.facebook.com'] },
};
function oauthProviderFor(u) {
try { try {
const h = new URL(u).hostname; const url = new URL(u);
return h === 'accounts.google.com' || h.endsWith('.accounts.google.com'); // YouTube and friends silently call window.open on accounts.google.com/
} catch (_) { return false; } // ...?passive=true&... at page load to pick up an existing session via
// postMessage. That flow doesn't work in a top-level popup (no parent
// context → "JavaScript отключен" fallback). Skip popups for them; an
// active login click never has passive=true.
if (url.searchParams.get('passive') === 'true') return null;
if (OAUTH_PROVIDERS[url.hostname]) {
return { host: url.hostname, cookieDomains: OAUTH_PROVIDERS[url.hostname] };
}
const pathRule = OAUTH_PATH_HOSTS[url.hostname];
if (pathRule && url.pathname.startsWith(pathRule.prefix)) {
return { host: url.hostname, cookieDomains: pathRule.cookies };
}
return null;
} catch (_) { return null; }
} }
async function migrateGoogleCookies(fromSess, toSess) { function isOAuthLoginUrl(u) { return oauthProviderFor(u) !== null; }
// Copy .google.com and .youtube.com cookies so the parent view sees the
// just-established login session. Domains must include both bare and async function migrateOAuthCookies(fromSess, toSess, cookieDomains) {
// dot-prefixed so subdomain cookies are picked up. for (const domain of cookieDomains) {
const domains = ['.google.com', 'accounts.google.com', '.youtube.com', 'www.youtube.com', '.googleusercontent.com'];
for (const domain of domains) {
let cookies = []; let cookies = [];
try { cookies = await fromSess.cookies.get({ domain }); } catch (_) { continue; } try { cookies = await fromSess.cookies.get({ domain }); } catch (_) { continue; }
for (const c of cookies) { for (const c of cookies) {
@@ -674,6 +758,8 @@ async function migrateGoogleCookies(fromSess, toSess) {
const activeLoginPopups = new Set(); const activeLoginPopups = new Set();
function openLoginPopup(parentView, url) { function openLoginPopup(parentView, url) {
const provider = oauthProviderFor(url);
if (!provider) return; // shouldn't be called for non-OAuth urls
for (const p of activeLoginPopups) { for (const p of activeLoginPopups) {
if (p.parentView === parentView && !p.window.isDestroyed()) { if (p.parentView === parentView && !p.window.isDestroyed()) {
p.window.focus(); p.window.focus();
@@ -687,7 +773,7 @@ function openLoginPopup(parentView, url) {
const popup = new BrowserWindow({ const popup = new BrowserWindow({
width: 600, width: 600,
height: 750, height: 750,
title: 'Вход в Google', title: 'Вход',
parent: mainWindow, parent: mainWindow,
modal: false, modal: false,
autoHideMenuBar: true, autoHideMenuBar: true,
@@ -703,11 +789,11 @@ function openLoginPopup(parentView, url) {
activeLoginPopups.add(entry); activeLoginPopups.add(entry);
let finalizing = false; let finalizing = false;
const finishLogin = async (newUrl) => { const finishLogin = async () => {
if (finalizing) return; if (finalizing) return;
finalizing = true; finalizing = true;
try { try {
await migrateGoogleCookies(loginSess, parentView.webContents.session); await migrateOAuthCookies(loginSess, parentView.webContents.session, provider.cookieDomains);
if (!parentView.webContents.isDestroyed()) parentView.webContents.reload(); if (!parentView.webContents.isDestroyed()) parentView.webContents.reload();
} finally { } finally {
if (!popup.isDestroyed()) popup.close(); if (!popup.isDestroyed()) popup.close();
@@ -715,13 +801,16 @@ function openLoginPopup(parentView, url) {
}; };
const checkRedirect = (newUrl) => { const checkRedirect = (newUrl) => {
if (!newUrl || isGoogleLoginUrl(newUrl)) return; if (!newUrl) return;
// Still on a login host (Google→Yandex→… cross-redirects are rare but allowed): stay.
if (isOAuthLoginUrl(newUrl)) return;
try { try {
const h = new URL(newUrl).hostname; const h = new URL(newUrl).hostname;
// Login flow handed control back to a non-Google host (youtube.com etc.) → success. // Login flow handed control back to a non-login host (the service we
if (h && !h.endsWith('.google.com') && h !== 'google.com') { // came from, e.g. youtube.com) → success.
finishLogin(newUrl); const loginHost = provider.host;
} const isStillOnProviderApex = h === loginHost || h.endsWith('.' + loginHost.replace(/^[^.]+\./, ''));
if (!isStillOnProviderApex) finishLogin();
} catch (_) {} } catch (_) {}
}; };
popup.webContents.on('will-redirect', (_e, u) => checkRedirect(u)); popup.webContents.on('will-redirect', (_e, u) => checkRedirect(u));
@@ -814,8 +903,8 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy, b
view.webContents.on('will-navigate', (e, newUrl) => { view.webContents.on('will-navigate', (e, newUrl) => {
if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; } if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; }
// accounts.google.com → top-level BrowserWindow popup (see openLoginPopup). // OAuth login URL → top-level BrowserWindow popup (see openLoginPopup).
if (isGoogleLoginUrl(newUrl)) { e.preventDefault(); openLoginPopup(view, newUrl); return; } if (isOAuthLoginUrl(newUrl)) { e.preventDefault(); openLoginPopup(view, newUrl); return; }
let newHostname = ''; let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; } try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; }
if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) { if (origHostname && newHostname && newHostname !== origHostname && !isTrustedDomain(newHostname)) {
@@ -827,12 +916,12 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy, b
trackNavigation(newUrl); trackNavigation(newUrl);
}); });
view.webContents.on('will-redirect', (e, u) => { view.webContents.on('will-redirect', (e, u) => {
if (isGoogleLoginUrl(u)) { e.preventDefault(); openLoginPopup(view, u); return; } if (isOAuthLoginUrl(u)) { e.preventDefault(); openLoginPopup(view, u); return; }
trackNavigation(u); trackNavigation(u);
}); });
view.webContents.setWindowOpenHandler(({ url: newUrl }) => { view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
// accounts.google.com → top-level BrowserWindow popup (see openLoginPopup). // OAuth login URL → top-level BrowserWindow popup (see openLoginPopup).
if (isGoogleLoginUrl(newUrl)) { openLoginPopup(view, newUrl); return { action: 'deny' }; } if (isOAuthLoginUrl(newUrl)) { openLoginPopup(view, newUrl); return { action: 'deny' }; }
let newHostname = ''; let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) {} try { newHostname = new URL(newUrl).hostname; } catch (_) {}
@@ -1179,18 +1268,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): // Multi-filter semantics (per user request):
// genres → AND (movie must match ALL selected genres) — TMDB comma-join in with_genres // 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 // 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 // years → OR (movie released in ANY selected year) — fan-out: one request per year, merge
// rating → min threshold (single) — vote_average.gte // rating → min threshold (single) — vote_average.gte
// themeKeywords → OR within theme (curated keyword IDs) — TMDB pipe-join in with_keywords
try { try {
const isBearer = apiKey.startsWith('eyJ'); const isBearer = apiKey.startsWith('eyJ');
const type = mediaType === 'tv' ? 'tv' : 'movie'; const type = mediaType === 'tv' ? 'tv' : 'movie';
const genreArr = Array.isArray(genreIds) ? genreIds.filter(Boolean) : []; const genreArr = Array.isArray(genreIds) ? genreIds.filter(Boolean) : [];
const countryArr = Array.isArray(countries) ? countries.filter(Boolean) : []; const countryArr = Array.isArray(countries) ? countries.filter(Boolean) : [];
const yearArr = Array.isArray(years) ? years.filter(Boolean) : []; const yearArr = Array.isArray(years) ? years.filter(Boolean) : [];
const keywordArr = Array.isArray(themeKeywords) ? themeKeywords.filter(Boolean) : [];
const buildParams = (yearOverride) => { const buildParams = (yearOverride) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -1201,6 +1292,7 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr
}); });
if (!isBearer) params.set('api_key', apiKey); if (!isBearer) params.set('api_key', apiKey);
if (genreArr.length) params.set('with_genres', genreArr.join(',')); // AND 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 (minRating) params.set('vote_average.gte', minRating);
if (countryArr.length) { if (countryArr.length) {
// Mix: codes that map to original_language go to with_original_language (pipe-OR), // Mix: codes that map to original_language go to with_original_language (pipe-OR),
@@ -1273,9 +1365,153 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr
} }
}); });
ipcMain.handle('search-movies', async (_event, query, sites) => { // Streaming search: один сайт ответил → шлём результаты немедленно, не ждём остальных.
const settled = await Promise.allSettled(sites.map(s => searchOneSite(s, query))); // Клиент передаёт searchId, чтобы можно было отменить устаревший поиск (token).
return settled.flatMap(r => r.status === 'fulfilled' ? r.value : []); 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', () => { ipcMain.handle('get-current-page', () => {

View File

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

View File

@@ -26,9 +26,34 @@ contextBridge.exposeInMainWorld('electron', {
checkUpdateNow: () => ipcRenderer.invoke('check-update-now'), checkUpdateNow: () => ipcRenderer.invoke('check-update-now'),
readConfig: () => ipcRenderer.invoke('read-config'), readConfig: () => ipcRenderer.invoke('read-config'),
writeConfig: (data) => ipcRenderer.send('write-config', data), 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), searchTmdb: (query, apiKey) => ipcRenderer.invoke('search-tmdb', query, apiKey),
discoverTmdb: (params) => ipcRenderer.invoke('discover-tmdb', params), discoverTmdb: (params) => ipcRenderer.invoke('discover-tmdb', params),
detectLogo: (siteUrl) => ipcRenderer.invoke('detect-logo', siteUrl),
toggleKiosk: () => ipcRenderer.invoke('toggle-kiosk'), toggleKiosk: () => ipcRenderer.invoke('toggle-kiosk'),
isKiosk: () => ipcRenderer.invoke('is-kiosk'), isKiosk: () => ipcRenderer.invoke('is-kiosk'),

View File

@@ -45,6 +45,81 @@ const Select: React.FC<{
) )
} }
// 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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={ref} className={`ms-select ms-searchable${open ? ' open' : ''}`}>
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
<span className={selected ? 'ms-select-active' : 'ms-select-placeholder'}>
{selected ? selected.label : placeholder}
</span>
{selected && (
<button className="ms-multi-clear" onClick={e => { e.stopPropagation(); onChange('') }} title="Сбросить">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
{open && (
<div className="ms-select-dropdown ms-searchable-dropdown" onClick={e => e.stopPropagation()}>
<div className="ms-searchable-input-wrap">
<svg className="ms-searchable-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
ref={inputRef}
className="ms-searchable-input"
placeholder="Найти тему..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="ms-searchable-list" style={{ maxHeight }}>
{filtered.map(o => (
<div
key={o.value}
className={`ms-select-opt${o.value === value ? ' active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setSearch('') }}
>{o.label}</div>
))}
{!filtered.length && <div className="ms-searchable-empty">Ничего не найдено</div>}
</div>
</div>
)}
</div>
)
}
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder. // Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
const MultiSelect: React.FC<{ const MultiSelect: React.FC<{
values: string[] values: string[]
@@ -152,6 +227,86 @@ const TV_GENRES = [
{ id: 10765, name: 'Фантастика' }, { id: 10768, name: 'Политика' }, { id: 37, 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 = [ const SORTS = [
{ value: 'popularity.desc', label: 'Популярные' }, { value: 'popularity.desc', label: 'Популярные' },
{ value: 'vote_average.desc', label: 'По рейтингу' }, { value: 'vote_average.desc', label: 'По рейтингу' },
@@ -226,6 +381,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie') const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
const [sortBy, setSortBy] = useState('popularity.desc') const [sortBy, setSortBy] = useState('popularity.desc')
const [genreIds, setGenreIds] = useState<number[]>([]) const [genreIds, setGenreIds] = useState<number[]>([])
const [themeId, setThemeId] = useState<string>('') // single-select
const [years, setYears] = useState<string[]>([]) const [years, setYears] = useState<string[]>([])
const [minRating, setMinRating] = useState('') const [minRating, setMinRating] = useState('')
const [countries, setCountries] = useState<string[]>([]) const [countries, setCountries] = useState<string[]>([])
@@ -280,7 +436,13 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
if (append) setLoadingMore(true) if (append) setLoadingMore(true)
else { setTmdbLoading(true); setMessage('') } else { setTmdbLoading(true); setMessage('') }
try { 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 (token !== discoverRef.current) return
if (res.error) { setMessage(`Ошибка: ${res.error}`); return } if (res.error) { setMessage(`Ошибка: ${res.error}`); return }
setTmdbResults(prev => append ? [...prev, ...res.results] : res.results) setTmdbResults(prev => append ? [...prev, ...res.results] : res.results)
@@ -291,14 +453,14 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
} finally { } finally {
if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) } if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) }
} }
}, [mediaType, sortBy, genreIds, years, minRating, countries]) }, [mediaType, sortBy, genreIds, themeId, years, minRating, countries])
useEffect(() => { useEffect(() => {
if (!configLoaded || !apiKey || isSearchMode) return if (!configLoaded || !apiKey || isSearchMode) return
setPage(1) setPage(1)
setTmdbResults([]) setTmdbResults([])
doDiscover(apiKey, 1, false) 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 searchRef = useRef(0)
const doTmdbSearch = async (q: string, key: string) => { const doTmdbSearch = async (q: string, key: string) => {
@@ -322,42 +484,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) setSitesLoading(true)
setMessage('') setMessage('')
setSiteResults([]) setSiteResults([])
try { }
const data = await window.electron!.searchMovies(q, sitesToSearch)
let filtered = data const y = yearHint ? parseInt(yearHint) : NaN
if (yearHint) {
const y = parseInt(yearHint)
const isTv = mt === 'tv' const isTv = mt === 'tv'
const yearDist = (r: SiteResult) => r.year ? Math.abs(parseInt(r.year) - y) : 0.5 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 filterBatch = (batch: SiteResult[]): SiteResult[] => {
const groups = new Map<string, SiteResult[]>() if (!yearHint) return batch
for (const r of data) { // Для фильмов выкидываем результаты с явно неверным годом (>1 год отличия).
const key = normalizeTitle(r.title) // Для сериалов год часто не совпадает (разные сезоны) — оставляем все.
if (!groups.has(key)) groups.set(key, []) const filtered = isTv ? batch : batch.filter(r => !r.year || yearDist(r) <= 1)
groups.get(key)!.push(r) return filtered.sort((a, b) => yearDist(a) - yearDist(b))
} }
const deduped: SiteResult[] = []
for (const group of groups.values()) { const cancel = window.electron!.searchMoviesStream(
const minDist = Math.min(...group.map(yearDist)) q,
deduped.push(...group.filter(r => yearDist(r) === minDist)) sitesToSearch,
} ({ source: _src, results }: { source: string; results: SiteResult[] }) => {
filtered = isTv const batch = filterBatch(results)
? deduped.sort((a, b) => yearDist(a) - yearDist(b)) if (!batch.length) return
: deduped.filter(r => !r.year || yearDist(r) <= 1).sort((a, b) => yearDist(a) - yearDist(b)) setSiteResults(prev => {
} // Дедупим по URL (если параллельный поиск по originalTitle принесёт те же ссылки).
setSiteResults(filtered) const seen = new Set(prev.map(r => r.url))
if (!filtered.length) setMessage('Не найдено ни на одном сайте') const fresh = batch.filter(r => !seen.has(r.url))
} catch { if (!fresh.length) return prev
setMessage('Ошибка поиска по сайтам') const merged = [...prev, ...fresh]
} finally { return yearHint ? merged.sort((a, b) => yearDist(a) - yearDist(b)) : merged
})
},
() => {
setSitesLoading(false) 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 handleSearch = () => {
const q = query.trim() const q = query.trim()
@@ -373,13 +555,10 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
setMessage('') setMessage('')
const searchTitle = movie.title || movie.originalTitle const searchTitle = movie.title || movie.originalTitle
doSiteSearch(searchTitle, sites, movie.year, movie.mediaType) doSiteSearch(searchTitle, sites, movie.year, movie.mediaType)
// Параллельный поиск по оригинальному названию — append=true, чтобы не сбросить первичный поиск.
// Дедуп по URL уже в doSiteSearch.
if (movie.originalTitle && movie.originalTitle !== movie.title) { if (movie.originalTitle && movie.originalTitle !== movie.title) {
window.electron!.searchMovies(movie.originalTitle, sites).then(extra => { doSiteSearch(movie.originalTitle, sites, movie.year, movie.mediaType, true)
setSiteResults(prev => {
const existing = new Set(prev.map(r => r.url))
return [...prev, ...extra.filter(r => !existing.has(r.url))]
})
}).catch(() => {})
} }
} }
@@ -443,11 +622,11 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
<div className="ms-type-toggle"> <div className="ms-type-toggle">
<button <button
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`} className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })} onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]); setThemeId('') })}
>Фильмы</button> >Фильмы</button>
<button <button
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`} className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })} onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]); setThemeId('') })}
>Сериалы</button> >Сериалы</button>
</div> </div>
@@ -485,6 +664,18 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
)} )}
</div> </div>
{/* Themes — searchable dropdown (71 тематика, длинный список) */}
{!isSearchMode && (
<div className="ms-theme-row">
<SearchableSelect
value={themeId}
onChange={v => handleFilterChange(() => setThemeId(v))}
options={THEMES.map(t => ({ value: t.id, label: t.label }))}
placeholder="Тематика (например: о войне, зомби, шахматы)"
/>
</div>
)}
{/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */} {/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
{!isSearchMode && ( {!isSearchMode && (
<div className="ms-genres"> <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 [settings, setSettings] = useState<SettingsData>(DEFAULT_SETTINGS)
const [newApp, setNewApp] = useState<AppEntry>({ name: '', imageUrl: '', url: '', useProxy: false }) 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(() => { useEffect(() => {
window.electron?.readConfig().then((cfg: SettingsData | null) => { 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 = () => { const addApp = () => {
if (!newApp.name || !newApp.url) return 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 } const updated = { ...settings, apps }
setSettings(updated) setSettings(updated)
saveSettings(updated) saveSettings(updated)
onAppsChange(apps) onAppsChange(apps)
setNewApp({ name: '', imageUrl: '', url: '', useProxy: false }) 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) => { const removeApp = (index: number) => {
@@ -268,12 +303,6 @@ const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
value={newApp.url} value={newApp.url}
onChange={e => setNewApp({ ...newApp, url: e.target.value })} 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"> <label className="proxy-switch-label add-proxy-label">
<span>Использовать прокси</span> <span>Использовать прокси</span>
<div <div

View File

@@ -4,22 +4,42 @@ import '../styles/dialogs.css'
declare global { declare global {
interface Window { interface Window {
electron?: { handleAction: (action: string) => void } electron?: {
handleAction: (action: string) => void
on: (channel: string, fn: (...args: any[]) => void) => () => void
}
__dialogData?: { text?: string } __dialogData?: { text?: string }
} }
} }
const ConfirmDialog = () => { const ConfirmDialog = () => {
// Cached view re-uses этот компонент: текст и видимость приходят по IPC.
// initialText из URL — backwards compat (если кто-то откроет dialog-confirm.html напрямую).
const initialText = new URLSearchParams(window.location.search).get('text') || ''
const [text, setText] = useState(initialText)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const params = new URLSearchParams(window.location.search)
const text = params.get('text') || window.__dialogData?.text || ''
useEffect(() => { useEffect(() => {
if (initialText) {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true))) requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
}
return window.electron?.on('dialog-confirm-set', (data: { text?: string; visible?: boolean }) => {
if (typeof data.text === 'string') setText(data.text)
if (typeof data.visible === 'boolean') {
if (data.visible) requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
else setVisible(false)
}
})
}, []) }, [])
useEffect(() => { useEffect(() => {
if (visible) document.body.classList.add('visible') if (visible) {
document.body.classList.remove('hiding')
document.body.classList.add('visible')
} else {
document.body.classList.remove('visible')
document.body.classList.add('hiding')
}
}, [visible]) }, [visible])
return ( return (

View File

@@ -22,9 +22,15 @@ declare global {
refreshPage: () => void refreshPage: () => void
readConfig: () => Promise<any> readConfig: () => Promise<any>
writeConfig: (data: any) => void 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 }> searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }>
discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; 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> getPageMeta: () => Promise<{ poster: string; title: string; url: string } | null>
installUpdate: () => Promise<boolean> installUpdate: () => Promise<boolean>
checkUpdateNow: () => Promise<boolean> checkUpdateNow: () => Promise<boolean>

View File

@@ -788,6 +788,48 @@ body {
.ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; } .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-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Theme dropdown row (заменил chip-row из v1.0.13 — 71 тематика, нужен поиск) */
.ms-theme-row { margin-bottom: 4px; }
.ms-theme-row .ms-searchable { min-width: 280px; max-width: 420px; }
/* Searchable dropdown — поиск + список */
.ms-searchable-dropdown {
display: flex;
flex-direction: column;
min-width: 280px;
padding: 0;
}
.ms-searchable-input-wrap {
position: relative;
border-bottom: 1px solid var(--border);
padding: 6px 10px 6px 30px;
}
.ms-searchable-icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: #777;
}
.ms-searchable-input {
width: 100%;
background: transparent;
border: none;
outline: none;
color: #eee;
font-size: 12px;
padding: 4px 0;
}
.ms-searchable-input::placeholder { color: #666; }
.ms-searchable-list { overflow-y: auto; }
.ms-searchable-empty {
padding: 12px;
text-align: center;
color: #666;
font-size: 11px;
font-style: italic;
}
.ms-genres-hint { .ms-genres-hint {
font-size: 10px; font-size: 10px;
color: #666; color: #666;