ani-client 1.1.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/dist/index.js CHANGED
@@ -33,6 +33,20 @@ var MEDIA_FIELDS = `
33
33
  trending
34
34
  tags { id name description category rank isMediaSpoiler }
35
35
  studios { nodes { id name isAnimationStudio siteUrl } }
36
+ relations {
37
+ edges {
38
+ relationType(version: 2)
39
+ node {
40
+ id
41
+ title { romaji english native userPreferred }
42
+ type
43
+ format
44
+ status
45
+ coverImage { large medium }
46
+ siteUrl
47
+ }
48
+ }
49
+ }
36
50
  isAdult
37
51
  siteUrl
38
52
  `;
@@ -215,16 +229,133 @@ query ($type: MediaType, $sort: [MediaSort], $page: Int, $perPage: Int) {
215
229
  }
216
230
  }
217
231
  }`;
218
-
219
- // src/errors/index.ts
220
- var AniListError = class extends Error {
221
- constructor(message, status, errors = []) {
222
- super(message);
223
- this.name = "AniListError";
224
- this.status = status;
225
- this.errors = errors;
232
+ var QUERY_MEDIA_BY_SEASON = `
233
+ query ($season: MediaSeason!, $seasonYear: Int!, $type: MediaType, $sort: [MediaSort], $page: Int, $perPage: Int) {
234
+ Page(page: $page, perPage: $perPage) {
235
+ pageInfo { total perPage currentPage lastPage hasNextPage }
236
+ media(season: $season, seasonYear: $seasonYear, type: $type, sort: $sort) {
237
+ ${MEDIA_FIELDS}
238
+ }
226
239
  }
227
- };
240
+ }`;
241
+ var MEDIA_LIST_FIELDS = `
242
+ id
243
+ mediaId
244
+ status
245
+ score(format: POINT_100)
246
+ progress
247
+ progressVolumes
248
+ repeat
249
+ priority
250
+ private
251
+ notes
252
+ startedAt { year month day }
253
+ completedAt { year month day }
254
+ updatedAt
255
+ createdAt
256
+ media {
257
+ ${MEDIA_FIELDS}
258
+ }
259
+ `;
260
+ var QUERY_RECOMMENDATIONS = `
261
+ query ($mediaId: Int!, $page: Int, $perPage: Int, $sort: [RecommendationSort]) {
262
+ Media(id: $mediaId) {
263
+ recommendations(page: $page, perPage: $perPage, sort: $sort) {
264
+ pageInfo { total perPage currentPage lastPage hasNextPage }
265
+ nodes {
266
+ id
267
+ rating
268
+ userRating
269
+ mediaRecommendation {
270
+ id
271
+ idMal
272
+ title { romaji english native userPreferred }
273
+ type
274
+ format
275
+ status
276
+ coverImage { extraLarge large medium color }
277
+ bannerImage
278
+ genres
279
+ averageScore
280
+ meanScore
281
+ popularity
282
+ favourites
283
+ siteUrl
284
+ }
285
+ user {
286
+ id
287
+ name
288
+ avatar { large medium }
289
+ }
290
+ }
291
+ }
292
+ }
293
+ }`;
294
+ var QUERY_USER_MEDIA_LIST = `
295
+ query ($userId: Int, $userName: String, $type: MediaType!, $status: MediaListStatus, $sort: [MediaListSort], $page: Int, $perPage: Int) {
296
+ Page(page: $page, perPage: $perPage) {
297
+ pageInfo { total perPage currentPage lastPage hasNextPage }
298
+ mediaList(userId: $userId, userName: $userName, type: $type, status: $status, sort: $sort) {
299
+ ${MEDIA_LIST_FIELDS}
300
+ }
301
+ }
302
+ }`;
303
+ var STUDIO_FIELDS = `
304
+ id
305
+ name
306
+ isAnimationStudio
307
+ siteUrl
308
+ favourites
309
+ media(page: 1, perPage: 25, sort: POPULARITY_DESC) {
310
+ pageInfo { total perPage currentPage lastPage hasNextPage }
311
+ nodes {
312
+ id
313
+ title { romaji english native userPreferred }
314
+ type
315
+ format
316
+ coverImage { large medium }
317
+ siteUrl
318
+ }
319
+ }
320
+ `;
321
+ var QUERY_STUDIO_BY_ID = `
322
+ query ($id: Int!) {
323
+ Studio(id: $id) {
324
+ ${STUDIO_FIELDS}
325
+ }
326
+ }`;
327
+ var QUERY_STUDIO_SEARCH = `
328
+ query ($search: String, $page: Int, $perPage: Int) {
329
+ Page(page: $page, perPage: $perPage) {
330
+ pageInfo { total perPage currentPage lastPage hasNextPage }
331
+ studios(search: $search) {
332
+ ${STUDIO_FIELDS}
333
+ }
334
+ }
335
+ }`;
336
+ var QUERY_GENRES = `
337
+ query {
338
+ GenreCollection
339
+ }`;
340
+ var QUERY_TAGS = `
341
+ query {
342
+ MediaTagCollection {
343
+ id
344
+ name
345
+ description
346
+ category
347
+ isAdult
348
+ }
349
+ }`;
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");
228
359
 
229
360
  // src/cache/index.ts
230
361
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
@@ -237,7 +368,7 @@ var MemoryCache = class {
237
368
  }
238
369
  /** Build a deterministic cache key from a query + variables pair. */
239
370
  static key(query, variables) {
240
- return query.trim() + "|" + JSON.stringify(variables, Object.keys(variables).sort());
371
+ return `${query.trim()}|${JSON.stringify(variables, Object.keys(variables).sort())}`;
241
372
  }
242
373
  /** Retrieve a cached value, or `undefined` if missing / expired. */
243
374
  get(key) {
@@ -248,11 +379,14 @@ var MemoryCache = class {
248
379
  this.store.delete(key);
249
380
  return void 0;
250
381
  }
382
+ this.store.delete(key);
383
+ this.store.set(key, entry);
251
384
  return entry.data;
252
385
  }
253
386
  /** Store a value in the cache. */
254
387
  set(key, data) {
255
388
  if (!this.enabled) return;
389
+ this.store.delete(key);
256
390
  if (this.maxSize > 0 && this.store.size >= this.maxSize) {
257
391
  const firstKey = this.store.keys().next().value;
258
392
  if (firstKey !== void 0) this.store.delete(firstKey);
@@ -271,6 +405,37 @@ var MemoryCache = class {
271
405
  get size() {
272
406
  return this.store.size;
273
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
+ }
274
439
  };
275
440
 
276
441
  // src/rate-limiter/index.ts
@@ -283,6 +448,8 @@ var RateLimiter = class {
283
448
  this.maxRetries = options.maxRetries ?? 3;
284
449
  this.retryDelayMs = options.retryDelayMs ?? 2e3;
285
450
  this.enabled = options.enabled ?? true;
451
+ this.timeoutMs = options.timeoutMs ?? 3e4;
452
+ this.retryOnNetworkError = options.retryOnNetworkError ?? true;
286
453
  }
287
454
  /**
288
455
  * Wait until it's safe to make a request (respects rate limit window).
@@ -300,66 +467,141 @@ var RateLimiter = class {
300
467
  this.timestamps.push(Date.now());
301
468
  }
302
469
  /**
303
- * Execute a fetch with automatic retry on 429 responses.
470
+ * Execute a fetch with automatic retry on 429 responses and network errors.
304
471
  */
305
- async fetchWithRetry(url, init) {
472
+ async fetchWithRetry(url, init, hooks) {
306
473
  await this.acquire();
307
474
  let lastResponse;
475
+ let lastError;
308
476
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
309
- const res = await fetch(url, init);
310
- if (res.status !== 429) {
311
- 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;
312
498
  }
313
- lastResponse = res;
314
- if (attempt === this.maxRetries) break;
315
- const retryAfter = res.headers.get("Retry-After");
316
- const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : this.retryDelayMs * (attempt + 1);
317
- await this.sleep(delayMs);
318
- await this.acquire();
319
499
  }
320
- 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
+ }
321
513
  }
322
514
  sleep(ms) {
323
515
  return new Promise((resolve) => setTimeout(resolve, ms));
324
516
  }
325
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
+ }
326
535
 
327
536
  // src/client/index.ts
328
537
  var DEFAULT_API_URL = "https://graphql.anilist.co";
329
538
  var AniListClient = class {
330
539
  constructor(options = {}) {
540
+ this.inFlight = /* @__PURE__ */ new Map();
331
541
  this.apiUrl = options.apiUrl ?? DEFAULT_API_URL;
332
542
  this.headers = {
333
543
  "Content-Type": "application/json",
334
544
  Accept: "application/json"
335
545
  };
336
546
  if (options.token) {
337
- this.headers["Authorization"] = `Bearer ${options.token}`;
547
+ this.headers.Authorization = `Bearer ${options.token}`;
338
548
  }
339
- this.cache = new MemoryCache(options.cache);
549
+ this.cacheAdapter = options.cacheAdapter ?? new MemoryCache(options.cache);
340
550
  this.rateLimiter = new RateLimiter(options.rateLimit);
551
+ this.hooks = options.hooks ?? {};
341
552
  }
342
553
  /**
343
554
  * @internal
344
555
  */
345
556
  async request(query, variables = {}) {
346
557
  const cacheKey = MemoryCache.key(query, variables);
347
- const cached = this.cache.get(cacheKey);
348
- if (cached !== void 0) return cached;
349
- const res = await this.rateLimiter.fetchWithRetry(this.apiUrl, {
350
- method: "POST",
351
- headers: this.headers,
352
- body: JSON.stringify({ query, variables })
353
- });
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
+ );
354
587
  const json = await res.json();
355
588
  if (!res.ok || json.errors) {
356
589
  const message = json.errors?.[0]?.message ?? `AniList API error (HTTP ${res.status})`;
357
590
  throw new AniListError(message, res.status, json.errors ?? []);
358
591
  }
359
592
  const data = json.data;
360
- this.cache.set(cacheKey, data);
593
+ await this.cacheAdapter.set(cacheKey, data);
594
+ this.hooks.onResponse?.(query, Date.now() - start, false);
361
595
  return data;
362
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
+ }
363
605
  /**
364
606
  * Fetch a single media entry by its AniList ID.
365
607
  *
@@ -386,22 +628,8 @@ var AniListClient = class {
386
628
  * ```
