Files
ESH-Media/main.js

874 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { app, BrowserWindow, WebContentsView, ipcMain, session } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const cheerio = require('cheerio');
const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron');
const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json');
const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v2.bin');
const DEFAULT_CONFIG = { apps: [], proxy: { host: '127.0.0.1', port: '7890' } };
let blockerPromise = null;
function getBlocker() {
if (blockerPromise) return blockerPromise;
blockerPromise = (async () => {
// Load from cache first (avoids re-downloading on every startup)
if (fs.existsSync(BLOCKER_CACHE_PATH)) {
try {
const data = fs.readFileSync(BLOCKER_CACHE_PATH);
const b = ElectronBlocker.deserialize(new Uint8Array(data));
console.log('[adblock] loaded from cache');
return b;
} catch (e) {
console.warn('[adblock] cache invalid, re-downloading:', e.message);
}
}
// Download filter lists (EasyList + EasyPrivacy + uBlock Origin + Russian ad networks)
console.log('[adblock] downloading filter lists...');
const fetchFn = (url, opts) => getProxySession().fetch(url, opts);
const russianLists = [
'https://filters.adtidy.org/extension/ublock/filters/1.txt', // AdGuard Russian
'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList
];
const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]);
// Whitelist TMDB so the movie search API is not blocked
b.addFilters(['@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^']);
fs.writeFileSync(BLOCKER_CACHE_PATH, Buffer.from(b.serialize()));
console.log('[adblock] filter lists downloaded and cached');
return b;
})();
return blockerPromise;
}
function enableBlockingInSession(sess) {
getBlocker()
.then(b => { b.enableBlockingInSession(sess); console.log('[adblock] enabled for session'); })
.catch(e => console.warn('[adblock] failed to enable:', e.message));
}
const isDev = !app.isPackaged;
const RENDERER_URL = 'http://localhost:5173';
const PRELOAD_PATH = path.join(__dirname, 'preload.js');
const EXTENSIONS_PATH = path.join(__dirname, 'extensions');
const HEADER_H = 50;
const SIDEBAR_COLLAPSED_W = 75;
let mainWindow = null;
let currentView = null;
let loaderView = null;
let openedApps = [];
const errorViews = [];
const confirmViews = [];
let proxySession = null;
let directSession = null;
let pendingNavigate = null; // { view, url } — cross-domain redirect awaiting confirmation
// --- Sessions ---
function getProxySession() {
if (!proxySession) {
proxySession = session.fromPartition('persist:proxy');
enableBlockingInSession(proxySession);
}
return proxySession;
}
function getDirectSession() {
if (!directSession) {
directSession = session.fromPartition('persist:direct');
directSession.setProxy({ proxyRules: 'direct://' });
enableBlockingInSession(directSession);
}
return directSession;
}
async function applyProxy(host, port) {
const proxyRules = `http=${host}:${port};https=${host}:${port};socks=socks5://${host}:${port}`;
await getProxySession().setProxy({ proxyRules });
await session.defaultSession.setProxy({ proxyRules });
}
// --- Extensions ---
async function loadExtensions() {
if (!fs.existsSync(EXTENSIONS_PATH)) return;
const entries = fs.readdirSync(EXTENSIONS_PATH, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const extPath = path.join(EXTENSIONS_PATH, entry.name);
// Fix Windows-style backslash paths in declarative_net_request manifest entries
const manifestPath = path.join(extPath, 'manifest.json');
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
let changed = false;
// Remove rule_resources entries whose files don't exist on disk
if (manifest.declarative_net_request?.rule_resources) {
const valid = manifest.declarative_net_request.rule_resources.filter(r => {
const rPath = r.path.replace(/^\//, '').split('/').join(path.sep);
return fs.existsSync(path.join(extPath, rPath));
});
if (valid.length !== manifest.declarative_net_request.rule_resources.length) {
manifest.declarative_net_request.rule_resources = valid;
changed = true;
console.log(`Removed ${manifest.declarative_net_request.rule_resources.length === 0 ? 'all' : 'missing'} DNR rule_resources for: ${entry.name}`);
}
}
// Remove service_worker — Electron doesn't support MV3 service workers for extensions
if (manifest.background?.service_worker) {
delete manifest.background.service_worker;
if (!Object.keys(manifest.background).length) delete manifest.background;
changed = true;
}
// Remove permissions unsupported by Electron to suppress warnings
const UNSUPPORTED_PERMS = new Set(['contextMenus', 'notifications', 'webNavigation', 'management']);
if (manifest.permissions) {
const filtered = manifest.permissions.filter(p => !UNSUPPORTED_PERMS.has(p));
if (filtered.length !== manifest.permissions.length) {
manifest.permissions = filtered;
changed = true;
}
}
if (changed) fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
} catch (e) {
console.warn('Failed to patch manifest for', entry.name, e.message);
}
}
// Load into all sessions so content scripts run in WebContentsViews too
const sessionsToLoad = [
session.defaultSession,
session.fromPartition('persist:proxy'),
session.fromPartition('persist:direct'),
];
for (const sess of sessionsToLoad) {
try {
await sess.loadExtension(extPath, { allowFileAccess: true });
} catch (e) {
// only log once (defaultSession gives the meaningful error)
if (sess === session.defaultSession)
console.warn('Failed to load extension', entry.name, e.message);
}
}
console.log('Loaded extension:', entry.name);
}
}
// --- Window ---
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
kiosk: true,
autoHideMenuBar: true,
webPreferences: {
preload: PRELOAD_PATH,
contextIsolation: true,
nodeIntegration: false,
},
});
if (isDev) {
mainWindow.loadURL(RENDERER_URL);
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
}
}
// --- View helpers ---
function getViewBounds(sidebarWidth) {
const w = sidebarWidth !== undefined ? sidebarWidth : SIDEBAR_COLLAPSED_W;
const { width, height } = mainWindow.getBounds();
return { x: w, y: HEADER_H, width: width - w, height: height - HEADER_H };
}
function addChild(view) {
mainWindow.contentView.addChildView(view);
}
function removeChild(view) {
try { mainWindow.contentView.removeChildView(view); } catch (_) {}
}
function bringOverlaysToTop() {
confirmViews.forEach(c => { removeChild(c.view); addChild(c.view); });
errorViews.forEach(v => { removeChild(v); addChild(v); });
}
function sendOpenedApps(activeName) {
mainWindow.webContents.send(
'update-opened-apps',
openedApps.map(a => ({ name: a.name, url: a.url, imageUrl: a.imageUrl })),
activeName
);
}
// --- Loader ---
function setLoader() {
if (loaderView) return;
const { width, height } = mainWindow.getBounds();
loaderView = new WebContentsView({ webPreferences: { contextIsolation: true, nodeIntegration: false } });
addChild(loaderView);
loaderView.setBounds({ x: 0, y: HEADER_H, width, height: height - HEADER_H });
if (isDev) {
loaderView.webContents.loadURL(`${RENDERER_URL}/loader.html`);
} else {
loaderView.webContents.loadFile(path.join(__dirname, 'dist', 'loader.html'));
}
}
function removeLoader() {
if (!loaderView) return;
const lv = loaderView;
loaderView = null;
lv.webContents.executeJavaScript(`document.body.style.opacity='0'`).catch(() => {});
setTimeout(() => { try { removeChild(lv); lv.webContents.destroy(); } catch (_) {} }, 260);
}
// --- Dialogs (WebContentsView overlays) ---
function dialogFadeOut(view, cb) {
view.webContents.executeJavaScript(
`document.body.classList.remove('visible');document.body.classList.add('hiding')`
).catch(() => {});
setTimeout(cb, 230);
}
function makeDialogView() {
const { width, height } = mainWindow.getBounds();
const view = new WebContentsView({
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: PRELOAD_PATH },
});
view.setBackgroundColor('#00000000');
view.setBounds({ x: 0, y: 0, width, height });
return view;
}
function setError(title, text) {
const view = makeDialogView();
errorViews.push(view);
const query = new URLSearchParams({ title: title || '', text: text || '' }).toString();
view.webContents.once('did-finish-load', () => { addChild(view); });
if (isDev) {
view.webContents.loadURL(`${RENDERER_URL}/dialog-error.html?${query}`);
} else {
view.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-error.html'), { query: { title: title || '', text: text || '' } });
}
}
function removeError() {
if (!errorViews.length) return;
const view = errorViews.pop();
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} });
}
function setConfirm(text, actionOnYes) {
const view = makeDialogView();
confirmViews.push({ view, actionOnYes });
const query = new URLSearchParams({ text: text || '' }).toString();
view.webContents.once('did-finish-load', () => { addChild(view); });
if (isDev) {
view.webContents.loadURL(`${RENDERER_URL}/dialog-confirm.html?${query}`);
} else {
view.webContents.loadFile(path.join(__dirname, 'dist', 'dialog-confirm.html'), { query: { text: text || '' } });
}
}
function removeConfirm() {
if (!confirmViews.length) return;
const { view } = confirmViews.pop();
dialogFadeOut(view, () => { try { removeChild(view); view.webContents.destroy(); } catch (_) {} });
}
function removeView(name) {
const idx = openedApps.findIndex(a => a.name === name);
if (idx === -1) return;
const [app] = openedApps.splice(idx, 1);
removeChild(app.view);
app.view.webContents.destroy();
if (currentView && currentView.name === name) currentView = null;
sendOpenedApps('home');
}
// --- IPC ---
ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) => {
if (!url || !name) return;
const existing = openedApps.find(a => a.name === name);
if (existing) {
if (currentView && currentView.view) removeChild(currentView.view);
addChild(existing.view);
bringOverlaysToTop();
currentView = existing;
currentView.view.setBounds(getViewBounds());
sendOpenedApps(name);
mainWindow.webContents.send('updateWebButtons', { history: existing.history, historyPosition: existing.historyPosition });
return;
}
if (currentView && currentView.view) removeChild(currentView.view);
setLoader();
const view = new WebContentsView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: useProxy ? getProxySession() : getDirectSession(),
},
});
const appEntry = { name, url, imageUrl, view, history: [url], historyPosition: 0 };
openedApps.push(appEntry);
currentView = appEntry;
view.setBounds(getViewBounds());
view.webContents.on('did-finish-load', () => {
removeLoader();
addChild(view);
bringOverlaysToTop();
// Inject fade-in overlay so the page appears smoothly instead of blinking
view.webContents.executeJavaScript(`
(function(){
if(document.__nfFade)return; document.__nfFade=true;
const o=document.createElement('div');
o.style.cssText='position:fixed;inset:0;background:#111;z-index:2147483647;pointer-events:none;transition:opacity 0.35s ease;';
document.documentElement.appendChild(o);
requestAnimationFrame(()=>requestAnimationFrame(()=>{
o.style.opacity='0';
setTimeout(()=>o.remove(),370);
}));
})()
`).catch(()=>{});
});
const trackNavigation = (navigatingUrl) => {
const app = openedApps.find(a => a.name === name);
if (!app) return;
if (navigatingUrl === app.history[app.historyPosition]) {
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
return;
}
if (app.historyPosition < app.history.length - 1) {
app.history = app.history.slice(0, app.historyPosition + 1);
}
app.history.push(navigatingUrl);
app.historyPosition = app.history.length - 1;
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
};
let origHostname = '';
try { origHostname = new URL(url).hostname; } catch (_) {}
view.webContents.on('will-navigate', (e, newUrl) => {
if (newUrl.startsWith('data:')) { trackNavigation(newUrl); return; }
let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) { trackNavigation(newUrl); return; }
if (origHostname && newHostname && newHostname !== origHostname) {
e.preventDefault();
pendingNavigate = { view, url: newUrl };
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
return;
}
trackNavigation(newUrl);
});
view.webContents.on('will-redirect', (_e, u) => trackNavigation(u));
view.webContents.setWindowOpenHandler(({ url: newUrl }) => {
let newHostname = '';
try { newHostname = new URL(newUrl).hostname; } catch (_) {}
if (origHostname && newHostname && newHostname !== origHostname) {
pendingNavigate = { view, url: newUrl };
setConfirm(`Перейти на "${newHostname}"?`, 'navigate-confirmed');
return { action: 'deny' };
}
trackNavigation(newUrl);
view.webContents.loadURL(newUrl);
return { action: 'deny' };
});
sendOpenedApps(name);
mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition });
view.webContents.loadURL(url).catch(() => {
removeView(name);
removeLoader();
setError('Ошибка', `Не удалось загрузить: ${name}`);
});
});
ipcMain.on('remove-view', (_event, name) => removeView(name || (currentView && currentView.name)));
ipcMain.on('hide-view', () => {
if (currentView && currentView.view) removeChild(currentView.view);
currentView = null;
});
ipcMain.on('show-view', (_event, name) => {
const app = openedApps.find(a => a.name === name);
if (!app) return;
if (currentView && currentView.view) removeChild(currentView.view);
currentView = app;
addChild(app.view);
bringOverlaysToTop();
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
});
let sidebarAnim = null;
const SIDEBAR_ANIM_MS = 250;
function animateSidebarResize(targetX) {
if (!currentView || !currentView.view) return;
if (sidebarAnim) { clearInterval(sidebarAnim); sidebarAnim = null; }
const fromX = currentView.view.getBounds().x;
if (fromX === targetX) return;
const startTime = Date.now();
// easeInOut
const ease = t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
sidebarAnim = setInterval(() => {
const t = Math.min((Date.now() - startTime) / SIDEBAR_ANIM_MS, 1);
const x = Math.round(fromX + (targetX - fromX) * ease(t));
if (currentView && currentView.view) {
const { width, height } = mainWindow.getBounds();
currentView.view.setBounds({ x, y: HEADER_H, width: width - x, height: height - HEADER_H });
}
if (t >= 1) { clearInterval(sidebarAnim); sidebarAnim = null; }
}, 16);
}
ipcMain.on('adjust-view', (_event, expanded) => {
animateSidebarResize(expanded ? 200 : SIDEBAR_COLLAPSED_W);
});
ipcMain.on('backwardPage', () => {
const app = openedApps.find(a => a.name === (currentView && currentView.name));
if (!app || app.historyPosition <= 0) return;
app.historyPosition--;
currentView.view.webContents.loadURL(app.history[app.historyPosition])
.catch(() => setError('Ошибка', 'Страница не найдена'));
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
});
ipcMain.on('forwardPage', () => {
const app = openedApps.find(a => a.name === (currentView && currentView.name));
if (!app || app.historyPosition >= app.history.length - 1) return;
app.historyPosition++;
currentView.view.webContents.loadURL(app.history[app.historyPosition])
.catch(() => setError('Ошибка', 'Страница не найдена'));
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
});
ipcMain.on('refreshPage', () => {
const app = openedApps.find(a => a.name === (currentView && currentView.name));
if (!app) return;
currentView.view.webContents.loadURL(app.history[app.historyPosition])
.catch(() => setError('Ошибка', 'Страница не найдена'));
});
ipcMain.on('collapseWithHeader', () => {
if (!currentView || !currentView.view) return;
const { width, height } = mainWindow.getBounds();
currentView.view.setBounds({ x: 0, y: 1, width, height: height - 1 });
});
ipcMain.on('expandWithHeader', () => {
if (!currentView || !currentView.view) return;
currentView.view.setBounds(getViewBounds());
});
ipcMain.on('set-proxy', async (_event, host, port) => applyProxy(host, port));
// --- Movie Search ---
const MOVIE_PARSERS = {
dle: {
buildUrl: (domain, query) =>
`https://${domain}/?do=search&subaction=search&story=${encodeURIComponent(query)}`,
parse: (html, domain) => {
const $ = cheerio.load(html);
const results = [];
const toAbs = (src) => {
if (!src) return '';
if (src.startsWith('http')) return src;
return src.startsWith('/') ? `https://${domain}${src}` : `https://${domain}/${src}`;
};
$('.short, .movie-item, .th-item, .card, article.item, .shortstory, article.shortStory, .shortStory').each((_, el) => {
const $el = $(el);
const $link = $el.find('h2 a, .th-title a, .title a, .short-title a, .card-title a, .name a, .hTitle a').first();
const title = $link.text().trim();
let href = $link.attr('href') || '';
if (href && !href.startsWith('http')) href = `https://${domain}${href}`;
const rawSrc = $el.find('img[data-src]').first().attr('data-src') || $el.find('img[src]').first().attr('src') || '';
const poster = rawSrc.startsWith('data:') ? '' : toAbs(rawSrc);
const year = $el.text().match(/\b(19|20)\d{2}\b/)?.[0];
if (title && href) results.push({ title, url: href, poster, year, source: domain });
});
return results;
},
},
hdrezka: {
buildUrl: (domain, query) =>
`https://${domain}/?do=search&subaction=search&story=${encodeURIComponent(query)}`,
parse: (html, domain) => {
const $ = cheerio.load(html);
const results = [];
const toAbs = (src) => {
if (!src) return '';
if (src.startsWith('http')) return src;
return src.startsWith('/') ? `https://${domain}${src}` : `https://${domain}/${src}`;
};
$('.b-content__inline_item').each((_, el) => {
const $el = $(el);
const $link = $el.find('.b-content__inline_item-link a').first();
const title = $link.text().trim();
const href = $link.attr('href') || '';
const poster = toAbs($el.find('img').first().attr('src') || '');
const year = $el.find('.b-content__inline_item-link div').text().match(/\b(19|20)\d{2}\b/)?.[0];
if (title && href) results.push({ title, url: href, poster, year, source: domain });
});
return results;
},
},
filmix: {
buildUrl: (domain, query) =>
`https://${domain}/search/${encodeURIComponent(query)}/`,
parse: (html, domain) => {
const $ = cheerio.load(html);
const results = [];
const toAbs = (src) => {
if (!src) return '';
if (src.startsWith('http')) return src;
return src.startsWith('/') ? `https://${domain}${src}` : `https://${domain}/${src}`;
};
$('.post-item, .movie-item, .item').each((_, el) => {
const $el = $(el);
const $link = $el.find('a.title, h2 a, .name a').first();
const title = $link.text().trim();
let href = $link.attr('href') || '';
if (href && !href.startsWith('http')) href = `https://${domain}${href}`;
const poster = toAbs($el.find('img').first().attr('src') || '');
const year = $el.text().match(/\b(19|20)\d{2}\b/)?.[0];
if (title && href) results.push({ title, url: href, poster, year, source: domain });
});
return results;
},
},
};
const SEARCH_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8',
};
async function searchHdrezkaAjax(site, query) {
try {
const url = `https://${site.domain}/engine/ajax/search.php`;
console.log(`[search] ${site.domain} -> AJAX`);
const resp = await getProxySession().fetch(url, {
method: 'POST',
body: `q=${encodeURIComponent(query)}`,
headers: {
...SEARCH_HEADERS,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': `https://${site.domain}/`,
},
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return [];
const html = await resp.text();
const $ = cheerio.load(html);
const results = [];
$('ul li a').each((_, el) => {
const $el = $(el);
const href = $el.attr('href') || '';
const title = $el.find('.enty').text().trim();
if (title && href) results.push({ title, url: href, poster: '', year: '', source: site.domain });
});
console.log(`[search] ${site.domain} AJAX found: ${results.length}`);
return results;
} catch (e) {
console.warn(`[search] ${site.domain} AJAX error:`, e.message);
return [];
}
}
function searchWithView(site, query) {
const parser = MOVIE_PARSERS[site.type];
if (!parser) return Promise.resolve([]);
const url = parser.buildUrl(site.domain, query);
console.log(`[search] ${site.domain} -> ${url} (browser)`);
return new Promise((resolve) => {
const view = new WebContentsView({
webPreferences: { contextIsolation: true, nodeIntegration: false, session: getProxySession() },
});
let extractTimer = null;
const cleanup = (results) => {
if (extractTimer) clearTimeout(extractTimer);
try { view.webContents.destroy(); } catch (_) {}
resolve(results);
};
const globalTimer = setTimeout(() => {
console.warn(`[search] ${site.domain} timeout`);
cleanup([]);
}, 25000);
const tryExtract = () => {
if (extractTimer) clearTimeout(extractTimer);
extractTimer = setTimeout(() => {
clearTimeout(globalTimer);
// Poll every 400ms until results found or 5s elapsed (handles AJAX-loaded content)
const deadline = Date.now() + 5000;
const poll = async () => {
try {
const html = await view.webContents.executeJavaScript('document.documentElement.outerHTML');
const results = parser.parse(html, site.domain);
if (results.length > 0 || Date.now() >= deadline) {
console.log(`[search] ${site.domain} browser found: ${results.length}`);
cleanup(results);
} else {
setTimeout(poll, 400);
}
} catch (e) {
console.warn(`[search] ${site.domain} extract error:`, e.message);
cleanup([]);
}
};
poll();
}, 800); // initial wait for JS redirects / challenge pages
};
view.webContents.on('did-finish-load', tryExtract);
view.webContents.on('did-fail-load', (_e, code, desc) => {
clearTimeout(globalTimer);
console.warn(`[search] ${site.domain} load failed: ${code} ${desc}`);
cleanup([]);
});
view.webContents.loadURL(url, { userAgent: SEARCH_HEADERS['User-Agent'] }).catch(e => {
clearTimeout(globalTimer);
console.warn(`[search] ${site.domain} loadURL error:`, e.message);
cleanup([]);
});
});
}
async function searchOneSite(site, query) {
if (site.type === 'hdrezka') return searchHdrezkaAjax(site, query);
return searchWithView(site, query);
}
ipcMain.handle('search-tmdb', async (_event, query, apiKey) => {
console.log(`[tmdb] searching: "${query}", key type: ${apiKey ? (apiKey.startsWith('eyJ') ? 'bearer' : 'api_key') : 'none'}`);
try {
const isBearer = apiKey.startsWith('eyJ');
const url = isBearer
? `https://api.themoviedb.org/3/search/multi?query=${encodeURIComponent(query)}&language=ru-RU&include_adult=false`
: `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&query=${encodeURIComponent(query)}&language=ru-RU&include_adult=false`;
const headers = isBearer
? { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }
: {};
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
console.log(`[tmdb] status: ${resp.status}`);
if (!resp.ok) {
const body = await resp.text().catch(() => '');
console.warn(`[tmdb] error body:`, body.slice(0, 200));
return { error: `TMDB error ${resp.status}`, results: [] };
}
const data = await resp.json();
const results = (data.results || [])
.filter(r => r.media_type === 'movie' || r.media_type === 'tv')
.slice(0, 20)
.map(r => ({
id: r.id,
mediaType: r.media_type,
title: r.title || r.name || '',
originalTitle: r.original_title || r.original_name || '',
year: (r.release_date || r.first_air_date || '').slice(0, 4),
poster: r.poster_path ? `https://image.tmdb.org/t/p/w300${r.poster_path}` : '',
overview: r.overview || '',
rating: r.vote_average ? r.vote_average.toFixed(1) : '',
}));
console.log(`[tmdb] results: ${results.length}`);
return { results };
} catch (e) {
console.error(`[tmdb] exception:`, e.message);
return { error: e.message, results: [] };
}
});
ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreId, year, minRating, country, page }) => {
try {
const isBearer = apiKey.startsWith('eyJ');
const type = mediaType === 'tv' ? 'tv' : 'movie';
const params = new URLSearchParams({
language: 'ru-RU',
sort_by: sortBy || 'popularity.desc',
page: String(page || 1),
include_adult: 'false',
});
if (!isBearer) params.set('api_key', apiKey);
if (genreId) params.set('with_genres', String(genreId));
if (year) {
if (type === 'movie') params.set('primary_release_year', year);
else params.set('first_air_date_year', year);
}
if (minRating) params.set('vote_average.gte', minRating);
if (country) {
const COUNTRY_LANG = {
RU: 'ru', JP: 'ja', KR: 'ko', CN: 'zh', FR: 'fr',
DE: 'de', IT: 'it', ES: 'es', SE: 'sv', DK: 'da', TR: 'tr', IN: 'hi',
};
const lang = COUNTRY_LANG[country];
if (lang) params.set('with_original_language', lang);
else params.set('with_origin_country', country);
}
const url = `https://api.themoviedb.org/3/discover/${type}?${params}`;
const headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {};
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
if (!resp.ok) return { error: `TMDB ${resp.status}`, results: [], totalPages: 1 };
const data = await resp.json();
const results = (data.results || []).map(r => ({
id: r.id,
mediaType: type,
title: (type === 'movie' ? r.title : r.name) || '',
originalTitle: (type === 'movie' ? r.original_title : r.original_name) || '',
year: ((type === 'movie' ? r.release_date : r.first_air_date) || '').slice(0, 4),
poster: r.poster_path ? `https://image.tmdb.org/t/p/w300${r.poster_path}` : '',
overview: r.overview || '',
rating: r.vote_average ? r.vote_average.toFixed(1) : '',
}));
return { results, totalPages: Math.min(data.total_pages || 1, 500) };
} catch (e) {
return { error: e.message, results: [], totalPages: 1 };
}
});
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 : []);
});
ipcMain.handle('get-current-page', () => {
if (!currentView) return null;
return {
name: currentView.name,
url: currentView.history[currentView.historyPosition],
imageUrl: currentView.imageUrl || '',
};
});
ipcMain.handle('read-config', () => {
try {
if (fs.existsSync(CONFIG_PATH)) {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
} catch (e) {
console.warn('Failed to read config:', e.message);
}
return DEFAULT_CONFIG;
});
ipcMain.on('write-config', (_event, data) => {
try {
fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
} catch (e) {
console.warn('Failed to write config:', e.message);
}
});
ipcMain.handle('is-kiosk', () => mainWindow.isKiosk());
ipcMain.handle('toggle-kiosk', () => {
if (mainWindow.isKiosk()) {
mainWindow.setKiosk(false);
mainWindow.maximize();
mainWindow.setMenuBarVisibility(false);
return false;
} else {
mainWindow.setKiosk(true);
return true;
}
});
ipcMain.on('confirm',(_event, text, actionOnYes) => setConfirm(text, actionOnYes));
ipcMain.on('action', (_event, action) => {
if (action === 'error') {
removeError();
} else if (action === 'confirmYes') {
const last = confirmViews[confirmViews.length - 1];
if (last) {
if (last.actionOnYes === 'navigate-confirmed' && pendingNavigate) {
const { view: pView, url: pUrl } = pendingNavigate;
pendingNavigate = null;
if (!pView.webContents.isDestroyed()) {
pView.webContents.loadURL(pUrl).catch(() => setError('Ошибка', `Не удалось загрузить: ${pUrl}`));
}
} else {
mainWindow.webContents.send(last.actionOnYes);
}
}
removeConfirm();
} else if (action === 'confirmNo') {
if (confirmViews.length && confirmViews[confirmViews.length - 1].actionOnYes === 'navigate-confirmed') {
pendingNavigate = null;
}
removeConfirm();
}
});
// --- App lifecycle ---
app.whenReady().then(async () => {
// Add Referer to image requests so hotlink protection doesn't block them
session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: ['https://*/*', 'http://*/*'] },
(details, callback) => {
const headers = details.requestHeaders;
if (details.resourceType === 'image' && !headers['Referer'] && !headers['referer']) {
try {
const u = new URL(details.url);
headers['Referer'] = `${u.protocol}//${u.hostname}/`;
} catch (_) {}
}
callback({ requestHeaders: headers });
}
);
// Apply proxy from config before blocker tries to download filter lists
try {
if (fs.existsSync(CONFIG_PATH)) {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
if (cfg?.proxy?.host && cfg?.proxy?.port) await applyProxy(cfg.proxy.host, cfg.proxy.port);
}
} catch (_) {}
enableBlockingInSession(session.defaultSession);
getProxySession();
getDirectSession();
await loadExtensions();
await createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) await createWindow();
});