5 Commits

Author SHA1 Message Date
c9c9e1171b fix(1.0.6): strip Trusted Types CSP on YouTube/Google to unbreak adblocker
YouTube response sends Content-Security-Policy: require-trusted-types-for
'script' which blocks the cliqz adblocker's inline-script injection used
to neutralize YT's anti-adblock detection (52 "HTMLScriptElement was
directly modified and will not be executed" console errors).

Strip require-trusted-types-for and trusted-types directives from CSP
and CSP-Report-Only headers for youtube.com / youtu.be / google.com /
gmail.com (and subdomains) via onHeadersReceived on all 3 sessions.
Other CSP directives stay intact so site-level security boundaries hold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:01:26 +03:00
461e7ed737 fix(1.0.5): revert Sec-CH-UA spoof (white pages), add DevTools shortcut
- Sec-CH-UA / Sec-CH-UA-Mobile / Sec-CH-UA-Platform header overrides on
  every request in 1.0.4 broke page rendering (all views white). Reverted
  to image-Referer-only behavior from 1.0.3. The Google "embedded browser"
  fix in 1.0.4 came primarily from the adblock whitelist (which IS kept)
  — Sec-CH-UA spoofing was the suspect for the regression.
- Ctrl+Shift+I and F12 now open DevTools on the main shell and on every
  in-app browser view. Always-on so kiosk machines can be debugged
  without leaving kiosk mode.
- Restore session sequenced (await 150ms between tabs) to avoid concurrent
  create-view races where multiple setLoader/addChild interleaved.
- Update banner now shows error state with a "Повторить" button instead
  of hiding it, so install-update failures are visible to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:24:38 +03:00
542be8135a fix: whitelist Google/OAuth domains in adblock, spoof Sec-CH-UA brand
Two-part fix for Google login "Возможно, этот браузер небезопасны" error:

1. The adblocker was eating Google integrity-check resources (gstatic,
   google-analytics, googletagmanager — flagged by EasyPrivacy). Add @@
   whitelist filters for Google, Yandex, Microsoft, Apple, Facebook,
   GitHub, VK, Mail.ru ecosystems. Also switch from non-existent
   addFilters() to updateFromDiff({added}) — previous TMDB whitelist was
   silently failing in a then().catch() and never applied. Adblock cache
   bumped to v3 so the new filters take effect.
2. Sec-CH-UA client-hints branding was leaking Electron app name as the
   browser brand. Override sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform
   headers via webRequest.onBeforeSendHeaders on all 3 sessions so
   embedded-browser detectors see real-Chrome brand list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:54:35 +03:00
a171f62629 feat: seamless auto-update via electron-updater, multi-select filters, session restore
- electron-updater wired with Gitea API discovery: setFeedURL dynamically
  per release (Gitea 1.24.7 lacks /releases/latest/download/ shortcut).
  Differential download via .blockmap saves ~70 MB per patch. Renderer
  banner shows states: available → downloading X% → ready. User clicks
  "Установить и перезапустить" → quitAndInstall replaces files + relaunches.
- Multi-select filters per user spec: genres AND (TMDB with_genres comma-
  joined), countries OR (pipe-joined into with_origin_country /
  with_original_language), years OR (fan-out one request per year, merge
  by id since TMDB has no discrete-year OR). Rating stays single threshold.
- Session persistence: openedSession {tabs, activeName} saved to config
  on tab create/show/hide/remove/in-app navigation, plus before-quit.
  Restored after did-finish-load via ipcMain.emit('create-view',...) per
  tab. Survives auto-update relaunch — bring user back to the same page.
- electron-builder publish config (generic provider) so latest.yml is
  generated during build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:42:25 +03:00
10361cf3c0 fix: strip Electron UA tokens, refresh bookmark star on in-app navigation
- main.js: app.userAgentFallback cleaned of Electron/X.X and ESH-Media/X.X
  tokens at startup; applied to default/proxy/direct sessions. Google's
  accounts page blocked Electron UA with "Поддержка JavaScript отключена".
- Header.tsx: subscribe currentUrl to updateWebButtons (already fired on
  each in-app navigation). Bookmark star now updates as user clicks
  between movies inside the same opened site, not only on app switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:27:26 +03:00
8 changed files with 678 additions and 104 deletions

347
main.js
View File