387
629
  */
388
630
  async searchMedia(options = {}) {
389
- const variables = {
390
- search: options.query,
391
- type: options.type,
392
- format: options.format,
393
- status: options.status,
394
- season: options.season,
395
- seasonYear: options.seasonYear,
396
- genre: options.genre,
397
- tag: options.tag,
398
- isAdult: options.isAdult,
399
- sort: options.sort,
400
- page: options.page ?? 1,
401
- perPage: options.perPage ?? 20
402
- };
403
- const data = await this.request(QUERY_MEDIA_SEARCH, variables);
404
- 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");
405
633
  }
406
634
  /**
407
635
  * Get currently trending anime or manga.
@@ -411,8 +639,7 @@ var AniListClient = class {
411
639
  * @param perPage - Results per page (default 20, max 50)
412
640
  */
413
641
  async getTrending(type = "ANIME", page = 1, perPage = 20) {
414
- const data = await this.request(QUERY_TRENDING, { type, page, perPage });
415
- return { pageInfo: data.Page.pageInfo, results: data.Page.media };
642
+ return this.pagedRequest(QUERY_TRENDING, { type, page, perPage }, "media");
416
643
  }
417
644
  /**
418
645
  * Fetch a character by AniList ID.
@@ -425,14 +652,8 @@ var AniListClient = class {
425
652
  * Search for characters by name.
426
653
  */
