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>
This commit is contained in:
2026-05-16 20:42:25 +03:00
parent 10361cf3c0
commit a171f62629
8 changed files with 565 additions and 93 deletions

287
main.js
View File

@@ -4,6 +4,7 @@ const fs = require('fs');
const os = require('os');
const cheerio = require('cheerio');
const { ElectronBlocker, adsAndTrackingLists } = require('@cliqz/adblocker-electron');
const { autoUpdater } = require('electron-updater');
const CONFIG_PATH = path.join(os.homedir(), '.ESH-Media.json');
const BLOCKER_CACHE_PATH = path.join(os.homedir(), '.ESH-Media-adblock-v2.bin');
@@ -211,37 +212,108 @@ function compareSemver(a, b) {
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() {
if (updateCheckInFlight) return;
if (!app.isPackaged) {
console.log('[updater] dev mode, skipping');
return;
}
updateCheckInFlight = true;
try {
// Discover latest release via Gitea API (no auth, public endpoint)
const res = await getDirectSession().fetch(
'https://gitea.esh-service.ru/api/v1/repos/public/ESH-Media/releases/latest'
);
if (!res.ok) return;
const data = await res.json();
const latest = (data.tag_name || '').replace(/^v/, '');
const latestTag = (data.tag_name || '').replace(/^v/, '');
const current = app.getVersion();
if (!latest || compareSemver(latest, current) <= 0) return;
const assets = (data.assets || []).map(a => ({ name: a.name, url: a.browser_download_url }));
// Prefer Windows installer (.exe) on Windows, AppImage/deb on Linux. Fall back to zip.
const isWin = process.platform === 'win32';
const isLinux = process.platform === 'linux';
const installer = assets.find(a => {
const n = a.name.toLowerCase();
if (isWin) return n.endsWith('.exe');
if (isLinux) return n.endsWith('.appimage') || n.endsWith('.deb');
return false;
}) || assets.find(a => a.name.toLowerCase().endsWith('.zip'));
mainWindow.webContents.send('update-available', {
version: latest,
currentVersion: current,
releaseUrl: data.html_url,
installerUrl: installer?.url || data.html_url,
installerName: installer?.name || '',
assets,
});
} catch (_) {}
if (!latestTag || compareSemver(latestTag, current) <= 0) {
console.log(`[updater] up to date (current=${current}, latest=${latestTag || 'none'})`);
return;
}
// Verify latest.yml is present in this release's assets — without it autoUpdater can't proceed.
const hasYml = (data.assets || []).some(a => a.name === 'latest.yml');
if (!hasYml) {
console.warn(`[updater] release v${latestTag} has no latest.yml — falling back to manual download link`);
const installer = (data.assets || []).find(a => a.name.toLowerCase().endsWith('.exe'));
sendUpdateStatus({
state: 'manual',
version: latestTag,
currentVersion: current,
installerUrl: installer?.browser_download_url || data.html_url,
installerName: installer?.name || '',
});
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 ---
async function createWindow() {
@@ -380,6 +452,59 @@ function removeView(name) {
app.view.webContents.destroy();
if (currentView && currentView.name === name) currentView = null;
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. ipcMain.emit triggers the handler
// synchronously; the view's loadURL is fire-and-forget. We chain via setTimeout to
// avoid stacking N loaders simultaneously.
for (const tab of sess.tabs) {
if (!tab?.name || !tab?.url) continue;
ipcMain.emit('create-view', { sender: mainWindow.webContents }, tab.name, tab.url, tab.imageUrl || '', 1.0, !!tab.useProxy);
}
// After all spawned, the last one is `currentView`. Switch to the saved active if different.
if (sess.activeName === 'home') {
ipcMain.emit('hide-view', { sender: mainWindow.webContents });
sendOpenedApps('home');
} else if (sess.activeName && sess.activeName !== currentView?.name) {
ipcMain.emit('show-view', { sender: mainWindow.webContents }, sess.activeName);
}
} catch (e) {
console.warn('[session] restore failed:', e.message);
}
}
// --- IPC ---
@@ -410,7 +535,7 @@ 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);
currentView = appEntry;
view.setBounds(getViewBounds());
@@ -447,6 +572,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
app.history.push(navigatingUrl);
app.historyPosition = app.history.length - 1;
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
};
let origHostname = '';
@@ -503,6 +629,7 @@ ipcMain.on('create-view', async (_event, name, url, imageUrl, _zoom, useProxy) =
sendOpenedApps(name);
mainWindow.webContents.send('updateWebButtons', { history: appEntry.history, historyPosition: appEntry.historyPosition });
scheduleSessionSave();
view.webContents.loadURL(url).catch(() => {
removeView(name);
@@ -516,6 +643,7 @@ ipcMain.on('remove-view', (_event, name) => removeView(name || (currentView && c
ipcMain.on('hide-view', () => {
if (currentView && currentView.view) removeChild(currentView.view);
currentView = null;
scheduleSessionSave();
});
ipcMain.on('show-view', (_event, name) => {
@@ -526,6 +654,7 @@ ipcMain.on('show-view', (_event, name) => {
addChild(app.view);
bringOverlaysToTop();
mainWindow.webContents.send('updateWebButtons', { history: app.history, historyPosition: app.historyPosition });
scheduleSessionSave();
});
let sidebarAnim = null;
@@ -818,38 +947,85 @@ 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 {
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);
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({
language: 'ru-RU',
sort_by: sortBy || 'popularity.desc',
page: String(page || 1),
include_adult: 'false',
});
if (!isBearer) params.set('api_key', apiKey);
if (genreArr.length) params.set('with_genres', genreArr.join(',')); // AND
if (minRating) params.set('vote_average.gte', minRating);
if (countryArr.length) {
// Mix: codes that map to original_language go to with_original_language (pipe-OR),
// raw country codes go to with_origin_country (pipe-OR). Both fields can coexist.
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 langs = [], origCountries = [];
for (const c of countryArr) {
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 headers = isBearer ? { 'Authorization': `Bearer ${apiKey}` } : {};
const resp = await getProxySession().fetch(url, { headers, signal: AbortSignal.timeout(8000) });
if (!resp.ok) return { results: [], totalPages: 0, status: resp.status };
const data = await resp.json();
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));
}
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 => ({
const results = aggregated.map(r => ({
id: r.id,
mediaType: type,
title: (type === 'movie' ? r.title : r.name) || '',
@@ -859,7 +1035,7 @@ ipcMain.handle('discover-tmdb', async (_event, { apiKey, mediaType, sortBy, genr
overview: r.overview || '',
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) {
return { error: e.message, results: [], totalPages: 1 };
}
@@ -1026,12 +1202,19 @@ app.whenReady().then(async () => {
await loadExtensions();
await createWindow();
mainWindow.webContents.once('did-finish-load', () => {
mainWindow.webContents.once('did-finish-load', async () => {
await restoreSession();
setTimeout(checkForUpdates, 4000);
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', () => {
if (process.platform !== 'darwin') app.quit();
});