diff --git a/main.js b/main.js index 84f00d1..278ec2c 100644 --- a/main.js +++ b/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(); }); diff --git a/package-lock.json b/package-lock.json index 5b2a4e7..6cb43a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "ESH-Media", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ESH-Media", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "@cliqz/adblocker-electron": "^1.34.0", "cheerio": "^1.2.0", + "electron-updater": "^6.8.3", "node-fetch": "^2.7.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -2305,7 +2306,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assert-plus": { @@ -3666,6 +3666,82 @@ "dev": true, "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": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4620,7 +4696,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4697,7 +4772,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lazystream": { @@ -4773,6 +4847,12 @@ "license": "MIT", "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": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -4781,6 +4861,13 @@ "license": "MIT", "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": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -5559,7 +5646,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -5953,6 +6039,12 @@ "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": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", diff --git a/package.json b/package.json index bf8be00..1400c3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ESH-Media", - "version": "1.0.2", + "version": "1.0.3", "private": true, "main": "main.js", "scripts": { @@ -16,6 +16,7 @@ "dependencies": { "@cliqz/adblocker-electron": "^1.34.0", "cheerio": "^1.2.0", + "electron-updater": "^6.8.3", "node-fetch": "^2.7.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -46,7 +47,10 @@ "extensions/**/*" ], "win": { - "target": ["nsis", "zip"], + "target": [ + "nsis", + "zip" + ], "icon": "public/favicon.ico" }, "nsis": { @@ -54,9 +58,18 @@ "allowToChangeInstallationDirectory": true }, "linux": { - "target": ["AppImage", "deb"], + "target": [ + "AppImage", + "deb" + ], "icon": "public/logo.png", "category": "Utility" - } + }, + "publish": [ + { + "provider": "generic", + "url": "https://gitea.esh-service.ru/public/ESH-Media/releases/download/latest/" + } + ] } } diff --git a/preload.js b/preload.js index e4a4069..e9eb08f 100644 --- a/preload.js +++ b/preload.js @@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld('electron', { refreshPage: () => ipcRenderer.send('refreshPage'), getCurrentPage: () => ipcRenderer.invoke('get-current-page'), getPageMeta: () => ipcRenderer.invoke('get-page-meta'), + installUpdate: () => ipcRenderer.invoke('install-update'), + checkUpdateNow: () => ipcRenderer.invoke('check-update-now'), readConfig: () => ipcRenderer.invoke('read-config'), writeConfig: (data) => ipcRenderer.send('write-config', data), searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites), diff --git a/src/components/Header.tsx b/src/components/Header.tsx index eb30ab3..9cff4ce 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -110,12 +110,24 @@ const Header: React.FC = ({ activeApp, setActiveApp, onAppsChange, const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search' 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(null) useEffect(() => { if (!window.electron) return - const off = window.electron.on('update-available', (info: { version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string }) => { - setUpdateInfo(info) + const off = window.electron.on('update-status', (info: UpdateStatus) => { + 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 }, []) @@ -278,13 +290,39 @@ const Header: React.FC = ({ activeApp, setActiveApp, onAppsChange, )} - {updateInfo && ( + {updateStatus && updateStatus.state !== 'error' && (
- Доступна версия {updateInfo.version}{updateInfo.currentVersion ? ` (текущая ${updateInfo.currentVersion})` : ''} - - + {updateStatus.state === 'available' && ( + <> + + Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}… + + )} + {updateStatus.state === 'downloading' && ( + <> + Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}% +
+
+
+ + )} + {updateStatus.state === 'ready' && ( + <> + Версия {updateStatus.version} готова к установке + + + )} + {updateStatus.state === 'manual' && ( + <> + Доступна {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''} + + + )} +
)} diff --git a/src/components/MovieSearch.tsx b/src/components/MovieSearch.tsx index 84db79c..aa97cb6 100644 --- a/src/components/MovieSearch.tsx +++ b/src/components/MovieSearch.tsx @@ -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(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 ( +
+
setOpen(o => !o)}> + 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label} + {values.length > 0 && ( + + )} + + + +
+ {open && ( +
e.stopPropagation()}> + {options.map(o => { + const on = values.includes(o.value) + return ( +
toggle(o.value)}> + {on && ( + + + + )} + {o.label} +
+ ) + })} +
+ )} +
+ ) +} + interface TmdbMovie { id: number mediaType: 'movie' | 'tv' @@ -159,10 +225,10 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia const [query, setQuery] = useState(initialQuery) const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie') const [sortBy, setSortBy] = useState('popularity.desc') - const [genreId, setGenreId] = useState(null) - const [year, setYear] = useState('') + const [genreIds, setGenreIds] = useState([]) + const [years, setYears] = useState([]) const [minRating, setMinRating] = useState('') - const [country, setCountry] = useState('') + const [countries, setCountries] = useState([]) const [page, setPage] = useState(1) const [totalPages, setTotalPages] = useState(1) @@ -214,7 +280,7 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia if (append) setLoadingMore(true) else { setTmdbLoading(true); setMessage('') } 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 (res.error) { setMessage(`Ошибка: ${res.error}`); return } setTmdbResults(prev => append ? [...prev, ...res.results] : res.results) @@ -225,14 +291,14 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia } finally { if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) } } - }, [mediaType, sortBy, genreId, year, minRating, country]) + }, [mediaType, sortBy, genreIds, years, minRating, countries]) useEffect(() => { if (!configLoaded || !apiKey || isSearchMode) return setPage(1) setTmdbResults([]) 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 doTmdbSearch = async (q: string, key: string) => { @@ -377,11 +443,11 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia
@@ -390,7 +456,7 @@ const MovieSearch: React.FC = ({ onOpenUrl, onBookmark, initia = ({ onOpenUrl, onBookmark, initia /> )} - {/* Year */} - handleFilterChange(() => setCountry(v))} options={COUNTRIES} placeholder="Страна" /> + handleFilterChange(() => setCountries(v))} + options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))} + placeholder="Страна" + /> )}
- {/* Genres */} + {/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */} {!isSearchMode && (
{genres.map(g => ( ))} + {genreIds.length > 1 && ( + все выбранные одновременно + )}
)} diff --git a/src/main.tsx b/src/main.tsx index 3992e09..d689d9e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -25,7 +25,10 @@ declare global { searchMovies: (query: string, sites: any[]) => Promise searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; 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 + checkUpdateNow: () => Promise + toggleKiosk: () => Promise isKiosk: () => Promise } } diff --git a/src/styles/main.css b/src/styles/main.css index 5562d69..48ef3df 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -788,6 +788,44 @@ body { .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-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 { display: block; margin: 20px auto 8px; @@ -1442,6 +1480,32 @@ body { } .update-banner-close:hover { color: #fff; } +.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 ---- */ .ms-retry-btn { background: none;