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', () => {