@@ -4,9 +4,10 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const cheerio = require('cheerio'); const cheerio = require('cheerio');
const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron'); const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron');
const { autoUpdater } = require('electron-updater');
const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json'); const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json');
const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v2.bin'); const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v3.bin');
const DEFAULT_TRUSTED_DOMAINS = [ const DEFAULT_TRUSTED_DOMAINS = [
// Google ecosystem (OAuth) // Google ecosystem (OAuth)
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com', 'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
@@ -71,8 +72,26 @@ function getBlocker() {
'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList 'https://easylist-downloads.adblockplus.org/ruadlist+easylist.txt', // RuAdList
]; ];
const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]); const b = await ElectronBlocker.fromLists(fetchFn, [...adsAndTrackingLists, ...russianLists]);
// Whitelist TMDB so the movie search API is not blocked // Whitelist domains that need ALL requests passed through unfiltered.
b.addFilters(['@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^']); // Tracking-list false positives on these break critical functionality:
// • Google: OAuth/login integrity checks fail without gstatic + analytics endpoints
// → "Возможно, этот браузер или приложение небезопасны" error
// • Yandex/Mail/Microsoft/Apple: same OAuth-style integrity flows
// • TMDB: movie search API and poster CDN
const whitelist = [
'@@||api.themoviedb.org^', '@@||image.tmdb.org^', '@@||themoviedb.org^',
'@@||google.com^', '@@||googleapis.com^', '@@||googleusercontent.com^',
'@@||gstatic.com^', '@@||youtube.com^', '@@||ytimg.com^', '@@||googlevideo.com^',
'@@||google-analytics.com^', '@@||googletagmanager.com^',
'@@||yandex.ru^', '@@||yandex.com^', '@@||yastatic.net^', '@@||mc.yandex.ru^',
'@@||github.com^', '@@||githubassets.com^', '@@||githubusercontent.com^',
'@@||vk.com^', '@@||vk.ru^', '@@||vkuser.net^',
'@@||mail.ru^', '@@||my.mail.ru^', '@@||imgsmail.ru^',
'@@||microsoft.com^', '@@||microsoftonline.com^', '@@||live.com^', '@@||office.com^',
'@@||apple.com^', '@@||icloud.com^',
'@@||facebook.com^', '@@||fbcdn.net^',
];
b.updateFromDiff({ added: whitelist });
fs.writeFileSync(BLOCKER_CACHE_PATH, Buffer.from(b.serialize())); fs.writeFileSync(BLOCKER_CACHE_PATH, Buffer.from(b.serialize()));
console.log('[adblock] filter lists downloaded and cached'); console.log('[adblock] filter lists downloaded and cached');
return b; return b;
@@ -110,6 +129,7 @@ let pendingNavigate = null; // { view, url } — cross-domain redirect awaiting
function getProxySession() { function getProxySession() {
if (!proxySession) { if (!proxySession) {
proxySession = session.fromPartition('persist:proxy'); proxySession = session.fromPartition('persist:proxy');
proxySession.setUserAgent(app.userAgentFallback);
enableBlockingInSession(proxySession); enableBlockingInSession(proxySession);
} }
return proxySession; return proxySession;
@@ -118,6 +138,7 @@ function getProxySession() {
function getDirectSession() { function getDirectSession() {
if (!directSession) { if (!directSession) {
directSession = session.fromPartition('persist:direct'); directSession = session.fromPartition('persist:direct');
directSession.setUserAgent(app.userAgentFallback);
directSession.setProxy({ proxyRules: 'direct://' }); directSession.setProxy({ proxyRules: 'direct://' });
enableBlockingInSession(directSession); enableBlockingInSession(directSession);
} }
@@ -209,39 +230,124 @@ function compareSemver(a, b) {
return 0; return 0;
} }
// --- Auto-update (electron-updater + Gitea API discovery) ---
//
// Gitea 1.24.7 doesn't expose /releases/latest/download/ as a stable URL shortcut,
// so we use the API to find the latest release at runtime, then point setFeedURL
// at THAT release's download directory. electron-updater fetches latest.yml from
// there and uses .blockmap files for differential downloads (saves ~70 MB per
// minor patch since most of the 80 MB installer is unchanged Electron runtime).
autoUpdater.autoDownload = true; // download in background as soon as we detect an update
autoUpdater.autoInstallOnAppQuit = false; // we'll manually trigger install — kiosk shouldn't surprise-restart mid-video
autoUpdater.allowDowngrade = false;
autoUpdater.logger = {
info: (m) => console.log('[updater]', m),
warn: (m) => console.warn('[updater]', m),
error: (m) => console.error('[updater]', m),
debug: () => {},
};
function sendUpdateStatus(payload) {
if (mainWindow && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('update-status', payload);
}
}
autoUpdater.on('update-available', (info) => {
sendUpdateStatus({ state: 'available', version: info.version, currentVersion: app.getVersion() });
});
autoUpdater.on('download-progress', (p) => {
sendUpdateStatus({ state: 'downloading', percent: Math.round(p.percent), transferred: p.transferred, total: p.total, bytesPerSecond: p.bytesPerSecond });
});
autoUpdater.on('update-downloaded', (info) => {
sendUpdateStatus({ state: 'ready', version: info.version, currentVersion: app.getVersion() });
});
autoUpdater.on('error', (err) => {
console.warn('[updater] error:', err?.message || err);
sendUpdateStatus({ state: 'error', message: err?.message || String(err) });
});
let updateCheckInFlight = false;
async function checkForUpdates() { async function checkForUpdates() {
if (updateCheckInFlight) return;
if (!app.isPackaged) {
console.log('[updater] dev mode, skipping');
return;
}
updateCheckInFlight = true;
try { try {
// Discover latest release via Gitea API (no auth, public endpoint)
const res = await getDirectSession().fetch( const res = await getDirectSession().fetch(
'https://gitea.esh-service.ru/api/v1/repos/public/ESH-Media/releases/latest' 'https://gitea.esh-service.ru/api/v1/repos/public/ESH-Media/releases/latest'
); );
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const latest = (data.tag_name || '').replace(/^v/, ''); const latestTag = (data.tag_name || '').replace(/^v/, '');
const current = app.getVersion(); const current = app.getVersion();
if (!latest || compareSemver(latest, current) <= 0) return; if (!latestTag || compareSemver(latestTag, current) <= 0) {
const assets = (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url })); console.log(`[updater] up to date (current=${current}, latest=${latestTag || 'none'})`);
// Prefer Windows installer (.exe) on Windows, AppImage/deb on Linux. Fall back to zip. return;
const isWin = process.platform === 'win32'; }
const isLinux = process.platform === 'linux'; // Verify latest.yml is present in this release's assets — without it autoUpdater can't proceed.
const installer = assets.find(a => { const hasYml = (data.assets || []).some(a => a.name === 'latest.yml');
const n = a.name.toLowerCase(); if (!hasYml) {
if (isWin) return n.endsWith('.exe'); console.warn(`[updater] release v${latestTag} has no latest.yml — falling back to manual download link`);
if (isLinux) return n.endsWith('.appimage') || n.endsWith('.deb'); const installer = (data.assets || []).find(a => a.name.toLowerCase().endsWith('.exe'));
return false; sendUpdateStatus({
}) || assets.find(a => a.name.toLowerCase().endsWith('.zip')); state: 'manual',
mainWindow.webContents.send('update-available', { version: latestTag,
version: latest,
currentVersion: current, currentVersion: current,
releaseUrl: data.html_url, installerUrl: installer?.browser_download_url || data.html_url,
installerUrl: installer?.url || data.html_url,
installerName: installer?.name || '', installerName: installer?.name || '',
assets,
}); });
} catch (_) {} return;
}
// Point feedURL at this release's download directory
const feedUrl = `https://gitea.esh-service.ru/public/ESH-Media/releases/download/v${latestTag}/`;
console.log(`[updater] setFeedURL ${feedUrl}`);
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
await autoUpdater.checkForUpdates();
} catch (e) {
console.warn('[updater] check failed:', e?.message || e);
} finally {
updateCheckInFlight = false;
}
} }
ipcMain.handle('install-update', () => {
// Quits the app and runs the downloaded installer with NSIS /S silent flag.
// Installer waits for the running process to exit, replaces files, then relaunches.
try {
setImmediate(() => autoUpdater.quitAndInstall(true, true));
return true;
} catch (e) {
console.warn('[updater] quitAndInstall failed:', e?.message);
return false;
}
});
ipcMain.handle('check-update-now', async () => {
await checkForUpdates();
return true;
});
// --- Window --- // --- Window ---
function attachDevToolsShortcut(webContents) {
// Ctrl+Shift+I / F12 open DevTools on this webContents.
// Always available so a kiosk machine can be debugged without un-kiosking.
webContents.on('before-input-event', (_e, input) => {
if (input.type !== 'keyDown') return;
const isDevToolsCombo =
(input.control && input.shift && (input.key === 'I' || input.key === 'i')) ||
input.key === 'F12';
if (isDevToolsCombo) {
try { webContents.openDevTools({ mode: 'detach' }); } catch (_) {}
}
});
}
async function createWindow() { async function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1280, width: 1280,
@@ -255,6 +361,8 @@ async function createWindow() {
}, },
}); });
attachDevToolsShortcut(mainWindow.webContents);
if (isDev) { if (isDev) {
mainWindow.loadURL(RENDERER_URL); mainWindow.loadURL(RENDERER_URL);
} else { } else {
@@ -378,6 +486,62 @@ function removeView(name) {
app.view.webContents.destroy(); app.view.webContents.destroy();
if (currentView && currentView.name === name) currentView = null; if (currentView && currentView.name === name) currentView = null;
sendOpenedApps('home'); sendOpenedApps('home');
scheduleSessionSave();
}
// --- Session persistence (preserves opened tabs across restart, incl. auto-update relaunch) ---
let sessionSaveTimer = null;
function scheduleSessionSave() {
if (sessionSaveTimer) clearTimeout(sessionSaveTimer);
sessionSaveTimer = setTimeout(saveSessionNow, 500);
}
function saveSessionNow() {
sessionSaveTimer = null;
try {
const tabs = openedApps.map(a => ({
name: a.name,
url: a.history[a.historyPosition] || a.url, // latest URL the user was on, not the initial one
imageUrl: a.imageUrl || '',
useProxy: !!a.useProxy,
}));
const activeName = currentView?.name || 'home';
const cfg = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : {};
cfg.openedSession = { tabs, activeName, savedAt: Date.now() };
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
} catch (e) {
console.warn('[session] save failed:', e.message);
}
}
async function restoreSession() {
try {
if (!fs.existsSync(CONFIG_PATH)) return;
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
const sess = cfg.openedSession;
if (!sess || !Array.isArray(sess.tabs) || !sess.tabs.length) return;
console.log(`[session] restoring ${sess.tabs.length} tab(s), active=${sess.activeName}`);
// Spawn each saved tab by replaying create-view, sequentially with a small delay.
// Concurrent create-view calls in v1.0.3 caused races: multiple setLoader/addChild
// interleaved → some views ended up unmounted (white screen). Spacing them out
// gives each view time to mount before the next steals currentView.
const fakeEvent = { sender: mainWindow.webContents };
for (const tab of sess.tabs) {
if (!tab?.name || !tab?.url) continue;
ipcMain.emit('create-view', fakeEvent, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy);
await new Promise(r => setTimeout(r, 150));
}
// Switch to saved active if it isn't already the last-spawned (currentView).
if (sess.activeName === 'home') {
ipcMain.emit('hide-view', fakeEvent);
sendOpenedApps('home');
} else if (sess.activeName && sess.activeName !== currentView?.name) {
ipcMain.emit('show-view', fakeEvent, sess.activeName);
}
} catch (e) {
console.warn('[session] restore failed:', e.message);
}
} }
// --- IPC --- // --- IPC ---
@@ -408,10 +572,11 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
}, },
}); });
const appEntry = { name, url, imageUrl, view, history: [url], historyPosition: 0 }; const appEntry = { name, url, imageUrl, useProxy: !!useProxy, view, history: [url], historyPosition: 0 };
openedApps.push(appEntry); openedApps.push(appEntry);
currentView = appEntry; currentView = appEntry;
view.setBounds(getViewBounds()); view.setBounds(getViewBounds());
attachDevToolsShortcut(view.webContents);
view.webContents.on('did-finish-load', () => { view.webContents.on('did-finish-load', () => {
removeLoader(); removeLoader();
@@ -445,6 +610,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
app.history.push(navigatingUrl); app.history.push(navigatingUrl);
app.historyPosition = app.history.length - 1; app.historyPosition = app.history.length - 1;
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition }); mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
}; };
let origHostname = ''; let origHostname = '';
@@ -501,6 +667,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
sendOpenedApps(name); sendOpenedApps(name);
mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition }); mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition });
scheduleSessionSave();
view.webContents.loadURL(url).catch(() => { view.webContents.loadURL(url).catch(() => {
removeView(name); removeView(name);
@@ -514,6 +681,7 @@ ipcMain.on('remove-view', (_event, name) => removeView(name || (currentView && c
ipcMain.on('hide-view', () => { ipcMain.on('hide-view', () => {
if (currentView && currentView.view) removeChild(currentView.view); if (currentView && currentView.view) removeChild(currentView.view);
currentView = null; currentView = null;
scheduleSessionSave();
}); });
ipcMain.on('show-view', (_event, name) => { ipcMain.on('show-view', (_event, name) => {
@@ -524,6 +692,7 @@ ipcMain.on('show-view', (_event, name) => {
addChild(app.view); addChild(app.view);
bringOverlaysToTop(); bringOverlaysToTop();
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition }); mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
}); });
let sidebarAnim = null; let sidebarAnim = null;
@@ -816,10 +985,20 @@ ipcMain.handle('search-tmdb', async (_event, query, apiKey) => {
} }
}); });
ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreId, year, minRating, country, page }) => { ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genreIds, years, minRating, countries, page }) => {
// 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
try { try {
const isBearer = apiKey.startsWith('eyJ'); const isBearer = apiKey.startsWith('eyJ');
const type = mediaType === 'tv' ? 'tv' : 'movie'; 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 buildParams = (yearOverride) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
language: 'ru-RU', language: 'ru-RU',
sort_by: sortBy || 'popularity.desc', sort_by: sortBy || 'popularity.desc',
@@ -827,27 +1006,64 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr
include_adult: 'false', include_adult: 'false',
}); });
if (!isBearer) params.set('api_key', apiKey); if (!isBearer) params.set('api_key', apiKey);
if (genreId) params.set('with_genres', String(genreId)); if (genreArr.length) params.set('with_genres', genreArr.join(',')); // AND
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 (minRating) params.set('vote_average.gte', minRating);
if (country) { if (countryArr.length) {
// Mix: codes that map to original_language go to with_original_language (pipe-OR),
// raw country codes go to with_origin_country (pipe-OR). Both fields can coexist.
const COUNTRY_LANG = { const COUNTRY_LANG = {
RU: 'ru', JP: 'ja', KR: 'ko', CN: 'zh', FR: 'fr', RU: 'ru', JP: 'ja', KR: 'ko', CN: 'zh', FR: 'fr',
DE: 'de', IT: 'it', ES: 'es', SE: 'sv', DK: 'da', TR: 'tr', IN: 'hi', DE: 'de', IT: 'it', ES: 'es', SE: 'sv', DK: 'da', TR: 'tr', IN: 'hi',
}; };
const lang = COUNTRY_LANG[country]; const langs = [], origCountries = [];
if (lang) params.set('with_original_language', lang); for (const c of countryArr) {
else params.set('with_origin_country', country); if (COUNTRY_LANG[c]) langs.push(COUNTRY_LANG[c]);
else origCountries.push(c);
} }
if (langs.length) params.set('with_original_language', langs.join('|'));
if (origCountries.length) params.set('with_origin_country', origCountries.join('|'));
}
if (yearOverride) {
if (type === 'movie') params.set('primary_release_year', String(yearOverride));
else params.set('first_air_date_year', String(yearOverride));
}
return params;
};
const fetchOnce = async (params) => {
const url = `https://api.themoviedb.org/3/discover/${type}?${params}`; const url = `https://api.themoviedb.org/3/discover/${type}?${params}`;
const headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {}; const headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {};
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) }); const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
if (!resp.ok) return { error: `TMDB ${resp.status}`, results: [], totalPages: 1 }; if (!resp.ok) return { results: [], totalPages: 0, status: resp.status };
const data = await resp.json(); const data = await resp.json();
const results = (data.results || []).map(r => ({ return { results: data.results || [], totalPages: data.total_pages || 1, status: 200 };
};
let aggregated = [];
let maxPages = 1;
if (yearArr.length === 0) {
const r = await fetchOnce(buildParams(null));
if (r.status !== 200) return { error: `TMDB ${r.status}`, results: [], totalPages: 1 };
aggregated = r.results;
maxPages = r.totalPages;
} else {
// Fan out per year, merge by id (TMDB has no OR for discrete years)
const settled = await Promise.allSettled(yearArr.map(y => fetchOnce(buildParams(y))));
const seen = new Set();
for (const s of settled) {
if (s.status !== 'fulfilled' || s.value.status !== 200) continue;
maxPages = Math.max(maxPages, s.value.totalPages);
for (const r of s.value.results) {
if (seen.has(r.id)) continue;
seen.add(r.id);
aggregated.push(r);
}
}
// Re-sort merged set by popularity (since TMDB sorted within each year)
aggregated.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
}
const results = aggregated.map(r => ({
id: r.id, id: r.id,
mediaType: type, mediaType: type,
title: (type === 'movie' ? r.title : r.name) || '', title: (type === 'movie' ? r.title : r.name) || '',
@@ -857,7 +1073,7 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr
overview: r.overview || '', overview: r.overview || '',
rating: r.vote_average ? r.vote_average.toFixed(1) : '', rating: r.vote_average ? r.vote_average.toFixed(1) : '',
})); }));
return { results, totalPages: Math.min(data.total_pages || 1, 500) }; return { results, totalPages: Math.min(maxPages, 500) };
} catch (e) { } catch (e) {
return { error: e.message, results: [], totalPages: 1 }; return { error: e.message, results: [], totalPages: 1 };
} }
@@ -981,7 +1197,23 @@ ipcMain.on('action', (_event, action) => {
// --- App lifecycle --- // --- App lifecycle ---
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Add Referer to image requests so hotlink protection doesn't block them // Strip Electron/app tokens from User-Agent: Google blocks Electron's default UA
// with "Поддержка JavaScript отключена" on accounts.google.com. We keep the Chrome
// version Electron advertises (sufficient for modern features) but remove the
// Electron/X.X.X and ESH-Media/X.X.X identifiers.
const cleanUserAgent = app.userAgentFallback
.replace(/Electron\/[\d.]+\s*/g, '')
.replace(/ESH-Media\/[\d.]+\s*/g, '')
.replace(/\s+/g, ' ')
.trim();
console.log('[ua]', cleanUserAgent);
app.userAgentFallback = cleanUserAgent;
session.defaultSession.setUserAgent(cleanUserAgent);
// Add Referer to image requests so hotlink protection doesn't block them.
// (Sec-CH-UA spoofing was tried in 1.0.4 and caused white pages — reverted.
// Google embedded-browser detection is now mitigated only via adblock whitelist
// of gstatic/google-analytics/etc., which previously was being eaten silently.)
session.defaultSession.webRequest.onBeforeSendHeaders( session.defaultSession.webRequest.onBeforeSendHeaders(
{ urls: ['https://*/*', 'http://*/*'] }, { urls: ['https://*/*', 'http://*/*'] },
(details, callback) => { (details, callback) => {
@@ -996,6 +1228,40 @@ app.whenReady().then(async () => {
} }
); );
// Strip Trusted Types directives from CSP for sites that enforce them
// (YouTube, Gmail, etc.). The cliqz adblocker injects inline scriptlets to
// neutralize anti-adblock tricks; those injections use plain script.text
// assignment which TT blocks → "An HTMLScriptElement was directly modified
// and will not be executed" (52+ console errors on YouTube). Without TT
// the adblocker's scripts run and YouTube works normally.
const TT_STRIP_HOSTS = [
'youtube.com', 'youtu.be', 'youtubekids.com',
'google.com', 'gmail.com', 'mail.google.com',
];
const stripTrustedTypes = (sess) => {
sess.webRequest.onHeadersReceived(
{ urls: ['https://*/*'] },
(details, callback) => {
let host = '';
try { host = new URL(details.url).hostname; } catch {}
const match = TT_STRIP_HOSTS.some(d => host === d || host.endsWith('.' + d));
const headers = details.responseHeaders;
if (!match || !headers) return callback({});
for (const k of Object.keys(headers)) {
if (/^content-security-policy(-report-only)?$/i.test(k)) {
headers[k] = headers[k].map(v => v
.replace(/require-trusted-types-for[^;]*;?\s*/gi, '')
.replace(/trusted-types[^;]*;?\s*/gi, ''));
}
}
callback({ responseHeaders: headers });
}
);
};
stripTrustedTypes(session.defaultSession);
stripTrustedTypes(getProxySession());
stripTrustedTypes(getDirectSession());
// Apply proxy from config before blocker tries to download filter lists // Apply proxy from config before blocker tries to download filter lists
loadTrustedDomainsFromDisk(); loadTrustedDomainsFromDisk();
try { try {
@@ -1011,12 +1277,19 @@ app.whenReady().then(async () => {
await loadExtensions(); await loadExtensions();
await createWindow(); await createWindow();
mainWindow.webContents.once('did-finish-load', () => { mainWindow.webContents.once('did-finish-load', async () => {
await restoreSession();
setTimeout(checkForUpdates, 4000); setTimeout(checkForUpdates, 4000);
setInterval(checkForUpdates, 60 * 60 * 1000); // re-check hourly for long-running kiosk sessions setInterval(checkForUpdates, 60 * 60 * 1000); // re-check hourly for long-running kiosk sessions
}); });
}); });
app.on('before-quit', () => {
// Final synchronous save (timer might be pending).
if (sessionSaveTimer) { clearTimeout(sessionSaveTimer); sessionSaveTimer = null; }
saveSessionNow();
});
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit(); if (process.platform !== 'darwin') app.quit();
}); });