427
654
  async searchCharacters(options = {}) {
428
- const variables = {
429
- search: options.query,
430
- sort: options.sort,
431
- page: options.page ?? 1,
432
- perPage: options.perPage ?? 20
433
- };
434
- const data = await this.request(QUERY_CHARACTER_SEARCH, variables);
435
- 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");
436
657
  }
437
658
  /**
438
659
  * Fetch a staff member by AniList ID.
@@ -445,13 +666,8 @@ var AniListClient = class {
445
666
  * Search for staff (voice actors, directors, etc.).
446
667
  */
447
668
  async searchStaff(options = {}) {
448
- const variables = {
449
- search: options.query,
450
- page: options.page ?? 1,
451
- perPage: options.perPage ?? 20
452
- };
453
- const data = await this.request(QUERY_STAFF_SEARCH, variables);
454
- 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");
455
671
  }
456
672
  /**
457
673
  * Fetch a user by AniList ID.
@@ -502,8 +718,7 @@ var AniListClient = class {
502
718
  page: options.page ?? 1,
503
719
  perPage: options.perPage ?? 20
504
720
  };
505
- const data = await this.request(QUERY_AIRING_SCHEDULE, variables);
506
- return { pageInfo: data.Page.pageInfo, results: data.Page.airingSchedules };
721
+ return this.pagedRequest(QUERY_AIRING_SCHEDULE, variables, "airingSchedules");
507
722
  }
508
723
  /**
509
724
  * Get manga that are currently releasing, sorted by most recently updated.
@@ -520,12 +735,14 @@ var AniListClient = class {
520
735
  * ```
521
736
  */
