ani-client 1.5.1 → 1.6.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
@@ -32,8 +32,9 @@ function parseAniListMarkdown(text) {
32
32
  }
33
33
 
34
34
  // src/utils/index.ts
35
+ var WHITESPACE_RE = /\s+/g;
35
36
  function normalizeQuery(query) {
36
- return query.replace(/\s+/g, " ").trim();
37
+ return query.replace(WHITESPACE_RE, " ").trim();
37
38
  }
38
39
  function clampPerPage(value) {
39
40
  return Math.min(Math.max(value, 1), 50);
@@ -45,6 +46,25 @@ function chunk(arr, size) {
45
46
  }
46
47
  return chunks;
47
48
  }
49
+ function validateId(id, label = "id") {
50
+ if (!Number.isFinite(id) || !Number.isInteger(id) || id < 1) {
51
+ throw new RangeError(`Invalid ${label}: expected a positive integer, got ${id}`);
52
+ }
53
+ }
54
+ function validateIds(ids, label = "id") {
55
+ for (const id of ids) {
56
+ validateId(id, label);
57
+ }
58
+ }
59
+ function sortObjectKeys(obj) {
60
+ if (obj === null || typeof obj !== "object") return obj;
61
+ if (Array.isArray(obj)) return obj.map(sortObjectKeys);
62
+ const sorted = {};
63
+ for (const key of Object.keys(obj).sort()) {
64
+ sorted[key] = sortObjectKeys(obj[key]);
65
+ }
66
+ return sorted;
67
+ }
48
68
 
49
69
  // src/cache/index.ts