104
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.0", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.0", "version": "1.0.2",
"dependencies": { "dependencies": {
"@cliqz/adblocker-electron": "^1.34.0", "@cliqz/adblocker-electron": "^1.34.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"electron-updater": "^6.8.3",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -2305,7 +2306,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assert-plus": { "node_modules/assert-plus": {
@@ -3666,6 +3666,82 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/electron-updater": {
"version": "6.8.3",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz",
"integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.5.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/builder-util-runtime": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"sax": "^1.2.4"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -4620,7 +4696,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -4697,7 +4772,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lazystream": { "node_modules/lazystream": {
@@ -4773,6 +4847,12 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -4781,6 +4861,13 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -5559,7 +5646,6 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=11.0.0" "node": ">=11.0.0"
@@ -5953,6 +6039,12 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tldts-core": { "node_modules/tldts-core": {
"version": "6.1.86", "version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ESH-Media", "name": "ESH-Media",
"version": "1.0.1", "version": "1.0.6",
"private": true, "private": true,
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
@@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@cliqz/adblocker-electron": "^1.34.0", "@cliqz/adblocker-electron": "^1.34.0",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"electron-updater": "^6.8.3",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -46,7 +47,10 @@
"extensions/**/*" "extensions/**/*"
], ],
"win": { "win": {
"target": ["nsis", "zip"], "target": [
"nsis",
"zip"
],
"icon": "public/favicon.ico" "icon": "public/favicon.ico"
}, },
"nsis": { "nsis": {
@@ -54,9 +58,18 @@
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true
}, },
"linux": { "linux": {
"target": ["AppImage", "deb"], "target": [
"AppImage",
"deb"
],
"icon": "public/logo.png", "icon": "public/logo.png",
"category": "Utility" "category": "Utility"
},
"publish": [
{
"provider": "generic",
"url": "https://gitea.esh-service.ru/public/ESH-Media/releases/download/latest/"
} }
]
} }
} }

