ani-client 1.2.0 → 1.3.0
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 +317 -296
- package/dist/index.d.mts +170 -11
- package/dist/index.d.ts +170 -11
- package/dist/index.js +347 -115
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +347 -116
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -15
package/dist/index.js
CHANGED
|
@@ -347,16 +347,15 @@ query {
|
|
|
347
347
|
isAdult
|
|
348
348
|
}
|
|
349
349
|
}`;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
};
|
|
350
|
+
function buildBatchQuery(ids, typeName, fields, prefix) {
|
|
351
|
+
const aliases = ids.map((id, i) => `${prefix}${i}: ${typeName}(id: ${id}) { ${fields} }`).join("\n ");
|
|
352
|
+
return `query {
|
|
353
|
+
${aliases}
|
|
354
|
+
}`;
|
|
355
|
+
}
|
|
356
|
+
var buildBatchMediaQuery = (ids) => buildBatchQuery(ids, "Media", MEDIA_FIELDS, "m");
|
|
357
|
+
var buildBatchCharacterQuery = (ids) => buildBatchQuery(ids, "Character", CHARACTER_FIELDS, "c");
|
|
358
|
+
var buildBatchStaffQuery = (ids) => buildBatchQuery(ids, "Staff", STAFF_FIELDS, "s");
|
|
360
359
|
|
|
361
360
|
// src/cache/index.ts
|
|
362
361
|
var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
|
|
@@ -369,7 +368,7 @@ var MemoryCache = class {
|
|
|
369
368
|
}
|
|
370
369
|
/** Build a deterministic cache key from a query + variables pair. */
|
|
371
370
|
static key(query, variables) {
|
|
372
|
-
return query.trim()
|
|
371
|
+
return `${query.trim()}|${JSON.stringify(variables, Object.keys(variables).sort())}`;
|
|
373
372
|
}
|
|
374
373
|
/** Retrieve a cached value, or `undefined` if missing / expired. */
|
|
375
374
|
get(key) {
|
|
@@ -380,11 +379,14 @@ var MemoryCache = class {
|
|
|
380
379
|
this.store.delete(key);
|
|
381
380
|
return void 0;
|
|
382
381
|
}
|
|
382
|
+
this.store.delete(key);
|
|
383
|
+
this.store.set(key, entry);
|
|
383
384
|
return entry.data;
|
|
384
385
|
}
|
|
385
386
|
/** Store a value in the cache. */
|
|
386
387
|
set(key, data) {
|
|
387
388
|
if (!this.enabled) return;
|
|
389
|
+
this.store.delete(key);
|
|
388
390
|
if (this.maxSize > 0 && this.store.size >= this.maxSize) {
|
|
389
391
|
const firstKey = this.store.keys().next().value;
|
|
390
392
|
if (firstKey !== void 0) this.store.delete(firstKey);
|
|
@@ -403,6 +405,37 @@ var MemoryCache = class {
|
|
|
403
405
|
get size() {
|
|
404
406
|
return this.store.size;
|
|
405
407
|
}
|
|
408
|
+
/** Return an iterator over all cache keys. */
|
|
409
|
+
keys() {
|
|
410
|
+
return this.store.keys();
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Remove all entries whose key matches the given pattern.
|
|
414
|
+
*
|
|
415
|
+
* @param pattern — A string (converted to RegExp) or RegExp.
|
|
416
|
+
* @returns Number of entries removed.
|
|
417
|
+
*/
|
|
418
|
+
invalidate(pattern) {
|
|
419
|
+
const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
420
|
+
let count = 0;
|
|
421
|
+
for (const key of [...this.store.keys()]) {
|
|
422
|
+
if (regex.test(key)) {
|
|
423
|
+
this.store.delete(key);
|
|
424
|
+
count++;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return count;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// src/errors/index.ts
|
|
432
|
+
var AniListError = class extends Error {
|
|
433
|
+
constructor(message, status, errors = []) {
|
|
434
|
+
super(message);
|
|
435
|
+
this.name = "AniListError";
|
|
436
|
+
this.status = status;
|
|
437
|
+
this.errors = errors;
|
|
438
|
+
}
|
|
406
439
|
};
|
|
407
440
|
|
|
408
441
|
// src/rate-limiter/index.ts
|
|
@@ -415,6 +448,8 @@ var RateLimiter = class {
|
|
|
415
448
|
this.maxRetries = options.maxRetries ?? 3;
|
|
416
449
|
this.retryDelayMs = options.retryDelayMs ?? 2e3;
|
|
417
450
|
this.enabled = options.enabled ?? true;
|
|
451
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
452
|
+
this.retryOnNetworkError = options.retryOnNetworkError ?? true;
|
|
418
453
|
}
|
|
419
454
|
/**
|
|
420
455
|
* Wait until it's safe to make a request (respects rate limit window).
|
|
@@ -432,66 +467,141 @@ var RateLimiter = class {
|
|
|
432
467
|
this.timestamps.push(Date.now());
|
|
433
468
|
}
|
|
434
469
|
/**
|
|
435
|
-
* Execute a fetch with automatic retry on 429 responses.
|
|
470
|
+
* Execute a fetch with automatic retry on 429 responses and network errors.
|
|
436
471
|
*/
|
|
437
|
-
async fetchWithRetry(url, init) {
|
|
472
|
+
async fetchWithRetry(url, init, hooks) {
|
|
438
473
|
await this.acquire();
|
|
439
474
|
let lastResponse;
|
|
475
|
+
let lastError;
|
|
440
476
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return res;
|
|
477
|
+
try {
|
|
478
|
+
const res = await this.fetchWithTimeout(url, init);
|
|
479
|
+
if (res.status !== 429) return res;
|
|
480
|
+
lastResponse = res;
|
|
481
|
+
if (attempt === this.maxRetries) break;
|
|
482
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
483
|
+
const delayMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1e3 : this.retryDelayMs * (attempt + 1);
|
|
484
|
+
hooks?.onRateLimit?.(delayMs);
|
|
485
|
+
hooks?.onRetry?.(attempt + 1, "HTTP 429", delayMs);
|
|
486
|
+
await this.sleep(delayMs);
|
|
487
|
+
await this.acquire();
|
|
488
|
+
} catch (err) {
|
|
489
|
+
lastError = err;
|
|
490
|
+
if (this.retryOnNetworkError && isNetworkError(err) && attempt < this.maxRetries) {
|
|
491
|
+
const delayMs = this.retryDelayMs * (attempt + 1);
|
|
492
|
+
hooks?.onRetry?.(attempt + 1, `Network error: ${err.message}`, delayMs);
|
|
493
|
+
await this.sleep(delayMs);
|
|
494
|
+
await this.acquire();
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
throw err;
|
|
444
498
|
}
|
|
445
|
-
lastResponse = res;
|
|
446
|
-
if (attempt === this.maxRetries) break;
|
|
447
|
-
const retryAfter = res.headers.get("Retry-After");
|
|
448
|
-
const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : this.retryDelayMs * (attempt + 1);
|
|
449
|
-
await this.sleep(delayMs);
|
|
450
|
-
await this.acquire();
|
|
451
499
|
}
|
|
452
|
-
return lastResponse;
|
|
500
|
+
if (lastResponse) return lastResponse;
|
|
501
|
+
throw lastError;
|
|
502
|
+
}
|
|
503
|
+
/** @internal */
|
|
504
|
+
async fetchWithTimeout(url, init) {
|
|
505
|
+
if (this.timeoutMs <= 0) return fetch(url, init);
|
|
506
|
+
const controller = new AbortController();
|
|
507
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
508
|
+
try {
|
|
509
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
510
|
+
} finally {
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
}
|
|
453
513
|
}
|
|
454
514
|
sleep(ms) {
|
|
455
515
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
456
516
|
}
|
|
457
517
|
};
|
|
518
|
+
var RETRYABLE_NETWORK_CODES = /* @__PURE__ */ new Set([
|
|
519
|
+
"ECONNRESET",
|
|
520
|
+
"ECONNREFUSED",
|
|
521
|
+
"ETIMEDOUT",
|
|
522
|
+
"ENOTFOUND",
|
|
523
|
+
"EAI_AGAIN",
|
|
524
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
525
|
+
"UND_ERR_SOCKET"
|
|
526
|
+
]);
|
|
527
|
+
function isNetworkError(err) {
|
|
528
|
+
if (err instanceof TypeError && err.message === "fetch failed") return true;
|
|
529
|
+
const code = err?.code;
|
|
530
|
+
if (code && RETRYABLE_NETWORK_CODES.has(code)) return true;
|
|
531
|
+
const cause = err?.cause?.code;
|
|
532
|
+
if (cause && RETRYABLE_NETWORK_CODES.has(cause)) return true;
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
458
535
|
|
|
459
536
|
// src/client/index.ts
|
|
460
537
|
var DEFAULT_API_URL = "https://graphql.anilist.co";
|
|
461
538
|
var AniListClient = class {
|
|
462
539
|
constructor(options = {}) {
|
|
540
|
+
this.inFlight = /* @__PURE__ */ new Map();
|
|
463
541
|
this.apiUrl = options.apiUrl ?? DEFAULT_API_URL;
|
|
464
542
|
this.headers = {
|
|
465
543
|
"Content-Type": "application/json",
|
|
466
544
|
Accept: "application/json"
|
|
467
545
|
};
|
|
468
546
|
if (options.token) {
|
|
469
|
-
this.headers
|
|
547
|
+
this.headers.Authorization = `Bearer ${options.token}`;
|
|
470
548
|
}
|
|
471
|
-
this.
|
|
549
|
+
this.cacheAdapter = options.cacheAdapter ?? new MemoryCache(options.cache);
|
|
472
550
|
this.rateLimiter = new RateLimiter(options.rateLimit);
|
|
551
|
+
this.hooks = options.hooks ?? {};
|
|
473
552
|
}
|
|
474
553
|
/**
|
|
475
554
|
* @internal
|
|
476
555
|
*/
|
|
477
556
|
async request(query, variables = {}) {
|
|
478
557
|
const cacheKey = MemoryCache.key(query, variables);
|
|
479
|
-
const cached = this.
|
|
480
|
-
if (cached !== void 0)
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
558
|
+
const cached = await this.cacheAdapter.get(cacheKey);
|
|
559
|
+
if (cached !== void 0) {
|
|
560
|
+
this.hooks.onCacheHit?.(cacheKey);
|
|
561
|
+
this.hooks.onResponse?.(query, 0, true);
|
|
562
|
+
return cached;
|
|
563
|
+
}
|
|
564
|
+
const existing = this.inFlight.get(cacheKey);
|
|
565
|
+
if (existing) return existing;
|
|
566
|
+
const promise = this.executeRequest(query, variables, cacheKey);
|
|
567
|
+
this.inFlight.set(cacheKey, promise);
|
|
568
|
+
try {
|
|
569
|
+
return await promise;
|
|
570
|
+
} finally {
|
|
571
|
+
this.inFlight.delete(cacheKey);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/** @internal */
|
|
575
|
+
async executeRequest(query, variables, cacheKey) {
|
|
576
|
+
const start = Date.now();
|
|
577
|
+
this.hooks.onRequest?.(query, variables);
|
|
578
|
+
const res = await this.rateLimiter.fetchWithRetry(
|
|
579
|
+
this.apiUrl,
|
|
580
|
+
{
|
|
581
|
+
method: "POST",
|
|
582
|
+
headers: this.headers,
|
|
583
|
+
body: JSON.stringify({ query, variables })
|
|
584
|
+
},
|
|
585
|
+
{ onRetry: this.hooks.onRetry, onRateLimit: this.hooks.onRateLimit }
|
|
586
|
+
);
|
|
486
587
|
const json = await res.json();
|
|
487
588
|
if (!res.ok || json.errors) {
|
|
488
589
|
const message = json.errors?.[0]?.message ?? `AniList API error (HTTP ${res.status})`;
|
|
489
590
|
throw new AniListError(message, res.status, json.errors ?? []);
|
|
490
591
|
}
|
|
491
592
|
const data = json.data;
|
|
492
|
-
this.
|
|
593
|
+
await this.cacheAdapter.set(cacheKey, data);
|
|
594
|
+
this.hooks.onResponse?.(query, Date.now() - start, false);
|
|
493
595
|
return data;
|
|
494
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* @internal
|
|
599
|
+
* Shorthand for paginated queries that follow the `Page { pageInfo, <field>[] }` pattern.
|
|
600
|
+
*/
|
|
601
|
+
async pagedRequest(query, variables, field) {
|
|
602
|
+
const data = await this.request(query, variables);
|
|
603
|
+
return { pageInfo: data.Page.pageInfo, results: data.Page[field] };
|
|
604
|
+
}
|
|
495
605
|
/**
|
|
496
606
|
* Fetch a single media entry by its AniList ID.
|
|
497
607
|
*
|
|
@@ -518,22 +628,8 @@ var AniListClient = class {
|
|
|
518
628
|
* ```
|
|
519
629
|
*/
|
|
520
630
|
async searchMedia(options = {}) {
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
type: options.type,
|
|
524
|
-
format: options.format,
|
|
525
|
-
status: options.status,
|
|
526
|
-
season: options.season,
|
|
527
|
-
seasonYear: options.seasonYear,
|
|
528
|
-
genre: options.genre,
|
|
529
|
-
tag: options.tag,
|
|
530
|
-
isAdult: options.isAdult,
|
|
531
|
-
sort: options.sort,
|
|
532
|
-
page: options.page ?? 1,
|
|
533
|
-
perPage: options.perPage ?? 20
|
|
534
|
-
};
|
|
535
|
-
const data = await this.request(QUERY_MEDIA_SEARCH, variables);
|
|
536
|
-
return { pageInfo: data.Page.pageInfo, results: data.Page.media };
|
|
631
|
+
const { query: search, page = 1, perPage = 20, ...filters } = options;
|
|
632
|
+
return this.pagedRequest(QUERY_MEDIA_SEARCH, { search, ...filters, page, perPage }, "media");
|
|
537
633
|
}
|
|
538
634
|
/**
|
|
539
635
|
* Get currently trending anime or manga.
|
|
@@ -543,8 +639,7 @@ var AniListClient = class {
|
|
|
543
639
|
* @param perPage - Results per page (default 20, max 50)
|
|
544
640
|
*/
|
|
545
641
|
async getTrending(type = "ANIME", page = 1, perPage = 20) {
|
|
546
|
-
|
|
547
|
-
return { pageInfo: data.Page.pageInfo, results: data.Page.media };
|
|
642
|
+
return this.pagedRequest(QUERY_TRENDING, { type, page, perPage }, "media");
|
|
548
643
|
}
|
|
549
644
|
/**
|
|
550
645
|
* Fetch a character by AniList ID.
|
|
@@ -557,14 +652,8 @@ var AniListClient = class {
|
|
|
557
652
|
* Search for characters by name.
|
|
558
653
|
*/
|
|
559
654
|
async searchCharacters(options = {}) {
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
sort: options.sort,
|
|
563
|
-
page: options.page ?? 1,
|
|
564
|
-
perPage: options.perPage ?? 20
|
|
565
|
-
};
|
|
566
|
-
const data = await this.request(QUERY_CHARACTER_SEARCH, variables);
|
|
567
|
-
return { pageInfo: data.Page.pageInfo, results: data.Page.characters };
|
|
655
|
+
const { query: search, page = 1, perPage = 20, ...rest } = options;
|
|
656
|
+
return this.pagedRequest(QUERY_CHARACTER_SEARCH, { search, ...rest, page, perPage }, "characters");
|
|
568
657
|
}
|
|
569
658
|
/**
|
|
570
659
|
* Fetch a staff member by AniList ID.
|
|
@@ -577,13 +666,8 @@ var AniListClient = class {
|
|
|
577
666
|
* Search for staff (voice actors, directors, etc.).
|
|
578
667
|
*/
|
|
579
668
|
async searchStaff(options = {}) {
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
page: options.page ?? 1,
|
|
583
|
-
perPage: options.perPage ?? 20
|
|
584
|
-
};
|
|
585
|
-
const data = await this.request(QUERY_STAFF_SEARCH, variables);
|
|
586
|
-
return { pageInfo: data.Page.pageInfo, results: data.Page.staff };
|
|
669
|
+
const { query: search, page = 1, perPage = 20 } = options;
|
|
670
|
+
return this.pagedRequest(QUERY_STAFF_SEARCH, { search, page, perPage }, "staff");
|
|
587
671
|
}
|
|
588
672
|
/**
|
|
589
673
|
* Fetch a user by AniList ID.
|
|
@@ -634,8 +718,7 @@ var AniListClient = class {
|
|
|
634
718
|
page: options.page ?? 1,
|
|
635
719
|
perPage: options.perPage ?? 20
|
|
636
720
|
};
|
|
637
|
-
|
|
638
|
-
return { pageInfo: data.Page.pageInfo, results: data.Page.airingSchedules };
|
|
721
|
+
return this.pagedRequest(QUERY_AIRING_SCHEDULE, variables, "airingSchedules");
|
|
639
722
|
}
|
|
640
723
|
/**
|
|
641
724
|
* Get manga that are currently releasing, sorted by most recently updated.
|
|
@@ -652,12 +735,14 @@ var AniListClient = class {
|
|
|
652
735
|
* ```
|
|
653
736
|
*/
|
|
654
737
|
async getAiredChapters(options = {}) {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
738
|
+
return this.pagedRequest(
|
|
739
|
+
QUERY_RECENT_CHAPTERS,
|
|
740
|
+
{
|
|
741
|
+
page: options.page ?? 1,
|
|
742
|
+
perPage: options.perPage ?? 20
|
|
743
|
+
},
|
|
744
|
+
"media"
|
|
745
|
+
);
|
|
661
746
|
}
|
|
662
747
|
/**
|
|
663
748
|
* Get upcoming (not yet released) anime and/or manga, sorted by popularity.
|
|
@@ -674,14 +759,16 @@ var AniListClient = class {
|
|
|
674
759
|
* ```
|
|
675
760
|
*/
|
|
676
761
|
async getPlanning(options = {}) {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
762
|
+
return this.pagedRequest(
|
|
763
|
+
QUERY_PLANNING,
|
|
764
|
+
{
|
|
765
|
+
type: options.type,
|
|
766
|
+
sort: options.sort ?? ["POPULARITY_DESC"],
|
|
767
|
+
page: options.page ?? 1,
|
|
768
|
+
perPage: options.perPage ?? 20
|
|
769
|
+
},
|
|
770
|
+
"media"
|
|
771
|
+
);
|
|
685
772
|
}
|
|
686
773
|
/**
|
|
687
774
|
* Get recommendations for a specific media.
|
|
@@ -732,16 +819,18 @@ var AniListClient = class {
|
|
|
732
819
|
* ```
|
|
733
820
|
*/
|
|
734
821
|
async getMediaBySeason(options) {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
822
|
+
return this.pagedRequest(
|
|
823
|
+
QUERY_MEDIA_BY_SEASON,
|
|
824
|
+
{
|
|
825
|
+
season: options.season,
|
|
826
|
+
seasonYear: options.seasonYear,
|
|
827
|
+
type: options.type ?? "ANIME",
|
|
828
|
+
sort: options.sort ?? ["POPULARITY_DESC"],
|
|
829
|
+
page: options.page ?? 1,
|
|
830
|
+
perPage: options.perPage ?? 20
|
|
831
|
+
},
|
|
832
|
+
"media"
|
|
833
|
+
);
|
|
745
834
|
}
|
|
746
835
|
/**
|
|
747
836
|
* Get a user's anime or manga list.
|
|
@@ -771,17 +860,19 @@ var AniListClient = class {
|
|
|
771
860
|
if (!options.userId && !options.userName) {
|
|
772
861
|
throw new Error("Either userId or userName must be provided");
|
|
773
862
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
863
|
+
return this.pagedRequest(
|
|
864
|
+
QUERY_USER_MEDIA_LIST,
|
|
865
|
+
{
|
|
866
|
+
userId: options.userId,
|
|
867
|
+
userName: options.userName,
|
|
868
|
+
type: options.type,
|
|
869
|
+
status: options.status,
|
|
870
|
+
sort: options.sort,
|
|
871
|
+
page: options.page ?? 1,
|
|
872
|
+
perPage: options.perPage ?? 20
|
|
873
|
+
},
|
|
874
|
+
"mediaList"
|
|
875
|
+
);
|
|
785
876
|
}
|
|
786
877
|
/**
|
|
787
878
|
* Fetch a studio by its AniList ID.
|
|
@@ -806,13 +897,15 @@ var AniListClient = class {
|
|
|
806
897
|
* ```
|
|
807
898
|
*/
|
|
808
899
|
async searchStudios(options = {}) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
900
|
+
return this.pagedRequest(
|
|
901
|
+
QUERY_STUDIO_SEARCH,
|
|
902
|
+
{
|
|
903
|
+
search: options.query,
|
|
904
|
+
page: options.page ?? 1,
|
|
905
|
+
perPage: options.perPage ?? 20
|
|
906
|
+
},
|
|
907
|
+
"studios"
|
|
908
|
+
);
|
|
816
909
|
}
|
|
817
910
|
/**
|
|
818
911
|
* Get all available genres on AniList.
|
|
@@ -860,7 +953,7 @@ var AniListClient = class {
|
|
|
860
953
|
* }
|
|
861
954
|
* ```
|
|
862
955
|
*/
|
|
863
|
-
async *paginate(fetchPage, maxPages =
|
|
956
|
+
async *paginate(fetchPage, maxPages = Number.POSITIVE_INFINITY) {
|
|
864
957
|
let page = 1;
|
|
865
958
|
let hasNext = true;
|
|
866
959
|
while (hasNext && page <= maxPages) {
|
|
@@ -872,17 +965,155 @@ var AniListClient = class {
|
|
|
872
965
|
page++;
|
|
873
966
|
}
|
|
874
967
|
}
|
|
968
|
+
// ── Batch queries ──
|
|
969
|
+
/**
|
|
970
|
+
* Fetch multiple media entries in a single API request.
|
|
971
|
+
* Uses GraphQL aliases to batch up to 50 IDs per call.
|
|
972
|
+
*
|
|
973
|
+
* @param ids - Array of AniList media IDs
|
|
974
|
+
* @returns Array of media objects (same order as input IDs)
|
|
975
|
+
*/
|
|
976
|
+
async getMediaBatch(ids) {
|
|
977
|
+
if (ids.length === 0) return [];
|
|
978
|
+
if (ids.length === 1) return [await this.getMedia(ids[0])];
|
|
979
|
+
return this.executeBatch(ids, buildBatchMediaQuery, "m");
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Fetch multiple characters in a single API request.
|
|
983
|
+
*
|
|
984
|
+
* @param ids - Array of AniList character IDs
|
|
985
|
+
* @returns Array of character objects (same order as input IDs)
|
|
986
|
+
*/
|
|
987
|
+
async getCharacterBatch(ids) {
|
|
988
|
+
if (ids.length === 0) return [];
|
|
989
|
+
if (ids.length === 1) return [await this.getCharacter(ids[0])];
|
|
990
|
+
return this.executeBatch(ids, buildBatchCharacterQuery, "c");
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Fetch multiple staff members in a single API request.
|
|
994
|
+
*
|
|
995
|
+
* @param ids - Array of AniList staff IDs
|
|
996
|
+
* @returns Array of staff objects (same order as input IDs)
|
|
997
|
+
*/
|
|
998
|
+
async getStaffBatch(ids) {
|
|
999
|
+
if (ids.length === 0) return [];
|
|
1000
|
+
if (ids.length === 1) return [await this.getStaff(ids[0])];
|
|
1001
|
+
return this.executeBatch(ids, buildBatchStaffQuery, "s");
|
|
1002
|
+
}
|
|
1003
|
+
/** @internal */
|
|
1004
|
+
async executeBatch(ids, buildQuery, prefix) {
|
|
1005
|
+
const chunks = this.chunk(ids, 50);
|
|
1006
|
+
const results = [];
|
|
1007
|
+
for (const chunk of chunks) {
|
|
1008
|
+
const query = buildQuery(chunk);
|
|
1009
|
+
const data = await this.request(query);
|
|
1010
|
+
results.push(...chunk.map((_, i) => data[`${prefix}${i}`]));
|
|
1011
|
+
}
|
|
1012
|
+
return results;
|
|
1013
|
+
}
|
|
1014
|
+
/** @internal */
|
|
1015
|
+
chunk(arr, size) {
|
|
1016
|
+
const chunks = [];
|
|
1017
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
1018
|
+
chunks.push(arr.slice(i, i + size));
|
|
1019
|
+
}
|
|
1020
|
+
return chunks;
|
|
1021
|
+
}
|
|
1022
|
+
// ── Cache management ──
|
|
875
1023
|
/**
|
|
876
1024
|
* Clear the entire response cache.
|
|
877
1025
|
*/
|
|
878
|
-
clearCache() {
|
|
879
|
-
this.
|
|
1026
|
+
async clearCache() {
|
|
1027
|
+
await this.cacheAdapter.clear();
|
|
880
1028
|
}
|
|
881
1029
|
/**
|
|
882
|
-
* Number of entries currently in the cache.
|
|
1030
|
+
* Number of entries currently in the cache (sync).
|
|
1031
|
+
* For async adapters like Redis, this may be approximate.
|
|
883
1032
|
*/
|
|
884
1033
|
get cacheSize() {
|
|
885
|
-
return this.
|
|
1034
|
+
return this.cacheAdapter.size;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Remove cache entries whose key matches the given pattern.
|
|
1038
|
+
*
|
|
1039
|
+
* @param pattern — A string (converted to RegExp) or RegExp
|
|
1040
|
+
* @returns Number of entries removed
|
|
1041
|
+
*/
|
|
1042
|
+
async invalidateCache(pattern) {
|
|
1043
|
+
if (this.cacheAdapter.invalidate) {
|
|
1044
|
+
return this.cacheAdapter.invalidate(pattern);
|
|
1045
|
+
}
|
|
1046
|
+
const allKeys = await this.cacheAdapter.keys();
|
|
1047
|
+
const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
1048
|
+
let count = 0;
|
|
1049
|
+
for (const key of allKeys) {
|
|
1050
|
+
if (regex.test(key)) {
|
|
1051
|
+
await this.cacheAdapter.delete(key);
|
|
1052
|
+
count++;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return count;
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
// src/cache/redis.ts
|
|
1060
|
+
var RedisCache = class {
|
|
1061
|
+
constructor(options) {
|
|
1062
|
+
this.client = options.client;
|
|
1063
|
+
this.prefix = options.prefix ?? "ani:";
|
|
1064
|
+
this.ttl = options.ttl ?? 86400;
|
|
1065
|
+
}
|
|
1066
|
+
prefixedKey(key) {
|
|
1067
|
+
return `${this.prefix}${key}`;
|
|
1068
|
+
}
|
|
1069
|
+
async get(key) {
|
|
1070
|
+
const raw = await this.client.get(this.prefixedKey(key));
|
|
1071
|
+
if (raw === null) return void 0;
|
|
1072
|
+
try {
|
|
1073
|
+
return JSON.parse(raw);
|
|
1074
|
+
} catch {
|
|
1075
|
+
return void 0;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
async set(key, data) {
|
|
1079
|
+
await this.client.set(this.prefixedKey(key), JSON.stringify(data), "EX", this.ttl);
|
|
1080
|
+
}
|
|
1081
|
+
async delete(key) {
|
|
1082
|
+
const count = await this.client.del(this.prefixedKey(key));
|
|
1083
|
+
return count > 0;
|
|
1084
|
+
}
|
|
1085
|
+
async clear() {
|
|
1086
|
+
const keys = await this.client.keys(`${this.prefix}*`);
|
|
1087
|
+
if (keys.length > 0) {
|
|
1088
|
+
await this.client.del(...keys);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Returns -1 because Redis keys can expire silently via TTL.
|
|
1093
|
+
* Use `getSize()` for an accurate count.
|
|
1094
|
+
*/
|
|
1095
|
+
get size() {
|
|
1096
|
+
return -1;
|
|
1097
|
+
}
|
|
1098
|
+
/** Get the actual number of keys with this prefix in Redis. */
|
|
1099
|
+
async getSize() {
|
|
1100
|
+
const keys = await this.client.keys(`${this.prefix}*`);
|
|
1101
|
+
return keys.length;
|
|
1102
|
+
}
|
|
1103
|
+
async keys() {
|
|
1104
|
+
const raw = await this.client.keys(`${this.prefix}*`);
|
|
1105
|
+
return raw.map((k) => k.slice(this.prefix.length));
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Remove all entries whose key matches the given glob pattern.
|
|
1109
|
+
*
|
|
1110
|
+
* @param pattern — A glob pattern (e.g. `"*Media*"`)
|
|
1111
|
+
* @returns Number of entries removed.
|
|
1112
|
+
*/
|
|
1113
|
+
async invalidate(pattern) {
|
|
1114
|
+
const keys = await this.client.keys(`${this.prefix}${pattern}`);
|
|
1115
|
+
if (keys.length === 0) return 0;
|
|
1116
|
+
return this.client.del(...keys);
|
|
886
1117
|
}
|
|
887
1118
|
};
|
|
888
1119
|
|
|
@@ -1039,5 +1270,6 @@ exports.MediaType = MediaType;
|
|
|
1039
1270
|
exports.MemoryCache = MemoryCache;
|
|
1040
1271
|
exports.RateLimiter = RateLimiter;
|
|
1041
1272
|
exports.RecommendationSort = RecommendationSort;
|
|
1273
|
+
exports.RedisCache = RedisCache;
|
|
1042
1274
|
//# sourceMappingURL=index.js.map
|
|
1043
1275
|
//# sourceMappingURL=index.js.map
|