522
737
  async getAiredChapters(options = {}) {
523
- const variables = {
524
- page: options.page ?? 1,
525
- perPage: options.perPage ?? 20
526
- };
527
- const data = await this.request(QUERY_RECENT_CHAPTERS, variables);
528
- return { pageInfo: data.Page.pageInfo, results: data.Page.media };
738
+ return this.pagedRequest(
739
+ QUERY_RECENT_CHAPTERS,
740
+ {
741
+ page: options.page ?? 1,
742
+ perPage: options.perPage ?? 20
743
+ },
744
+ "media"
745
+ );
529
746
  }
530
747
  /**
531
748
  * Get upcoming (not yet released) anime and/or manga, sorted by popularity.
@@ -542,26 +759,361 @@ var AniListClient = class {
542
759
  * ```
543
760
  */
544
761
  async getPlanning(options = {}) {
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
+ );
772
+ }
773
+ /**
774
+ * Get recommendations for a specific media.
775
+ *
776
+ * Returns other anime/manga that users have recommended based on the given media.
777
+ *
778
+ * @param mediaId - The AniList media ID
779
+ * @param options - Optional sort / pagination parameters
780
+ * @returns Paginated list of recommendations
781
+ *
782
+ * @example
783
+ * ```ts
784
+ * // Get recommendations for Cowboy Bebop
785
+ * const recs = await client.getRecommendations(1);
786
+ * recs.results.forEach((r) =>
787
+ * console.log(`${r.mediaRecommendation.title.romaji} (rating: ${r.rating})`)
788
+ * );
789
+ * ```
790
+ */
791
+ async getRecommendations(mediaId, options = {}) {
545
792
  const variables = {
546
- type: options.type,
547
- sort: options.sort ?? ["POPULARITY_DESC"],
793
+ mediaId,
794
+ sort: options.sort ?? ["RATING_DESC"],
548
795
  page: options.page ?? 1,
549
796
  perPage: options.perPage ?? 20
550
797
  };
551
- const data = await this.request(QUERY_PLANNING, variables);
552
- return { pageInfo: data.Page.pageInfo, results: data.Page.media };
798
+ const data = await this.request(QUERY_RECOMMENDATIONS, variables);
799
+ return {
800
+ pageInfo: data.Media.recommendations.pageInfo,
801
+ results: data.Media.recommendations.nodes
802
+ };
553
803
  }
