nekostream 2026.2.7 → 2026.2.9

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/discord.js CHANGED
@@ -9,8 +9,12 @@ exports.setWatchingPresence = setWatchingPresence;
9
9
  exports.clearDiscordPresence = clearDiscordPresence;
10
10
  exports.toggleDiscordPresence = toggleDiscordPresence;
11
11
  const discord_rpc_1 = __importDefault(require("discord-rpc"));
12
+ const anilist_1 = require("./scrapers/anilist");
12
13
  const storage_1 = require("./storage");
13
- const CLIENT_ID = '1490936797541437440'; // NekoStream Discord App
14
+ const CLIENT_ID = '1490936797541437440';
15
+ const DEFAULT_LARGE_IMAGE = process.env.NEKOSTREAM_RPC_IMAGE_KEY || 'nekostream';
16
+ const DEFAULT_SMALL_IMAGE = process.env.NEKOSTREAM_RPC_SMALL_IMAGE_KEY;
17
+ const DISCORD_TEXT_LIMIT = 120;
14
18
  let rpcClient = null;
15
19
  let startTimestamp = null;
16
20
  let currentDetails;
@@ -19,57 +23,110 @@ let currentFeature;
19
23
  let currentAnime;
20
24
  let currentEpisode;
21
25
  let isWatching = false;
26
+ let activityRevision = 0;
22
27
  const COMMON_BUTTONS = [
23
28
  { label: 'NekoStream', url: 'https://www.npmjs.com/package/nekostream' },
24
- { label: 'Tham gia Discord', url: 'https://discord.gg/Y2kq2y26pZ' }
29
+ { label: 'Discord', url: 'https://discord.gg/Y2kq2y26pZ' },
25
30
  ];
