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/README.md +41 -3
- package/dist/index.d.mts +112 -3
- package/dist/index.d.ts +112 -3
- package/dist/index.js +177 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +177 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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 ──
|