804
+ /**
805
+ * Get anime (or manga) for a specific season and year.
806
+ *
807
+ * @param options - Season, year and optional filter / pagination parameters
808
+ * @returns Paginated list of media for the given season
809
+ *
810
+ * @example
811
+ * ```ts
812
+ * import { MediaSeason } from "ani-client";
813
+ *
814
+ * const winter2026 = await client.getMediaBySeason({
815
+ * season: MediaSeason.WINTER,
816
+ * seasonYear: 2026,
817
+ * perPage: 10,
818
+ * });
819
+ * ```
820
+ */
821
+ async getMediaBySeason(options) {
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
+ );
834
+ }
835
+ /**
836
+ * Get a user's anime or manga list.
837
+ *
838
+ * Provide either `userId` or `userName` to identify the user.
839
+ * Requires `type` (ANIME or MANGA). Optionally filter by list status.
840
+ *
841
+ * @param options - User identifier, media type, and optional filters
842
+ * @returns Paginated list of media list entries
843
+ *
844
+ * @example
845
+ * ```ts
846
+ * import { MediaType, MediaListStatus } from "ani-client";
847
+ *
848
+ * // Get a user's completed anime list
849
+ * const list = await client.getUserMediaList({
850
+ * userName: "AniList",
851
+ * type: MediaType.ANIME,
852
+ * status: MediaListStatus.COMPLETED,
853
+ * });
854
+ * list.results.forEach((entry) =>
855
+ * console.log(`${entry.media.title.romaji} — ${entry.score}/100`)
856
+ * );
857
+ * ```
858
+ */
859
+ async getUserMediaList(options) {
860
+ if (!options.userId && !options.userName) {
861
+ throw new Error("Either userId or userName must be provided");
862
+ }
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
+ );
876
+ }
877
+ /**
878
+ * Fetch a studio by its AniList ID.
879
+ *
880
+ * Returns studio details along with its most popular productions.
881
+ *
882
+ * @param id - The AniList studio ID
883
+ */
884
+ async getStudio(id) {
885
+ const data = await this.request(QUERY_STUDIO_BY_ID, { id });
886
+ return data.Studio;
887
+ }
888
+ /**
889
+ * Search for studios by name.
890
+ *
891
+ * @param options - Search / pagination parameters
892
+ * @returns Paginated list of studios
893
+ *
894
+ * @example
895
+ * ```ts
896
+ * const studios = await client.searchStudios({ query: "MAPPA" });
897
+ * ```
898
+ */
899
+ async searchStudios(options = {}) {
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
+ );
909
+ }
910
+ /**
911
+ * Get all available genres on AniList.
912
+ *
913
+ * @returns Array of genre strings (e.g. "Action", "Adventure", ...)
914
+ */
915
+ async getGenres() {
916
+ const data = await this.request(QUERY_GENRES);
917
+ return data.GenreCollection;
918
+ }
919
+ /**
920
+ * Get all available media tags on AniList.
921
+ *
922
+ * @returns Array of tag objects with id, name, description, category, isAdult
923
+ */
924
+ async getTags() {
925
+ const data = await this.request(QUERY_TAGS);
926
+ return data.MediaTagCollection;
927
+ }
928
+ /**
929
+ * Auto-paginating async iterator.
930
+ *
931
+ * Wraps any paginated method and yields individual items across all pages.
932
+ * Stops when `hasNextPage` is `false` or `maxPages` is reached.
933
+ *
934
+ * @param fetchPage - A function that takes a page number and returns a `PagedResult<T>`
935
+ * @param maxPages - Maximum number of pages to fetch (default: Infinity)
936
+ * @returns An async iterable iterator of individual items
937
+ *
938
+ * @example
939
+ * ```ts
940
+ * // Iterate over all search results
941
+ * for await (const anime of client.paginate((page) =>
942
+ * client.searchMedia({ query: "Naruto", page, perPage: 10 })
943
+ * )) {
944
+ * console.log(anime.title.romaji);
945
+ * }
946
+ *
947
+ * // Limit to 3 pages
948
+ * for await (const anime of client.paginate(
949
+ * (page) => client.getTrending(MediaType.ANIME, page, 20),
950
+ * 3,
951
+ * )) {
952
+ * console.log(anime.title.romaji);
953
+ * }
954
+ * ```
955
+ */
956
+ async *paginate(fetchPage, maxPages = Number.POSITIVE_INFINITY) {
957
+ let page = 1;
958
+ let hasNext = true;
959
+ while (hasNext && page <= maxPages) {
960
+ const result = await fetchPage(page);
961
+ for (const item of result.results) {
962
+ yield item;
963
+ }
964
+ hasNext = result.pageInfo.hasNextPage === true;
965
+ page++;
966
+ }
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 ──
554
1023
  /**
555
1024
  * Clear the entire response cache.
556
1025
  */
557
- clearCache() {
558
- this.cache.clear();
1026
+ async clearCache() {
1027
+ await this.cacheAdapter.clear();
559
1028
  }
560
1029
  /**
561
- * 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.
562
1032
  */
563
1033
  get cacheSize() {
564
- return this.cache.size;
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);
565
1117
  }
566
1118
  };
