nekostream 2026.4.3 → 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.
Files changed (47) hide show
  1. package/assets/icon.ico +0 -0
  2. package/assets/icon.png +0 -0
  3. package/bin/nekostream.js +1 -1
  4. package/desktop-main.js +418 -0
  5. package/desktop-preload.js +12 -0
  6. package/dist/cli/application.js +64 -0
  7. package/dist/cli/domain-startup.js +6 -5
  8. package/dist/cli/feedback.js +22 -7
  9. package/dist/cli/flows/anime-flow.js +58 -38
  10. package/dist/cli/flows/auth-flow.js +5 -4
  11. package/dist/cli/flows/provider-lists.js +3 -3
  12. package/dist/cli/menus/history-menu.js +13 -10
  13. package/dist/cli/menus/home-menu.js +63 -33
  14. package/dist/cli/menus/settings-menu.js +35 -30
  15. package/dist/cli/text.js +26 -0
  16. package/dist/cli/theme.js +49 -19
  17. package/dist/cli/update-check.js +71 -8
  18. package/dist/cli/web-server.js +765 -0
  19. package/dist/index.js +3 -2
  20. package/dist/player.js +35 -24
  21. package/dist/prompts-wrapper.js +798 -154
  22. package/dist/providers.js +36 -5
  23. package/dist/scrapers/adblock-policy.js +46 -2
  24. package/dist/scrapers/anilist-api.js +24 -6
  25. package/dist/scrapers/auth/animevietsub/lists.js +8 -5
  26. package/dist/scrapers/auth/animevietsub/login.js +50 -21
  27. package/dist/scrapers/auth/animevietsub/parsers.js +197 -46
  28. package/dist/scrapers/auth/auth-session-service.js +1 -0
  29. package/dist/scrapers/auth/browser-login.js +41 -13
  30. package/dist/scrapers/base.js +4 -1
  31. package/dist/scrapers/crawler.js +35 -2
  32. package/dist/scrapers/domain-resolver.js +71 -4
  33. package/dist/scrapers/http-client.js +2 -2
  34. package/dist/scrapers/providers/anime47.js +1 -11
  35. package/dist/scrapers/providers/animehay.js +7 -7
  36. package/dist/scrapers/providers/animevietsub/parsers/cards.js +4 -4
  37. package/dist/scrapers/providers/animevietsub/parsers/detail.js +19 -18
  38. package/dist/scrapers/providers/animevietsub/streams/server-parser.js +24 -4
  39. package/dist/scrapers/providers/animevietsub.js +124 -23
  40. package/dist/scrapers/vietnamese-titles.js +97 -0
  41. package/dist/storage.js +29 -1
  42. package/dist/ui.js +104 -55
  43. package/package.json +24 -8
  44. package/player.html +36 -14
  45. package/web/dist/assets/index-Bz4BjC9K.js +2241 -0
  46. package/web/dist/assets/index-CrfAE4Fa.css +1 -0
  47. package/web/dist/index.html +22 -0
Binary file
Binary file
package/bin/nekostream.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  // NekoStream CLI Entry Point
4
4
  require('../dist/index.js');
@@ -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
+
@@ -1,12 +1,43 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.runApplication = runApplication;
7
+ const chalk_1 = __importDefault(require("chalk"));
4
8
  const discord_1 = require("../discord");
5
9
  const logger_1 = require("../logger");
6
10
  const storage_1 = require("../storage");
11
+ const ui_1 = require("../ui");
12
+ const theme_1 = require("./theme");
7
13
  const domain_startup_1 = require("./domain-startup");
8
14
  const home_menu_1 = require("./menus/home-menu");
9
15
  const update_check_1 = require("./update-check");
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
+ }
10
41
  /**
11
42
  * Application entry point: bring up the runtime services, then hand control to
12
43
  * the home menu loop, which owns the interactive session until exit.
@@ -15,6 +46,39 @@ const update_check_1 = require("./update-check");
15
46
  * process when a newer version exists, so there is no point probing first.
16
47
  */