50
70
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
@@ -58,7 +78,7 @@ var MemoryCache = class {
58
78
  /** Build a deterministic cache key from a query + variables pair. */
59
79
  static key(query, variables) {
60
80
  const normalized = normalizeQuery(query);
61
- return `${normalized}|${JSON.stringify(variables, Object.keys(variables).sort())}`;
81
+ return `${normalized}|${JSON.stringify(sortObjectKeys(variables))}`;
62
82
  }
63
83
  /** Retrieve a cached value, or `undefined` if missing / expired. */
64
84
  get(key) {
@@ -326,6 +346,53 @@ var USER_FIELDS = `
326
346
  manga { count meanScore minutesWatched episodesWatched chaptersRead volumesRead }
327
347
  }
328
348
  `;
349
+ var USER_FAVORITES_FIELDS = `
350
+ favourites {
351
+ anime(perPage: 25) {
352
+ nodes {
353
+ id
354
+ title { romaji english native userPreferred }
355
+ coverImage { large medium }
356
+ type
357
+ format
358
+ siteUrl
359
+ }
360
+ }
361
+ manga(perPage: 25) {
362
+ nodes {
363
+ id
364
+ title { romaji english native userPreferred }
365
+ coverImage { large medium }
366
+ type
367
+ format
368
+ siteUrl
369
+ }
370
+ }
371
+ characters(perPage: 25) {
372
+ nodes {
373
+ id
374
+ name { full native }
375
+ image { large medium }
376
+ siteUrl
377
+ }
378
+ }
379
+ staff(perPage: 25) {
380
+ nodes {
381
+ id
382
+ name { full native }
383
+ image { large medium }
384
+ siteUrl
385
+ }
386
+ }
387
+ studios(perPage: 25) {
388
+ nodes {
389
+ id
390
+ name
391
+ siteUrl
392
+ }
393
+ }
394
+ }
395
+ `;
329
396
  var MEDIA_LIST_FIELDS = `
330
397
  id
331
398
  mediaId
@@ -586,6 +653,22 @@ query ($userId: Int, $userName: String, $type: MediaType!, $status: MediaListSta
586
653
  }
587
654
  }
588
655
  }`;
656
+ var QUERY_USER_FAVORITES_BY_ID = `
657
+ query ($id: Int!) {
658
+ User(id: $id) {
659
+ id
660
+ name
661
+ ${USER_FAVORITES_FIELDS}
662
+ }
663
+ }`;
664
+ var QUERY_USER_FAVORITES_BY_NAME = `
665
+ query ($name: String!) {
666
+ User(name: $name) {
667
+ id
668
+ name
669
+ ${USER_FAVORITES_FIELDS}
670
+ }
671
+ }`;
589
672
 
590
673
  // src/queries/studio.ts
591
674
  var QUERY_STUDIO_BY_ID = `
@@ -794,6 +877,7 @@ var RateLimiter = class {
794
877
  this.enabled = options.enabled ?? true;
795
878
  this.timeoutMs = options.timeoutMs ?? 3e4;
796
879
  this.retryOnNetworkError = options.retryOnNetworkError ?? true;
880
+ this.retryStrategy = options.retryStrategy;
797
881
  this.timestamps = new Array(this.maxRequests).fill(0);
798
882
  }
799
883
  /**
@@ -854,8 +938,11 @@ var RateLimiter = class {
854
938
  if (lastResponse) return lastResponse;
855
939
  throw lastError;
856
940
  }
857
- /** @internal — Exponential backoff with jitter, capped at 30s */
941
+ /** @internal — Exponential backoff with jitter, capped at 30s (or custom strategy) */
858
942
  exponentialDelay(attempt) {
943
+ if (this.retryStrategy) {
944
+ return this.retryStrategy(attempt, this.retryDelayMs);
945
+ }
859
946
  const base = this.retryDelayMs * 2 ** attempt;
860
947
  const jitter = Math.random() * 1e3;
861
948
  return Math.min(base + jitter, 3e4);
@@ -895,6 +982,7 @@ function isNetworkError(err) {
895
982
 
896
983
  // src/client/character.ts
897
984
  async function getCharacter(client, id, include) {
985
+ validateId(id, "characterId");
898
986
  const query = include?.voiceActors ? QUERY_CHARACTER_BY_ID_WITH_VA : QUERY_CHARACTER_BY_ID;
899
987
  const data = await client.request(query, { id });
900
988
  return data.Character;
@@ -1142,6 +1230,7 @@ var ThreadSort = /* @__PURE__ */ ((ThreadSort2) => {
1142
1230
 
1143
1231
  // src/client/media.ts
1144
1232
  async function getMedia(client, id, include) {
1233
+ validateId(id, "mediaId");
1145
1234
  const query = buildMediaByIdQuery(include);
1146
1235
  const data = await client.request(query, { id });
1147
1236
  return data.Media;
@@ -1262,9 +1351,9 @@ async function getWeeklySchedule(client, date = /* @__PURE__ */ new Date()) {
1262
1351
  perPage: 50
1263
1352
  })
1264
1353
  );
1354
+ const names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1265
1355
  for await (const episode of iterator) {
1266
1356
  const epDate = new Date(episode.airingAt * 1e3);
1267
- const names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1268
1357
  const dayName = names[epDate.getDay()];
1269
1358
  schedule[dayName].push(episode);
1270
1359
  }
@@ -1273,6 +1362,7 @@ async function getWeeklySchedule(client, date = /* @__PURE__ */ new Date()) {
1273
1362
 
1274
1363
  // src/client/staff.ts
1275
1364
  async function getStaff(client, id, include) {
1365
+ validateId(id, "staffId");
1276
1366
  if (include?.media) {
1277
1367
  const perPage = typeof include.media === "object" ? include.media.perPage ?? 25 : 25;
1278
1368
  const data2 = await client.request(QUERY_STAFF_BY_ID_WITH_MEDIA, { id, perPage });
@@ -1292,6 +1382,7 @@ async function searchStaff(client, options = {}) {
1292
1382
 
1293
1383
  // src/client/studio.ts
1294
1384
  async function getStudio(client, id) {
1385
+ validateId(id, "studioId");
1295
1386
  const data = await client.request(QUERY_STUDIO_BY_ID, { id });
1296
1387
  return data.Studio;
1297
1388
  }
@@ -1309,6 +1400,7 @@ async function searchStudios(client, options = {}) {
1309
1400
 
1310
1401
  // src/client/thread.ts
1311
1402
  async function getThread(client, id) {
1403
+ validateId(id, "threadId");
1312
1404
  const data = await client.request(QUERY_THREAD_BY_ID, { id });
1313
1405
  return data.Thread;
1314
1406
  }
@@ -1331,6 +1423,7 @@ async function getRecentThreads(client, options = {}) {
1331
1423
  // src/client/user.ts
1332
1424
  async function getUser(client, idOrName) {
1333
1425
  if (typeof idOrName === "number") {
1426
+ validateId(idOrName, "userId");
1334
1427
  const data2 = await client.request(QUERY_USER_BY_ID, { id: idOrName });
1335
1428
  return data2.User;
1336
1429
  }
@@ -1359,6 +1452,28 @@ async function getUserMediaList(client, options) {
1359
1452
  "mediaList"
1360
1453
  );
1361
1454
  }
1455
+ async function getUserFavorites(client, idOrName) {
1456
+ if (typeof idOrName === "number") {
1457
+ validateId(idOrName, "userId");
1458
+ const data2 = await client.request(QUERY_USER_FAVORITES_BY_ID, {
1459
+ id: idOrName
1460
+ });
1461
+ return mapFavorites(data2.User.favourites);
1462
+ }
1463
+ const data = await client.request(QUERY_USER_FAVORITES_BY_NAME, {
1464
+ name: idOrName
1465
+ });
1466
+ return mapFavorites(data.User.favourites);
1467
+ }
1468
+ function mapFavorites(fav) {
1469
+ return {
1470
+ anime: fav.anime?.nodes ?? [],
1471
+ manga: fav.manga?.nodes ?? [],
1472
+ characters: fav.characters?.nodes ?? [],
1473
+ staff: fav.staff?.nodes ?? [],
1474
+ studios: fav.studios?.nodes ?? []
1475
+ };
1476
+ }
1362
1477
 
1363
1478
  // src/client/index.ts
1364
1479
  var DEFAULT_API_URL = "https://graphql.anilist.co";
@@ -1376,6 +1491,21 @@ var AniListClient = class {
1376
1491
  this.cacheAdapter = options.cacheAdapter ?? new MemoryCache(options.cache);
1377
1492
  this.rateLimiter = new RateLimiter(options.rateLimit);
1378
1493
  this.hooks = options.hooks ?? {};
1494
+ this.signal = options.signal;
1495
+ }
1496
+ /**
1497
+ * The current rate limit information from the last API response.
1498
+ * Updated after every non-cached request.
1499
+ */
1500
+ get rateLimitInfo() {
1501
+ return this._rateLimitInfo;
1502
+ }
1503
+ /**
1504
+ * Metadata about the last request (duration, cache status, rate limit info).
1505
+ * Useful for debugging and monitoring.
1506
+ */
1507
+ get lastRequestMeta() {
1508
+ return this._lastRequestMeta;
1379
1509
  }
1380
1510
  // ── Core infrastructure (internal) ──
1381
1511
  /** @internal */
@@ -1384,6 +1514,8 @@ var AniListClient = class {
1384
1514
  const cached = await this.cacheAdapter.get(cacheKey);
1385
1515
  if (cached !== void 0) {
1386
1516
  this.hooks.onCacheHit?.(cacheKey);
1517
+ const meta = { durationMs: 0, fromCache: true };
1518
+ this._lastRequestMeta = meta;
1387
1519
  this.hooks.onResponse?.(query, 0, true);
1388
1520
  return cached;
1389
1521
  }
@@ -1407,7 +1539,8 @@ var AniListClient = class {
1407
1539
  {
1408
1540
  method: "POST",
1409
1541
  headers: this.headers,
1410
- body: JSON.stringify({ query: minifiedQuery, variables })
1542
+ body: JSON.stringify({ query: minifiedQuery, variables }),
1543
+ signal: this.signal
1411
1544
  },
1412
1545
  { onRetry: this.hooks.onRetry, onRateLimit: this.hooks.onRateLimit }
1413
1546
  );
@@ -1416,9 +1549,22 @@ var AniListClient = class {
1416
1549
  const message = json.errors?.[0]?.message ?? `AniList API error (HTTP ${res.status})`;
1417
1550
  throw new AniListError(message, res.status, json.errors ?? []);
1418
1551
  }
1552
+ const rlLimit = res.headers.get("X-RateLimit-Limit");
1553
+ const rlRemaining = res.headers.get("X-RateLimit-Remaining");
1554
+ const rlReset = res.headers.get("X-RateLimit-Reset");
1555
+ if (rlLimit && rlRemaining && rlReset) {
1556
+ this._rateLimitInfo = {
1557
+ limit: Number.parseInt(rlLimit, 10),
1558
+ remaining: Number.parseInt(rlRemaining, 10),
1559
+ reset: Number.parseInt(rlReset, 10)
1560
+ };
1561
+ }
1562
+ const durationMs = Date.now() - start;
1419
1563
  const data = json.data;
1420
1564
  await this.cacheAdapter.set(cacheKey, data);
1421
- this.hooks.onResponse?.(query, Date.now() - start, false);
1565
+ const meta = { durationMs, fromCache: false, rateLimitInfo: this._rateLimitInfo };
1566
+ this._lastRequestMeta = meta;
1567
+ this.hooks.onResponse?.(query, durationMs, false, this._rateLimitInfo);
1422
1568
  return data;
1423
1569
  }
1424
1570
  /** @internal */
@@ -1522,6 +1668,21 @@ var AniListClient = class {
1522
1668
  async getUserMediaList(options) {
1523
1669
  return getUserMediaList(this, options);
1524
1670
  }
1671
+ /**
1672
+ * Fetch a user's favorite anime, manga, characters, staff, and studios.
1673
+ *
1674
+ * @param idOrName - AniList user ID (number) or username (string)
1675
+ * @returns The user's favorites grouped by category
1676
+ *
1677
+ * @example
1678
+ * ```typescript
1679
+ * const favs = await client.getUserFavorites("AniList");
1680
+ * favs.anime.forEach(a => console.log(a.title.romaji));
1681
+ * ```
1682
+ */
1683
+ async getUserFavorites(idOrName) {
1684
+ return getUserFavorites(this, idOrName);
1685
+ }
1525
1686
  // ── Studios ──
1526
1687
  /** Fetch a studio by its AniList ID. */
1527
1688
  async getStudio(id) {
@@ -1579,30 +1740,34 @@ var AniListClient = class {
1579
1740
  /** Fetch multiple media entries in a single API request. */
1580
1741
  async getMediaBatch(ids) {
1581
1742
  if (ids.length === 0) return [];
1743
+ validateIds(ids, "mediaId");
1582
1744
  if (ids.length === 1) return [await this.getMedia(ids[0])];
1583
1745
  return this.executeBatch(ids, buildBatchMediaQuery, "m");
1584
1746
  }
1585
1747
  /** Fetch multiple characters in a single API request. */
1586
1748
  async getCharacterBatch(ids) {
1587
1749
  if (ids.length === 0) return [];
1750
+ validateIds(ids, "characterId");
1588
1751
  if (ids.length === 1) return [await this.getCharacter(ids[0])];
1589
1752
  return this.executeBatch(ids, buildBatchCharacterQuery, "c");
1590
1753
  }
1591
1754
  /** Fetch multiple staff members in a single API request. */
1592
1755
  async getStaffBatch(ids) {
1593
1756
  if (ids.length === 0) return [];
1757
+ validateIds(ids, "staffId");
1594
1758
  if (ids.length === 1) return [await this.getStaff(ids[0])];
1595
1759
  return this.executeBatch(ids, buildBatchStaffQuery, "s");
1596
1760
  }
1597
1761
  /** @internal */
1598
1762
  async executeBatch(ids, buildQuery, prefix) {
1599
1763
  const chunks = chunk(ids, 50);
1600
- const chunkResults = [];
1601
- for (const idChunk of chunks) {
1602
- const query = buildQuery(idChunk);
1603
- const data = await this.request(query);
1604
- chunkResults.push(idChunk.map((_, i) => data[`${prefix}${i}`]));
1605
- }
1764
+ const chunkResults = await Promise.all(
1765
+ chunks.map(async (idChunk) => {
1766
+ const query = buildQuery(idChunk);
1767
+ const data = await this.request(query);
1768
+ return idChunk.map((_, i) => data[`${prefix}${i}`]);
1769
+ })
1770
+ );
1606
1771
  return chunkResults.flat();
1607
1772
  }
1608
1773
  // ── Cache management ──