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,
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 });
const html = `
`;
loaderView.webContents.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(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) ---
const DIALOG_STYLES = `
*{margin:0;padding:0;box-sizing:border-box}
body{
background:rgba(0,0,0,0);
display:flex;align-items:center;justify-content:center;
height:100vh;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
transition:background 0.22s ease;
}
body.visible{background:rgba(0,0,0,0.78)}
body.hiding{background:rgba(0,0,0,0)}
.card{
background:#1c1c1c;border:1px solid rgba(255,255,255,0.1);
border-radius:12px;padding:32px 36px;text-align:center;
min-width:300px;max-width:420px;box-shadow:0 24px 64px rgba(0,0,0,0.8);
opacity:0;transform:scale(0.92) translateY(10px);
transition:opacity 0.22s ease,transform 0.22s cubic-bezier(0.34,1.56,0.64,1);
}
body.visible .card{opacity:1;transform:scale(1) translateY(0)}
body.hiding .card{opacity:0;transform:scale(0.95) translateY(6px)}
.title{font-size:17px;font-weight:700;color:#fff;margin-bottom:10px}
.msg{font-size:13px;color:#999;line-height:1.5;margin-bottom:26px}
.btns{display:flex;gap:10px;justify-content:center}
button{padding:10px 26px;border:none;border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity 0.15s,transform 0.1s}
button:hover{opacity:0.85} button:active{transform:scale(0.97)}
.btn-yes{background:#E50914;color:#fff}
.btn-no,.btn-ok{background:rgba(255,255,255,0.1);color:#ccc}
`;
function dialogFadeIn(view) {
view.webContents.executeJavaScript(
`requestAnimationFrame(()=>requestAnimationFrame(()=>document.body.classList.add('visible')))`
).catch(() => {});
}
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 html = `
`;
view.webContents.once('did-finish-load', () => { addChild(view); dialogFadeIn(view); });
view.webContents.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
}
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 html = `
`;
view.webContents.once('did-finish-load', () => { addChild(view); dialogFadeIn(view); });
view.webContents.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
}
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.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);
enableBlockingInSession(session.fromPartition('persist:proxy'));
enableBlockingInSession(session.fromPartition('persist:direct'));
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();
});