nekostream 2026.1.4 → 2026.2.2

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/dist/cli/theme.js CHANGED
@@ -6,30 +6,35 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.glyphs = exports.uiText = exports.palette = void 0;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
8
  exports.palette = {
9
- primary: '#5eead4',
10
- accent: '#f472b6',
11
- blue: '#60a5fa',
12
- violet: '#a78bfa',
13
- success: '#34d399',
14
- warning: '#fbbf24',
15
- danger: '#fb7185',
16
- muted: '#94a3b8',
17
- border: '#334155',
18
- text: '#f8fafc'
9
+ primary: '#58a6ff',
10
+ accent: '#db61a2',
11
+ cyan: '#39c5cf',
12
+ green: '#3fb950',
13
+ yellow: '#d29922',
14
+ red: '#f85149',
15
+ text: '#f0f6fc',
16
+ muted: '#8b949e',
17
+ subtle: '#6e7681',
18
+ border: '#30363d',
19
+ borderActive: '#3b82f6'
19
20
  };
20
21
  exports.uiText = {
21
22
  brand: chalk_1.default.hex(exports.palette.primary).bold,
22
23
  title: chalk_1.default.hex(exports.palette.text).bold,
23
24
  subtitle: chalk_1.default.hex(exports.palette.muted),
24
25
  section: chalk_1.default.hex(exports.palette.accent).bold,
25
- label: chalk_1.default.hex(exports.palette.blue),
26
+ label: chalk_1.default.hex(exports.palette.primary).bold,
26
27
  value: chalk_1.default.white,
28
+ quietValue: chalk_1.default.hex(exports.palette.text),
27
29
  muted: chalk_1.default.hex(exports.palette.muted),
28
- success: chalk_1.default.hex(exports.palette.success),
29
- warning: chalk_1.default.hex(exports.palette.warning),
30
- danger: chalk_1.default.hex(exports.palette.danger),
30
+ subtle: chalk_1.default.hex(exports.palette.subtle),
31
+ success: chalk_1.default.hex(exports.palette.green),
32
+ warning: chalk_1.default.hex(exports.palette.yellow),
33
+ danger: chalk_1.default.hex(exports.palette.red),
31
34
  border: chalk_1.default.hex(exports.palette.border),
32
- accent: chalk_1.default.hex(exports.palette.accent)
35
+ activeBorder: chalk_1.default.hex(exports.palette.borderActive),
36
+ accent: chalk_1.default.hex(exports.palette.accent),
37
+ focus: chalk_1.default.hex(exports.palette.cyan).bold
33
38
  };
34
39
  exports.glyphs = {
35
40
  topLeft: '╭',
@@ -40,7 +45,8 @@ exports.glyphs = {
40
45
  vertical: '│',
41
46
  teeLeft: '├',
42
47
  teeRight: '┤',
43
- dot: '•',
48
+ cross: '┼',
49
+ dot: '·',
44
50
  pointer: '›',
45
51
  back: '←',
46
52
  play: '▶',
package/dist/discord.js CHANGED
@@ -20,7 +20,7 @@ let currentAnime;
20
20
  let currentEpisode;
21
21
  let isWatching = false;
22
22
  const COMMON_BUTTONS = [
23
- { label: 'NekoStreamCLI', url: 'https://www.npmjs.com/package/nekostream-cli' },
23
+ { label: 'NekoStream', url: 'https://www.npmjs.com/package/nekostream' },
24
24
  { label: 'Tham gia Discord', url: 'https://discord.gg/Y2kq2y26pZ' }
25
25
  ];
26
26
  const imageCache = {};
@@ -42,7 +42,7 @@ async function fetchAnimeImage(title) {
42
42
  }
43
43
  }
44
44
  catch (e) { }
45
- return 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/72x72/1f4fa.png'; // Fallback
45
+ return undefined;
46
46
  }
