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/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();