26
- const imageCache = {};
27
- async function fetchAnimeImage(title) {
28
- const cleanTitle = title.replace(/(vietsub|thuyết minh|tập.*|phần.*)/gi, '').trim();
29
- if (imageCache[cleanTitle])
30
- return imageCache[cleanTitle];
31
+ const imageCache = new Map();
32
+ function cleanTitleForSearch(title) {
33
+ return title
34
+ .replace(/\s+-\s*Tập\s*\d+.*$/iu, '')
35
+ .replace(/\s+-\s*Episode\s*\d+.*$/iu, '')
36
+ .replace(/\b(VietSub|Thuyết Minh|Lồng Tiếng|Full HD|HD)\b/giu, '')
37
+ .replace(/\s+/g, ' ')
38
+ .trim();
39
+ }
40
+ function cleanPresenceText(text, fallback) {
41
+ const value = (text || fallback)
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+ if (value.length <= DISCORD_TEXT_LIMIT)
45
+ return value;
46
+ return `${value.slice(0, DISCORD_TEXT_LIMIT - 1).trimEnd()}…`;
47
+ }
48
+ function formatProvider(provider) {
49
+ return provider ? provider.toUpperCase() : 'NEKOSTREAM';
50
+ }
51
+ function buildBrowsingState(provider, feature, animeTitle) {
52
+ if (animeTitle)
53
+ return `${cleanPresenceText(animeTitle, 'Anime')} - ${formatProvider(provider)}`;
54
+ if (provider && feature)
55
+ return `${formatProvider(provider)} - ${feature}`;
56
+ if (provider)
57
+ return formatProvider(provider);
58
+ return feature || 'CLI';
59
+ }
60
+ async function fetchJikanImage(title) {
31
61
  try {
32
- const res = await fetch(`https://api.jikan.moe/v4/anime?q=${encodeURIComponent(cleanTitle)}&limit=1`, { signal: AbortSignal.timeout(3000) });
33
- if (res.ok) {
34
- const data = await res.json();
35
- if (data.data && data.data.length > 0) {
36
- const imageUrl = data.data[0].images?.jpg?.large_image_url || data.data[0].images?.jpg?.image_url;
37
- if (imageUrl) {
38
- imageCache[cleanTitle] = imageUrl;
39
- return imageUrl;
40
- }
41
- }
42
- }
62
+ const response = await fetch(`https://api.jikan.moe/v4/anime?q=${encodeURIComponent(title)}&limit=1`, { signal: AbortSignal.timeout(3000) });
63
+ if (!response.ok)
64
+ return undefined;
65
+ const data = await response.json();
66
+ return data.data?.[0]?.images?.webp?.large_image_url ||
67
+ data.data?.[0]?.images?.jpg?.large_image_url ||
68
+ data.data?.[0]?.images?.webp?.image_url ||
69
+ data.data?.[0]?.images?.jpg?.image_url;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ async function fetchAnimeImage(title) {
76
+ const cleanTitle = cleanTitleForSearch(title);
77
+ if (!cleanTitle)
78
+ return undefined;
79
+ if (imageCache.has(cleanTitle))
80
+ return imageCache.get(cleanTitle);
81
+ const anilist = await (0, anilist_1.enrichWithAniList)(cleanTitle, 3500);
82
+ const imageUrl = anilist.banner || anilist.cover || await fetchJikanImage(cleanTitle);
83
+ imageCache.set(cleanTitle, imageUrl);
84
+ return imageUrl;
85
+ }
86
+ async function resolveLargeImage(animeTitle) {
87
+ if (!animeTitle) {
88
+ return { key: DEFAULT_LARGE_IMAGE, text: 'NekoStream' };
43
89
  }
44
- catch (e) { }
45
- return undefined;
90
+ const cleanTitle = cleanTitleForSearch(animeTitle);
91
+ const imageUrl = await fetchAnimeImage(cleanTitle);
92
+ return {
93
+ key: imageUrl || DEFAULT_LARGE_IMAGE,
94
+ text: cleanPresenceText(cleanTitle, 'Anime'),
95
+ };
96
+ }
97
+ function buildBaseActivity(largeImage) {
98
+ return {
99
+ largeImageKey: largeImage.key,
100
+ largeImageText: largeImage.text,
101
+ ...(DEFAULT_SMALL_IMAGE ? { smallImageKey: DEFAULT_SMALL_IMAGE, smallImageText: 'NekoStream' } : {}),
102
+ instance: false,
103
+ buttons: COMMON_BUTTONS,
104
+ };
46
105
  }
47
106
  async function initDiscord() {
48
107
  const settings = (0, storage_1.loadSettings)();
49
108
  if (settings.discordRpcEnabled === false)
50
109
  return;
51
110
  if (rpcClient)
52
- return; // Already initialized
111
+ return;
53
112
  try {
54
113
  discord_rpc_1.default.register(CLIENT_ID);
55
114
  rpcClient = new discord_rpc_1.default.Client({ transport: 'ipc' });
56
115
  rpcClient.on('ready', () => {
57
- // Restore presence if it was set before ready
58
- refreshPresence();
116
+ void refreshPresence();
59
117
  });
60
- // Catch errors silently to avoid crashing CLI if Discord is closed
61
118
  rpcClient.login({ clientId: CLIENT_ID }).catch(() => { });
62
119
  }
63
- catch (e) {
64
- // Ignore RPC initialization errors
120
+ catch {
121
+ // Discord RPC should never block the CLI.
65
122
  }
66
123
  }
67
- function refreshPresence() {
124
+ async function refreshPresence() {
68
125
  if (isWatching && currentAnime && currentEpisode && currentProvider) {
69
- setWatchingPresence(currentAnime, currentEpisode, currentProvider);
126
+ await setWatchingPresence(currentAnime, currentEpisode, currentProvider);
70
127
  }
71
128
  else {
72
- setBrowsingPresence(currentDetails, currentProvider, currentFeature, currentAnime);
129
+ await setBrowsingPresence(currentDetails, currentProvider, currentFeature, currentAnime);
73
130
  }
74
131
  }
75
132
  function safeSetActivity(activity) {
@@ -81,9 +138,12 @@ function safeSetActivity(activity) {
81
138
  try {
82
139
  rpcClient.setActivity(activity).catch(() => { });
83
140
  }
84
- catch (e) { }
141
+ catch {
142
+ // Ignore transient Discord IPC failures.
143
+ }
85
144
  }
86
145
  async function setBrowsingPresence(details, provider, feature, animeTitle) {
146
+ const revision = ++activityRevision;
87
147
  isWatching = false;
88
148
  currentDetails = details;
89
149
  currentProvider = provider;
@@ -92,50 +152,49 @@ async function setBrowsingPresence(details, provider, feature, animeTitle) {
92
152
  currentEpisode = undefined;
93
153
  if (!startTimestamp)
94
154
  startTimestamp = new Date();
95
- const stateStr = provider ? `${provider.toUpperCase()}${feature ? ` | ${feature}` : ''}` : (feature || 'NekoStream CLI');
96
- const largeImageKey = animeTitle ? await fetchAnimeImage(animeTitle) : undefined;
97
- const largeImageText = animeTitle || 'NekoStream';
155
+ const largeImage = await resolveLargeImage(animeTitle);
156
+ if (revision !== activityRevision)
157
+ return;
98
158
  safeSetActivity({
99
- details: details || 'Đang lướt Menu Chính',
100
- state: stateStr,
159
+ details: cleanPresenceText(details, animeTitle ? 'Đang xem thông tin phim' : 'Đang duyệt NekoStream'),
160
+ state: cleanPresenceText(buildBrowsingState(provider, feature, animeTitle), 'NekoStream CLI'),
101
161
  startTimestamp,
102
- ...(largeImageKey ? { largeImageKey } : {}),
103
- largeImageText,
104
- instance: false,
105
- buttons: COMMON_BUTTONS
162
+ ...buildBaseActivity(largeImage),
106
163
  });
107
164
  }
108
165
  async function setWatchingPresence(animeTitle, episodeName, provider) {
166
+ const revision = ++activityRevision;
109
167
  isWatching = true;
110
168
  currentAnime = animeTitle;
111
169
  currentEpisode = episodeName;
112
170
  currentProvider = provider;
113
- startTimestamp = new Date(); // Reset timer for playback
114
- const largeImageKey = await fetchAnimeImage(animeTitle);
171
+ startTimestamp = new Date();
172
+ const largeImage = await resolveLargeImage(animeTitle);
173
+ if (revision !== activityRevision)
174
+ return;
115
175
  safeSetActivity({
116
- details: `Đang xem: ${animeTitle}`,
117
- state: `${provider.toUpperCase()} | ${episodeName}`,
176
+ details: cleanPresenceText(animeTitle, 'Đang xem anime'),
177
+ state: cleanPresenceText(`${episodeName} - ${formatProvider(provider)}`, 'Đang phát'),
118
178
  startTimestamp,
119
- ...(largeImageKey ? { largeImageKey } : {}),
120
- largeImageText: animeTitle,
121
- smallImageText: 'Playing',
122
- instance: false,
123
- buttons: COMMON_BUTTONS
179
+ ...buildBaseActivity(largeImage),
124
180
  });
125
181
  }
126
182
  function clearDiscordPresence() {
183
+ activityRevision++;
127
184
  if (rpcClient) {
128
185
  try {
129
186
  rpcClient.clearActivity().catch(() => { });
130
187
  rpcClient.destroy().catch(() => { });
131
188
  }
132
- catch (e) { }
189
+ catch {
190
+ // Ignore Discord IPC cleanup failures.
191
+ }
133
192
  rpcClient = null;
134
193
  }
135
194
  }
136
195
  function toggleDiscordPresence(enabled) {
137
196
  if (enabled) {
138
- initDiscord();
197
+ void initDiscord();
139
198
  }
140
199
  else {
141
200
  clearDiscordPresence();
package/dist/index.js CHANGED
@@ -235,7 +235,7 @@ async function showAnimeList(providerName, title, list) {
235
235
  }
236
236
  }
237
237
  async function openAnimeMenu(providerName, animeId) {
238
- (0, discord_1.setBrowsingPresence)(`Đang tải dữ liệu: ${animeId}...`, providerName, 'Tải dữ liệu');
238
+ (0, discord_1.setBrowsingPresence)('Đang tải thông tin phim', providerName, 'Tải dữ liệu');
239
239
  const provider = (0, providers_1.getProvider)(providerName);
240
240
  // Fetch details & episodes
241
241
  const epsSpinner = (0, ora_1.default)('Đang tải thông tin phim và danh sách tập...').start();
@@ -275,7 +275,7 @@ async function openAnimeMenu(providerName, animeId) {
275
275
  };
276
276
  }
277
277
  while (true) {
278
- (0, discord_1.setBrowsingPresence)(`Đang xem thông tin: ${selectedAnime ? selectedAnime.title : animeId}`, providerName, 'Thông tin Phim');
278
+ (0, discord_1.setBrowsingPresence)('Đang xem thông tin phim', providerName, 'Thông tin Phim', selectedAnime ? selectedAnime.title : animeId);
279
279
  (0, ui_1.clearScreen)();
280
280
  (0, ui_1.printBanner)(`Provider: ${providerName.toUpperCase()}`, selectedAnime ? selectedAnime.title : animeId);
281
281
  if (selectedAnime)
@@ -371,7 +371,7 @@ async function openAnimeMenu(providerName, animeId) {
371
371
  try {
372
372
  (0, discord_1.setWatchingPresence)(selectedAnime.title, episode.title || `Episode ${episode.number}`, providerName);
373
373
  await (0, player_1.launchPlayer)(streamInfo);
374
- (0, discord_1.setBrowsingPresence)(`Đang xem thông tin: ${selectedAnime.title}`, providerName, 'Thông tin Phim');
374
+ (0, discord_1.setBrowsingPresence)('Đang xem thông tin phim', providerName, 'Thông tin Phim', selectedAnime.title);
375
375
  (0, ui_1.printSuccess)('\nTrình phát đã đóng.');
376
376
  await sleep(500);
377
377
  }
@@ -576,12 +576,7 @@ async function checkUpdate() {
576
576
  const latestVersion = data.version;
577
577
  if (latestVersion && latestVersion !== currentVersion) {
578
578
  (0, ui_1.clearScreen)();
579
- console.log(chalk_1.default.yellow('╭─────────────────────────────────────────────────────────────╮'));
580
- console.log(chalk_1.default.yellow('│ │'));
581
- console.log(chalk_1.default.yellow(`│ NEKOSTREAM ĐÃ CÓ CẬP NHẬT MỚI ! ${chalk_1.default.red(currentVersion)} → ${chalk_1.default.green(latestVersion)} │`));
582
- console.log(chalk_1.default.yellow('│ Vui lòng cập nhật ứng dụng CLI để tiếp tục sử dụng ứng dụng. │'));
583
- console.log(chalk_1.default.yellow('│ │'));
584
- console.log(chalk_1.default.yellow('╰─────────────────────────────────────────────────────────────╯\n'));
579
+ (0, ui_1.printUpdateNotice)(currentVersion, latestVersion, NPM_PACKAGE_NAME);
585
580
  const { update } = await (0, prompts_wrapper_1.default)({
586
581
  type: 'confirm',
587
582
  name: 'update',
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildServer = buildServer;
7
+ const fastify_1 = __importDefault(require("fastify"));
8
+ const cors_1 = __importDefault(require("@fastify/cors"));
9
+ const rate_limit_1 = __importDefault(require("@fastify/rate-limit"));
10
+ const swagger_1 = __importDefault(require("@fastify/swagger"));
11
+ const swagger_ui_1 = __importDefault(require("@fastify/swagger-ui"));
12
+ const http_1 = require("./http");
13
+ const cache_1 = require("./services/cache");
14
+ const server_token_1 = require("./services/server-token");
15
+ const provider_service_1 = require("./services/provider-service");
16
+ const token_store_1 = require("./services/token-store");
17
+ const session_store_1 = require("./services/session-store");
18
+ const health_1 = require("./routes/health");
19
+ const providers_1 = require("./routes/providers");
20
+ const me_1 = require("./routes/me");
21
+ const admin_1 = require("./routes/admin");
22
+ async function buildServer(config) {
23
+ const app = (0, fastify_1.default)({ logger: true });
24
+ const tokenStore = new token_store_1.TokenStore(config.dataDir);
25
+ const sessions = new session_store_1.CoreSessionStore(config.dataDir);
26
+ const auth = (0, http_1.createAuthHooks)(config, tokenStore);
27
+ const cache = new cache_1.TtlCache();
28
+ const serverTokens = new server_token_1.ServerTokenService(config.signingSecret, config.serverTokenTtlMs);
29
+ const providerService = new provider_service_1.ProviderService(cache, serverTokens);
30
+ (0, http_1.registerErrorHandler)(app);
31
+ await app.register(cors_1.default, {
32
+ origin: config.corsOrigins.length > 0 ? config.corsOrigins : false
33
+ });
34
+ await app.register(rate_limit_1.default, {
35
+ max: config.rateLimitMax,
36
+ timeWindow: config.rateLimitWindow,
37
+ keyGenerator: (request) => request.headers.authorization || request.ip
38
+ });
39
+ await app.register(swagger_1.default, {
40
+ openapi: {
41
+ info: {
42
+ title: 'NekoStream Core API',
43
+ version: '1.0.0'
44
+ }
45
+ }
46
+ });
47
+ await app.register(swagger_ui_1.default, { routePrefix: '/docs' });
48
+ await (0, health_1.registerHealthRoutes)(app);
49
+ await (0, providers_1.registerProviderRoutes)(app, providerService, auth.requireAuth);
50
+ await (0, me_1.registerMeRoutes)(app, sessions, auth.requireAuth);
51
+ await (0, admin_1.registerAdminRoutes)(app, tokenStore, auth.requireAdmin);
52
+ return app;
53
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadCoreConfig = loadCoreConfig;
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ function splitList(value) {
11
+ return (value || '')
12
+ .split(',')
13
+ .map((item) => item.trim())
14
+ .filter(Boolean);
15
+ }
16
+ function readPort() {
17
+ const raw = process.env.NEKOSTREAM_CORE_PORT || process.env.PORT || '7331';
18
+ const port = Number(raw);
19
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
20
+ throw new Error(`Invalid NEKOSTREAM_CORE_PORT: ${raw}`);
21
+ }
22
+ return port;
23
+ }
24
+ function requireProductionSecret(name, fallback) {
25
+ const value = process.env[name];
26
+ if (value && value.trim())
27
+ return value.trim();
28
+ if (process.env.NODE_ENV === 'production') {
29
+ throw new Error(`${name} is required in production`);
30
+ }
31
+ return fallback;
32
+ }
33
+ function loadCoreConfig() {
34
+ const devSeed = crypto_1.default.createHash('sha256').update(`${os_1.default.hostname()}:${os_1.default.userInfo().username}:nekostream-core`).digest('hex');
35
+ const apiTokens = splitList(process.env.NEKOSTREAM_CORE_TOKEN || process.env.NEKOSTREAM_CORE_TOKENS);
36
+ const devApiToken = `dev-${devSeed.slice(0, 24)}`;
37
+ const effectiveApiTokens = apiTokens.length > 0
38
+ ? apiTokens
39
+ : [requireProductionSecret('NEKOSTREAM_CORE_TOKEN', devApiToken)];
40
+ return {
41
+ host: process.env.NEKOSTREAM_CORE_HOST || '0.0.0.0',
42
+ port: readPort(),
43
+ dataDir: process.env.NEKOSTREAM_CORE_DATA_DIR || path_1.default.join(os_1.default.homedir(), '.nekostream-core'),
44
+ apiTokens: effectiveApiTokens,
45
+ adminToken: requireProductionSecret('NEKOSTREAM_CORE_ADMIN_TOKEN', `admin-${devSeed.slice(24, 48)}`),
46
+ signingSecret: requireProductionSecret('NEKOSTREAM_CORE_SECRET', devSeed),
47
+ corsOrigins: splitList(process.env.NEKOSTREAM_CORE_CORS_ORIGINS),
48
+ rateLimitMax: Number(process.env.NEKOSTREAM_CORE_RATE_LIMIT_MAX || '120'),
49
+ rateLimitWindow: process.env.NEKOSTREAM_CORE_RATE_LIMIT_WINDOW || '1 minute',
50
+ serverTokenTtlMs: Number(process.env.NEKOSTREAM_CORE_SERVER_TOKEN_TTL_MS || 10 * 60 * 1000)
51
+ };
52
+ }
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ApiError = void 0;
7
+ exports.ok = ok;
8
+ exports.fail = fail;
9
+ exports.createAuthHooks = createAuthHooks;
10
+ exports.registerErrorHandler = registerErrorHandler;
11
+ const crypto_1 = __importDefault(require("crypto"));
12
+ class ApiError extends Error {
13
+ statusCode;
14
+ code;
15
+ constructor(statusCode, code, message) {
16
+ super(message);
17
+ this.statusCode = statusCode;
18
+ this.code = code;
19
+ }
20
+ }
21
+ exports.ApiError = ApiError;
22
+ function ok(data, meta = {}) {
23
+ return { ok: true, data, meta, error: null };
24
+ }
25
+ function fail(error) {
26
+ return {
27
+ ok: false,
28
+ data: null,
29
+ meta: {},
30
+ error: {
31
+ code: error.code,
32
+ message: error.message
33
+ }
34
+ };
35
+ }
36
+ function tokenFromRequest(request) {
37
+ const header = request.headers.authorization;
38
+ if (!header)
39
+ return null;
40
+ const match = header.match(/^Bearer\s+(.+)$/i);
41
+ return match ? match[1].trim() : null;
42
+ }
43
+ function safeEqual(a, b) {
44
+ const left = Buffer.from(a);
45
+ const right = Buffer.from(b);
46
+ if (left.length !== right.length)
47
+ return false;
48
+ return crypto_1.default.timingSafeEqual(left, right);
49
+ }
50
+ function createAuthHooks(config, tokenStore) {
51
+ async function requireAuth(request) {
52
+ const token = tokenFromRequest(request);
53
+ if (!token)
54
+ throw new ApiError(401, 'UNAUTHORIZED', 'Missing bearer token');
55
+ const validEnvToken = config.apiTokens.some((candidate) => safeEqual(candidate, token));
56
+ if (validEnvToken || tokenStore.verifyToken(token))
57
+ return;
58
+ throw new ApiError(401, 'UNAUTHORIZED', 'Invalid bearer token');
59
+ }
60
+ async function requireAdmin(request) {
61
+ const token = tokenFromRequest(request);
62
+ if (!token || !safeEqual(config.adminToken, token)) {
63
+ throw new ApiError(403, 'FORBIDDEN', 'Admin token required');
64
+ }
65
+ }
66
+ return { requireAuth, requireAdmin };
67
+ }
68
+ function registerErrorHandler(app) {
69
+ app.setErrorHandler((error, _request, reply) => {
70
+ if (error instanceof ApiError) {
71
+ reply.status(error.statusCode).send(fail(error));
72
+ return;
73
+ }
74
+ const statusCode = typeof error.statusCode === 'number' ? error.statusCode : 500;
75
+ const message = error instanceof Error ? error.message : 'Internal error';
76
+ reply.status(statusCode).send(fail(new ApiError(statusCode, 'INTERNAL_ERROR', message)));
77
+ });
78
+ }
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadCoreConfig = exports.buildServer = void 0;
4
+ const config_1 = require("./config");
5
+ Object.defineProperty(exports, "loadCoreConfig", { enumerable: true, get: function () { return config_1.loadCoreConfig; } });
6
+ const app_1 = require("./app");
7
+ Object.defineProperty(exports, "buildServer", { enumerable: true, get: function () { return app_1.buildServer; } });
8
+ async function main() {
9
+ const config = (0, config_1.loadCoreConfig)();
10
+ const app = await (0, app_1.buildServer)(config);
11
+ await app.listen({ host: config.host, port: config.port });
12
+ app.log.info(`NekoStream Core listening on http://${config.host}:${config.port}`);
13
+ if (process.env.NODE_ENV !== 'production' && !process.env.NEKOSTREAM_CORE_TOKEN) {
14
+ app.log.warn('Using generated development API token. Set NEKOSTREAM_CORE_TOKEN for stable access.');
15
+ }
16
+ }
17
+ if (require.main === module) {
18
+ main().catch((error) => {
19
+ console.error(error);
20
+ process.exit(1);
21
+ });
22
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerAdminRoutes = registerAdminRoutes;
4
+ const http_1 = require("../http");
5
+ const providers_1 = require("../../providers");
6
+ async function registerAdminRoutes(app, tokenStore, requireAdmin) {
7
+ app.get('/v1/admin/tokens', { onRequest: requireAdmin }, async () => {
8
+ return (0, http_1.ok)(tokenStore.list());
9
+ });
10
+ app.post('/v1/admin/tokens', { onRequest: requireAdmin }, async () => {
11
+ return (0, http_1.ok)(tokenStore.create());
12
+ });
13
+ app.delete('/v1/admin/tokens/:tokenId', { onRequest: requireAdmin }, async (request) => {
14
+ const deleted = tokenStore.delete(request.params.tokenId);
15
+ if (!deleted)
16
+ throw new http_1.ApiError(404, 'TOKEN_NOT_FOUND', 'Token not found');
17
+ return (0, http_1.ok)({ deleted });
18
+ });
19
+ app.get('/v1/admin/metrics', { onRequest: requireAdmin }, async () => {
20
+ const memory = process.memoryUsage();
21
+ return (0, http_1.ok)({
22
+ uptime: process.uptime(),
23
+ memory,
24
+ timestamp: new Date().toISOString()
25
+ });
26
+ });
27
+ app.post('/v1/admin/providers/:provider/domain', { onRequest: requireAdmin }, async (request) => {
28
+ const providerName = request.params.provider.toLowerCase();
29
+ if (!providers_1.providers[providerName])
30
+ throw new http_1.ApiError(404, 'PROVIDER_NOT_FOUND', 'Provider not found');
31
+ const raw = request.body?.domain?.trim();
32
+ if (!raw)
33
+ throw new http_1.ApiError(400, 'BAD_DOMAIN', 'domain is required');
34
+ const url = (raw.startsWith('http') ? raw : `https://${raw}`).replace(/\/$/, '');
35
+ const provider = (0, providers_1.getProvider)(providerName);
36
+ provider.baseUrl = url;
37
+ return (0, http_1.ok)({ provider: providerName, baseUrl: provider.baseUrl });
38
+ });
39
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerHealthRoutes = registerHealthRoutes;
4
+ const http_1 = require("../http");
5
+ async function registerHealthRoutes(app) {
6
+ app.get('/v1/health', async () => {
7
+ return (0, http_1.ok)({
8
+ status: 'ok',
9
+ service: 'nekostream-core',
10
+ uptime: process.uptime(),
11
+ timestamp: new Date().toISOString()
12
+ });
13
+ });
14
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerMeRoutes = registerMeRoutes;
4
+ const http_1 = require("../http");
5
+ function bearer(request) {
6
+ const header = request.headers.authorization || '';
7
+ const match = header.match(/^Bearer\s+(.+)$/i);
8
+ if (!match)
9
+ throw new http_1.ApiError(401, 'UNAUTHORIZED', 'Missing bearer token');
10
+ return match[1].trim();
11
+ }
12
+ async function registerMeRoutes(app, sessions, requireAuth) {
13
+ app.get('/v1/me/providers/:provider/status', { onRequest: requireAuth }, async (request) => {
14
+ const session = sessions.get(bearer(request), request.params.provider);
15
+ return (0, http_1.ok)({
16
+ provider: request.params.provider,
17
+ loggedIn: Boolean(session),
18
+ cookieCount: session?.cookies?.length || 0,
19
+ capturedAt: session?.capturedAt,
20
+ userDisplayName: session?.userDisplayName
21
+ });
22
+ });
23
+ app.post('/v1/me/providers/:provider/session', { onRequest: requireAuth }, async (request) => {
24
+ const body = request.body || {};
25
+ const session = {
26
+ provider: request.params.provider,
27
+ cookies: Array.isArray(body.cookies) ? body.cookies : [],
28
+ capturedAt: body.capturedAt || new Date().toISOString(),
29
+ source: body.source || 'manual',
30
+ authConfirmed: body.authConfirmed,
31
+ userDisplayName: body.userDisplayName,
32
+ userAvatarUrl: body.userAvatarUrl,
33
+ userId: body.userId,
34
+ accessToken: body.accessToken,
35
+ localStorageState: body.localStorageState
36
+ };
37
+ sessions.set(bearer(request), request.params.provider, session);
38
+ return (0, http_1.ok)({ provider: request.params.provider, saved: true });
39
+ });
40
+ app.delete('/v1/me/providers/:provider/session', { onRequest: requireAuth }, async (request) => {
41
+ const deleted = sessions.delete(bearer(request), request.params.provider);
42
+ return (0, http_1.ok)({ provider: request.params.provider, deleted });
43
+ });
44
+ app.get('/v1/me/providers/:provider/lists/:type', { onRequest: requireAuth }, async () => {
45
+ throw new http_1.ApiError(501, 'NOT_IMPLEMENTED', 'Server-scoped provider list fetching is not implemented yet');
46
+ });
47
+ app.get('/v1/me/providers/:provider/notifications', { onRequest: requireAuth }, async () => {
48
+ throw new http_1.ApiError(501, 'NOT_IMPLEMENTED', 'Server-scoped notifications are not implemented yet');
49
+ });
50
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerProviderRoutes = registerProviderRoutes;
4
+ const http_1 = require("../http");
5
+ function numberQuery(value, fallback, max) {
6
+ const parsed = Number(value);
7
+ if (!Number.isFinite(parsed))
8
+ return fallback;
9
+ return Math.max(1, Math.min(max, Math.floor(parsed)));
10
+ }
11
+ async function registerProviderRoutes(app, providerService, requireAuth) {
12
+ app.get('/v1/providers', { onRequest: requireAuth }, async () => {
13
+ return (0, http_1.ok)(providerService.listProviders());
14
+ });
15
+ app.get('/v1/providers/:provider/home', { onRequest: requireAuth }, async (request) => {
16
+ const section = request.query.section || 'trending';
17
+ if (section !== 'trending' && section !== 'latest') {
18
+ throw new http_1.ApiError(400, 'BAD_SECTION', 'section must be trending or latest');
19
+ }
20
+ const result = await providerService.home(request.params.provider, section);
21
+ return (0, http_1.ok)(result.value, { provider: request.params.provider, section, cached: result.cached });
22
+ });
23
+ app.get('/v1/providers/:provider/search', { onRequest: requireAuth }, async (request) => {
24
+ const page = numberQuery(request.query.page, 1, 999);
25
+ const pageSize = numberQuery(request.query.pageSize, 20, 100);
26
+ const result = await providerService.search(request.params.provider, request.query.q || '', page, pageSize);
27
+ return (0, http_1.ok)(result.items, {
28
+ provider: request.params.provider,
29
+ page: result.currentPage,
30
+ totalPages: result.totalPages,
31
+ totalItems: result.totalItems,
32
+ cached: result.cached
33
+ });
34
+ });
35
+ app.get('/v1/providers/:provider/anime/:animeId', { onRequest: requireAuth }, async (request) => {
36
+ const result = await providerService.detail(request.params.provider, request.params.animeId);
37
+ return (0, http_1.ok)(result.value, { provider: request.params.provider, cached: result.cached });
38
+ });
39
+ app.get('/v1/providers/:provider/anime/:animeId/episodes', { onRequest: requireAuth }, async (request) => {
40
+ const result = await providerService.episodes(request.params.provider, request.params.animeId);
41
+ return (0, http_1.ok)(result.value, { provider: request.params.provider, cached: result.cached });
42
+ });
43
+ app.get('/v1/providers/:provider/episodes/:episodeId/servers', { onRequest: requireAuth }, async (request) => {
44
+ const result = await providerService.servers(request.params.provider, request.params.episodeId);
45
+ return (0, http_1.ok)(result.items, { provider: request.params.provider, cached: result.cached });
46
+ });
47
+ app.post('/v1/providers/:provider/streams/resolve', { onRequest: requireAuth }, async (request) => {
48
+ if (!request.body?.serverId)
49
+ throw new http_1.ApiError(400, 'BAD_SERVER_ID', 'serverId is required');
50
+ const stream = await providerService.resolveStream(request.params.provider, request.body.serverId);
51
+ return (0, http_1.ok)(stream, { provider: request.params.provider, cached: false });
52
+ });
53
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cacheTtl = exports.TtlCache = void 0;
4
+ class TtlCache {
5
+ entries = new Map();
6
+ get(key) {
7
+ const entry = this.entries.get(key);
8
+ if (!entry)
9
+ return null;
10
+ if (entry.expiresAt <= Date.now()) {
11
+ this.entries.delete(key);
12
+ return null;
13
+ }
14
+ return entry.value;
15
+ }
16
+ set(key, value, ttlMs) {
17
+ this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
18
+ return value;
19
+ }
20
+ async getOrSet(key, ttlMs, loader) {
21
+ const cached = this.get(key);
22
+ if (cached !== null)
23
+ return { value: cached, cached: true };
24
+ const value = await loader();
25
+ this.set(key, value, ttlMs);
26
+ return { value, cached: false };
27
+ }
28
+ }
29
+ exports.TtlCache = TtlCache;
30
+ exports.cacheTtl = {
31
+ home: 5 * 60 * 1000,
32
+ search: 5 * 60 * 1000,
33
+ detail: 30 * 60 * 1000,
34
+ episodes: 10 * 60 * 1000,
35
+ servers: 2 * 60 * 1000
36
+ };
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProviderService = void 0;
4
+ const providers_1 = require("../../providers");
5
+ const http_1 = require("../http");
6
+ const cache_1 = require("./cache");
7
+ function normalizeProvider(name) {
8
+ return String(name || '').toLowerCase();
9
+ }
10
+ function providerOrThrow(name) {
11
+ const providerName = normalizeProvider(name);
12
+ if (!providers_1.providers[providerName])
13
+ throw new http_1.ApiError(404, 'PROVIDER_NOT_FOUND', `Provider not found: ${name}`);
14
+ return { providerName, provider: (0, providers_1.getProvider)(providerName) };
15
+ }
16
+ function filterSearch(results, page, pageSize) {
17
+ const safePage = Math.max(1, page || 1);
18
+ const start = (safePage - 1) * pageSize;
19
+ return {
20
+ items: results.slice(start, start + pageSize),
21
+ currentPage: safePage,
22
+ totalPages: Math.max(1, Math.ceil(results.length / pageSize)),
23
+ totalItems: results.length
24
+ };
25
+ }
26
+ class ProviderService {
27
+ cache;
28
+ serverTokens;
29
+ constructor(cache, serverTokens) {
30
+ this.cache = cache;
31
+ this.serverTokens = serverTokens;
32
+ }
33
+ listProviders() {
34
+ return Object.entries(providers_1.providers).map(([id, provider]) => ({
35
+ id,
36
+ name: provider.name,
37
+ baseUrl: provider.baseUrl
38
+ }));
39
+ }
40
+ async home(providerNameRaw, section) {
41
+ const { providerName, provider } = providerOrThrow(providerNameRaw);
42
+ return await this.cache.getOrSet(`${providerName}:home:${section}`, cache_1.cacheTtl.home, () => provider.getHomeCards(section));
43
+ }
44
+ async search(providerNameRaw, query, page, pageSize) {
45
+ const { providerName, provider } = providerOrThrow(providerNameRaw);
46
+ const q = String(query || '').trim();
47
+ if (!q)
48
+ throw new http_1.ApiError(400, 'BAD_QUERY', 'q is required');
49
+ const results = await this.cache.getOrSet(`${providerName}:search:${q}`, cache_1.cacheTtl.search, () => provider.search(q));
50
+ return { ...filterSearch(results.value, page, pageSize), cached: results.cached };
51
+ }
52
+ async detail(providerNameRaw, animeId) {
53
+ const { providerName, provider } = providerOrThrow(providerNameRaw);
54
+ return await this.cache.getOrSet(`${providerName}:detail:${animeId}`, cache_1.cacheTtl.detail, async () => {
55
+ const detail = await provider.getAnimeDetail(animeId);
56
+ if (!detail)
57
+ throw new http_1.ApiError(404, 'ANIME_NOT_FOUND', 'Anime not found');
58
+ return detail;
59
+ });
60
+ }
61
+ async episodes(providerNameRaw, animeId) {
62
+ const { providerName, provider } = providerOrThrow(providerNameRaw);
63
+ return await this.cache.getOrSet(`${providerName}:episodes:${animeId}`, cache_1.cacheTtl.episodes, () => provider.getEpisodes(animeId));
64
+ }
65
+ async servers(providerNameRaw, episodeId) {
66
+ const { providerName, provider } = providerOrThrow(providerNameRaw);
67
+ const result = await this.cache.getOrSet(`${providerName}:servers:${episodeId}`, cache_1.cacheTtl.servers, () => provider.getVideoServers(episodeId));
68
+ const items = result.value.map((server) => {
69
+ const signed = this.serverTokens.sign(providerName, server);
70
+ return {
71
+ serverId: signed.serverId,
72
+ expiresAt: signed.expiresAt,
73
+ name: server.name,
74
+ quality: server.quality,
75
+ type: server.type,
76
+ source: server.source
77
+ };
78
+ });
79
+ return { items, cached: result.cached };
80
+ }
81
+ async resolveStream(providerNameRaw, serverId) {
82
+ const { providerName, provider } = providerOrThrow(providerNameRaw);
83
+ const payload = this.serverTokens.verify(serverId, providerName);
84
+ const stream = await provider.extractStreamUrl(payload.server);
85
+ if (!stream?.url)
86
+ throw new http_1.ApiError(502, 'STREAM_RESOLVE_FAILED', 'Could not resolve stream');
87
+ return stream;
88
+ }
89
+ }
90
+ exports.ProviderService = ProviderService;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ServerTokenService = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const http_1 = require("../http");
9
+ function encode(value) {
10
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
11
+ }
12
+ function decode(value) {
13
+ return JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
14
+ }
15
+ class ServerTokenService {
16
+ secret;
17
+ ttlMs;
18
+ constructor(secret, ttlMs) {
19
+ this.secret = secret;
20
+ this.ttlMs = ttlMs;
21
+ }
22
+ sign(provider, server) {
23
+ const payload = {
24
+ provider,
25
+ server,
26
+ exp: Date.now() + this.ttlMs
27
+ };
28
+ const body = encode(payload);
29
+ const sig = crypto_1.default.createHmac('sha256', this.secret).update(body).digest('base64url');
30
+ return { serverId: `${body}.${sig}`, expiresAt: payload.exp };
31
+ }
32
+ verify(serverId, expectedProvider) {
33
+ const [body, sig] = String(serverId || '').split('.');
34
+ if (!body || !sig)
35
+ throw new http_1.ApiError(401, 'INVALID_SERVER_TOKEN', 'Invalid serverId');
36
+ const expectedSig = crypto_1.default.createHmac('sha256', this.secret).update(body).digest('base64url');
37
+ const left = Buffer.from(sig);
38
+ const right = Buffer.from(expectedSig);
39
+ if (left.length !== right.length || !crypto_1.default.timingSafeEqual(left, right)) {
40
+ throw new http_1.ApiError(401, 'INVALID_SERVER_TOKEN', 'Invalid serverId signature');
41
+ }
42
+ const payload = decode(body);
43
+ if (payload.exp <= Date.now())
44
+ throw new http_1.ApiError(410, 'SERVER_TOKEN_EXPIRED', 'serverId expired');
45
+ if (expectedProvider && payload.provider !== expectedProvider) {
46
+ throw new http_1.ApiError(401, 'INVALID_SERVER_TOKEN', 'serverId provider mismatch');
47
+ }
48
+ return payload;
49
+ }
50
+ }
51
+ exports.ServerTokenService = ServerTokenService;
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CoreSessionStore = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ class CoreSessionStore {
11
+ filePath;
12
+ constructor(dataDir) {
13
+ fs_1.default.mkdirSync(dataDir, { recursive: true });
14
+ this.filePath = path_1.default.join(dataDir, 'provider-sessions.json');
15
+ }
16
+ ownerKey(apiToken) {
17
+ return crypto_1.default.createHash('sha256').update(apiToken).digest('hex');
18
+ }
19
+ load() {
20
+ try {
21
+ if (!fs_1.default.existsSync(this.filePath))
22
+ return {};
23
+ return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ save(data) {
30
+ fs_1.default.writeFileSync(this.filePath, JSON.stringify(data, null, 2), 'utf8');
31
+ }
32
+ get(apiToken, provider) {
33
+ return this.load()[this.ownerKey(apiToken)]?.[provider] ?? null;
34
+ }
35
+ set(apiToken, provider, session) {
36
+ const data = this.load();
37
+ const owner = this.ownerKey(apiToken);
38
+ data[owner] = data[owner] || {};
39
+ data[owner][provider] = { ...session, provider };
40
+ this.save(data);
41
+ }
42
+ delete(apiToken, provider) {
43
+ const data = this.load();
44
+ const owner = this.ownerKey(apiToken);
45
+ if (!data[owner]?.[provider])
46
+ return false;
47
+ delete data[owner][provider];
48
+ this.save(data);
49
+ return true;
50
+ }
51
+ }
52
+ exports.CoreSessionStore = CoreSessionStore;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TokenStore = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ class TokenStore {
11
+ filePath;
12
+ constructor(dataDir) {
13
+ fs_1.default.mkdirSync(dataDir, { recursive: true });
14
+ this.filePath = path_1.default.join(dataDir, 'tokens.json');
15
+ }
16
+ load() {
17
+ try {
18
+ if (!fs_1.default.existsSync(this.filePath))
19
+ return [];
20
+ return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ save(tokens) {
27
+ fs_1.default.writeFileSync(this.filePath, JSON.stringify(tokens, null, 2), 'utf8');
28
+ }
29
+ hash(token) {
30
+ return crypto_1.default.createHash('sha256').update(token).digest('hex');
31
+ }
32
+ verifyToken(token) {
33
+ const hash = this.hash(token);
34
+ return this.load().some((entry) => entry.hash === hash);
35
+ }
36
+ list() {
37
+ return this.load().map(({ id, createdAt }) => ({ id, createdAt }));
38
+ }
39
+ create() {
40
+ const raw = `nks_${crypto_1.default.randomBytes(32).toString('base64url')}`;
41
+ const entry = {
42
+ id: crypto_1.default.randomUUID(),
43
+ hash: this.hash(raw),
44
+ createdAt: new Date().toISOString()
45
+ };
46
+ const tokens = this.load();
47
+ tokens.push(entry);
48
+ this.save(tokens);
49
+ return { id: entry.id, token: raw, createdAt: entry.createdAt };
50
+ }
51
+ delete(id) {
52
+ const tokens = this.load();
53
+ const next = tokens.filter((entry) => entry.id !== id);
54
+ this.save(next);
55
+ return next.length !== tokens.length;
56
+ }
57
+ }
58
+ exports.TokenStore = TokenStore;
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.printUpdateNotice = printUpdateNotice;
41
42
  exports.printStatusStrip = printStatusStrip;
42
43
  exports.drawAnimeCard = drawAnimeCard;
43
44
  exports.drawAnimeInfoCard = drawAnimeInfoCard;
@@ -132,6 +133,21 @@ function printBanner(title, subtitle) {
132
133
  console.log(borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight));
133
134
  console.log('');
134
135
  }
136
+ function printUpdateNotice(currentVersion, latestVersion, packageName) {
137
+ const width = (0, text_1.terminalWidth)(86, 62);
138
+ const innerWidth = width - 4;
139
+ const title = `${theme_1.uiText.warning('Cập nhật mới')} ${theme_1.uiText.danger(currentVersion)} ${theme_1.uiText.subtle('->')} ${theme_1.uiText.success(latestVersion)}`;
140
+ const command = `npm i -g ${packageName}@latest`;
141
+ console.log('');
142
+ console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight, true));
143
+ console.log(boxedLine(`${theme_1.uiText.brand('NekoStream')} ${theme_1.uiText.subtle(theme_1.glyphs.dot)} ${title}`, width, true));
144
+ console.log(borderLine(width, theme_1.glyphs.teeLeft, theme_1.glyphs.teeRight));
145
+ for (const line of (0, text_1.wrapText)('Vui lòng cập nhật CLI để tiếp tục sử dụng ứng dụng.', innerWidth)) {
146
+ console.log(boxedLine(theme_1.uiText.value(line), width));
147
+ }
148
+ console.log(boxedLine(`${theme_1.uiText.label('Lệnh')} ${theme_1.uiText.focus(command)}`, width));
149
+ console.log(`${borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight)}\n`);
150
+ }
135
151
  function printStatusStrip(items) {
136
152
  const width = (0, text_1.terminalWidth)(92, 58);
137
153
  const innerWidth = width - 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nekostream",
3
- "version": "2026.2.7",
3
+ "version": "2026.2.9",
4
4
  "description": "Bettter version of NekoStream ",
5
5
  "keywords": [
6
6
  "anime",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "main": "dist/index.js",
19
19
  "bin": {
20
- "nekostream": "./bin/nekostream.js"
20
+ "nekostream": "bin/nekostream.js"
21
21
  },
22
22
  "scripts": {
23
23
  "start": "tsx index.ts",
@@ -43,6 +43,7 @@
43
43
  "cross-fetch": "^4.1.0",
44
44
  "discord-rpc": "^4.0.1",
45
45
  "electron": "^34.0.0",
46
+ "fastify": "^5.9.0",
46
47
  "hls.js": "^1.6.0",
47
48
  "ora": "^5.4.1",
48
49
  "playwright": "^1.60.0",