17
48
  async function runApplication() {
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
+ }
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)();
70
+ const instance = await (0, web_server_1.startWebServer)();
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')}`);
73
+ (0, web_server_1.openBrowser)(instance.url);
74
+ return new Promise(() => { });
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
+ }
18
82
  (0, storage_1.ensureDataDir)();
19
83
  await (0, update_check_1.checkUpdate)();
20
84
  await (0, domain_startup_1.resolveProviderDomains)();
@@ -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('Đang dò domain provider...');
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(` Domain ${outcome.provider} đã đổi -> ${outcome.baseUrl}`));
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(` Không dò được domain sống cho: ${names}. Dùng domain đã lưu.`));
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
  }
@@ -12,17 +12,23 @@ exports.reportFailure = reportFailure;
12
12
  exports.reportEmpty = reportEmpty;
13
13
  exports.withSpinner = withSpinner;
14
14
  exports.withSilentSpinner = withSilentSpinner;
15
+ exports.warnIfDeprecatedProvider = warnIfDeprecatedProvider;
15
16
  const chalk_1 = __importDefault(require("chalk"));
16
17
  const ora_1 = __importDefault(require("ora"));
17
18
  const ui_1 = require("../ui");
19
+ const theme_1 = require("./theme");
18
20
  /** How long a transient message stays on screen before the menu repaints. */
19
- exports.NOTICE_DELAY_MS = 2000;
20
- exports.CONFIRM_DELAY_MS = 1000;
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
+ }
21
27
  async function sleep(ms) {
22
28
  return new Promise((resolve) => setTimeout(resolve, ms));
23
29
  }
24
30
  function formatToggle(value) {
25
- return value ? chalk_1.default.green('Bật') : chalk_1.default.red('Tắt');
31
+ return value ? chalk_1.default.hex(theme_1.palette.green)('bật') : chalk_1.default.hex(theme_1.palette.red)('tắt');
26
32
  }
27
33
  /**
28
34
  * Drain buffered keypresses so a prompt does not immediately consume input
@@ -44,12 +50,12 @@ function formatError(error) {
44
50
  /** Print a failure and hold it on screen long enough to be read. */
45
51
  async function reportFailure(label, error) {
46
52
  const detail = error === undefined ? '' : `: ${formatError(error)}`;
47
- (0, ui_1.printError)(`\n${label}${detail}`);
53
+ (0, ui_1.printError)(`\n${label.toLowerCase()}${detail}`);
48
54
  await sleep(exports.NOTICE_DELAY_MS);
49
55
  }
50
56
  /** Print an empty-result notice and hold it on screen. */
51
57
  async function reportEmpty(message) {
52
- (0, ui_1.printError)(`\n${message}`);
58
+ (0, ui_1.printError)(`\n${message.toLowerCase()}`);
53
59
  await sleep(exports.NOTICE_DELAY_MS);
54
60
  }
55
61
  /**
@@ -57,7 +63,7 @@ async function reportEmpty(message) {
57
63
  * after showing `failure` to the user — callers treat that as "abort this step".
58
64
  */
59
65
  async function withSpinner(message, failure, task) {
60
- const spinner = (0, ora_1.default)(message).start();
66
+ const spinner = createSpinner(message);
61
67
  try {
62
68
  const value = await task();
63
69
  spinner.stop();
@@ -74,7 +80,7 @@ async function withSpinner(message, failure, task) {
74
80
  * caller can decide the final message.
75
81
  */
76
82
  async function withSilentSpinner(message, task) {
77
- const spinner = (0, ora_1.default)(message).start();
83
+ const spinner = createSpinner(message);
78
84
  try {
79
85
  const value = await task();
80
86
  spinner.stop();
@@ -85,3 +91,12 @@ async function withSilentSpinner(message, task) {
85
91
  return { ok: false, error };
86
92
  }
87
93
  }
94
+ /**
95
+ * Warn the user in red if they switch to a provider that may no longer work properly.
96
+ */
97
+ async function warnIfDeprecatedProvider(provider) {
98
+ if (provider === 'anime47' || provider === 'animehay') {
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'));
100
+ await sleep(exports.NOTICE_DELAY_MS);
101
+ }
102
+ }