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 = `
${title}
${text}
`; 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 = `
${text}
`; 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(); });