View File

@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld('electron', {
refreshPage: () => ipcRenderer.send('refreshPage'), refreshPage: () => ipcRenderer.send('refreshPage'),
getCurrentPage: () => ipcRenderer.invoke('get-current-page'), getCurrentPage: () => ipcRenderer.invoke('get-current-page'),
getPageMeta: () => ipcRenderer.invoke('get-page-meta'), getPageMeta: () => ipcRenderer.invoke('get-page-meta'),
installUpdate: () => ipcRenderer.invoke('install-update'),
checkUpdateNow: () => ipcRenderer.invoke('check-update-now'),
readConfig: () => ipcRenderer.invoke('read-config'), readConfig: () => ipcRenderer.invoke('read-config'),
writeConfig: (data) => ipcRenderer.send('write-config', data), writeConfig: (data) => ipcRenderer.send('write-config', data),
searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites), searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites),

View File

@@ -21,6 +21,7 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
const [rightDisabled, setRightDisabled] = useState(true) const [rightDisabled, setRightDisabled] = useState(true)
const [refreshDisabled, setRefreshDisabled] = useState(true) const [refreshDisabled, setRefreshDisabled] = useState(true)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeAppRef = useRef(activeApp) const activeAppRef = useRef(activeApp)
useEffect(() => { activeAppRef.current = activeApp }, [activeApp]) useEffect(() => { activeAppRef.current = activeApp }, [activeApp])
@@ -36,6 +37,7 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
setLeftDisabled(app.historyPosition === 0) setLeftDisabled(app.historyPosition === 0)
setRightDisabled(app.historyPosition === app.history.length - 1) setRightDisabled(app.historyPosition === app.history.length - 1)
setRefreshDisabled(false) setRefreshDisabled(false)
setCurrentUrl(app.history[app.historyPosition] || '')
}) })
return () => { offCloseApp(); offWebButtons() } return () => { offCloseApp(); offWebButtons() }
}, [setActiveApp]) }, [setActiveApp])
@@ -108,12 +110,24 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search' const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search'
const [isKiosk, setIsKiosk] = useState(true) const [isKiosk, setIsKiosk] = useState(true)
const [updateInfo, setUpdateInfo] = useState<{ version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string } | null>(null) type UpdateStatus =
| { state: 'available'; version: string; currentVersion?: string }
| { state: 'downloading'; percent: number; bytesPerSecond?: number; transferred?: number; total?: number; version?: string; currentVersion?: string }
| { state: 'ready'; version: string; currentVersion?: string }
| { state: 'manual'; version: string; currentVersion?: string; installerUrl: string; installerName?: string }
| { state: 'error'; message: string }
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
useEffect(() => { useEffect(() => {
if (!window.electron) return if (!window.electron) return
const off = window.electron.on('update-available', (info: { version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string }) => { const off = window.electron.on('update-status', (info: UpdateStatus) => {
setUpdateInfo(info) setUpdateStatus(prev => {
// Preserve version across state transitions when payload omits it (download-progress)
if (info.state === 'downloading' && prev && 'version' in prev && prev.version) {
return { ...info, version: info.version || prev.version, currentVersion: info.currentVersion || ('currentVersion' in prev ? prev.currentVersion : undefined) }
}
return info
})
}) })
return off return off
}, []) }, [])
@@ -149,12 +163,9 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
} }
useEffect(() => { useEffect(() => {
if (!appOpen) { setIsBookmarked(false); return } if (!appOpen || !currentUrl) { setIsBookmarked(false); return }
window.electron?.getCurrentPage().then((page: any) => { setIsBookmarked(bookmarks.some(b => b.url === currentUrl))
if (!page) return }, [currentUrl, bookmarks, appOpen])
setIsBookmarked(bookmarks.some(b => b.url === page.url))
})
}, [activeApp, bookmarks, appOpen])
return ( return (
<> <>
@@ -279,13 +290,47 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
)} )}
</div> </div>
{updateInfo && ( {updateStatus && (
<div className="update-banner"> <div className={`update-banner${updateStatus.state === 'error' ? ' error' : ''}`}>
<span>Доступна версия {updateInfo.version}{updateInfo.currentVersion ? ` (текущая ${updateInfo.currentVersion})` : ''}</span> {updateStatus.state === 'available' && (
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.installerUrl, '', 1.0, false)}> <>
{updateInfo.installerName ? 'Скачать установщик' : 'Открыть релиз'} <span className="update-banner-spinner" />
<span>Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
</>
)}
{updateStatus.state === 'error' && (
<>
<span>Ошибка обновления: {updateStatus.message}</span>
<button className="update-banner-btn" onClick={() => window.electron?.checkUpdateNow?.()}>
Повторить
</button> </button>
<button className="update-banner-close" onClick={() => setUpdateInfo(null)}></button> </>
)}
{updateStatus.state === 'downloading' && (
<>
<span>Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}%</span>
<div className="update-banner-progress">
<div className="update-banner-progress-bar" style={{ width: `${updateStatus.percent}%` }} />
</div>
</>
)}
{updateStatus.state === 'ready' && (
<>
<span>Версия {updateStatus.version} готова к установке</span>
<button className="update-banner-btn" onClick={() => window.electron?.installUpdate?.()}>
Установить и перезапустить
</button>
</>
)}
{updateStatus.state === 'manual' && (
<>
<span>Доступна {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateStatus.installerUrl, '', 1.0, false)}>
Скачать установщик
</button>
</>
)}
<button className="update-banner-close" onClick={() => setUpdateStatus(null)}></button>
</div> </div>
)} )}

