nekostream 2026.4.4 → 2026.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/bin/nekostream.js +1 -1
- package/desktop-main.js +418 -0
- package/desktop-preload.js +12 -0
- package/dist/cli/application.js +59 -7
- package/dist/cli/domain-startup.js +6 -5
- package/dist/cli/feedback.js +13 -8
- package/dist/cli/flows/anime-flow.js +58 -38
- package/dist/cli/flows/auth-flow.js +5 -4
- package/dist/cli/flows/provider-lists.js +3 -3
- package/dist/cli/menus/history-menu.js +13 -10
- package/dist/cli/menus/home-menu.js +52 -37
- package/dist/cli/menus/settings-menu.js +32 -29
- package/dist/cli/text.js +26 -0
- package/dist/cli/theme.js +49 -19
- package/dist/cli/update-check.js +47 -7
- package/dist/cli/web-server.js +214 -3
- package/dist/index.js +3 -2
- package/dist/player.js +35 -24
- package/dist/prompts-wrapper.js +798 -154
- package/dist/providers.js +36 -5
- package/dist/scrapers/adblock-policy.js +37 -2
- package/dist/scrapers/auth/animevietsub/lists.js +8 -5
- package/dist/scrapers/auth/animevietsub/login.js +50 -21
- package/dist/scrapers/auth/animevietsub/parsers.js +197 -46
- package/dist/scrapers/auth/auth-session-service.js +1 -0
- package/dist/scrapers/auth/browser-login.js +41 -13
- package/dist/scrapers/crawler.js +35 -2
- package/dist/scrapers/domain-resolver.js +71 -4
- package/dist/scrapers/http-client.js +2 -2
- package/dist/scrapers/providers/anime47.js +0 -10
- package/dist/scrapers/providers/animehay.js +2 -2
- package/dist/scrapers/providers/animevietsub.js +0 -10
- package/dist/storage.js +29 -1
- package/dist/ui.js +104 -55
- package/package.json +19 -6
- package/player.html +36 -14
- package/web/dist/assets/index-Bz4BjC9K.js +2241 -0
- package/web/dist/assets/index-CrfAE4Fa.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BByTtXWy.css +0 -1
- package/web/dist/assets/index-Y4PiRbR7.js +0 -2171
package/assets/icon.ico
ADDED
|
Binary file
|
package/assets/icon.png
ADDED
|
Binary file
|
package/bin/nekostream.js
CHANGED
package/desktop-main.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NekoStream Desktop Application Main Process (Electron)
|
|
3
|
+
* Bridges the Material 3 Web interface with native Windows desktop capabilities.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { app, BrowserWindow, session, ipcMain, shell, webFrameMain } = require('electron')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
const {
|
|
9
|
+
AD_PROBE_HOSTS,
|
|
10
|
+
isIntrusiveAdHost,
|
|
11
|
+
hostnameOf,
|
|
12
|
+
hostMatches,
|
|
13
|
+
isProviderHost,
|
|
14
|
+
isStreamHost,
|
|
15
|
+
isPlaybackHost,
|
|
16
|
+
} = require('./dist/player-policy')
|
|
17
|
+
const { getResolvedBaseUrl } = require('./dist/scrapers/domain-resolver')
|
|
18
|
+
|
|
19
|
+
// Disable Blink automation features for smoother provider playback
|
|
20
|
+
app.commandLine.appendSwitch('disable-blink-features', 'AutomationControlled')
|
|
21
|
+
app.commandLine.appendSwitch('enable-features', 'VaapiVideoDecoder')
|
|
22
|
+
|
|
23
|
+
// Single instance lock
|
|
24
|
+
const gotTheLock = app.requestSingleInstanceLock()
|
|
25
|
+
if (!gotTheLock) {
|
|
26
|
+
app.quit()
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let mainWindow = null
|
|
31
|
+
let serverInstance = null
|
|
32
|
+
|
|
33
|
+
function getIconPath() {
|
|
34
|
+
const iconExt = process.platform === 'win32' ? 'icon.ico' : 'icon.png'
|
|
35
|
+
const iconPath = path.join(__dirname, 'assets', iconExt)
|
|
36
|
+
return iconPath
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function startBackendServer() {
|
|
40
|
+
try {
|
|
41
|
+
const { ensureDataDir } = require('./dist/storage')
|
|
42
|
+
const { resolveProviderDomains } = require('./dist/cli/domain-startup')
|
|
43
|
+
const { initDiscord } = require('./dist/discord')
|
|
44
|
+
const { startWebServer } = require('./dist/cli/web-server')
|
|
45
|
+
|
|
46
|
+
ensureDataDir()
|
|
47
|
+
await resolveProviderDomains().catch((err) => {
|
|
48
|
+
console.warn('[Desktop Startup] Warning resolving domains:', err.message)
|
|
49
|
+
})
|
|
50
|
+
await initDiscord().catch(() => {})
|
|
51
|
+
|
|
52
|
+
// Bind to ephemeral port on 127.0.0.1 for maximum isolation and speed
|
|
53
|
+
const instance = await startWebServer(0, '127.0.0.1')
|
|
54
|
+
return instance
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error('[Desktop Startup] Failed to start internal web server:', err)
|
|
57
|
+
throw err
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setupSessionSecurity() {
|
|
62
|
+
const sess = session.defaultSession
|
|
63
|
+
|
|
64
|
+
// Network-level ad-blocking (like browser extensions)
|
|
65
|
+
sess.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (details, callback) => {
|
|
66
|
+
const hostname = hostnameOf(details.url)
|
|
67
|
+
const lowerUrl = details.url.toLowerCase()
|
|
68
|
+
|
|
69
|
+
// 1. Unconditional block for intrusive / gambling / popup ad networks
|
|
70
|
+
const isIntrusiveAd =
|
|
71
|
+
isIntrusiveAdHost(hostname) ||
|
|
72
|
+
(lowerUrl.includes('banner') &&
|
|
73
|
+
(lowerUrl.includes('casino') ||
|
|
74
|
+
lowerUrl.includes('tai-xiu') ||
|
|
75
|
+
lowerUrl.includes('keo-nha-cai')))
|
|
76
|
+
|
|
77
|
+
if (isIntrusiveAd) {
|
|
78
|
+
return callback({ cancel: true })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2. Block ad tracking domains in players / iframes
|
|
82
|
+
if (details.url && details.url.includes('devtools-warning')) {
|
|
83
|
+
return callback({ cancel: true })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Probes from local app or providers are allowed; other ad probes blocked
|
|
87
|
+
const fromLocal =
|
|
88
|
+
hostname === 'localhost' ||
|
|
89
|
+
hostname === '127.0.0.1' ||
|
|
90
|
+
isProviderHost(hostnameOf(details.referrer)) ||
|
|
91
|
+
isProviderHost(hostnameOf(details.initiator))
|
|
92
|
+
|
|
93
|
+
if (!fromLocal && hostMatches(hostname, AD_PROBE_HOSTS)) {
|
|
94
|
+
return callback({ cancel: true })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return callback({ cancel: false })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Strip CSP and X-Frame-Options on playback hosts
|
|
101
|
+
sess.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
|
|
102
|
+
if (!isPlaybackHost(hostnameOf(details.url))) {
|
|
103
|
+
return callback({ cancel: false })
|
|
104
|
+
}
|
|
105
|
+
const responseHeaders = { ...details.responseHeaders }
|
|
106
|
+
for (const key of Object.keys(responseHeaders)) {
|
|
107
|
+
const lowerKey = key.toLowerCase()
|
|
108
|
+
if (
|
|
109
|
+
lowerKey === 'content-security-policy' ||
|
|
110
|
+
lowerKey === 'content-security-policy-report-only' ||
|
|
111
|
+
lowerKey === 'x-frame-options'
|
|
112
|
+
) {
|
|
113
|
+
delete responseHeaders[key]
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
callback({ cancel: false, responseHeaders })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Relax CORS and inject appropriate Referer for video streaming CDNs
|
|
120
|
+
sess.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => {
|
|
121
|
+
const headers = { ...details.requestHeaders }
|
|
122
|
+
const url = details.url.toLowerCase()
|
|
123
|
+
let avsBase = 'https://animevietsub.sale/'
|
|
124
|
+
try {
|
|
125
|
+
avsBase = getResolvedBaseUrl('animevietsub') || avsBase
|
|
126
|
+
if (!avsBase.endsWith('/')) avsBase += '/'
|
|
127
|
+
} catch {}
|
|
128
|
+
|
|
129
|
+
// Provider stream CDN referers
|
|
130
|
+
if (
|
|
131
|
+
url.includes('abyss') ||
|
|
132
|
+
url.includes('hydrax') ||
|
|
133
|
+
url.includes('iamcdn') ||
|
|
134
|
+
url.includes('short.ink')
|
|
135
|
+
) {
|
|
136
|
+
headers['Referer'] = avsBase
|
|
137
|
+
headers['Origin'] = new URL(avsBase).origin
|
|
138
|
+
} else if (url.includes('anime47')) {
|
|
139
|
+
headers['Referer'] = 'https://anime47.com/'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
callback({ requestHeaders: headers })
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// Block permission requests except fullscreen
|
|
146
|
+
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
|
147
|
+
if (permission === 'fullscreen') {
|
|
148
|
+
return callback(true)
|
|
149
|
+
}
|
|
150
|
+
callback(false)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function createMainWindow(appUrl) {
|
|
155
|
+
mainWindow = new BrowserWindow({
|
|
156
|
+
title: 'NekoStream',
|
|
157
|
+
width: 1360,
|
|
158
|
+
height: 860,
|
|
159
|
+
minWidth: 1024,
|
|
160
|
+
minHeight: 680,
|
|
161
|
+
center: true,
|
|
162
|
+
frame: true, // Native Windows Title Bar as requested
|
|
163
|
+
autoHideMenuBar: true, // Clean look without traditional Alt menu bar
|
|
164
|
+
backgroundColor: '#0f0b13', // Material 3 surface color (zero white flash)
|
|
165
|
+
icon: getIconPath(),
|
|
166
|
+
show: false,
|
|
167
|
+
webPreferences: {
|
|
168
|
+
preload: path.join(__dirname, 'desktop-preload.js'),
|
|
169
|
+
contextIsolation: true,
|
|
170
|
+
nodeIntegration: false,
|
|
171
|
+
sandbox: false,
|
|
172
|
+
webSecurity: false, // Allows HLS playback across diverse provider CDNs without browser CORS blocks
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
mainWindow.once('ready-to-show', () => {
|
|
177
|
+
if (mainWindow) {
|
|
178
|
+
mainWindow.show()
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Deny all popup windows and ad tabs from players/iframes
|
|
183
|
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
184
|
+
if (url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1')) {
|
|
185
|
+
return { action: 'allow' }
|
|
186
|
+
}
|
|
187
|
+
// Block popup windows from stream players
|
|
188
|
+
return { action: 'deny' }
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Frame script injector to neutralize anti-adblock detection & popup triggers in HDX / Abyss / Hydrax
|
|
192
|
+
const injectIntoFrame = (frame) => {
|
|
193
|
+
try {
|
|
194
|
+
if (!frame || (frame.isDestroyed && frame.isDestroyed())) return
|
|
195
|
+
// Never inject into the top-level main window
|
|
196
|
+
if (!frame.parent) return
|
|
197
|
+
|
|
198
|
+
const frameUrl = frame.url || ''
|
|
199
|
+
const host = hostnameOf(frameUrl)
|
|
200
|
+
// Only inject into stream hosts or any iframe under playback hosts
|
|
201
|
+
if (host && !isStreamHost(host) && !isPlaybackHost(host)) {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
frame
|
|
206
|
+
.executeJavaScript(
|
|
207
|
+
`
|
|
208
|
+
(function() {
|
|
209
|
+
if (window.__neko_cleaner_active__) return;
|
|
210
|
+
window.__neko_cleaner_active__ = true;
|
|
211
|
+
|
|
212
|
+
// --- 1. Neutralize window.open to prevent popup blockers from throwing on .focus() ---
|
|
213
|
+
// Anti-adblock scripts on Abyss/Hydrax catch .focus() TypeError on blocked windows to trigger
|
|
214
|
+
// "Due to certain reasons (AdBlock/Sandbox), ads are not being displayed..."
|
|
215
|
+
try {
|
|
216
|
+
window.open = function(url) {
|
|
217
|
+
return {
|
|
218
|
+
closed: false,
|
|
219
|
+
focus: function() {},
|
|
220
|
+
blur: function() {},
|
|
221
|
+
close: function() {},
|
|
222
|
+
postMessage: function() {},
|
|
223
|
+
location: { href: url || '' }
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
} catch(e) {}
|
|
227
|
+
|
|
228
|
+
// --- 2. Prevent anti-adblock document.write from wiping out the player ---
|
|
229
|
+
try {
|
|
230
|
+
const _origDocWrite = document.write.bind(document);
|
|
231
|
+
document.write = function(content) {
|
|
232
|
+
if (
|
|
233
|
+
typeof content === 'string' &&
|
|
234
|
+
(content.includes('AdBlock/Sandbox') ||
|
|
235
|
+
content.includes('Due to certain reasons') ||
|
|
236
|
+
content.includes('disable AdBlock') ||
|
|
237
|
+
content.includes('removing the Sandbox'))
|
|
238
|
+
) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
return _origDocWrite(content);
|
|
242
|
+
};
|
|
243
|
+
} catch(e) {}
|
|
244
|
+
|
|
245
|
+
// --- 3. CSS block for ads ---
|
|
246
|
+
if (!document.getElementById('__neko_adblock__')) {
|
|
247
|
+
const style = document.createElement('style');
|
|
248
|
+
style.id = '__neko_adblock__';
|
|
249
|
+
style.textContent = [
|
|
250
|
+
'img[src*="in88"],img[src*="quayhu"],img[src*="nohu"],',
|
|
251
|
+
'img[src*="188bet"],img[src*="kubet"],img[src*="w88"],img[src*="fun88"],img[src*="sin88"],',
|
|
252
|
+
'div[style*="sin88"],div[style*="in88"],',
|
|
253
|
+
'[class*="art-ad"],[class*="artplayer-ad"],[class*="art-ads"],[class*="artplayer-ads"],',
|
|
254
|
+
'[class*="ads-container"],[class*="ad-container"],[class*="pause-ad"],[id*="pause-ad"],',
|
|
255
|
+
'[class*="ads-pause"],[id*="ads-pause"],[class*="overlay-ad"],[id*="overlay-ad"],',
|
|
256
|
+
'[class*="popup-ad"],[id*="popup-ad"],[class*="quangcao"],[id*="quangcao"],',
|
|
257
|
+
'[class*="qc-"],[id*="qc-"] { display:none!important }'
|
|
258
|
+
].join('');
|
|
259
|
+
(document.head || document.documentElement).appendChild(style);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- 4. DOM cleaner & Abyss Overlay Bypass ---
|
|
263
|
+
const AD_TEXTS = ['Đóng quảng cáo', 'Đóng và xem tiếp', 'Quảng cáo'];
|
|
264
|
+
const AD_SRCS = ['in88', 'quayhu', 'nohu', '188bet', 'kubet', 'w88', 'fun88', 'sin88'];
|
|
265
|
+
|
|
266
|
+
const clean = () => {
|
|
267
|
+
try {
|
|
268
|
+
// Bypass Abyss/Hydrax popup click-jacking by rewriting the overlay click event
|
|
269
|
+
const overlay = document.getElementById('overlay');
|
|
270
|
+
if (overlay) {
|
|
271
|
+
overlay.onclick = (e) => {
|
|
272
|
+
if (e) {
|
|
273
|
+
e.stopPropagation();
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
}
|
|
276
|
+
if (window.jwplayer && typeof window.jwplayer === 'function') {
|
|
277
|
+
try {
|
|
278
|
+
window.jwplayer().play();
|
|
279
|
+
} catch(err) {}
|
|
280
|
+
}
|
|
281
|
+
overlay.remove();
|
|
282
|
+
};
|
|
283
|
+
overlay.ontouchend = overlay.onclick;
|
|
284
|
+
|
|
285
|
+
// Auto-remove overlay if player is active
|
|
286
|
+
if (document.querySelector('video') || (window.jwplayer && typeof window.jwplayer === 'function')) {
|
|
287
|
+
try {
|
|
288
|
+
window.jwplayer().play();
|
|
289
|
+
} catch(e) {}
|
|
290
|
+
overlay.remove();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!document.body) return;
|
|
295
|
+
document.querySelectorAll('div,section,article,button,a,span,img').forEach(el => {
|
|
296
|
+
const txt = (el.innerText || el.textContent || '').trim();
|
|
297
|
+
const src = (el.src || el.getAttribute('src') || el.getAttribute('href') || '').toLowerCase();
|
|
298
|
+
const isAdTxt = AD_TEXTS.some(t => txt === t || txt.startsWith(t));
|
|
299
|
+
const isAdSrc = AD_SRCS.some(k => src.includes(k));
|
|
300
|
+
if (!isAdTxt && !isAdSrc) return;
|
|
301
|
+
|
|
302
|
+
let node = el;
|
|
303
|
+
for (let i = 0; i < 10; i++) {
|
|
304
|
+
const p = node.parentElement;
|
|
305
|
+
if (!p || p === document.body || p === document.documentElement) break;
|
|
306
|
+
if (p.querySelector('video') || p.querySelector('canvas')) break;
|
|
307
|
+
|
|
308
|
+
const className = (p.className || '').toString().toLowerCase();
|
|
309
|
+
const id = (p.id || '').toString().toLowerCase();
|
|
310
|
+
const isAdContainer = className.includes('ad') || className.includes('qc') || className.includes('banner') || className.includes('popup') || className.includes('overlay') ||
|
|
311
|
+
id.includes('ad') || id.includes('qc') || id.includes('banner') || id.includes('popup') || id.includes('overlay');
|
|
312
|
+
|
|
313
|
+
const cs = getComputedStyle(p);
|
|
314
|
+
if (cs.position === 'fixed' || cs.position === 'absolute' || isAdContainer) {
|
|
315
|
+
p.remove(); return;
|
|
316
|
+
}
|
|
317
|
+
node = p;
|
|
318
|
+
}
|
|
319
|
+
el.remove();
|
|
320
|
+
});
|
|
321
|
+
} catch(e) {}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
clean();
|
|
325
|
+
setInterval(clean, 250);
|
|
326
|
+
})();
|
|
327
|
+
`
|
|
328
|
+
)
|
|
329
|
+
.catch(() => {})
|
|
330
|
+
} catch (err) {
|
|
331
|
+
console.error('[Desktop] Failed to inject frame:', err)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
mainWindow.webContents.on('did-frame-finish-load', (e, isMainFrame, frameProcessId, frameRoutingId) => {
|
|
336
|
+
if (!isMainFrame) {
|
|
337
|
+
const frame = webFrameMain.fromId(frameProcessId, frameRoutingId)
|
|
338
|
+
if (frame) {
|
|
339
|
+
injectIntoFrame(frame)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
mainWindow.webContents.on('frame-created', (e, details) => {
|
|
345
|
+
const frame = details.frame
|
|
346
|
+
injectIntoFrame(frame)
|
|
347
|
+
setTimeout(() => {
|
|
348
|
+
injectIntoFrame(frame)
|
|
349
|
+
}, 300)
|
|
350
|
+
setTimeout(() => {
|
|
351
|
+
injectIntoFrame(frame)
|
|
352
|
+
}, 1000)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
mainWindow.on('closed', () => {
|
|
356
|
+
mainWindow = null
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
await mainWindow.loadURL(appUrl)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// IPC Handlers
|
|
363
|
+
ipcMain.on('window:minimize', () => {
|
|
364
|
+
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.minimize()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
ipcMain.on('window:maximize', () => {
|
|
368
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
369
|
+
if (mainWindow.isMaximized()) mainWindow.unmaximize()
|
|
370
|
+
else mainWindow.maximize()
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
ipcMain.on('window:close', () => {
|
|
375
|
+
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.close()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
ipcMain.on('shell:openExternal', (event, url) => {
|
|
379
|
+
if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
|
|
380
|
+
shell.openExternal(url)
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// App Lifecycle
|
|
385
|
+
app.on('second-instance', () => {
|
|
386
|
+
if (mainWindow) {
|
|
387
|
+
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
388
|
+
mainWindow.focus()
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
app.whenReady().then(async () => {
|
|
393
|
+
try {
|
|
394
|
+
setupSessionSecurity()
|
|
395
|
+
serverInstance = await startBackendServer()
|
|
396
|
+
await createMainWindow(serverInstance.url)
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error('Fatal Desktop launch error:', err)
|
|
399
|
+
app.quit()
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
app.on('window-all-closed', () => {
|
|
404
|
+
if (process.platform !== 'darwin') {
|
|
405
|
+
app.quit()
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
app.on('before-quit', async () => {
|
|
410
|
+
try {
|
|
411
|
+
const { clearDiscordPresence } = require('./dist/discord')
|
|
412
|
+
clearDiscordPresence()
|
|
413
|
+
} catch {}
|
|
414
|
+
if (serverInstance && typeof serverInstance.close === 'function') {
|
|
415
|
+
await serverInstance.close().catch(() => {})
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { contextBridge, ipcRenderer } = require('electron')
|
|
2
|
+
|
|
3
|
+
contextBridge.exposeInMainWorld('desktopApi', {
|
|
4
|
+
isElectron: true,
|
|
5
|
+
platform: process.platform,
|
|
6
|
+
version: process.env.NEKOSTREAM_VERSION || '2026.4.5',
|
|
7
|
+
minimize: () => ipcRenderer.send('window:minimize'),
|
|
8
|
+
maximize: () => ipcRenderer.send('window:maximize'),
|
|
9
|
+
close: () => ipcRenderer.send('window:close'),
|
|
10
|
+
openExternal: (url) => ipcRenderer.send('shell:openExternal', url)
|
|
11
|
+
})
|
|
12
|
+
|
package/dist/cli/application.js
CHANGED
|
@@ -8,10 +8,36 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
8
8
|
const discord_1 = require("../discord");
|
|
9
9
|
const logger_1 = require("../logger");
|
|
10
10
|
const storage_1 = require("../storage");
|
|
11
|
+
const ui_1 = require("../ui");
|
|
12
|
+
const theme_1 = require("./theme");
|
|
11
13
|
const domain_startup_1 = require("./domain-startup");
|
|
12
14
|
const home_menu_1 = require("./menus/home-menu");
|
|
13
15
|
const update_check_1 = require("./update-check");
|
|
14
16
|
const web_server_1 = require("./web-server");
|
|
17
|
+
const player_1 = require("../player");
|
|
18
|
+
function printHelp(version) {
|
|
19
|
+
const heading = (text) => chalk_1.default.hex(theme_1.palette.muted).bold(text);
|
|
20
|
+
const flag = (name) => chalk_1.default.hex(theme_1.palette.soft)(name);
|
|
21
|
+
console.log(`
|
|
22
|
+
${chalk_1.default.hex(theme_1.palette.text).bold('nekostream')} ${chalk_1.default.hex(theme_1.palette.subtle)(`v${version}`)}
|
|
23
|
+
${chalk_1.default.hex(theme_1.palette.muted)('stream anime from vietnamese providers, right in your terminal.')}
|
|
24
|
+
|
|
25
|
+
${heading('USAGE')}
|
|
26
|
+
${flag('$ nekostream [command|flags]')}
|
|
27
|
+
|
|
28
|
+
${heading('FLAGS')}
|
|
29
|
+
${flag('-v, --version')} show version number and exit
|
|
30
|
+
${flag('-h, --help')} show this help message and exit
|
|
31
|
+
${flag('--web')} start headless local web server and open in browser
|
|
32
|
+
${flag('--desktop, --app')} launch standalone native desktop player app
|
|
33
|
+
|
|
34
|
+
${heading('EXAMPLES')}
|
|
35
|
+
${flag('$ nekostream')} launch interactive terminal UI
|
|
36
|
+
${flag('$ nekostream --web')} launch local web player interface
|
|
37
|
+
${flag('$ nekostream --desktop')} launch native desktop application
|
|
38
|
+
${flag('$ nekostream --version')} print installed version
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
15
41
|
/**
|
|
16
42
|
* Application entry point: bring up the runtime services, then hand control to
|
|
17
43
|
* the home menu loop, which owns the interactive session until exit.
|
|
@@ -20,17 +46,43 @@ const web_server_1 = require("./web-server");
|
|
|
20
46
|
* process when a newer version exists, so there is no point probing first.
|
|
21
47
|
*/
|
|
22
48
|
async function runApplication() {
|
|
23
|
-
(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
(
|
|
49
|
+
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
50
|
+
console.log(`nekostream v${(0, ui_1.getVersion)()}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (process.argv.includes('--help') || process.argv.includes('-h') || process.argv.includes('help')) {
|
|
54
|
+
printHelp((0, ui_1.getVersion)());
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (process.argv.includes('--desktop') ||
|
|
58
|
+
process.argv.includes('desktop') ||
|
|
59
|
+
process.argv.includes('--app') ||
|
|
60
|
+
process.argv.includes('app')) {
|
|
61
|
+
console.log(`\n${chalk_1.default.hex(theme_1.palette.accent)('✶')} ${chalk_1.default.hex(theme_1.palette.soft)('desktop — đang khởi chạy ứng dụng native...')}`);
|
|
62
|
+
await (0, player_1.launchDesktopApp)();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
28
65
|
if (process.argv.includes('--web') || process.argv.includes('web')) {
|
|
66
|
+
(0, storage_1.ensureDataDir)();
|
|
67
|
+
// The web UI reports playback through /api/stream, so this process is the
|
|
68
|
+
// one that owns Discord presence for the whole web session.
|
|
69
|
+
(0, discord_1.initDiscord)();
|
|
29
70
|
const instance = await (0, web_server_1.startWebServer)();
|
|
30
|
-
console.log(chalk_1.default.
|
|
31
|
-
console.log(chalk_1.default.
|
|
71
|
+
console.log(`\n${chalk_1.default.hex(theme_1.palette.accent)('✶')} ${chalk_1.default.hex(theme_1.palette.soft)('web — giao diện chạy tại')} ${chalk_1.default.hex(theme_1.palette.primary).bold.underline(instance.url)}`);
|
|
72
|
+
console.log(`${chalk_1.default.hex(theme_1.palette.subtle)('nhấn ctrl+c để dừng server.\n')}`);
|
|
32
73
|
(0, web_server_1.openBrowser)(instance.url);
|
|
33
74
|
return new Promise(() => { });
|
|
34
75
|
}
|
|
76
|
+
if (!process.stdin.isTTY) {
|
|
77
|
+
console.error(chalk_1.default.hex(theme_1.palette.yellow)('nekostream requires an interactive TTY terminal to run the interactive UI.'));
|
|
78
|
+
console.error(chalk_1.default.hex(theme_1.palette.subtle)("run 'nekostream --help' for available flags and options."));
|
|
79
|
+
process.exitCode = 2;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
(0, storage_1.ensureDataDir)();
|
|
83
|
+
await (0, update_check_1.checkUpdate)();
|
|
84
|
+
await (0, domain_startup_1.resolveProviderDomains)();
|
|
85
|
+
await (0, discord_1.initDiscord)();
|
|
86
|
+
(0, logger_1.debugLog)('NekoStream CLI started');
|
|
35
87
|
await (0, home_menu_1.showHomeMenu)();
|
|
36
88
|
}
|
|
@@ -9,6 +9,7 @@ const ora_1 = __importDefault(require("ora"));
|
|
|
9
9
|
const logger_1 = require("../logger");
|
|
10
10
|
const domain_resolver_1 = require("../scrapers/domain-resolver");
|
|
11
11
|
const feedback_1 = require("./feedback");
|
|
12
|
+
const theme_1 = require("./theme");
|
|
12
13
|
/**
|
|
13
14
|
* The fast path — probing the domain already in use — normally answers well
|
|
14
15
|
* inside this window, so no spinner is drawn at all and startup looks unchanged.
|
|
@@ -32,7 +33,7 @@ function summarize(outcomes) {
|
|
|
32
33
|
function delayedSpinner(text) {
|
|
33
34
|
let spinner;
|
|
34
35
|
const timer = setTimeout(() => {
|
|
35
|
-
spinner = (0, ora_1.default)(text).start();
|
|
36
|
+
spinner = (0, ora_1.default)({ text, spinner: theme_1.spinnerStyle, color: 'yellow' }).start();
|
|
36
37
|
}, SPINNER_DELAY_MS);
|
|
37
38
|
return {
|
|
38
39
|
stop: () => {
|
|
@@ -53,10 +54,10 @@ function delayedSpinner(text) {
|
|
|
53
54
|
* simply mean the cached or seeded domain is used as-is.
|
|
54
55
|
*/
|
|
55
56
|
async function resolveProviderDomains() {
|
|
56
|
-
const spinner = delayedSpinner('
|
|
57
|
+
const spinner = delayedSpinner('đang dò domain provider...');
|
|
57
58
|
let outcomes;
|
|
58
59
|
try {
|
|
59
|
-
outcomes = await (0, domain_resolver_1.resolveAllDomains)();
|
|
60
|
+
outcomes = await (0, domain_resolver_1.resolveAllDomains)(undefined, { onlyStale: true });
|
|
60
61
|
}
|
|
61
62
|
finally {
|
|
62
63
|
spinner.stop();
|
|
@@ -69,7 +70,7 @@ async function resolveProviderDomains() {
|
|
|
69
70
|
// expected case and the whole point of the fast path.
|
|
70
71
|
if (changed.length > 0) {
|
|
71
72
|
for (const outcome of changed) {
|
|
72
|
-
console.log(chalk_1.default.cyan(`
|
|
73
|
+
console.log(`${chalk_1.default.hex(theme_1.palette.cyan)(` domain ${outcome.provider} đã đổi`)} ${chalk_1.default.hex(theme_1.palette.subtle)('→')} ${chalk_1.default.hex(theme_1.palette.soft)(outcome.baseUrl)}`);
|
|
73
74
|
}
|
|
74
75
|
await (0, feedback_1.sleep)(feedback_1.CONFIRM_DELAY_MS);
|
|
75
76
|
return;
|
|
@@ -79,7 +80,7 @@ async function resolveProviderDomains() {
|
|
|
79
80
|
// cannot act on; with it, the line only follows visible work.
|
|
80
81
|
if (failed.length > 0 && spinner.wasShown()) {
|
|
81
82
|
const names = failed.map((o) => o.provider).join(', ');
|
|
82
|
-
console.log(chalk_1.default.yellow(`
|
|
83
|
+
console.log(chalk_1.default.hex(theme_1.palette.yellow)(` không dò được domain sống cho: ${names}. dùng domain đã lưu.`));
|
|
83
84
|
await (0, feedback_1.sleep)(feedback_1.CONFIRM_DELAY_MS);
|
|
84
85
|
}
|
|
85
86
|
}
|
package/dist/cli/feedback.js
CHANGED
|
@@ -16,14 +16,19 @@ exports.warnIfDeprecatedProvider = warnIfDeprecatedProvider;
|
|
|
16
16
|
const chalk_1 = __importDefault(require("chalk"));
|
|
17
17
|
const ora_1 = __importDefault(require("ora"));
|
|
18
18
|
const ui_1 = require("../ui");
|
|
19
|
+
const theme_1 = require("./theme");
|
|
19
20
|
/** How long a transient message stays on screen before the menu repaints. */
|
|
20
|
-
exports.NOTICE_DELAY_MS =
|
|
21
|
-
exports.CONFIRM_DELAY_MS =
|
|
21
|
+
exports.NOTICE_DELAY_MS = 1000;
|
|
22
|
+
exports.CONFIRM_DELAY_MS = 500;
|
|
23
|
+
/** Spinner in the theme's braille frames; warm color to match the accent. */
|
|
24
|
+
function createSpinner(message) {
|
|
25
|
+
return (0, ora_1.default)({ text: message, spinner: theme_1.spinnerStyle, color: 'yellow' }).start();
|
|
26
|
+
}
|
|
22
27
|
async function sleep(ms) {
|
|
23
28
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
29
|
}
|
|
25
30
|
function formatToggle(value) {
|
|
26
|
-
return value ? chalk_1.default.green('
|
|
31
|
+
return value ? chalk_1.default.hex(theme_1.palette.green)('bật') : chalk_1.default.hex(theme_1.palette.red)('tắt');
|
|
27
32
|
}
|
|
28
33
|
/**
|
|
29
34
|
* Drain buffered keypresses so a prompt does not immediately consume input
|
|
@@ -45,12 +50,12 @@ function formatError(error) {
|
|
|
45
50
|
/** Print a failure and hold it on screen long enough to be read. */
|
|
46
51
|
async function reportFailure(label, error) {
|
|
47
52
|
const detail = error === undefined ? '' : `: ${formatError(error)}`;
|
|
48
|
-
(0, ui_1.printError)(`\n${label}${detail}`);
|
|
53
|
+
(0, ui_1.printError)(`\n${label.toLowerCase()}${detail}`);
|
|
49
54
|
await sleep(exports.NOTICE_DELAY_MS);
|
|
50
55
|
}
|
|
51
56
|
/** Print an empty-result notice and hold it on screen. */
|
|
52
57
|
async function reportEmpty(message) {
|
|
53
|
-
(0, ui_1.printError)(`\n${message}`);
|
|
58
|
+
(0, ui_1.printError)(`\n${message.toLowerCase()}`);
|
|
54
59
|
await sleep(exports.NOTICE_DELAY_MS);
|
|
55
60
|
}
|
|
56
61
|
/**
|
|
@@ -58,7 +63,7 @@ async function reportEmpty(message) {
|
|
|
58
63
|
* after showing `failure` to the user — callers treat that as "abort this step".
|
|
59
64
|
*/
|
|
60
65
|
async function withSpinner(message, failure, task) {
|
|
61
|
-
const spinner = (
|
|
66
|
+
const spinner = createSpinner(message);
|
|
62
67
|
try {
|
|
63
68
|
const value = await task();
|
|
64
69
|
spinner.stop();
|
|
@@ -75,7 +80,7 @@ async function withSpinner(message, failure, task) {
|
|
|
75
80
|
* caller can decide the final message.
|
|
76
81
|
*/
|
|
77
82
|
async function withSilentSpinner(message, task) {
|
|
78
|
-
const spinner = (
|
|
83
|
+
const spinner = createSpinner(message);
|
|
79
84
|
try {
|
|
80
85
|
const value = await task();
|
|
81
86
|
spinner.stop();
|
|
@@ -91,7 +96,7 @@ async function withSilentSpinner(message, task) {
|
|
|
91
96
|
*/
|
|
92
97
|
async function warnIfDeprecatedProvider(provider) {
|
|
93
98
|
if (provider === 'anime47' || provider === 'animehay') {
|
|
94
|
-
console.log(chalk_1.default.red('\
|
|
99
|
+
console.log(chalk_1.default.hex(theme_1.palette.red)('\nprovider này có thể không còn hoạt động đúng chức năng, khuyên bạn chuyển sang animevietsub để có trải nghiệm tốt nhất.\n'));
|
|
95
100
|
await sleep(exports.NOTICE_DELAY_MS);
|
|
96
101
|
}
|
|
97
102
|
}
|