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:
287
main.js
287
main.js
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user