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.mjs CHANGED
@@ -31,6 +31,20 @@ var MEDIA_FIELDS = `
31
31
  trending
32
32
  tags { id name description category rank isMediaSpoiler }
33
33
  studios { nodes { id name isAnimationStudio siteUrl } }
34
+ relations {
35
+ edges {
36
+ relationType(version: 2)
37
+ node {
38
+ id
39
+ title { romaji english native userPreferred }
40
+ type
41
+ format
42
+ status
43
+ coverImage { large medium }
44
+ siteUrl
45
+ }
46
+ }
47
+ }
34
48
  isAdult
35
49
  siteUrl
36
50
  `;
@@ -213,16 +227,133 @@ query ($type: MediaType, $sort: [MediaSort], $page: Int, $perPage: Int) {
213
227
  }
214
228
  }
215
229
  }`;
216
-
217
- // src/errors/index.ts
218
- var AniListError = class extends Error {
219
- constructor(message, status, errors = []) {
220
- super(message);
221
- this.name = "AniListError";
222
- this.status = status;
223
- this.errors = errors;
230
+ var QUERY_MEDIA_BY_SEASON = `
231
+ query ($season: MediaSeason!, $seasonYear: Int!, $type: MediaType, $sort: [MediaSort], $page: Int, $perPage: Int) {
232
+ Page(page: $page, perPage: $perPage) {
233
+ pageInfo { total perPage currentPage lastPage hasNextPage }
234
+ media(season: $season, seasonYear: $seasonYear, type: $type, sort: $sort) {
235
+ ${MEDIA_FIELDS}
236
+ }
224
237
  }
225
- };
238
+ }`;
239
+ var MEDIA_LIST_FIELDS = `
240
+ id
241
+ mediaId
242
+ status
243
+ score(format: POINT_100)
244
+ progress
245
+ progressVolumes
246
+ repeat
247
+ priority
248
+ private
249
+ notes
250
+ startedAt { year month day }
251
+ completedAt { year month day }
252
+ updatedAt
253
+ createdAt
254
+ media {
255
+ ${MEDIA_FIELDS}
256
+ }
257
+ `;
258
+ var QUERY_RECOMMENDATIONS = `
259
+ query ($mediaId: Int!, $page: Int, $perPage: Int, $sort: [RecommendationSort]) {
260
+ Media(id: $mediaId) {
261
+ recommendations(page: $page, perPage: $perPage, sort: $sort) {
262
+ pageInfo { total perPage currentPage lastPage hasNextPage }
263
+ nodes {
264
+ id
265
+ rating
266
+ userRating
267
+ mediaRecommendation {
268
+ id
269
+ idMal
270
+ title { romaji english native userPreferred }
271
+ type
272
+ format
273
+ status
274
+ coverImage { extraLarge large medium color }
275
+ bannerImage
276
+ genres
277
+ averageScore
278
+ meanScore
279
+ popularity
280
+ favourites
281
+ siteUrl
282
+ }
283
+ user {
284
+ id
285
+ name
286
+ avatar { large medium }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }`;
292
+ var QUERY_USER_MEDIA_LIST = `
293
+ query ($userId: Int, $userName: String, $type: MediaType!, $status: MediaListStatus, $sort: [MediaListSort], $page: Int, $perPage: Int) {
294
+ Page(page: $page, perPage: $perPage) {
295
+ pageInfo { total perPage currentPage lastPage hasNextPage }
296
+ mediaList(userId: $userId, userName: $userName, type: $type, status: $status, sort: $sort) {
297
+ ${MEDIA_LIST_FIELDS}
298
+ }
299
+ }
300
+ }`;
301
+ var STUDIO_FIELDS = `
302
+ id
303
+ name
304
+ isAnimationStudio
305
+ siteUrl
306
+ favourites
307
+ media(page: 1, perPage: 25, sort: POPULARITY_DESC) {
308
+ pageInfo { total perPage currentPage lastPage hasNextPage }
309
+ nodes {
310
+ id
311
+ title { romaji english native userPreferred }
312
+ type
313
+ format
314
+ coverImage { large medium }
315
+ siteUrl
316
+ }
317
+ }
318
+ `;
319
+ var QUERY_STUDIO_BY_ID = `
320
+ query ($id: Int!) {
321
+ Studio(id: $id) {
322
+ ${STUDIO_FIELDS}
323
+ }
324
+ }`;
325
+ var QUERY_STUDIO_SEARCH = `
326
+ query ($search: String, $page: Int, $perPage: Int) {
327
+ Page(page: $page, perPage: $perPage) {
328
+ pageInfo { total perPage currentPage lastPage hasNextPage }
329
+ studios(search: $search) {
330
+ ${STUDIO_FIELDS}
331
+ }
332
+ }
333
+ }`;
334
+ var QUERY_GENRES = `
335
+ query {
336
+ GenreCollection
337
+ }`;
338
+ var QUERY_TAGS = `
339
+ query {
340
+ MediaTagCollection {
341
+ id
342
+ name
343
+ description
344
+ category
345
+ isAdult
346
+ }
347
+ }`;
348
+ function buildBatchQuery(ids, typeName, fields, prefix) {
349
+ const aliases = ids.map((id, i) => `${prefix}${i}: ${typeName}(id: ${id}) { ${fields} }`).join("\n ");
350
+ return `query {
351
+ ${aliases}
352
+ }`;
353
+ }
354
+ var buildBatchMediaQuery = (ids) => buildBatchQuery(ids, "Media", MEDIA_FIELDS, "m");
355
+ var buildBatchCharacterQuery = (ids) => buildBatchQuery(ids, "Character", CHARACTER_FIELDS, "c");
356
+ var buildBatchStaffQuery = (ids) => buildBatchQuery(ids, "Staff", STAFF_FIELDS, "s");
226
357
 
227
358
  // src/cache/index.ts
228
359
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
@@ -235,7 +366,7 @@ var MemoryCache = class {
235
366
  }
236
367
  /** Build a deterministic cache key from a query + variables pair. */
237
368
  static key(query, variables) {
238
- return query.trim() + "|" + JSON.stringify(variables, Object.keys(variables).sort());
369
+ return `${query.trim()}|${JSON.stringify(variables, Object.keys(variables).sort())}`;
239
370
  }
240
371
  /** Retrieve a cached value, or `undefined` if missing / expired. */
241
372
  get(key) {
@@ -246,11 +377,14 @@ var MemoryCache = class {
246
377
  this.store.delete(key);
247
378
  return void 0;
248
379
  }
380
+ this.store.delete(key);
381
+ this.store.set(key, entry);
249
382
  return entry.data;
250
383
  }
251
384
  /** Store a value in the cache. */
252
385
  set(key, data) {
253
386
  if (!this.enabled) return;
387
+ this.store.delete(key);
254
388
  if (this.maxSize > 0 && this.store.size >= this.maxSize) {
255
389
  const firstKey = this.store.keys().next().value;
256
390
  if (firstKey !== void 0) this.store.delete(firstKey);
@@ -269,6 +403,37 @@ var MemoryCache = class {
269
403
  get size() {
270
404
  return this.store.size;
271
405
  }
406
+ /** Return an iterator over all cache keys. */
407
+ keys() {
408
+ return this.store.keys();
409
+ }
410
+ /**
411
+ * Remove all entries whose key matches the given pattern.
412
+ *
413
+ * @param pattern — A string (converted to RegExp) or RegExp.
414
+ * @returns Number of entries removed.
415
+ */
416
+ invalidate(pattern) {
417
+ const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
418
+ let count = 0;
419
+ for (const key of [...this.store.keys()]) {
420
+ if (regex.test(key)) {
421
+ this.store.delete(key);
422
+ count++;
423
+ }
424
+ }
425
+ return count;
426
+ }
427
+ };
428
+
429
+ // src/errors/index.ts
430
+ var AniListError = class extends Error {
431
+ constructor(message, status, errors = []) {
432
+ super(message);
433
+ this.name = "AniListError";
434
+ this.status = status;
435
+ this.errors = errors;
436
+ }
272
437
  };
273
438
 
274
439
  // src/rate-limiter/index.ts
@@ -281,6 +446,8 @@ var RateLimiter = class {
281
446
  this.maxRetries = options.maxRetries ?? 3;
282
447
  this.retryDelayMs = options.retryDelayMs ?? 2e3;
283
448
  this.enabled = options.enabled ?? true;
449
+ this.timeoutMs = options.timeoutMs ?? 3e4;
450
+ this.retryOnNetworkError = options.retryOnNetworkError ?? true;
284
451
  }
285
452
  /**
286
453
  * Wait until it's safe to make a request (respects rate limit window).
@@ -298,66 +465,141 @@ var RateLimiter = class {
298
465
  this.timestamps.push(Date.now());
299
466
  }
300
467
  /**
301
- * Execute a fetch with automatic retry on 429 responses.
468
+ * Execute a fetch with automatic retry on 429 responses and network errors.
302
469
  */
303
- async fetchWithRetry(url, init) {
470
+ async fetchWithRetry(url, init, hooks) {
304
471
  await this.acquire();
305
472
  let lastResponse;
473
+ let lastError;
306
474
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
307
- const res = await fetch(url, init);
308
- if (res.status !== 429) {
309
- return res;
475
+ try {
476
+ const res = await this.fetchWithTimeout(url, init);
477
+ if (res.status !== 429) return res;
478
+ lastResponse = res;
479
+ if (attempt === this.maxRetries) break;
480
+ const retryAfter = res.headers.get("Retry-After");
481
+ const delayMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1e3 : this.retryDelayMs * (attempt + 1);
482
+ hooks?.onRateLimit?.(delayMs);
483
+ hooks?.onRetry?.(attempt + 1, "HTTP 429", delayMs);
484
+ await this.sleep(delayMs);
485
+ await this.acquire();
486
+ } catch (err) {
487
+ lastError = err;
488
+ if (this.retryOnNetworkError && isNetworkError(err) && attempt < this.maxRetries) {
489
+ const delayMs = this.retryDelayMs * (attempt + 1);
490
+ hooks?.onRetry?.(attempt + 1, `Network error: ${err.message}`, delayMs);
491
+ await this.sleep(delayMs);
492
+ await this.acquire();
493
+ continue;
494
+ }
495
+ throw err;
310
496
  }
311
- lastResponse = res;
312
- if (attempt === this.maxRetries) break;
313
- const retryAfter = res.headers.get("Retry-After");
314
- const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : this.retryDelayMs * (attempt + 1);
315
- await this.sleep(delayMs);
316
- await this.acquire();
317
497
  }
318
- return lastResponse;
498
+ if (lastResponse) return lastResponse;
499
+ throw lastError;
500
+ }
501
+ /** @internal */
502
+ async fetchWithTimeout(url, init) {
503
+ if (this.timeoutMs <= 0) return fetch(url, init);
504
+ const controller = new AbortController();
505
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
506
+ try {
507
+ return await fetch(url, { ...init, signal: controller.signal });
508
+ } finally {
509
+ clearTimeout(timer);
510
+ }
319
511
  }
320
512
  sleep(ms) {
321
513
  return new Promise((resolve) => setTimeout(resolve, ms));
322
514
  }
323
515
  };
516
+ var RETRYABLE_NETWORK_CODES = /* @__PURE__ */ new Set([
517
+ "ECONNRESET",
518
+ "ECONNREFUSED",
519
+ "ETIMEDOUT",
520
+ "ENOTFOUND",
521
+ "EAI_AGAIN",
522
+ "UND_ERR_CONNECT_TIMEOUT",
523
+ "UND_ERR_SOCKET"
524
+ ]);
525
+ function isNetworkError(err) {
526
+ if (err instanceof TypeError && err.message === "fetch failed") return true;
527
+ const code = err?.code;
528
+ if (code && RETRYABLE_NETWORK_CODES.has(code)) return true;
529
+ const cause = err?.cause?.code;
530
+ if (cause && RETRYABLE_NETWORK_CODES.has(cause)) return true;
531
+ return false;
532
+ }
324
533
 
325
534
  // src/client/index.ts
326
535
  var DEFAULT_API_URL = "https://graphql.anilist.co";
327
536
  var AniListClient = class {
328
537
  constructor(options = {}) {
538
+ this.inFlight = /* @__PURE__ */ new Map();
329
539
  this.apiUrl = options.apiUrl ?? DEFAULT_API_URL;
330
540
  this.headers = {
331
541
  "Content-Type": "application/json",
332
542
  Accept: "application/json"
333
543
  };
334
544
  if (options.token) {
335
- this.headers["Authorization"] = `Bearer ${options.token}`;
545
+ this.headers.Authorization = `Bearer ${options.token}`;
336
546
  }
337
- this.cache = new MemoryCache(options.cache);
547
+ this.cacheAdapter = options.cacheAdapter ?? new MemoryCache(options.cache);
338
548
  this.rateLimiter = new RateLimiter(options.rateLimit);
549
+ this.hooks = options.hooks ?? {};
339
550
  }
340
551
  /**
341
552
  * @internal
342
553
  */
343
554
  async request(query, variables = {}) {
344
555
  const cacheKey = MemoryCache.key(query, variables);
345
- const cached = this.cache.get(cacheKey);
346
- if (cached !== void 0) return cached;
347
- const res = await this.rateLimiter.fetchWithRetry(this.apiUrl, {
348
- method: "POST",
349
- headers: this.headers,
350
- body: JSON.stringify({ query, variables })
351
- });
556
+ const cached = await this.cacheAdapter.get(cacheKey);
557
+ if (cached !== void 0) {
558
+ this.hooks.onCacheHit?.(cacheKey);
559
+ this.hooks.onResponse?.(query, 0, true);
560
+ return cached;
561
+ }
562
+ const existing = this.inFlight.get(cacheKey);
563
+ if (existing) return existing;
564
+ const promise = this.executeRequest(query, variables, cacheKey);
565
+ this.inFlight.set(cacheKey, promise);
566
+ try {
567
+ return await promise;
568
+ } finally {
569
+ this.inFlight.delete(cacheKey);
570
+ }
571
+ }
572
+ /** @internal */
573
+ async executeRequest(query, variables, cacheKey) {
574
+ const start = Date.now();
575
+ this.hooks.onRequest?.(query, variables);
576
+ const res = await this.rateLimiter.fetchWithRetry(
577
+ this.apiUrl,
578
+ {
579
+ method: "POST",
580
+ headers: this.headers,
581
+ body: JSON.stringify({ query, variables })
582
+ },
583
+ { onRetry: this.hooks.onRetry, onRateLimit: this.hooks.onRateLimit }
584
+ );
352
585
  const json = await res.json();
353
586
  if (!res.ok || json.errors) {
354
587
  const message = json.errors?.[0]?.message ?? `AniList API error (HTTP ${res.status})`;
355
588
  throw new AniListError(message, res.status, json.errors ?? []);
356
589
  }
357
590
  const data = json.data;
358
- this.cache.set(cacheKey, data);
591
+ await this.cacheAdapter.set(cacheKey, data);
592
+ this.hooks.onResponse?.(query, Date.now() - start, false);
359
593
  return data;
360
594
  }
595
+ /**
596
+ * @internal
597
+ * Shorthand for paginated queries that follow the `Page { pageInfo, <field>[] }` pattern.
598
+ */
599
+ async pagedRequest(query, variables, field) {
600
+ const data = await this.request(query, variables);
601
+ return { pageInfo: data.Page.pageInfo, results: data.Page[field] };
602
+ }
361
603
  /**
362
604
  * Fetch a single media entry by its AniList ID.
363
605
  *
@@ -384,22 +626,8 @@ var AniListClient = class {
384
626
  * ```
385
627
  */
386
628
  async searchMedia(options = {}) {
387
- const variables = {
388
- search: options.query,
389
- type: options.type,
390
- format: options.format,
391
- status: options.status,
392
- season: options.season,
393
- seasonYear: options.seasonYear,
394
- genre: options.genre,
395
- tag: options.tag,
396
- isAdult: options.isAdult,
397
- sort: options.sort,
398
- page: options.page ?? 1,
399
- perPage: options.perPage ?? 20
400
- };
401
- const data = await this.request(QUERY_MEDIA_SEARCH, variables);
402
- return { pageInfo: data.Page.pageInfo, results: data.Page.media };
629
+ const { query: search, page = 1, perPage = 20, ...filters } = options;
630
+ return this.pagedRequest(QUERY_MEDIA_SEARCH, { search, ...filters, page, perPage }, "media");
403
631
  }
404
632
  /**
405
633
  * Get currently trending anime or manga.
@@ -409,8 +637,7 @@ var AniListClient = class {
409
637
  * @param perPage - Results per page (default 20, max 50)
410
638
  */
411
639
  async getTrending(type = "ANIME", page = 1, perPage = 20) {
412
- const data = await this.request(QUERY_TRENDING, { type, page, perPage });
413
- return { pageInfo: data.Page.pageInfo, results: data.Page.media };
640
+ return this.pagedRequest(QUERY_TRENDING, { type, page, perPage }, "media");
414
641
  }
415
642
  /**
416
643
  * Fetch a character by AniList ID.
@@ -423,14 +650,8 @@ var AniListClient = class {
423
650
  * Search for characters by name.
424
651
  */
425
652
  async searchCharacters(options = {}) {
426
- const variables = {
427
- search: options.query,
428
- sort: options.sort,
429
- page: options.page ?? 1,
430
- perPage: options.perPage ?? 20
431
- };
432
- const data = await this.request(QUERY_CHARACTER_SEARCH, variables);
433
- return { pageInfo: data.Page.pageInfo, results: data.Page.characters };
653
+ const { query: search, page = 1, perPage = 20, ...rest } = options;
654
+ return this.pagedRequest(QUERY_CHARACTER_SEARCH, { search, ...rest, page, perPage }, "characters");
434
655
  }
435
656
  /**
436
657
  * Fetch a staff member by AniList ID.
@@ -443,13 +664,8 @@ var AniListClient = class {
443
664
  * Search for staff (voice actors, directors, etc.).
444
665
  */
445
666
  async searchStaff(options = {}) {
446
- const variables = {
447
- search: options.query,
448
- page: options.page ?? 1,
449
- perPage: options.perPage ?? 20
450
- };
451
- const data = await this.request(QUERY_STAFF_SEARCH, variables);
452
- return { pageInfo: data.Page.pageInfo, results: data.Page.staff };
667
+ const { query: search, page = 1, perPage = 20 } = options;
668
+ return this.pagedRequest(QUERY_STAFF_SEARCH, { search, page, perPage }, "staff");
453
669
  }
454
670
  /**
455
671
  * Fetch a user by AniList ID.
@@ -500,8 +716,7 @@ var AniListClient = class {
500
716
  page: options.page ?? 1,
501
717
  perPage: options.perPage ?? 20
502
718
  };
503
- const data = await this.request(QUERY_AIRING_SCHEDULE, variables);
504
- return { pageInfo: data.Page.pageInfo, results: data.Page.airingSchedules };
719
+ return this.pagedRequest(QUERY_AIRING_SCHEDULE, variables, "airingSchedules");
505
720
  }
506
721
  /**
507
722
  * Get manga that are currently releasing, sorted by most recently updated.
@@ -518,12 +733,14 @@ var AniListClient = class {
518
733
  * ```
519
734
  */
520
735
  async getAiredChapters(options = {}) {
521
- const variables = {
522
- page: options.page ?? 1,
523
- perPage: options.perPage ?? 20
524
- };
525
- const data = await this.request(QUERY_RECENT_CHAPTERS, variables);
526
- return { pageInfo: data.Page.pageInfo, results: data.Page.media };
736
+ return this.pagedRequest(
737
+ QUERY_RECENT_CHAPTERS,
738
+ {
739
+ page: options.page ?? 1,
740
+ perPage: options.perPage ?? 20
741
+ },
742
+ "media"
743
+ );
527
744
  }
528
745
  /**
529
746
  * Get upcoming (not yet released) anime and/or manga, sorted by popularity.
@@ -540,26 +757,361 @@ var AniListClient = class {
540
757
  * ```
541
758
  */
542
759
  async getPlanning(options = {}) {
760
+ return this.pagedRequest(
761
+ QUERY_PLANNING,
762
+ {
763
+ type: options.type,
764
+ sort: options.sort ?? ["POPULARITY_DESC"],
765
+ page: options.page ?? 1,
766
+ perPage: options.perPage ?? 20
767
+ },
768
+ "media"
769
+ );
770
+ }
771
+ /**
772
+ * Get recommendations for a specific media.
773
+ *
774
+ * Returns other anime/manga that users have recommended based on the given media.
775
+ *
776
+ * @param mediaId - The AniList media ID
777
+ * @param options - Optional sort / pagination parameters
778
+ * @returns Paginated list of recommendations
779
+ *
780
+ * @example
781
+ * ```ts
782
+ * // Get recommendations for Cowboy Bebop
783
+ * const recs = await client.getRecommendations(1);
784
+ * recs.results.forEach((r) =>
785
+ * console.log(`${r.mediaRecommendation.title.romaji} (rating: ${r.rating})`)
786
+ * );
787
+ * ```
788
+ */
789
+ async getRecommendations(mediaId, options = {}) {
543
790
  const variables = {
544
- type: options.type,
545
- sort: options.sort ?? ["POPULARITY_DESC"],
791
+ mediaId,
792
+ sort: options.sort ?? ["RATING_DESC"],
546
793
  page: options.page ?? 1,
547
794
  perPage: options.perPage ?? 20
548
795
  };
549
- const data = await this.request(QUERY_PLANNING, variables);
550
- return { pageInfo: data.Page.pageInfo, results: data.Page.media };
796
+ const data = await this.request(QUERY_RECOMMENDATIONS, variables);
797
+ return {
798
+ pageInfo: data.Media.recommendations.pageInfo,
799
+ results: data.Media.recommendations.nodes
800
+ };
551
801
  }
802
+ /**
803
+ * Get anime (or manga) for a specific season and year.
804
+ *
805
+ * @param options - Season, year and optional filter / pagination parameters
806
+ * @returns Paginated list of media for the given season
807
+ *
808
+ * @example
809
+ * ```ts
810
+ * import { MediaSeason } from "ani-client";
811
+ *
812
+ * const winter2026 = await client.getMediaBySeason({
813
+ * season: MediaSeason.WINTER,
814
+ * seasonYear: 2026,
815
+ * perPage: 10,
816
+ * });
817
+ * ```
818
+ */
819
+ async getMediaBySeason(options) {
820
+ return this.pagedRequest(
821
+ QUERY_MEDIA_BY_SEASON,
822
+ {
823
+ season: options.season,
824
+ seasonYear: options.seasonYear,
825
+ type: options.type ?? "ANIME",
826
+ sort: options.sort ?? ["POPULARITY_DESC"],
827
+ page: options.page ?? 1,
828
+ perPage: options.perPage ?? 20
829
+ },
830
+ "media"
831
+ );
832
+ }
833
+ /**
834
+ * Get a user's anime or manga list.
835
+ *
836
+ * Provide either `userId` or `userName` to identify the user.
837
+ * Requires `type` (ANIME or MANGA). Optionally filter by list status.
838
+ *
839
+ * @param options - User identifier, media type, and optional filters
840
+ * @returns Paginated list of media list entries
841
+ *
842
+ * @example
843
+ * ```ts
844
+ * import { MediaType, MediaListStatus } from "ani-client";
845
+ *
846
+ * // Get a user's completed anime list
847
+ * const list = await client.getUserMediaList({
848
+ * userName: "AniList",
849
+ * type: MediaType.ANIME,
850
+ * status: MediaListStatus.COMPLETED,
851
+ * });
852
+ * list.results.forEach((entry) =>
853
+ * console.log(`${entry.media.title.romaji} — ${entry.score}/100`)
854
+ * );
855
+ * ```
856
+ */
857
+ async getUserMediaList(options) {
858
+ if (!options.userId && !options.userName) {
859
+ throw new Error("Either userId or userName must be provided");
860
+ }
861
+ return this.pagedRequest(
862
+ QUERY_USER_MEDIA_LIST,
863
+ {
864
+ userId: options.userId,
865
+ userName: options.userName,
866
+ type: options.type,
867
+ status: options.status,
868
+ sort: options.sort,
869
+ page: options.page ?? 1,
870
+ perPage: options.perPage ?? 20
871
+ },
872
+ "mediaList"
873
+ );
874
+ }
875
+ /**
876
+ * Fetch a studio by its AniList ID.
877
+ *
878
+ * Returns studio details along with its most popular productions.
879
+ *
880
+ * @param id - The AniList studio ID
881
+ */
882
+ async getStudio(id) {
883
+ const data = await this.request(QUERY_STUDIO_BY_ID, { id });
884
+ return data.Studio;
885
+ }
886
+ /**
887
+ * Search for studios by name.
888
+ *
889
+ * @param options - Search / pagination parameters
890
+ * @returns Paginated list of studios
891
+ *
892
+ * @example
893
+ * ```ts
894
+ * const studios = await client.searchStudios({ query: "MAPPA" });
895
+ * ```
896
+ */
897
+ async searchStudios(options = {}) {
898
+ return this.pagedRequest(
899
+ QUERY_STUDIO_SEARCH,
900
+ {
901
+ search: options.query,
902
+ page: options.page ?? 1,
903
+ perPage: options.perPage ?? 20
904
+ },
905
+ "studios"
906
+ );
907
+ }
908
+ /**
909
+ * Get all available genres on AniList.
910
+ *
911
+ * @returns Array of genre strings (e.g. "Action", "Adventure", ...)
912
+ */
913
+ async getGenres() {
914
+ const data = await this.request(QUERY_GENRES);
915
+ return data.GenreCollection;
916
+ }
917
+ /**
918
+ * Get all available media tags on AniList.
919
+ *
920
+ * @returns Array of tag objects with id, name, description, category, isAdult
921
+ */
922
+ async getTags() {
923
+ const data = await this.request(QUERY_TAGS);
924
+ return data.MediaTagCollection;
925
+ }
926
+ /**
927
+ * Auto-paginating async iterator.
928
+ *
929
+ * Wraps any paginated method and yields individual items across all pages.
930
+ * Stops when `hasNextPage` is `false` or `maxPages` is reached.
931
+ *
932
+ * @param fetchPage - A function that takes a page number and returns a `PagedResult<T>`
933
+ * @param maxPages - Maximum number of pages to fetch (default: Infinity)
934
+ * @returns An async iterable iterator of individual items
935
+ *
936
+ * @example
937
+ * ```ts
938
+ * // Iterate over all search results
939
+ * for await (const anime of client.paginate((page) =>
940
+ * client.searchMedia({ query: "Naruto", page, perPage: 10 })
941
+ * )) {
942
+ * console.log(anime.title.romaji);
943
+ * }
944
+ *
945
+ * // Limit to 3 pages
946
+ * for await (const anime of client.paginate(
947
+ * (page) => client.getTrending(MediaType.ANIME, page, 20),
948
+ * 3,
949
+ * )) {
950
+ * console.log(anime.title.romaji);
951
+ * }
952
+ * ```
953
+ */
954
+ async *paginate(fetchPage, maxPages = Number.POSITIVE_INFINITY) {
955
+ let page = 1;
956
+ let hasNext = true;
957
+ while (hasNext && page <= maxPages) {
958
+ const result = await fetchPage(page);
959
+ for (const item of result.results) {
960
+ yield item;
961
+ }
962
+ hasNext = result.pageInfo.hasNextPage === true;
963
+ page++;
964
+ }
965
+ }
966
+ // ── Batch queries ──
967
+ /**
968
+ * Fetch multiple media entries in a single API request.
969
+ * Uses GraphQL aliases to batch up to 50 IDs per call.
970
+ *
971
+ * @param ids - Array of AniList media IDs
972
+ * @returns Array of media objects (same order as input IDs)
973
+ */
974
+ async getMediaBatch(ids) {
975
+ if (ids.length === 0) return [];
976
+ if (ids.length === 1) return [await this.getMedia(ids[0])];
977
+ return this.executeBatch(ids, buildBatchMediaQuery, "m");
978
+ }
979
+ /**
980
+ * Fetch multiple characters in a single API request.
981
+ *
982
+ * @param ids - Array of AniList character IDs
983
+ * @returns Array of character objects (same order as input IDs)
984
+ */
985
+ async getCharacterBatch(ids) {
986
+ if (ids.length === 0) return [];
987
+ if (ids.length === 1) return [await this.getCharacter(ids[0])];
988
+ return this.executeBatch(ids, buildBatchCharacterQuery, "c");
989
+ }
990
+ /**
991
+ * Fetch multiple staff members in a single API request.
992
+ *
993
+ * @param ids - Array of AniList staff IDs
994
+ * @returns Array of staff objects (same order as input IDs)
995
+ */
996
+ async getStaffBatch(ids) {
997
+ if (ids.length === 0) return [];
998
+ if (ids.length === 1) return [await this.getStaff(ids[0])];
999
+ return this.executeBatch(ids, buildBatchStaffQuery, "s");
1000
+ }
1001
+ /** @internal */
1002
+ async executeBatch(ids, buildQuery, prefix) {
1003
+ const chunks = this.chunk(ids, 50);
1004
+ const results = [];
1005
+ for (const chunk of chunks) {
1006
+ const query = buildQuery(chunk);
1007
+ const data = await this.request(query);
1008
+ results.push(...chunk.map((_, i) => data[`${prefix}${i}`]));
1009
+ }
1010
+ return results;
1011
+ }
1012
+ /** @internal */
1013
+ chunk(arr, size) {
1014
+ const chunks = [];
1015
+ for (let i = 0; i < arr.length; i += size) {
1016
+ chunks.push(arr.slice(i, i + size));
1017
+ }
1018
+ return chunks;
1019
+ }
1020
+ // ── Cache management ──
552
1021
  /**
553
1022
  * Clear the entire response cache.
554
1023
  */
555
- clearCache() {
556
- this.cache.clear();
1024
+ async clearCache() {
1025
+ await this.cacheAdapter.clear();
557
1026
  }
558
1027
  /**
559
- * Number of entries currently in the cache.
1028
+ * Number of entries currently in the cache (sync).
1029
+ * For async adapters like Redis, this may be approximate.
560
1030
  */
561
1031
  get cacheSize() {
562
- return this.cache.size;
1032
+ return this.cacheAdapter.size;
1033
+ }
1034
+ /**
1035
+ * Remove cache entries whose key matches the given pattern.
1036
+ *
1037
+ * @param pattern — A string (converted to RegExp) or RegExp
1038
+ * @returns Number of entries removed
1039
+ */
1040
+ async invalidateCache(pattern) {
1041
+ if (this.cacheAdapter.invalidate) {
1042
+ return this.cacheAdapter.invalidate(pattern);
1043
+ }
1044
+ const allKeys = await this.cacheAdapter.keys();
1045
+ const regex = typeof pattern === "string" ? new RegExp(pattern) : pattern;
1046
+ let count = 0;
1047
+ for (const key of allKeys) {
1048
+ if (regex.test(key)) {
1049
+ await this.cacheAdapter.delete(key);
1050
+ count++;
1051
+ }
1052
+ }
1053
+ return count;
1054
+ }
1055
+ };
1056
+
1057
+ // src/cache/redis.ts
1058
+ var RedisCache = class {
1059
+ constructor(options) {
1060
+ this.client = options.client;
1061
+ this.prefix = options.prefix ?? "ani:";
1062
+ this.ttl = options.ttl ?? 86400;
1063
+ }
1064
+ prefixedKey(key) {
1065
+ return `${this.prefix}${key}`;
1066
+ }
1067
+ async get(key) {
1068
+ const raw = await this.client.get(this.prefixedKey(key));
1069
+ if (raw === null) return void 0;
1070
+ try {
1071
+ return JSON.parse(raw);
1072
+ } catch {
1073
+ return void 0;
1074
+ }
1075
+ }
1076
+ async set(key, data) {
1077
+ await this.client.set(this.prefixedKey(key), JSON.stringify(data), "EX", this.ttl);
1078
+ }
1079
+ async delete(key) {
1080
+ const count = await this.client.del(this.prefixedKey(key));
1081
+ return count > 0;
1082
+ }
1083
+ async clear() {
1084
+ const keys = await this.client.keys(`${this.prefix}*`);
1085
+ if (keys.length > 0) {
1086
+ await this.client.del(...keys);
1087
+ }
1088
+ }
1089
+ /**
1090
+ * Returns -1 because Redis keys can expire silently via TTL.
1091
+ * Use `getSize()` for an accurate count.
1092
+ */
1093
+ get size() {
1094
+ return -1;
1095
+ }
1096
+ /** Get the actual number of keys with this prefix in Redis. */
1097
+ async getSize() {
1098
+ const keys = await this.client.keys(`${this.prefix}*`);
1099
+ return keys.length;
1100
+ }
1101
+ async keys() {
1102
+ const raw = await this.client.keys(`${this.prefix}*`);
1103
+ return raw.map((k) => k.slice(this.prefix.length));
1104
+ }
1105
+ /**
1106
+ * Remove all entries whose key matches the given glob pattern.
1107
+ *
1108
+ * @param pattern — A glob pattern (e.g. `"*Media*"`)
1109
+ * @returns Number of entries removed.
1110
+ */
1111
+ async invalidate(pattern) {
1112
+ const keys = await this.client.keys(`${this.prefix}${pattern}`);
1113
+ if (keys.length === 0) return 0;
1114
+ return this.client.del(...keys);
563
1115
  }
564
1116
  };
565
1117
 
@@ -635,7 +1187,72 @@ var CharacterSort = /* @__PURE__ */ ((CharacterSort2) => {
635
1187
  CharacterSort2["FAVOURITES"] = "FAVOURITES";
636
1188
  return CharacterSort2;
637
1189
  })(CharacterSort || {});
1190
+ var MediaRelationType = /* @__PURE__ */ ((MediaRelationType2) => {
1191
+ MediaRelationType2["ADAPTATION"] = "ADAPTATION";
1192
+ MediaRelationType2["PREQUEL"] = "PREQUEL";
1193
+ MediaRelationType2["SEQUEL"] = "SEQUEL";
1194
+ MediaRelationType2["PARENT"] = "PARENT";
1195
+ MediaRelationType2["SIDE_STORY"] = "SIDE_STORY";
1196
+ MediaRelationType2["CHARACTER"] = "CHARACTER";
1197
+ MediaRelationType2["SUMMARY"] = "SUMMARY";
1198
+ MediaRelationType2["ALTERNATIVE"] = "ALTERNATIVE";
1199
+ MediaRelationType2["SPIN_OFF"] = "SPIN_OFF";
1200
+ MediaRelationType2["OTHER"] = "OTHER";
1201
+ MediaRelationType2["SOURCE"] = "SOURCE";
1202
+ MediaRelationType2["COMPILATION"] = "COMPILATION";
1203
+ MediaRelationType2["CONTAINS"] = "CONTAINS";
1204
+ return MediaRelationType2;
1205
+ })(MediaRelationType || {});
1206
+ var RecommendationSort = /* @__PURE__ */ ((RecommendationSort2) => {
1207
+ RecommendationSort2["ID"] = "ID";
1208
+ RecommendationSort2["ID_DESC"] = "ID_DESC";
1209
+ RecommendationSort2["RATING"] = "RATING";
1210
+ RecommendationSort2["RATING_DESC"] = "RATING_DESC";
1211
+ return RecommendationSort2;
1212
+ })(RecommendationSort || {});
1213
+ var MediaListStatus = /* @__PURE__ */ ((MediaListStatus2) => {
1214
+ MediaListStatus2["CURRENT"] = "CURRENT";
1215
+ MediaListStatus2["PLANNING"] = "PLANNING";
1216
+ MediaListStatus2["COMPLETED"] = "COMPLETED";
1217
+ MediaListStatus2["DROPPED"] = "DROPPED";
1218
+ MediaListStatus2["PAUSED"] = "PAUSED";
1219
+ MediaListStatus2["REPEATING"] = "REPEATING";
1220
+ return MediaListStatus2;
1221
+ })(MediaListStatus || {});
1222
+ var MediaListSort = /* @__PURE__ */ ((MediaListSort2) => {
1223
+ MediaListSort2["MEDIA_ID"] = "MEDIA_ID";
1224
+ MediaListSort2["MEDIA_ID_DESC"] = "MEDIA_ID_DESC";
1225
+ MediaListSort2["SCORE"] = "SCORE";
1226
+ MediaListSort2["SCORE_DESC"] = "SCORE_DESC";
1227
+ MediaListSort2["STATUS"] = "STATUS";
1228
+ MediaListSort2["STATUS_DESC"] = "STATUS_DESC";
1229
+ MediaListSort2["PROGRESS"] = "PROGRESS";
1230
+ MediaListSort2["PROGRESS_DESC"] = "PROGRESS_DESC";
1231
+ MediaListSort2["PROGRESS_VOLUMES"] = "PROGRESS_VOLUMES";
1232
+ MediaListSort2["PROGRESS_VOLUMES_DESC"] = "PROGRESS_VOLUMES_DESC";
1233
+ MediaListSort2["REPEAT"] = "REPEAT";
1234
+ MediaListSort2["REPEAT_DESC"] = "REPEAT_DESC";
1235
+ MediaListSort2["PRIORITY"] = "PRIORITY";
1236
+ MediaListSort2["PRIORITY_DESC"] = "PRIORITY_DESC";
1237
+ MediaListSort2["STARTED_ON"] = "STARTED_ON";
1238
+ MediaListSort2["STARTED_ON_DESC"] = "STARTED_ON_DESC";
1239
+ MediaListSort2["FINISHED_ON"] = "FINISHED_ON";
1240
+ MediaListSort2["FINISHED_ON_DESC"] = "FINISHED_ON_DESC";
1241
+ MediaListSort2["ADDED_TIME"] = "ADDED_TIME";
1242
+ MediaListSort2["ADDED_TIME_DESC"] = "ADDED_TIME_DESC";
1243
+ MediaListSort2["UPDATED_TIME"] = "UPDATED_TIME";
1244
+ MediaListSort2["UPDATED_TIME_DESC"] = "UPDATED_TIME_DESC";
1245
+ MediaListSort2["MEDIA_TITLE_ROMAJI"] = "MEDIA_TITLE_ROMAJI";
1246
+ MediaListSort2["MEDIA_TITLE_ROMAJI_DESC"] = "MEDIA_TITLE_ROMAJI_DESC";
1247
+ MediaListSort2["MEDIA_TITLE_ENGLISH"] = "MEDIA_TITLE_ENGLISH";
1248
+ MediaListSort2["MEDIA_TITLE_ENGLISH_DESC"] = "MEDIA_TITLE_ENGLISH_DESC";
1249
+ MediaListSort2["MEDIA_TITLE_NATIVE"] = "MEDIA_TITLE_NATIVE";
1250
+ MediaListSort2["MEDIA_TITLE_NATIVE_DESC"] = "MEDIA_TITLE_NATIVE_DESC";
1251
+ MediaListSort2["MEDIA_POPULARITY"] = "MEDIA_POPULARITY";
1252
+ MediaListSort2["MEDIA_POPULARITY_DESC"] = "MEDIA_POPULARITY_DESC";
1253
+ return MediaListSort2;
1254
+ })(MediaListSort || {});
638
1255
 
639
- export { AiringSort, AniListClient, AniListError, CharacterSort, MediaFormat, MediaSeason, MediaSort, MediaStatus, MediaType, MemoryCache, RateLimiter };
1256
+ export { AiringSort, AniListClient, AniListError, CharacterSort, MediaFormat, MediaListSort, MediaListStatus, MediaRelationType, MediaSeason, MediaSort, MediaStatus, MediaType, MemoryCache, RateLimiter, RecommendationSort, RedisCache };
640
1257
  //# sourceMappingURL=index.mjs.map
641
1258
  //# sourceMappingURL=index.mjs.map