567
1119
 
@@ -637,17 +1189,87 @@ var CharacterSort = /* @__PURE__ */ ((CharacterSort2) => {
637
1189
  CharacterSort2["FAVOURITES"] = "FAVOURITES";
638
1190
  return CharacterSort2;
639
1191
  })(CharacterSort || {});
1192
+ var MediaRelationType = /* @__PURE__ */ ((MediaRelationType2) => {
1193
+ MediaRelationType2["ADAPTATION"] = "ADAPTATION";
1194
+ MediaRelationType2["PREQUEL"] = "PREQUEL";
1195
+ MediaRelationType2["SEQUEL"] = "SEQUEL";
1196
+ MediaRelationType2["PARENT"] = "PARENT";
1197
+ MediaRelationType2["SIDE_STORY"] = "SIDE_STORY";
1198
+ MediaRelationType2["CHARACTER"] = "CHARACTER";
1199
+ MediaRelationType2["SUMMARY"] = "SUMMARY";
1200
+ MediaRelationType2["ALTERNATIVE"] = "ALTERNATIVE";
1201
+ MediaRelationType2["SPIN_OFF"] = "SPIN_OFF";
1202
+ MediaRelationType2["OTHER"] = "OTHER";
1203
+ MediaRelationType2["SOURCE"] = "SOURCE";
1204
+ MediaRelationType2["COMPILATION"] = "COMPILATION";
1205
+ MediaRelationType2["CONTAINS"] = "CONTAINS";
1206
+ return MediaRelationType2;
1207
+ })(MediaRelationType || {});
1208
+ var RecommendationSort = /* @__PURE__ */ ((RecommendationSort2) => {
1209
+ RecommendationSort2["ID"] = "ID";
1210
+ RecommendationSort2["ID_DESC"] = "ID_DESC";
1211
+ RecommendationSort2["RATING"] = "RATING";
1212
+ RecommendationSort2["RATING_DESC"] = "RATING_DESC";
1213
+ return RecommendationSort2;
1214
+ })(RecommendationSort || {});
1215
+ var MediaListStatus = /* @__PURE__ */ ((MediaListStatus2) => {
1216
+ MediaListStatus2["CURRENT"] = "CURRENT";
1217
+ MediaListStatus2["PLANNING"] = "PLANNING";
1218
+ MediaListStatus2["COMPLETED"] = "COMPLETED";
1219
+ MediaListStatus2["DROPPED"] = "DROPPED";
1220
+ MediaListStatus2["PAUSED"] = "PAUSED";
1221
+ MediaListStatus2["REPEATING"] = "REPEATING";
1222
+ return MediaListStatus2;
1223
+ })(MediaListStatus || {});
1224
+ var MediaListSort = /* @__PURE__ */ ((MediaListSort2) => {
1225
+ MediaListSort2["MEDIA_ID"] = "MEDIA_ID";
1226
+ MediaListSort2["MEDIA_ID_DESC"] = "MEDIA_ID_DESC";
1227
+ MediaListSort2["SCORE"] = "SCORE";
1228
+ MediaListSort2["SCORE_DESC"] = "SCORE_DESC";
1229
+ MediaListSort2["STATUS"] = "STATUS";
1230
+ MediaListSort2["STATUS_DESC"] = "STATUS_DESC";
1231
+ MediaListSort2["PROGRESS"] = "PROGRESS";
1232
+ MediaListSort2["PROGRESS_DESC"] = "PROGRESS_DESC";
1233
+ MediaListSort2["PROGRESS_VOLUMES"] = "PROGRESS_VOLUMES";
1234
+ MediaListSort2["PROGRESS_VOLUMES_DESC"] = "PROGRESS_VOLUMES_DESC";
1235
+ MediaListSort2["REPEAT"] = "REPEAT";
1236
+ MediaListSort2["REPEAT_DESC"] = "REPEAT_DESC";
1237
+ MediaListSort2["PRIORITY"] = "PRIORITY";
1238
+ MediaListSort2["PRIORITY_DESC"] = "PRIORITY_DESC";
1239
+ MediaListSort2["STARTED_ON"] = "STARTED_ON";
1240
+ MediaListSort2["STARTED_ON_DESC"] = "STARTED_ON_DESC";
1241
+ MediaListSort2["FINISHED_ON"] = "FINISHED_ON";
1242
+ MediaListSort2["FINISHED_ON_DESC"] = "FINISHED_ON_DESC";
1243
+ MediaListSort2["ADDED_TIME"] = "ADDED_TIME";
1244
+ MediaListSort2["ADDED_TIME_DESC"] = "ADDED_TIME_DESC";
1245
+ MediaListSort2["UPDATED_TIME"] = "UPDATED_TIME";
1246
+ MediaListSort2["UPDATED_TIME_DESC"] = "UPDATED_TIME_DESC";
1247
+ MediaListSort2["MEDIA_TITLE_ROMAJI"] = "MEDIA_TITLE_ROMAJI";
1248
+ MediaListSort2["MEDIA_TITLE_ROMAJI_DESC"] = "MEDIA_TITLE_ROMAJI_DESC";
1249
+ MediaListSort2["MEDIA_TITLE_ENGLISH"] = "MEDIA_TITLE_ENGLISH";
1250
+ MediaListSort2["MEDIA_TITLE_ENGLISH_DESC"] = "MEDIA_TITLE_ENGLISH_DESC";
1251
+ MediaListSort2["MEDIA_TITLE_NATIVE"] = "MEDIA_TITLE_NATIVE";
1252
+ MediaListSort2["MEDIA_TITLE_NATIVE_DESC"] = "MEDIA_TITLE_NATIVE_DESC";
1253
+ MediaListSort2["MEDIA_POPULARITY"] = "MEDIA_POPULARITY";
1254
+ MediaListSort2["MEDIA_POPULARITY_DESC"] = "MEDIA_POPULARITY_DESC";
1255
+ return MediaListSort2;
1256
+ })(MediaListSort || {});
640
1257
 
641
1258
  exports.AiringSort = AiringSort;
642
1259
  exports.AniListClient = AniListClient;
643
1260
  exports.AniListError = AniListError;
644
1261
  exports.CharacterSort = CharacterSort;
645
1262
  exports.MediaFormat = MediaFormat;
1263
+ exports.MediaListSort = MediaListSort;
1264
+ exports.MediaListStatus = MediaListStatus;
1265
+ exports.MediaRelationType = MediaRelationType;
646
1266
  exports.MediaSeason = MediaSeason;
647
1267
  exports.MediaSort = MediaSort;
648
1268
  exports.MediaStatus = MediaStatus;
649
1269
  exports.MediaType = MediaType;
650
1270
  exports.MemoryCache = MemoryCache;
651
1271
  exports.RateLimiter = RateLimiter;
1272
+ exports.RecommendationSort = RecommendationSort;
1273
+ exports.RedisCache = RedisCache;
652
1274
  //# sourceMappingURL=index.js.map
653
1275
  //# sourceMappingURL=index.js.map