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:
155
main.js
155
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. <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', () => {
|
||||
|
||||
Reference in New Issue
Block a user