nekostream 2026.0.1
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/README.md +30 -0
- package/bin/nekostream.js +4 -0
- package/dist/discord.js +144 -0
- package/dist/fetch-avs.js +22 -0
- package/dist/index.js +808 -0
- package/dist/player.js +37 -0
- package/dist/prompts-wrapper.js +81 -0
- package/dist/providers.js +35 -0
- package/dist/scrapers/anilist-api.js +516 -0
- package/dist/scrapers/anilist.js +82 -0
- package/dist/scrapers/auth-service.js +1245 -0
- package/dist/scrapers/base.js +41 -0
- package/dist/scrapers/crawler.js +301 -0
- package/dist/scrapers/external-api.js +68 -0
- package/dist/scrapers/fingerprint.js +240 -0
- package/dist/scrapers/interceptor.js +288 -0
- package/dist/scrapers/jikan-api.js +60 -0
- package/dist/scrapers/providers/anime47.js +941 -0
- package/dist/scrapers/providers/animehay.js +640 -0
- package/dist/scrapers/providers/animevietsub.js +1455 -0
- package/dist/storage.js +215 -0
- package/dist/test-a47-eps.js +69 -0
- package/dist/test-a47-real.js +56 -0
- package/dist/test-a47.js +8 -0
- package/dist/ui.js +187 -0
- package/isolate.css +115 -0
- package/isolate.js +178 -0
- package/package.json +60 -0
- package/player-main.js +225 -0
- package/player.html +376 -0
package/dist/player.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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.launchPlayer = launchPlayer;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
async function launchPlayer(streamInfo) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
// If compiled, __dirname is .../dist. If dev, __dirname is root.
|
|
12
|
+
const isCompiled = __dirname.endsWith('dist') || __dirname.endsWith('dist\\') || __dirname.endsWith('dist/');
|
|
13
|
+
const basePath = isCompiled ? path_1.default.join(__dirname, '..') : __dirname;
|
|
14
|
+
const mainScript = path_1.default.join(basePath, 'player-main.js');
|
|
15
|
+
const env = {
|
|
16
|
+
...process.env,
|
|
17
|
+
NEKOSTREAM_CLI_STREAM: Buffer.from(JSON.stringify(streamInfo)).toString('base64')
|
|
18
|
+
};
|
|
19
|
+
const electronBinary = require('electron');
|
|
20
|
+
const electronProcess = (0, child_process_1.spawn)(electronBinary, [mainScript], {
|
|
21
|
+
env,
|
|
22
|
+
stdio: 'ignore',
|
|
23
|
+
shell: false
|
|
24
|
+
});
|
|
25
|
+
electronProcess.on('close', (code) => {
|
|
26
|
+
if (code === 0) {
|
|
27
|
+
resolve();
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
reject(new Error(`Player exited with code ${code}`));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
electronProcess.on('error', (err) => {
|
|
34
|
+
reject(err);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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.Separator = void 0;
|
|
7
|
+
exports.default = prompts;
|
|
8
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
9
|
+
Object.defineProperty(exports, "Separator", { enumerable: true, get: function () { return prompts_1.Separator; } });
|
|
10
|
+
const readline_1 = __importDefault(require("readline"));
|
|
11
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
12
|
+
async function prompts(options) {
|
|
13
|
+
const ac = new AbortController();
|
|
14
|
+
// Ensure keypress events are emitted on stdin so we can intercept Esc
|
|
15
|
+
if (process.stdin.isTTY) {
|
|
16
|
+
readline_1.default.emitKeypressEvents(process.stdin);
|
|
17
|
+
}
|
|
18
|
+
const onKeypress = (str, key) => {
|
|
19
|
+
if (key && key.name === 'escape') {
|
|
20
|
+
ac.abort(new Error('ESC_PRESSED'));
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
process.stdin.on('keypress', onKeypress);
|
|
24
|
+
try {
|
|
25
|
+
if (options.type === 'select') {
|
|
26
|
+
const choices = options.choices.map((c) => {
|
|
27
|
+
if (c.separator)
|
|
28
|
+
return new prompts_1.Separator(chalk_1.default.magenta.bold(`\n [ ${c.separator} ]`));
|
|
29
|
+
return {
|
|
30
|
+
name: c.title,
|
|
31
|
+
value: c.value,
|
|
32
|
+
description: c.description
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
if (options.message.includes('Esc')) {
|
|
36
|
+
options.message = options.message.replace('(Press Esc to go back)', '(Nhấn Esc để quay lại)').trim();
|
|
37
|
+
choices.push({ name: '[0] 🔙 Go Back', value: '__GOBACK__' });
|
|
38
|
+
}
|
|
39
|
+
const result = await (0, prompts_1.select)({
|
|
40
|
+
message: options.message,
|
|
41
|
+
choices: choices,
|
|
42
|
+
pageSize: 15,
|
|
43
|
+
loop: false,
|
|
44
|
+
theme: {
|
|
45
|
+
helpMode: 'always',
|
|
46
|
+
style: {
|
|
47
|
+
keysHelpTip: () => '↑↓: cuộn • ↵: chọn • Esc: quay lại'
|
|
48
|
+
}
|
|
49
|
+
} // Use as any to inject our custom keysHelpTip if supported, otherwise fallback
|
|
50
|
+
}, { signal: ac.signal });
|
|
51
|
+
if (result === '__GOBACK__') {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
return { [options.name]: result };
|
|
55
|
+
}
|
|
56
|
+
if (options.type === 'text') {
|
|
57
|
+
const result = await (0, prompts_1.input)({
|
|
58
|
+
message: options.message.replace('(Press Esc to go back)', '(Nhấn Esc để quay lại)').trim(),
|
|
59
|
+
default: options.initial
|
|
60
|
+
}, { signal: ac.signal });
|
|
61
|
+
return { [options.name]: result };
|
|
62
|
+
}
|
|
63
|
+
if (options.type === 'confirm') {
|
|
64
|
+
const result = await (0, prompts_1.confirm)({
|
|
65
|
+
message: options.message
|
|
66
|
+
}, { signal: ac.signal });
|
|
67
|
+
return { [options.name]: result };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
// Catch Inquirer abort errors (Ctrl+C or our custom Esc abort)
|
|
72
|
+
if (err.name === 'ExitPromptError' || err.name === 'AbortPromptError' || err.message === 'ESC_PRESSED') {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
process.stdin.off('keypress', onKeypress);
|
|
79
|
+
}
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.providers = void 0;
|
|
4
|
+
exports.getProvider = getProvider;
|
|
5
|
+
const animevietsub_1 = require("./scrapers/providers/animevietsub");
|
|
6
|
+
const anime47_1 = require("./scrapers/providers/anime47");
|
|
7
|
+
const animehay_1 = require("./scrapers/providers/animehay");
|
|
8
|
+
exports.providers = {
|
|
9
|
+
animevietsub: new animevietsub_1.AnimeVietsubProvider(),
|
|
10
|
+
anime47: new anime47_1.Anime47Provider(),
|
|
11
|
+
animehay: new animehay_1.AnimehayProvider()
|
|
12
|
+
};
|
|
13
|
+
function getProvider(name) {
|
|
14
|
+
const providerName = name.toLowerCase();
|
|
15
|
+
const provider = exports.providers[providerName];
|
|
16
|
+
if (!provider) {
|
|
17
|
+
throw new Error(`Provider not found: ${name}`);
|
|
18
|
+
}
|
|
19
|
+
// Inject custom domain if configured
|
|
20
|
+
try {
|
|
21
|
+
const { loadSettings } = require('./storage');
|
|
22
|
+
const settings = loadSettings();
|
|
23
|
+
if (settings.providerDomains && settings.providerDomains[providerName]) {
|
|
24
|
+
let customDomain = settings.providerDomains[providerName];
|
|
25
|
+
if (!customDomain.startsWith('http'))
|
|
26
|
+
customDomain = 'https://' + customDomain;
|
|
27
|
+
// Remove trailing slash
|
|
28
|
+
provider.baseUrl = customDomain.replace(/\/$/, '');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
// Ignore storage errors here to avoid breaking provider resolution
|
|
33
|
+
}
|
|
34
|
+
return provider;
|
|
35
|
+
}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// AniList API service for fetching anime metadata and images
|
|
3
|
+
// Uses GraphQL API - https://docs.anilist.co/guide/introduction
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.anilistApi = exports.AniListApiService = void 0;
|
|
6
|
+
// ============ Cache Implementation ============
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
|
|
9
|
+
function getCached(key) {
|
|
10
|
+
const cached = cache.get(key);
|
|
11
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
12
|
+
return cached.data;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function setCache(key, data) {
|
|
17
|
+
cache.set(key, { data, timestamp: Date.now() });
|
|
18
|
+
}
|
|
19
|
+
// ============ Session-level Result Cache ============
|
|
20
|
+
// Prevents triple API calls (cover + banner + metadata) for same anime in one session
|
|
21
|
+
const sessionResultCache = new Map();
|
|
22
|
+
// ============ GraphQL Queries ============
|
|
23
|
+
const SEARCH_QUERY = `
|
|
24
|
+
query ($search: String, $page: Int, $perPage: Int) {
|
|
25
|
+
Page(page: $page, perPage: $perPage) {
|
|
26
|
+
media(search: $search, type: ANIME, sort: SEARCH_MATCH, isAdult: false) {
|
|
27
|
+
id
|
|
28
|
+
idMal
|
|
29
|
+
title {
|
|
30
|
+
romaji
|
|
31
|
+
english
|
|
32
|
+
native
|
|
33
|
+
}
|
|
34
|
+
synonyms
|
|
35
|
+
coverImage {
|
|
36
|
+
extraLarge
|
|
37
|
+
large
|
|
38
|
+
medium
|
|
39
|
+
}
|
|
40
|
+
bannerImage
|
|
41
|
+
description(asHtml: false)
|
|
42
|
+
episodes
|
|
43
|
+
status
|
|
44
|
+
averageScore
|
|
45
|
+
genres
|
|
46
|
+
studios(isMain: true) {
|
|
47
|
+
nodes {
|
|
48
|
+
name
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
const GET_BY_ID_QUERY = `
|
|
56
|
+
query ($id: Int) {
|
|
57
|
+
Media(id: $id, type: ANIME) {
|
|
58
|
+
id
|
|
59
|
+
idMal
|
|
60
|
+
title {
|
|
61
|
+
romaji
|
|
62
|
+
english
|
|
63
|
+
native
|
|
64
|
+
}
|
|
65
|
+
synonyms
|
|
66
|
+
coverImage {
|
|
67
|
+
extraLarge
|
|
68
|
+
large
|
|
69
|
+
medium
|
|
70
|
+
}
|
|
71
|
+
bannerImage
|
|
72
|
+
description(asHtml: false)
|
|
73
|
+
episodes
|
|
74
|
+
status
|
|
75
|
+
averageScore
|
|
76
|
+
genres
|
|
77
|
+
studios(isMain: true) {
|
|
78
|
+
nodes {
|
|
79
|
+
name
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
const GET_BY_MAL_ID_QUERY = `
|
|
86
|
+
query ($idMal: Int) {
|
|
87
|
+
Media(idMal: $idMal, type: ANIME) {
|
|
88
|
+
id
|
|
89
|
+
idMal
|
|
90
|
+
title {
|
|
91
|
+
romaji
|
|
92
|
+
english
|
|
93
|
+
native
|
|
94
|
+
}
|
|
95
|
+
synonyms
|
|
96
|
+
coverImage {
|
|
97
|
+
extraLarge
|
|
98
|
+
large
|
|
99
|
+
medium
|
|
100
|
+
}
|
|
101
|
+
bannerImage
|
|
102
|
+
description(asHtml: false)
|
|
103
|
+
episodes
|
|
104
|
+
status
|
|
105
|
+
averageScore
|
|
106
|
+
genres
|
|
107
|
+
studios(isMain: true) {
|
|
108
|
+
nodes {
|
|
109
|
+
name
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
`;
|
|
115
|
+
// ============ AniList API Service ============
|
|
116
|
+
class AniListApiService {
|
|
117
|
+
endpoint = 'https://graphql.anilist.co';
|
|
118
|
+
lastRequestTime = 0;
|
|
119
|
+
rateLimitRemaining = 90;
|
|
120
|
+
rateLimitReset = 0;
|
|
121
|
+
requestQueue = Promise.resolve();
|
|
122
|
+
transformUrlsInObject(obj) {
|
|
123
|
+
if (!obj)
|
|
124
|
+
return obj;
|
|
125
|
+
if (typeof obj === 'string') {
|
|
126
|
+
// Transform AniList CDN URLs to go through our image proxy
|
|
127
|
+
if (obj.startsWith('https://s4.anilist.co') ||
|
|
128
|
+
obj.startsWith('https://media.anilist.co')) {
|
|
129
|
+
return `nekostream-image://proxy?url=${encodeURIComponent(obj)}`;
|
|
130
|
+
}
|
|
131
|
+
return obj;
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(obj)) {
|
|
134
|
+
return obj.map((item) => this.transformUrlsInObject(item));
|
|
135
|
+
}
|
|
136
|
+
if (typeof obj === 'object') {
|
|
137
|
+
const result = {};
|
|
138
|
+
for (const key of Object.keys(obj)) {
|
|
139
|
+
result[key] = this.transformUrlsInObject(obj[key]);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
return obj;
|
|
144
|
+
}
|
|
145
|
+
async graphqlRequest(query, variables) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
this.requestQueue = this.requestQueue.then(async () => {
|
|
148
|
+
try {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
// Dynamic throttling based on remaining requests
|
|
151
|
+
if (this.rateLimitRemaining <= 5 && now < this.rateLimitReset) {
|
|
152
|
+
const waitTime = this.rateLimitReset - now;
|
|
153
|
+
console.log(`[AniList] Rate limit low (${this.rateLimitRemaining} left). Waiting ${waitTime}ms...`);
|
|
154
|
+
await new Promise(r => setTimeout(r, waitTime + 500));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Soft delay to spread out requests sequentially
|
|
158
|
+
const timeSinceLastRequest = Date.now() - this.lastRequestTime;
|
|
159
|
+
const minDelay = this.rateLimitRemaining < 30 ? 1000 : 350;
|
|
160
|
+
if (timeSinceLastRequest < minDelay) {
|
|
161
|
+
await new Promise(r => setTimeout(r, minDelay - timeSinceLastRequest));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this.lastRequestTime = Date.now();
|
|
165
|
+
const response = await fetch(this.endpoint, {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: {
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
'Accept': 'application/json',
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify({ query, variables }),
|
|
172
|
+
});
|
|
173
|
+
// Parse rate limit headers
|
|
174
|
+
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
175
|
+
const reset = response.headers.get('x-ratelimit-reset');
|
|
176
|
+
if (remaining)
|
|
177
|
+
this.rateLimitRemaining = parseInt(remaining, 10);
|
|
178
|
+
if (reset) {
|
|
179
|
+
const resetTime = parseInt(reset, 10);
|
|
180
|
+
if (resetTime > 1000000000) {
|
|
181
|
+
this.rateLimitReset = resetTime * 1000;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Handle rate limiting
|
|
185
|
+
if (response.status === 429) {
|
|
186
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
187
|
+
let waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60000;
|
|
188
|
+
if (this.rateLimitReset && this.rateLimitReset > Date.now()) {
|
|
189
|
+
waitTime = Math.max(waitTime, this.rateLimitReset - Date.now());
|
|
190
|
+
}
|
|
191
|
+
console.warn(`[AniList] Rate limited! Waiting ${waitTime}ms...`);
|
|
192
|
+
await new Promise(r => setTimeout(r, waitTime + 1000));
|
|
193
|
+
const result = await this.graphqlRequest(query, variables);
|
|
194
|
+
resolve(result);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
throw new Error(`AniList API error: ${response.status}`);
|
|
199
|
+
}
|
|
200
|
+
const rawJson = await response.json();
|
|
201
|
+
const json = this.transformUrlsInObject(rawJson);
|
|
202
|
+
if (json.errors && json.errors.length > 0) {
|
|
203
|
+
const error = json.errors[0];
|
|
204
|
+
if (error.status === 429) {
|
|
205
|
+
await new Promise(r => setTimeout(r, 60000));
|
|
206
|
+
const result = await this.graphqlRequest(query, variables);
|
|
207
|
+
resolve(result);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`AniList GraphQL error: ${error.message}`);
|
|
211
|
+
}
|
|
212
|
+
resolve(json);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
reject(error);
|
|
216
|
+
}
|
|
217
|
+
}).catch(reject);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Search anime by title
|
|
222
|
+
*/
|
|
223
|
+
async searchAnime(title, limit = 8) {
|
|
224
|
+
const cacheKey = `anilist-search:${title.toLowerCase()}`;
|
|
225
|
+
const cached = getCached(cacheKey);
|
|
226
|
+
if (cached)
|
|
227
|
+
return cached;
|
|
228
|
+
try {
|
|
229
|
+
const cleanTitle = this.cleanTitle(title);
|
|
230
|
+
const response = await this.graphqlRequest(SEARCH_QUERY, { search: cleanTitle, page: 1, perPage: limit });
|
|
231
|
+
const results = response.data?.Page?.media || [];
|
|
232
|
+
if (results.length > 0) {
|
|
233
|
+
setCache(cacheKey, results);
|
|
234
|
+
}
|
|
235
|
+
return results;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
console.error(`AniList searchAnime error for "${title}":`, error);
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Get anime by AniList ID
|
|
244
|
+
*/
|
|
245
|
+
async getAnimeById(anilistId) {
|
|
246
|
+
const cacheKey = `anilist-by-id:${anilistId}`;
|
|
247
|
+
const cached = getCached(cacheKey);
|
|
248
|
+
if (cached)
|
|
249
|
+
return cached;
|
|
250
|
+
try {
|
|
251
|
+
const response = await this.graphqlRequest(GET_BY_ID_QUERY, { id: anilistId });
|
|
252
|
+
const anime = response.data?.Media || null;
|
|
253
|
+
if (anime)
|
|
254
|
+
setCache(cacheKey, anime);
|
|
255
|
+
return anime;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
console.error(`AniList getAnimeById error for ID ${anilistId}:`, error);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get anime by MAL ID (cross-reference)
|
|
264
|
+
*/
|
|
265
|
+
async getAnimeByMalId(malId) {
|
|
266
|
+
const cacheKey = `anilist-by-mal:${malId}`;
|
|
267
|
+
const cached = getCached(cacheKey);
|
|
268
|
+
if (cached)
|
|
269
|
+
return cached;
|
|
270
|
+
try {
|
|
271
|
+
const response = await this.graphqlRequest(GET_BY_MAL_ID_QUERY, { idMal: malId });
|
|
272
|
+
const anime = response.data?.Media || null;
|
|
273
|
+
if (anime)
|
|
274
|
+
setCache(cacheKey, anime);
|
|
275
|
+
return anime;
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
console.error(`AniList getAnimeByMalId error for MAL ID ${malId}:`, error);
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Core search with session-level caching.
|
|
284
|
+
* Returns the best matching AniListMedia or null.
|
|
285
|
+
* This is the single entry point - cover, banner, and metadata all use this.
|
|
286
|
+
*/
|
|
287
|
+
async findBestAnime(title, titleAlt) {
|
|
288
|
+
// Build a stable session cache key
|
|
289
|
+
const sessionKey = `${title.toLowerCase()}|${(titleAlt || '').toLowerCase()}`;
|
|
290
|
+
if (sessionResultCache.has(sessionKey)) {
|
|
291
|
+
return sessionResultCache.get(sessionKey);
|
|
292
|
+
}
|
|
293
|
+
const match = await this.findByTitleVariants(title, titleAlt);
|
|
294
|
+
// Store in session cache (null also cached to prevent redundant searches)
|
|
295
|
+
sessionResultCache.set(sessionKey, match);
|
|
296
|
+
return match;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Tries title variants in priority order, stops as soon as a confident match is found.
|
|
300
|
+
* Max 2 API queries in most cases.
|
|
301
|
+
*/
|
|
302
|
+
async findByTitleVariants(title, titleAlt) {
|
|
303
|
+
// Split multi-name strings by comma → get individual clean names
|
|
304
|
+
const altVariants = titleAlt
|
|
305
|
+
? titleAlt.split(',').map(s => s.trim()).filter(s => s.length >= 2)
|
|
306
|
+
: [];
|
|
307
|
+
const titleVariants = title
|
|
308
|
+
? title.split(',').map(s => s.trim()).filter(s => s.length >= 2)
|
|
309
|
+
: [];
|
|
310
|
+
// Priority: best alt names first, then main names
|
|
311
|
+
// Use Set to deduplicate
|
|
312
|
+
const seen = new Set();
|
|
313
|
+
const queriesToTry = [];
|
|
314
|
+
for (const v of [...altVariants, ...titleVariants]) {
|
|
315
|
+
const lower = v.toLowerCase();
|
|
316
|
+
if (!seen.has(lower)) {
|
|
317
|
+
seen.add(lower);
|
|
318
|
+
queriesToTry.push(v);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Filter out bad queries:
|
|
322
|
+
// - Too short (< 4 chars) - prevents 3-letter acronyms like NGO, SAO, EVA causing false matches
|
|
323
|
+
// - All uppercase & short (≤ 5 chars) - abbreviations: NGO, SAO, BOT, etc.
|
|
324
|
+
const isUsableQuery = (q) => {
|
|
325
|
+
const trimmed = q.trim();
|
|
326
|
+
if (trimmed.length < 4)
|
|
327
|
+
return false;
|
|
328
|
+
if (trimmed.length <= 5 && trimmed === trimmed.toUpperCase() && /^[A-Z]+$/.test(trimmed))
|
|
329
|
+
return false;
|
|
330
|
+
return true;
|
|
331
|
+
};
|
|
332
|
+
const validQueries = queriesToTry.filter(isUsableQuery);
|
|
333
|
+
if (validQueries.length === 0) {
|
|
334
|
+
console.log(`[AniList] All queries filtered out (too short/acronym) for: "${title}" / "${titleAlt}"`);
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
for (const query of validQueries) {
|
|
338
|
+
console.log('[AniList] Searching with query:', query);
|
|
339
|
+
const results = await this.searchAnime(query);
|
|
340
|
+
if (results.length > 0) {
|
|
341
|
+
const match = this.findBestMatch(results, title, titleAlt);
|
|
342
|
+
if (match) {
|
|
343
|
+
console.log(`[AniList] ✅ Matched: "${match.title.romaji}" (score threshold passed)`);
|
|
344
|
+
return match;
|
|
345
|
+
}
|
|
346
|
+
// Got results but no confident match → try next variant
|
|
347
|
+
console.log(`[AniList] Results found for "${query}" but no confident match, trying next...`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
console.log(`[AniList] No match found for: "${title}" / "${titleAlt}"`);
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Get high-quality cover image (uses session cache)
|
|
355
|
+
*/
|
|
356
|
+
async getCoverImage(title, titleAlt) {
|
|
357
|
+
try {
|
|
358
|
+
const match = await this.findBestAnime(title, titleAlt);
|
|
359
|
+
if (!match)
|
|
360
|
+
return null;
|
|
361
|
+
const cover = match.coverImage.extraLarge || match.coverImage.large || match.coverImage.medium || null;
|
|
362
|
+
if (cover) {
|
|
363
|
+
console.log('[AniList] Found cover for', match.title.romaji, ':', cover.substring(0, 50));
|
|
364
|
+
}
|
|
365
|
+
return cover;
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
console.error('AniList getCoverImage error:', error);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Get banner/background image (1900x400) - uses session cache
|
|
374
|
+
*/
|
|
375
|
+
async getBannerImage(title, titleAlt) {
|
|
376
|
+
try {
|
|
377
|
+
const match = await this.findBestAnime(title, titleAlt);
|
|
378
|
+
if (!match)
|
|
379
|
+
return null;
|
|
380
|
+
const banner = match.bannerImage || null;
|
|
381
|
+
if (banner) {
|
|
382
|
+
console.log('[AniList] Found banner for', match.title.romaji);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
console.log('[AniList] No banner available for:', match.title.romaji);
|
|
386
|
+
}
|
|
387
|
+
return banner;
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
console.error('AniList getBannerImage error:', error);
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Get ALL metadata in one call (cover + banner + synopsis + etc.)
|
|
396
|
+
* Uses session cache - no extra API calls if findBestAnime already ran.
|
|
397
|
+
*/
|
|
398
|
+
async getEnhancedMetadata(title, titleAlt) {
|
|
399
|
+
try {
|
|
400
|
+
const match = await this.findBestAnime(title, titleAlt);
|
|
401
|
+
if (!match)
|
|
402
|
+
return null;
|
|
403
|
+
return {
|
|
404
|
+
anilistId: match.id,
|
|
405
|
+
malId: match.idMal,
|
|
406
|
+
cover: match.coverImage.extraLarge || match.coverImage.large,
|
|
407
|
+
banner: match.bannerImage,
|
|
408
|
+
synopsis: match.description,
|
|
409
|
+
score: match.averageScore,
|
|
410
|
+
episodes: match.episodes,
|
|
411
|
+
status: match.status,
|
|
412
|
+
studios: match.studios?.nodes.map(s => s.name),
|
|
413
|
+
genres: match.genres,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
console.error('AniList getEnhancedMetadata error:', error);
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Clean title for better search results
|
|
423
|
+
*/
|
|
424
|
+
cleanTitle(title) {
|
|
425
|
+
return title
|
|
426
|
+
// Remove Vietnamese suffixes
|
|
427
|
+
.replace(/\s*(vietsub|thuyết minh|lồng tiếng|fhd|hd|full|tập \d+)/gi, '')
|
|
428
|
+
// Remove season indicators
|
|
429
|
+
.replace(/\s*(phần|season|ss|mùa)\s*\d+/gi, '')
|
|
430
|
+
// Remove special characters
|
|
431
|
+
.replace(/[△▽★☆♪♫]/g, '')
|
|
432
|
+
// Remove bracketed content
|
|
433
|
+
.replace(/\s*\([^)]+\)\s*/g, ' ')
|
|
434
|
+
.replace(/\s*\[[^\]]+\]\s*/g, ' ')
|
|
435
|
+
// Clean whitespace
|
|
436
|
+
.replace(/\s+/g, ' ')
|
|
437
|
+
.trim();
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Find best matching anime from search results.
|
|
441
|
+
* Uses romaji/english/native/synonyms for matching.
|
|
442
|
+
* Returns null if no result meets the confidence threshold (score > 15).
|
|
443
|
+
*/
|
|
444
|
+
findBestMatch(results, title, titleAlt) {
|
|
445
|
+
if (results.length === 0)
|
|
446
|
+
return null;
|
|
447
|
+
const normalizeStr = (s) => s.toLowerCase()
|
|
448
|
+
.replace(/[△▽★☆♪♫]/g, '')
|
|
449
|
+
.replace(/[^\w\s]/g, '')
|
|
450
|
+
.replace(/\s+/g, ' ')
|
|
451
|
+
.trim();
|
|
452
|
+
// We compare against all comma-split variants for better matching
|
|
453
|
+
const searchTitles = title
|
|
454
|
+
.split(',')
|
|
455
|
+
.map(s => normalizeStr(this.cleanTitle(s.trim())))
|
|
456
|
+
.filter(Boolean);
|
|
457
|
+
const searchAltTitles = titleAlt
|
|
458
|
+
? titleAlt.split(',').map(s => normalizeStr(this.cleanTitle(s.trim()))).filter(Boolean)
|
|
459
|
+
: [];
|
|
460
|
+
const allSearchTerms = [...new Set([...searchAltTitles, ...searchTitles])];
|
|
461
|
+
// Threshold score - prevents unrelated matches (like Evangelion from "NGO")
|
|
462
|
+
let bestScore = 15;
|
|
463
|
+
let bestMatch = null;
|
|
464
|
+
for (const anime of results) {
|
|
465
|
+
let score = 0;
|
|
466
|
+
const aniTitle = anime.title.romaji ? normalizeStr(anime.title.romaji) : '';
|
|
467
|
+
const aniTitleEn = anime.title.english ? normalizeStr(anime.title.english) : '';
|
|
468
|
+
const aniTitleNative = anime.title.native ? normalizeStr(anime.title.native) : '';
|
|
469
|
+
const aniSynonyms = (anime.synonyms || []).map(s => normalizeStr(s));
|
|
470
|
+
const allAniNames = [aniTitle, aniTitleEn, aniTitleNative, ...aniSynonyms].filter(Boolean);
|
|
471
|
+
for (const query of allSearchTerms) {
|
|
472
|
+
if (!query)
|
|
473
|
+
continue;
|
|
474
|
+
// Exact match: highest score
|
|
475
|
+
if (allAniNames.some(n => n === query)) {
|
|
476
|
+
score += 100;
|
|
477
|
+
}
|
|
478
|
+
// Partial match: AniList title contains query or vice versa
|
|
479
|
+
if (aniTitle && (aniTitle.includes(query) || query.includes(aniTitle))) {
|
|
480
|
+
score += 50;
|
|
481
|
+
}
|
|
482
|
+
if (aniTitleEn && (aniTitleEn.includes(query) || query.includes(aniTitleEn))) {
|
|
483
|
+
score += 40;
|
|
484
|
+
}
|
|
485
|
+
// Synonym matching (curated by AniList team - highly reliable)
|
|
486
|
+
for (const syn of aniSynonyms) {
|
|
487
|
+
if (syn === query) {
|
|
488
|
+
score += 70; // Exact synonym match
|
|
489
|
+
}
|
|
490
|
+
else if (syn.includes(query) || query.includes(syn)) {
|
|
491
|
+
score += 25; // Partial synonym match
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// Word-level overlap scoring
|
|
495
|
+
const queryWords = query.split(' ').filter(w => w.length > 2);
|
|
496
|
+
const aniWords = aniTitle.split(' ').filter(w => w.length > 2);
|
|
497
|
+
if (queryWords.length > 0 && aniWords.length > 0) {
|
|
498
|
+
const overlap = queryWords.filter(w => aniWords.includes(w)).length;
|
|
499
|
+
const overlapRatio = overlap / Math.max(queryWords.length, aniWords.length);
|
|
500
|
+
score += Math.round(overlapRatio * 40);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Popularity boost for disambiguation (but not dominant)
|
|
504
|
+
if (anime.averageScore)
|
|
505
|
+
score += anime.averageScore / 20;
|
|
506
|
+
if (score > bestScore) {
|
|
507
|
+
bestScore = score;
|
|
508
|
+
bestMatch = anime;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return bestMatch;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
exports.AniListApiService = AniListApiService;
|
|
515
|
+
// Singleton instance
|
|
516
|
+
exports.anilistApi = new AniListApiService();
|