47
47
  async function initDiscord() {
48
48
  const settings = (0, storage_1.loadSettings)();
@@ -93,13 +93,13 @@ async function setBrowsingPresence(details, provider, feature, animeTitle) {
93
93
  if (!startTimestamp)
94
94
  startTimestamp = new Date();
95
95
  const stateStr = provider ? `${provider.toUpperCase()}${feature ? ` | ${feature}` : ''}` : (feature || 'NekoStream CLI');
96
- const largeImageKey = animeTitle ? await fetchAnimeImage(animeTitle) : 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/72x72/1f4fa.png';
96
+ const largeImageKey = animeTitle ? await fetchAnimeImage(animeTitle) : undefined;
97
97
  const largeImageText = animeTitle || 'NekoStream';
98
98
  safeSetActivity({
99
99
  details: details || 'Đang lướt Menu Chính',
100
100
  state: stateStr,
101
101
  startTimestamp,
102
- largeImageKey,
102
+ ...(largeImageKey ? { largeImageKey } : {}),
103
103
  largeImageText,
104
104
  instance: false,
105
105
  buttons: COMMON_BUTTONS
@@ -116,9 +116,8 @@ async function setWatchingPresence(animeTitle, episodeName, provider) {
116
116
  details: `Đang xem: ${animeTitle}`,
117
117
  state: `${provider.toUpperCase()} | ${episodeName}`,
118
118
  startTimestamp,
119
- largeImageKey,
119
+ ...(largeImageKey ? { largeImageKey } : {}),
120
120
  largeImageText: animeTitle,
121
- smallImageKey: 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/72x72/25b6.png',
122
121
  smallImageText: 'Playing',
123
122
  instance: false,
124
123
  buttons: COMMON_BUTTONS
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ const ui_1 = require("./ui");
48
48
  const storage_1 = require("./storage");
49
49
  const discord_1 = require("./discord");
50
50
  const auth_service_1 = require("./scrapers/auth-service");
51
+ const NPM_PACKAGE_NAME = 'nekostream';
51
52
  async function sleep(ms) {
52
53
  return new Promise(resolve => setTimeout(resolve, ms));
53
54
  }
@@ -279,7 +280,7 @@ async function openAnimeMenu(providerName, animeId) {
279
280
  if (selectedAnime)
280
281
  (0, ui_1.drawAnimeCard)(selectedAnime);
281
282
  flushStdin();
282
- const episodeChoices = [{ separator: 'DANH SÁCH TẬP' }];
283
+ const episodeChoices = [];
283
284
  episodes.forEach((ep, idx) => {
284
285
  const cleanTitle = (ep.title || `Episode ${ep.number}`).replace(/\r?\n|\r/g, ' ').trim();
285
286
  episodeChoices.push({
@@ -288,7 +289,7 @@ async function openAnimeMenu(providerName, animeId) {
288
289
  });
289
290
  });
290
291
  const { episode } = await (0, prompts_wrapper_1.default)({
291
- type: 'select',
292
+ type: 'grid',
292
293
  name: 'episode',
293
294
  message: 'Chọn tập phim (Esc: quay lại)',
294
295
  choices: episodeChoices
@@ -561,7 +562,7 @@ async function checkUpdate() {
561
562
  const basePath = isCompiled ? path_1.default.join(__dirname, '..') : __dirname;
562
563
  const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(basePath, 'package.json'), 'utf-8'));
563
564
  const currentVersion = pkg.version;
564
- const res = await fetch('https://registry.npmjs.org/nekostream-cli/latest', { signal: AbortSignal.timeout(2000) });
565
+ const res = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout(2000) });
565
566
  const data = await res.json();
566
567
  const latestVersion = data.version;
567
568
  if (latestVersion && latestVersion !== currentVersion) {
@@ -579,18 +580,19 @@ async function checkUpdate() {
579
580
  initial: true
580
581
  });
581
582
  if (update) {
582
- const spinner = (0, ora_1.default)('Đang cập nhật (npm i -g nekostream-cli@latest)...').start();
583
+ const installCommand = `npm i -g ${NPM_PACKAGE_NAME}@latest`;
584
+ const spinner = (0, ora_1.default)(`Đang cập nhật (${installCommand})...`).start();
583
585
  try {
584
- (0, child_process_1.execSync)('npm i -g nekostream-cli@latest', { stdio: 'ignore' });
586
+ (0, child_process_1.execSync)(installCommand, { stdio: 'ignore' });
585
587
  spinner.succeed(chalk_1.default.green('Đã cập nhật thành công! Vui lòng chạy lại lệnh để sử dụng bản mới.'));
586
588
  }
587
589
  catch (e) {
588
- spinner.fail(chalk_1.default.red('Cập nhật thất bại. Vui lòng chạy thủ công: npm i -g nekostream-cli@latest'));
590
+ spinner.fail(chalk_1.default.red(`Cập nhật thất bại. Vui lòng chạy thủ công: ${installCommand}`));
589
591
  }
590
592
  process.exit(0);
591
593
  }
592
594
  else {
593
- console.log(chalk_1.default.red('\nVui lòng chạy `npm i -g nekostream-cli@latest` để cập nhật thủ công.'));
595
+ console.log(chalk_1.default.red(`\nVui lòng chạy \`npm i -g ${NPM_PACKAGE_NAME}@latest\` để cập nhật thủ công.`));
594
596
  process.exit(0);
595
597
  }
596
598
  }
@@ -612,9 +614,10 @@ async function main() {
612
614
  ? chalk_1.default.green(`${authStatus.userDisplayName || 'Đã đăng nhập'} (${authStatus.cookieCount || 0} cookies)`)
613
615
  : chalk_1.default.red('Chưa đăng nhập');
614
616
  (0, ui_1.printBanner)(`Provider: ${currentProviderName.toUpperCase()}`, 'Trang chủ');
615
- console.log(chalk_1.default.cyan('NekoStream Dashboard'));
616
- console.log(`Tài khoản: ${usernameDisplay}`);
617
- console.log(`Provider: ${chalk_1.default.green(currentProviderName)}\n`);
617
+ (0, ui_1.printStatusStrip)([
618
+ { label: 'Tài khoản', value: usernameDisplay },
619
+ { label: 'Provider', value: chalk_1.default.green(currentProviderName) }
620
+ ]);
618
621
  const dynamicChoices = [
619
622
  { separator: 'KHÁM PHÁ' },
620
623
  { title: 'Tìm anime', value: 'search', description: 'Tìm theo tên phim' },
@@ -10,6 +10,7 @@ Object.defineProperty(exports, "Separator", { enumerable: true, get: function ()
10
10
  const chalk_1 = __importDefault(require("chalk"));
11
11
  const readline_1 = __importDefault(require("readline"));
12
12
  const theme_1 = require("./cli/theme");
13
+ const text_1 = require("./cli/text");
13
14
  function shouldOfferBack(message) {
14
15
  return /esc|back|quay lại|thoát/i.test(message);
15
16
  }
@@ -22,6 +23,15 @@ function normalizeMessage(message) {
22
23
  .replace(/\s+/g, ' ')
23
24
  .trim();
24
25
  }
26
+ function formatSeparator(label, leadingBreak = true) {
27
+ const width = (0, text_1.terminalWidth)(64, 42);
28
+ const text = ` ${label.trim().toUpperCase()} `;
29
+ const fill = '─'.repeat(Math.max(0, width - (0, text_1.visibleLength)(text) - 2));
30
+ return `${leadingBreak ? '\n' : ''}${theme_1.uiText.section(text)}${theme_1.uiText.border(fill)}`;
31
+ }
32
+ function promptMessage(message) {
33
+ return `${theme_1.uiText.title(message)} ${theme_1.uiText.subtle('(Esc quay lại)')}`;
34
+ }
25
35
  function mapChoices(choices, includeBack) {
26
36
  const hasBackChoice = choices.some((choice) => {
27
37
  const value = String(choice.value ?? '').toLowerCase();
@@ -30,7 +40,7 @@ function mapChoices(choices, includeBack) {
30
40
  });
31
41
  const mapped = choices.map((choice) => {
32
42
  if (choice.separator) {
33
- return new prompts_1.Separator(theme_1.uiText.section(`\n ${choice.separator} `));
43
+ return new prompts_1.Separator(formatSeparator(choice.separator));
34
44
  }
35
45
  return {
36
46
  name: choice.title || String(choice.value ?? ''),
@@ -47,6 +57,102 @@ function mapChoices(choices, includeBack) {
47
57
  }
48
58
  return mapped;
49
59
  }
60
+ function getGridChoices(choices) {
61
+ return choices
62
+ .filter((choice) => !choice.separator)
63
+ .map((choice) => ({
64
+ name: choice.title || String(choice.value ?? ''),
65
+ value: choice.value
66
+ }));
67
+ }
68
+ function gridLayout(choices) {
69
+ const width = (0, text_1.terminalWidth)(96, 58);
70
+ const longest = choices.reduce((max, choice) => Math.max(max, (0, text_1.visibleLength)(choice.name)), 0);
71
+ const columnWidth = Math.max(14, Math.min(28, longest + 2));
72
+ const columns = Math.max(1, Math.min(6, Math.floor(width / (columnWidth + 3)), choices.length));
73
+ const rows = Math.ceil(choices.length / columns);
74
+ return { columnWidth, columns, rows };
75
+ }
76
+ async function selectGrid(options, signal) {
77
+ const choices = getGridChoices(options.choices || []);
78
+ if (choices.length === 0)
79
+ return undefined;
80
+ let selected = 0;
81
+ let renderedLines = 0;
82
+ const message = normalizeMessage(options.message || 'Chọn');
83
+ const render = () => {
84
+ if (renderedLines > 0) {
85
+ readline_1.default.moveCursor(process.stdout, 0, -renderedLines);
86
+ readline_1.default.cursorTo(process.stdout, 0);
87
+ readline_1.default.clearScreenDown(process.stdout);
88
+ }
89
+ const { columnWidth, columns, rows } = gridLayout(choices);
90
+ const lines = [
91
+ `${theme_1.uiText.focus('?')} ${promptMessage(message)}`,
92
+ formatSeparator('DANH SÁCH TẬP', false)
93
+ ];
94
+ for (let row = 0; row < rows; row++) {
95
+ const cells = [];
96
+ for (let col = 0; col < columns; col++) {
97
+ const index = col * rows + row;
98
+ if (index >= choices.length)
99
+ continue;
100
+ const prefix = index === selected ? theme_1.uiText.focus(theme_1.glyphs.pointer) : ' ';
101
+ const label = (0, text_1.truncate)(choices[index].name, columnWidth - 2);
102
+ const cell = `${prefix} ${index === selected ? theme_1.uiText.focus(label) : theme_1.uiText.value(label)}`;
103
+ cells.push((0, text_1.padRight)(cell, columnWidth));
104
+ }
105
+ lines.push(cells.join(theme_1.uiText.border(` ${theme_1.glyphs.vertical} `)));
106
+ }
107
+ lines.push(chalk_1.default.gray('↑↓←→ di chuyển · Enter chọn · Esc quay lại'));
108
+ process.stdout.write(`${lines.join('\n')}\n`);
109
+ renderedLines = lines.length;
110
+ };
111
+ return await new Promise((resolve, reject) => {
112
+ const cleanup = () => {
113
+ process.stdin.off('keypress', onKeypress);
114
+ signal.removeEventListener('abort', onAbort);
115
+ process.stdout.write('\x1B[?25h');
116
+ };
117
+ const done = (value) => {
118
+ cleanup();
119
+ if (renderedLines > 0) {
120
+ readline_1.default.moveCursor(process.stdout, 0, -renderedLines);
121
+ readline_1.default.cursorTo(process.stdout, 0);
122
+ readline_1.default.clearScreenDown(process.stdout);
123
+ }
124
+ resolve(value);
125
+ };
126
+ const onAbort = () => {
127
+ cleanup();
128
+ reject(new Error('ESC_PRESSED'));
129
+ };
130
+ const onKeypress = (_str, key) => {
131
+ const { rows } = gridLayout(choices);
132
+ if (key?.name === 'escape') {
133
+ done(undefined);
134
+ return;
135
+ }
136
+ if (key?.name === 'return' || key?.name === 'enter') {
137
+ done(choices[selected].value);
138
+ return;
139
+ }
140
+ if (key?.name === 'up')
141
+ selected = Math.max(0, selected - 1);
142
+ if (key?.name === 'down')
143
+ selected = Math.min(choices.length - 1, selected + 1);
144
+ if (key?.name === 'left')
145
+ selected = Math.max(0, selected - rows);
146
+ if (key?.name === 'right')
147
+ selected = Math.min(choices.length - 1, selected + rows);
148
+ render();
149
+ };
150
+ signal.addEventListener('abort', onAbort);
151
+ process.stdin.on('keypress', onKeypress);
152
+ process.stdout.write('\x1B[?25l');
153
+ render();
154
+ });
155
+ }
50
156
  async function prompts(options) {
51
157
  const ac = new AbortController();
52
158
  if (process.stdin.isTTY) {
@@ -57,21 +163,34 @@ async function prompts(options) {
57
163
  ac.abort(new Error('ESC_PRESSED'));
58
164
  }
59
165
  };
60
- process.stdin.on('keypress', onKeypress);
166
+ if (options.type !== 'grid') {
167
+ process.stdin.on('keypress', onKeypress);
168
+ }
61
169
  try {
170
+ if (options.type === 'grid') {
171
+ const result = await selectGrid(options, ac.signal);
172
+ return result ? { [options.name]: result } : {};
173
+ }
62
174
  if (options.type === 'select') {
63
175
  const message = normalizeMessage(options.message || 'Chọn');
64
176
  const includeBack = shouldOfferBack(options.message || '');
65
177
  const choices = mapChoices(options.choices || [], includeBack);
66
178
  const result = await (0, prompts_1.select)({
67
- message: `${theme_1.uiText.title(message)} ${theme_1.uiText.muted('(Esc: quay lại)')}`,
179
+ message: promptMessage(message),
68
180
  choices,
69
181
  pageSize: options.pageSize || 15,
70
182
  loop: false,
71
183
  theme: {
72
184
  helpMode: 'always',
185
+ prefix: {
186
+ idle: theme_1.uiText.focus('?'),
187
+ done: theme_1.uiText.success('>')
188
+ },
189
+ icon: {
190
+ cursor: theme_1.uiText.focus(theme_1.glyphs.pointer)
191
+ },
73
192
  style: {
74
- keysHelpTip: () => chalk_1.default.gray('↑↓ di chuyển • Enter chọn • Esc quay lại')
193
+ keysHelpTip: () => chalk_1.default.gray('↑↓ di chuyển · Enter chọn · Esc quay lại')
75
194
  }
76
195
  }
77
196
  }, { signal: ac.signal, clearPromptOnDone: true });
@@ -81,7 +200,7 @@ async function prompts(options) {
81
200
  }
82
201
  if (options.type === 'text') {
83
202
  const result = await (0, prompts_1.input)({
84
- message: `${theme_1.uiText.title(normalizeMessage(options.message || 'Nhập'))} ${theme_1.uiText.muted('(Esc: quay lại)')}`,
203
+ message: promptMessage(normalizeMessage(options.message || 'Nhập')),
85
204
  default: options.initial
86
205
  }, { signal: ac.signal, clearPromptOnDone: true });
87
206
  return { [options.name]: result };
@@ -101,7 +220,9 @@ async function prompts(options) {
101
220
  throw err;
102
221
  }
103
222
  finally {
104
- process.stdin.off('keypress', onKeypress);
223
+ if (options.type !== 'grid') {
224
+ process.stdin.off('keypress', onKeypress);
225
+ }
105
226
  }
106
227
  return {};
107
228
  }
@@ -49,6 +49,13 @@ const logger_1 = require("../../logger");
49
49
  class AnimeVietsubProvider extends base_1.BaseScraper {
50
50
  name = 'AnimeVietsub';
51
51
  baseUrl = 'https://animevietsub.site';
52
+ static knownBaseUrls = [
53
+ 'https://animevietsub.love',
54
+ 'https://animevietsub.site',
55
+ 'https://animevietsub.fan',
56
+ 'https://animevietsub.bz',
57
+ 'https://animevietsub.tv'
58
+ ];
52
59
  // Stable Chrome 124 profile to align with the player's webview user agent
53
60
  stableProfile = {
54
61
  ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
@@ -57,6 +64,78 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
57
64
  secChUaPlatform: '"Windows"',
58
65
  acceptLanguage: 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
59
66
  };
67
+ normalizeBaseUrl(value) {
68
+ const url = value.startsWith('http') ? value : `https://${value}`;
69
+ return url.replace(/\/$/, '');
70
+ }
71
+ isAnimeVietsubUrl(value) {
72
+ try {
73
+ return new URL(value).hostname.toLowerCase().includes('animevietsub');
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ rememberWorkingDomain(finalUrl, sourceUrl) {
80
+ if (!this.isAnimeVietsubUrl(finalUrl))
81
+ return;
82
+ const finalBaseUrl = new URL(finalUrl).origin;
83
+ const currentBaseUrl = this.normalizeBaseUrl(this.baseUrl);
84
+ if (finalBaseUrl === currentBaseUrl)
85
+ return;
86
+ this.baseUrl = finalBaseUrl;
87
+ try {
88
+ const settings = (0, storage_1.loadSettings)();
89
+ (0, storage_1.saveSettings)({
90
+ providerDomains: {
91
+ ...(settings.providerDomains || {}),
92
+ animevietsub: finalBaseUrl
93
+ }
94
+ });
95
+ (0, logger_1.debugLog)(`[AnimeVietsub] Domain updated: ${sourceUrl || currentBaseUrl} -> ${finalBaseUrl}`);
96
+ }
97
+ catch (error) {
98
+ (0, logger_1.debugWarn)('[AnimeVietsub] Could not save discovered domain:', error);
99
+ }
100
+ }
101
+ getAlternateUrls(originalUrl) {
102
+ let parsed;
103
+ try {
104
+ parsed = new URL(originalUrl);
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ const path = `${parsed.pathname}${parsed.search}`;
110
+ const currentBaseUrl = this.normalizeBaseUrl(this.baseUrl);
111
+ const candidates = [
112
+ currentBaseUrl,
113
+ ...AnimeVietsubProvider.knownBaseUrls.map((url) => this.normalizeBaseUrl(url))
114
+ ];
115
+ return [...new Set(candidates)]
116
+ .filter((baseUrl) => baseUrl !== parsed.origin)
117
+ .map((baseUrl) => `${baseUrl}${path}`);
118
+ }
119
+ async fetchHtmlFromAlternateDomains(originalUrl, options) {
120
+ if (!this.isAnimeVietsubUrl(originalUrl))
121
+ return null;
122
+ for (const alternateUrl of this.getAlternateUrls(originalUrl)) {
123
+ try {
124
+ (0, logger_1.debugWarn)(`[AnimeVietsub] Trying alternate domain: ${alternateUrl}`);
125
+ const html = await this.fetchHtml(alternateUrl, {
126
+ ...options,
127
+ retries: 0,
128
+ allowDomainFallback: false
129
+ });
130
+ this.rememberWorkingDomain(alternateUrl, originalUrl);
131
+ return html;
132
+ }
133
+ catch (error) {
134
+ (0, logger_1.debugWarn)(`[AnimeVietsub] Alternate domain failed: ${alternateUrl}`, error);
135
+ }
136
+ }
137
+ return null;
138
+ }
60
139
  // User endpoints omitted in standalone CLI
61
140
  toMediaProxyUrl(sourceUrl, referer) {
62
141
  const query = new URLSearchParams({
@@ -125,6 +204,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
125
204
  const timeoutMs = options.timeoutMs ?? 20000;
126
205
  const retries = options.retries ?? 2;
127
206
  const useBrowserFallback = options.useBrowserFallback ?? true;
207
+ const allowDomainFallback = options.allowDomainFallback ?? true;
128
208
  // --- Playwright-first for known-blocked hosts ---
129
209
  // Skip the fetch loop entirely and go straight to Playwright for hosts
130
210
  // that consistently return 520/403 on raw fetch() calls.
@@ -174,6 +254,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
174
254
  if (!response.ok) {
175
255
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
176
256
  }
257
+ this.rememberWorkingDomain(response.url, url);
177
258
  const html = await response.text();
178
259
  // Botasaurus: CloudflareDetectionException — detect CF challenge on 200 response
179
260
  if ((0, fingerprint_1.isCloudflareChallengePage)(html)) {
@@ -216,16 +297,36 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
216
297
  }
217
298
  if (error instanceof Error) {
218
299
  if (this.isTimeoutLikeError(error)) {
300
+ if (allowDomainFallback) {
301
+ const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
302
+ if (fallbackHtml)
303
+ return fallbackHtml;
304
+ }
219
305
  throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
220
306
  }
307
+ if (allowDomainFallback) {
308
+ const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
309
+ if (fallbackHtml)
310
+ return fallbackHtml;
311
+ }
221
312
  throw new Error(`Lỗi kết nối: ${error.message}`);
222
313
  }
223
314
  throw error;
224
315
  }
225
316
  }
226
317
  if (lastError instanceof Error && this.isTimeoutLikeError(lastError)) {
318
+ if (allowDomainFallback) {
319
+ const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
320
+ if (fallbackHtml)
321
+ return fallbackHtml;
322
+ }
227
323
  throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
228
324
  }
325
+ if (allowDomainFallback) {
326
+ const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
327
+ if (fallbackHtml)
328
+ return fallbackHtml;
329
+ }
229
330
  throw new Error('Lỗi kết nối không xác định');
230
331
  }
231
332
  /**
@@ -340,6 +441,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
340
441
  }
341
442
  // Extra settle time
342
443
  await page.waitForTimeout(1000);
444
+ this.rememberWorkingDomain(page.url(), url);
343
445
  const html = await page.content();
344
446
  if (!html || html.length < 100) {
345
447
  throw new Error('Playwright trả về nội dung trống');
package/dist/ui.js CHANGED
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.clearScreen = clearScreen;
40
40
  exports.printBanner = printBanner;
41
+ exports.printStatusStrip = printStatusStrip;
41
42
  exports.drawAnimeCard = drawAnimeCard;
42
43
  exports.drawAnimeInfoCard = drawAnimeInfoCard;
43
44
  exports.printHint = printHint;
@@ -81,80 +82,100 @@ function getVersion() {
81
82
  return 'unknown';
82
83
  }
83
84
  }
84
- function borderLine(width, left, right) {
85
- return theme_1.uiText.border(`${left}${theme_1.glyphs.horizontal.repeat(width - 2)}${right}`);
85
+ function borderLine(width, left, right, active = false) {
86
+ const color = active ? theme_1.uiText.activeBorder : theme_1.uiText.border;
87
+ return color(`${left}${theme_1.glyphs.horizontal.repeat(width - 2)}${right}`);
86
88
  }
87
- function boxedLine(content, width) {
88
- return `${theme_1.uiText.border(theme_1.glyphs.vertical)} ${(0, text_1.padRight)(content, width - 4)} ${theme_1.uiText.border(theme_1.glyphs.vertical)}`;
89
+ function boxedLine(content, width, active = false) {
90
+ const left = active ? theme_1.uiText.activeBorder(theme_1.glyphs.vertical) : theme_1.uiText.border(theme_1.glyphs.vertical);
91
+ const right = theme_1.uiText.border(theme_1.glyphs.vertical);
92
+ return `${left} ${(0, text_1.padRight)(content, width - 4)} ${right}`;
93
+ }
94
+ function innerRule(label, width) {
95
+ const text = label ? ` ${label} ` : '';
96
+ const fill = theme_1.glyphs.horizontal.repeat(Math.max(0, width - (0, text_1.visibleLength)(text)));
97
+ return theme_1.uiText.border(`${text}${fill}`);
98
+ }
99
+ function renderMeta(label, value) {
100
+ return `${theme_1.uiText.label(label)} ${theme_1.uiText.value((0, text_1.cleanInline)(value))}`;
101
+ }
102
+ function renderMetadataLines(items, innerWidth) {
103
+ const divider = chalk_1.default.hex(theme_1.palette.border)(` ${theme_1.glyphs.vertical} `);
104
+ const pieces = items.map((item) => renderMeta(item.label, item.value));
105
+ return (0, text_1.wrapText)(pieces.join(divider), innerWidth);
106
+ }
107
+ function printPanelHeader(anime, width, innerWidth) {
108
+ console.log(boxedLine(theme_1.uiText.accent((0, text_1.truncate)(anime.title, innerWidth)), width, true));
109
+ if (anime.titleAlt) {
110
+ console.log(boxedLine(theme_1.uiText.muted((0, text_1.truncate)(anime.titleAlt, innerWidth)), width, true));
111
+ }
112
+ }
113
+ function animeMetadata(anime) {
114
+ const genres = anime.genres && anime.genres.length > 0 ? anime.genres.join(', ') : undefined;
115
+ return [
116
+ { label: 'Tập', value: anime.episodeCount },
117
+ { label: 'Năm', value: anime.year && anime.year > 1900 ? anime.year : undefined },
118
+ { label: 'Trạng thái', value: anime.status },
119
+ { label: 'Thể loại', value: genres }
120
+ ];
89
121
  }
90
122
  function printBanner(title, subtitle) {
91
- const width = (0, text_1.terminalWidth)(86, 54);
123
+ const width = (0, text_1.terminalWidth)(92, 58);
92
124
  const version = getVersion();
93
- const brand = `${theme_1.uiText.brand('NekoStream')} ${theme_1.uiText.muted(`v${version}`)}`;
94
- const heading = title ? theme_1.uiText.title(title) : theme_1.uiText.title('CLI');
125
+ const brand = `${theme_1.uiText.brand('NekoStream')} ${theme_1.uiText.subtle(`v${version}`)}`;
126
+ const heading = title ? theme_1.uiText.title(title) : theme_1.uiText.title('Terminal');
95
127
  const sub = subtitle ? theme_1.uiText.subtitle(subtitle) : theme_1.uiText.subtitle('Fast anime browsing from your terminal');
96
128
  console.log('');
97
- console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
98
- console.log(boxedLine(`${brand} ${theme_1.uiText.muted(theme_1.glyphs.dot)} ${heading}`, width));
129
+ console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight, true));
130
+ console.log(boxedLine(`${brand} ${theme_1.uiText.subtle(theme_1.glyphs.dot)} ${heading}`, width, true));
99
131
  console.log(boxedLine(sub, width));
100
132
  console.log(borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight));
101
133
  console.log('');
102
134
  }
103
- function renderMeta(label, value) {
104
- return `${theme_1.uiText.label(label)} ${theme_1.uiText.value((0, text_1.cleanInline)(value))}`;
135
+ function printStatusStrip(items) {
136
+ const width = (0, text_1.terminalWidth)(92, 58);
137
+ const innerWidth = width - 4;
138
+ console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
139
+ for (const line of renderMetadataLines(items, innerWidth)) {
140
+ console.log(boxedLine(line, width, true));
141
+ }
142
+ console.log(`${borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight)}\n`);
105
143
  }
106
144
  function drawAnimeCard(anime) {
107
- const width = (0, text_1.terminalWidth)(88, 56);
145
+ const width = (0, text_1.terminalWidth)(92, 58);
108
146
  const innerWidth = width - 4;
109
147
  const description = (0, text_1.cleanDescription)(anime.descriptionVi) ||
110
148
  (0, text_1.cleanDescription)(anime.description) ||
111
149
  (0, text_1.cleanDescription)(anime.descriptionEn);
112
- const genres = anime.genres && anime.genres.length > 0 ? anime.genres.join(', ') : undefined;
113
- const metadata = [
114
- renderMeta('Tập:', anime.episodeCount),
115
- renderMeta('Năm:', anime.year && anime.year > 1900 ? anime.year : undefined),
116
- renderMeta('Trạng thái:', anime.status),
117
- renderMeta('Thể loại:', genres)
118
- ];
119
- console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
120
- console.log(boxedLine(theme_1.uiText.accent((0, text_1.truncate)(anime.title, innerWidth)), width));
121
- if (anime.titleAlt) {
122
- console.log(boxedLine(theme_1.uiText.muted((0, text_1.truncate)(anime.titleAlt, innerWidth)), width));
123
- }
150
+ console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight, true));
151
+ printPanelHeader(anime, width, innerWidth);
124
152
  console.log(borderLine(width, theme_1.glyphs.teeLeft, theme_1.glyphs.teeRight));
125
- for (const line of (0, text_1.wrapText)(metadata.join(chalk_1.default.hex(theme_1.palette.border)(' | ')), innerWidth)) {
153
+ for (const line of renderMetadataLines(animeMetadata(anime), innerWidth)) {
126
154
  console.log(boxedLine(line, width));
127
155
  }
128
156
  if (description) {
129
157
  console.log(boxedLine('', width));
130
- const lines = (0, text_1.wrapText)(description, innerWidth);
131
- lines.forEach((line) => {
158
+ console.log(boxedLine(innerRule('Mô tả', innerWidth), width));
159
+ for (const line of (0, text_1.wrapText)(description, innerWidth)) {
132
160
  console.log(boxedLine(theme_1.uiText.muted(line), width));
133
- });
161
+ }
134
162
  }
135
163
  console.log(`${borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight)}\n`);
136
164
  }
137
165
  function drawAnimeInfoCard(anime, selectedEpisode) {
138
- const width = (0, text_1.terminalWidth)(88, 56);
166
+ const width = (0, text_1.terminalWidth)(92, 58);
139
167
  const innerWidth = width - 4;
140
- const genres = anime.genres && anime.genres.length > 0 ? anime.genres.join(', ') : undefined;
141
- const metadata = [
142
- renderMeta('Tập:', anime.episodeCount),
143
- renderMeta('Năm:', anime.year && anime.year > 1900 ? anime.year : undefined),
144
- renderMeta('Trạng thái:', anime.status),
145
- renderMeta('Thể loại:', genres)
146
- ];
147
- console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
148
- console.log(boxedLine(theme_1.uiText.accent((0, text_1.truncate)(anime.title, innerWidth)), width));
149
- if (anime.titleAlt) {
150
- console.log(boxedLine(theme_1.uiText.muted((0, text_1.truncate)(anime.titleAlt, innerWidth)), width));
151
- }
168
+ console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight, true));
169
+ printPanelHeader(anime, width, innerWidth);
152
170
  console.log(borderLine(width, theme_1.glyphs.teeLeft, theme_1.glyphs.teeRight));
153
- for (const line of (0, text_1.wrapText)(metadata.join(chalk_1.default.hex(theme_1.palette.border)(' | ')), innerWidth)) {
171
+ for (const line of renderMetadataLines(animeMetadata(anime), innerWidth)) {
154
172
  console.log(boxedLine(line, width));
155
173
  }
156
174
  if (selectedEpisode) {
157
- console.log(boxedLine(`${theme_1.uiText.label('Đang chọn:')} ${theme_1.uiText.value(selectedEpisode)}`, width));
175
+ console.log(boxedLine('', width));
176
+ for (const line of (0, text_1.wrapText)(`${renderMeta('Đang chọn', selectedEpisode)}`, innerWidth)) {
177
+ console.log(boxedLine(line, width));
178
+ }
158
179
  }
159
180
  console.log(`${borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight)}\n`);
160
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nekostream",
3
- "version": "2026.1.4",
3
+ "version": "2026.2.2",
4
4
  "description": "Bettter version of NekoStream ",
5
5
  "keywords": [
6
6
  "anime",
package/player-main.js CHANGED
@@ -4,13 +4,28 @@ const path = require('path')
4
4
 
5
5
  let mainWindow = null
6
6
 
7
- function logToRenderer(msg) {
8
- if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
9
- mainWindow.webContents.send('main-log', msg);
10
- }
11
- }
12
-
13
- function createWindow() {
7
+ function logToRenderer(msg) {
8
+ if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
9
+ mainWindow.webContents.send('main-log', msg);
10
+ }
11
+ }
12
+
13
+ ipcMain.on('player:minimize', () => {
14
+ if (mainWindow && !mainWindow.isDestroyed()) {
15
+ mainWindow.minimize()
16
+ }
17
+ })
18
+
19
+ ipcMain.on('player:toggle-maximize', () => {
20
+ if (!mainWindow || mainWindow.isDestroyed()) return
21
+ if (mainWindow.isMaximized()) {
22
+ mainWindow.unmaximize()
23
+ } else {
24
+ mainWindow.maximize()
25
+ }
26
+ })
27
+
28
+ function createWindow() {
14
29
  const streamData = process.env.NEKOSTREAM_CLI_STREAM
15
30
  if (!streamData) {
16
31
  console.error('No stream data provided.')
package/player.html CHANGED
@@ -14,48 +14,82 @@
14
14
  overflow: hidden;
15
15
  font-family: sans-serif;
16
16
  }
17
- #player-container {
18
- width: 100%;
19
- height: 100%;
20
- }
17
+ #player-container {
18
+ width: 100%;
19
+ height: 100%;
20
+ }
21
21
  webview {
22
22
  width: 100%;
23
23
  height: 100%;
24
24
  border: none;
25
25
  }
26
- #close-btn, .dev-btn {
27
- position: absolute;
28
- top: 20px;
29
- z-index: 9999;
30
- background: rgba(0, 0, 0, 0.5);
31
- color: white;
32
- border: 1px solid rgba(255, 255, 255, 0.2);
33
- padding: 8px 16px;
34
- cursor: pointer;
35
- border-radius: 4px;
36
- font-size: 14px;
37
- transition: background 0.2s;
38
- -webkit-app-region: no-drag;
39
- }
40
- #close-btn {
41
- right: 20px;
42
- }
43
- #close-btn:hover {
44
- background: rgba(255, 0, 0, 0.7);
45
- }
46
- .dev-btn:hover {
47
- background: rgba(37, 99, 235, 0.7);
48
- }
49
- .drag-region {
50
- position: absolute;
51
- top: 0;
52
- left: 0;
53
- right: 80px;
54
- height: 40px;
55
- -webkit-app-region: drag;
56
- z-index: 9998;
57
- }
58
- #loading-overlay {
26
+ #top-nav {
27
+ position: absolute;
28
+ top: 0;
29
+ left: 0;
30
+ right: 0;
31
+ height: 44px;
32
+ z-index: 10000;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ background: linear-gradient(to bottom, rgba(5, 8, 13, 0.82), rgba(5, 8, 13, 0.22), rgba(5, 8, 13, 0));
37
+ color: rgba(255, 255, 255, 0.92);
38
+ -webkit-app-region: drag;
39
+ user-select: none;
40
+ pointer-events: auto;
41
+ }
42
+ .nav-title {
43
+ min-width: 0;
44
+ padding-left: 16px;
45
+ font-size: 13px;
46
+ font-weight: 600;
47
+ letter-spacing: 0.01em;
48
+ color: rgba(255, 255, 255, 0.88);
49
+ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ }
54
+ .window-controls {
55
+ display: flex;
56
+ height: 32px;
57
+ margin-right: 10px;
58
+ overflow: hidden;
59
+ border: 1px solid rgba(255, 255, 255, 0.12);
60
+ border-radius: 6px;
61
+ background: rgba(8, 12, 20, 0.38);
62
+ backdrop-filter: blur(10px);
63
+ pointer-events: auto;
64
+ -webkit-app-region: no-drag;
65
+ }
66
+ .window-btn {
67
+ width: 42px;
68
+ height: 32px;
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ border: 0;
73
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
74
+ background: transparent;
75
+ color: rgba(255, 255, 255, 0.82);
76
+ font-size: 16px;
77
+ line-height: 1;
78
+ cursor: pointer;
79
+ transition: background 0.15s ease, color 0.15s ease;
80
+ }
81
+ .window-btn:last-child {
82
+ border-right: 0;
83
+ }
84
+ .window-btn:hover {
85
+ background: rgba(255, 255, 255, 0.14);
86
+ color: #fff;
87
+ }
88
+ .window-btn.close:hover {
89
+ background: #e5484d;
90
+ color: #fff;
91
+ }
92
+ #loading-overlay {
59
93
  position: absolute;
60
94
  top: 0;
61
95
  left: 0;
@@ -92,10 +126,16 @@
92
126
  <script src="./node_modules/hls.js/dist/hls.min.js"></script>
93
127
  <script src="./node_modules/artplayer/dist/artplayer.js"></script>
94
128
  </head>
95
- <body>
96
- <div class="drag-region"></div>
97
- <button id="close-btn" onclick="window.close()">✕ Đóng</button>
98
- <div id="loading-overlay">
129
+ <body>
130
+ <header id="top-nav">
131
+ <div class="nav-title" id="nav-title">NekoStream Player</div>
132
+ <div class="window-controls">
133
+ <button class="window-btn" id="window-minimize" title="Minimize">-</button>
134
+ <button class="window-btn" id="window-maximize" title="Maximize">□</button>
135
+ <button class="window-btn close" id="window-close" title="Close">×</button>
136
+ </div>
137
+ </header>
138
+ <div id="loading-overlay">
99
139
  <div class="spinner"></div>
100
140
  <div class="loading-text">Đang tải và làm sạch trình phát...</div>
101
141
  </div>
@@ -103,9 +143,19 @@
103
143
 
104
144
  <script>
105
145
  const { ipcRenderer } = require('electron');
106
- ipcRenderer.on('main-log', (e, msg) => {
107
- console.log('[Main Process]', msg);
108
- });
146
+ ipcRenderer.on('main-log', (e, msg) => {
147
+ console.log('[Main Process]', msg);
148
+ });
149
+
150
+ document.getElementById('window-minimize').addEventListener('click', () => {
151
+ ipcRenderer.send('player:minimize');
152
+ });
153
+ document.getElementById('window-maximize').addEventListener('click', () => {
154
+ ipcRenderer.send('player:toggle-maximize');
155
+ });
156
+ document.getElementById('window-close').addEventListener('click', () => {
157
+ window.close();
158
+ });
109
159
 
110
160
  // Keyboard shortcut: Escape to close
111
161
  document.addEventListener('keydown', (e) => {
@@ -113,8 +163,10 @@
113
163
  });
114
164
 
115
165
 
116
- window.initPlayer = function(streamInfo, baseDir, cleanUA) {
117
- const container = document.getElementById('player-container');
166
+ window.initPlayer = function(streamInfo, baseDir, cleanUA) {
167
+ const container = document.getElementById('player-container');
168
+ const navTitle = document.getElementById('nav-title');
169
+ navTitle.textContent = streamInfo.provider ? `NekoStream · ${streamInfo.provider}` : 'NekoStream Player';
118
170
 
119
171
  if (streamInfo.type === 'iframe') {
120
172
  const overlay = document.getElementById('loading-overlay');