View File

@@ -45,6 +45,72 @@ const Select: React.FC<{
) )
} }
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
const MultiSelect: React.FC<{
values: string[]
onChange: (v: string[]) => void
options: { value: string; label: string }[]
placeholder: string
maxHeight?: number
}> = ({ values, onChange, options, placeholder, maxHeight = 260 }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const toggle = (v: string) => {
onChange(values.includes(v) ? values.filter(x => x !== v) : [...values, v])
}
const label = values.length === 0
? placeholder
: values.length === 1
? (options.find(o => o.value === values[0])?.label ?? values[0])
: `${placeholder}: ${values.length}`
return (
<div ref={ref} className={`ms-select${open ? ' open' : ''}`}>
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
<span className={values.length > 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label}</span>
{values.length > 0 && (
<button className="ms-multi-clear" onClick={e => { e.stopPropagation(); onChange([]) }} title="Сбросить">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
{open && (
<div className="ms-select-dropdown ms-multi-dropdown" style={{ maxHeight }} onClick={e => e.stopPropagation()}>
{options.map(o => {
const on = values.includes(o.value)
return (
<div key={o.value} className={`ms-select-opt ms-multi-opt${on ? ' active' : ''}`} onClick={() => toggle(o.value)}>
<span className={`ms-checkbox${on ? ' on' : ''}`}>{on && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}</span>
{o.label}
</div>
)
})}
</div>
)}
</div>
)
}
interface TmdbMovie { interface TmdbMovie {
id: number id: number
mediaType: 'movie' | 'tv' mediaType: 'movie' | 'tv'
@@ -159,10 +225,10 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
const [query, setQuery] = useState(initialQuery) const [query, setQuery] = useState(initialQuery)
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie') const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
const [sortBy, setSortBy] = useState('popularity.desc') const [sortBy, setSortBy] = useState('popularity.desc')
const [genreId, setGenreId] = useState<number | null>(null) const [genreIds, setGenreIds] = useState<number[]>([])
const [year, setYear] = useState('') const [years, setYears] = useState<string[]>([])
const [minRating, setMinRating] = useState('') const [minRating, setMinRating] = useState('')
const [country, setCountry] = useState('') const [countries, setCountries] = useState<string[]>([])
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1)
@@ -214,7 +280,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
if (append) setLoadingMore(true) if (append) setLoadingMore(true)
else { setTmdbLoading(true); setMessage('') } else { setTmdbLoading(true); setMessage('') }
try { try {
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreId, year, minRating, country, page: pg }) const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds, years, minRating, countries, page: pg })
if (token !== discoverRef.current) return if (token !== discoverRef.current) return
if (res.error) { setMessage(`Ошибка: ${res.error}`); return } if (res.error) { setMessage(`Ошибка: ${res.error}`); return }
setTmdbResults(prev => append ? [...prev, ...res.results] : res.results) setTmdbResults(prev => append ? [...prev, ...res.results] : res.results)
@@ -225,14 +291,14 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
} finally { } finally {
if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) } if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) }
} }
}, [mediaType, sortBy, genreId, year, minRating, country]) }, [mediaType, sortBy, genreIds, years, minRating, countries])
useEffect(() => { useEffect(() => {
if (!configLoaded || !apiKey || isSearchMode) return if (!configLoaded || !apiKey || isSearchMode) return
setPage(1) setPage(1)
setTmdbResults([]) setTmdbResults([])
doDiscover(apiKey, 1, false) doDiscover(apiKey, 1, false)
}, [configLoaded, apiKey, mediaType, sortBy, genreId, year, minRating, country, isSearchMode]) }, [configLoaded, apiKey, mediaType, sortBy, genreIds, years, minRating, countries, isSearchMode])
const searchRef = useRef(0) const searchRef = useRef(0)
const doTmdbSearch = async (q: string, key: string) => { const doTmdbSearch = async (q: string, key: string) => {
@@ -377,11 +443,11 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
<div className="ms-type-toggle"> <div className="ms-type-toggle">
<button <button
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`} className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreId(null) })} onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })}
>Фильмы</button> >Фильмы</button>
<button <button
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`} className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreId(null) })} onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })}
>Сериалы</button> >Сериалы</button>
</div> </div>
@@ -390,7 +456,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
<Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} /> <Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} />
)} )}
{/* Min rating */} {/* Min rating (single, threshold) */}
{!isSearchMode && ( {!isSearchMode && (
<Select <Select
value={minRating} value={minRating}
@@ -400,34 +466,45 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
/> />
)} )}
{/* Year */} {/* Years (multi, OR) */}
<Select <MultiSelect
value={year} values={years}
onChange={v => handleFilterChange(() => setYear(v))} onChange={v => handleFilterChange(() => setYears(v))}
options={[{ value: '', label: 'Год' }, ...YEARS.map(y => ({ value: String(y), label: String(y) }))]} options={YEARS.map(y => ({ value: String(y), label: String(y) }))}
placeholder="Год" placeholder="Год"
/> />
{/* Country */} {/* Countries (multi, OR) */}
{!isSearchMode && ( {!isSearchMode && (
<Select value={country} onChange={v => handleFilterChange(() => setCountry(v))} options={COUNTRIES} placeholder="Страна" /> <MultiSelect
values={countries}
onChange={v => handleFilterChange(() => setCountries(v))}
options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))}
placeholder="Страна"
/>
)} )}
</div> </div>
{/* Genres */} {/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
{!isSearchMode && ( {!isSearchMode && (
<div className="ms-genres"> <div className="ms-genres">
<button <button
className={`ms-genre-chip${genreId === null ? ' active' : ''}`} className={`ms-genre-chip${genreIds.length === 0 ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setGenreId(null))} onClick={() => handleFilterChange(() => setGenreIds([]))}
title="Сбросить жанры"
>Все</button> >Все</button>
{genres.map(g => ( {genres.map(g => (
<button <button
key={g.id} key={g.id}
className={`ms-genre-chip${genreId === g.id ? ' active' : ''}`} className={`ms-genre-chip${genreIds.includes(g.id) ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setGenreId(g.id))} onClick={() => handleFilterChange(() =>
setGenreIds(prev => prev.includes(g.id) ? prev.filter(x => x !== g.id) : [...prev, g.id])
)}
>{g.name}</button> >{g.name}</button>
))} ))}
{genreIds.length > 1 && (
<span className="ms-genres-hint">все выбранные одновременно</span>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -25,7 +25,10 @@ declare global {
searchMovies: (query: string, sites: any[]) => Promise<any[]> searchMovies: (query: string, sites: any[]) => Promise<any[]>
searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }> searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }>
discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }> discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }>
toggleKiosk: () => void getPageMeta: () => Promise<{ poster: string; title: string; url: string } | null>
installUpdate: () => Promise<boolean>
checkUpdateNow: () => Promise<boolean>
toggleKiosk: () => Promise<boolean>
isKiosk: () => Promise<boolean> isKiosk: () => Promise<boolean>
} }
} }

