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