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.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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
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 ──
|