View File

@@ -788,6 +788,44 @@ body {
.ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; } .ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; }
.ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; } .ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.ms-genres-hint {
font-size: 10px;
color: #666;
font-style: italic;
align-self: center;
margin-left: 6px;
}
/* Multi-select dropdown */
.ms-multi-dropdown { overflow-y: auto; }
.ms-multi-opt { display: flex; align-items: center; gap: 8px; }
.ms-multi-opt.active { color: #eee; font-weight: 500; }
.ms-checkbox {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1.5px solid #555;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.ms-checkbox.on { background: var(--accent); border-color: var(--accent); }
.ms-multi-clear {
background: transparent;
border: none;
color: #777;
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
margin-left: auto;
margin-right: 2px;
}
.ms-multi-clear:hover { color: #fff; }
.ms-load-more { .ms-load-more {
display: block; display: block;
margin: 20px auto 8px; margin: 20px auto 8px;
@@ -1442,6 +1480,37 @@ body {
} }
.update-banner-close:hover { color: #fff; } .update-banner-close:hover { color: #fff; }
.update-banner.error {
border-color: rgba(229,9,20,0.5);
background: rgba(40,10,12,0.95);
}
.update-banner-progress {
flex: 1;
height: 4px;
background: rgba(255,255,255,0.08);
border-radius: 2px;
overflow: hidden;
min-width: 80px;
max-width: 200px;
}
.update-banner-progress-bar {
height: 100%;
background: linear-gradient(90deg, #E50914, #ff5252);
transition: width 0.3s ease;
}
.update-banner-spinner {
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.15);
border-top-color: #E50914;
border-radius: 50%;
animation: ub-spin 0.8s linear infinite;
display: inline-block;
}
@keyframes ub-spin { to { transform: rotate(360deg); } }
/* ---- Retry btn ---- */ /* ---- Retry btn ---- */
.ms-retry-btn { .ms-retry-btn {
background: none; background: none;