nekostream 2026.2.7 → 2026.2.8
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 +109 -50
- package/dist/index.js +3 -3
- package/dist/server/app.js +53 -0
- package/dist/server/config.js +52 -0
- package/dist/server/http.js +78 -0
- package/dist/server/index.js +22 -0
- package/dist/server/routes/admin.js +39 -0
- package/dist/server/routes/health.js +14 -0
- package/dist/server/routes/me.js +50 -0
- package/dist/server/routes/providers.js +53 -0
- package/dist/server/services/cache.js +36 -0
- package/dist/server/services/provider-service.js +90 -0
- package/dist/server/services/server-token.js +51 -0
- package/dist/server/services/session-store.js +52 -0
- package/dist/server/services/token-store.js +58 -0
- package/package.json +3 -2
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';
|
|
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: '
|
|
29
|
+
{ label: 'Discord', url: 'https://discord.gg/Y2kq2y26pZ' },
|
|
25
30
|
];
|
|
26
|
-
const imageCache =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
64
|
-
//
|
|
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
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
155
|
+
const largeImage = await resolveLargeImage(animeTitle);
|
|
156
|
+
if (revision !== activityRevision)
|
|
157
|
+
return;
|
|
98
158
|
safeSetActivity({
|
|
99
|
-
details: details
|
|
100
|
-
state:
|
|
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
|
-
...(
|
|
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();
|
|
114
|
-
const
|
|
171
|
+
startTimestamp = new Date();
|
|
172
|
+
const largeImage = await resolveLargeImage(animeTitle);
|
|
173
|
+
if (revision !== activityRevision)
|
|
174
|
+
return;
|
|
115
175
|
safeSetActivity({
|
|
116
|
-
details:
|
|
117
|
-
state: `${
|
|
176
|
+
details: cleanPresenceText(animeTitle, 'Đang xem anime'),
|
|
177
|
+
state: cleanPresenceText(`${episodeName} - ${formatProvider(provider)}`, 'Đang phát'),
|
|
118
178
|
startTimestamp,
|
|
119
|
-
...(
|
|
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
|
|
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)(
|
|
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)(
|
|
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)(
|
|
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
|
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nekostream",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.8",
|
|
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": "
|
|
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",
|