ani-client 1.7.0 → 1.8.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
@@ -127,28 +127,52 @@ function sortObjectKeys(obj) {
127
127
  // src/cache/index.ts
128
128
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
129
129
  var MemoryCache = class {
130
+ ttl;
131
+ maxSize;
132
+ enabled;
133
+ swrMs;
134
+ store = /* @__PURE__ */ new Map();
135
+ _hits = 0;
136
+ _misses = 0;
137
+ _stales = 0;
130
138
  constructor(options = {}) {
131
- this.store = /* @__PURE__ */ new Map();
132
139
  this.ttl = options.ttl ?? ONE_DAY_MS;
133
140
  this.maxSize = options.maxSize ?? 500;
134
141
  this.enabled = options.enabled ?? true;
142
+ this.swrMs = options.staleWhileRevalidateMs ?? 0;
135
143
  }
136
144
  /** Build a deterministic cache key from a query + variables pair. */
137
145
  static key(query, variables) {
138
146
  const normalized = normalizeQuery(query);
139
147
  return `${normalized}|${JSON.stringify(sortObjectKeys(variables))}`;
140
148
  }
141
- /** Retrieve a cached value, or `undefined` if missing / expired. */
149
+ /**
150
+ * Retrieve a cached value, or `undefined` if missing / expired.
151
+ * With stale-while-revalidate enabled, returns stale data within the grace window
152
+ * and flags it so the caller can refresh in the background.
153
+ */
142
154
  get(key) {
143
155
  if (!this.enabled) return void 0;
144
156
  const entry = this.store.get(key);
145
- if (!entry) return void 0;
146
- if (Date.now() > entry.expiresAt) {
157
+ if (!entry) {
158
+ this._misses++;
159
+ return void 0;
160
+ }
161
+ const now = Date.now();
162
+ if (now > entry.expiresAt) {
163
+ if (this.swrMs > 0 && now <= entry.expiresAt + this.swrMs) {
164
+ this.store.delete(key);
165
+ this.store.set(key, entry);
166
+ this._stales++;
167
+ return entry.data;
168
+ }
147
169
  this.store.delete(key);
170
+ this._misses++;
148
171
  return void 0;
149
172
  }
150
173
  this.store.delete(key);
151
174
  this.store.set(key, entry);
175
+ this._hits++;
152
176
  return entry.data;
153
177
  }
154
178
  /** Store a value in the cache. */
@@ -165,9 +189,12 @@ var MemoryCache = class {
165
189
  delete(key) {
166
190
  return this.store.delete(key);
167
191
  }
168
- /** Clear the entire cache. */
192
+ /** Clear the entire cache and reset statistics. */
169
193
  clear() {
170
194
  this.store.clear();
195
+ this._hits = 0;
196
+ this._misses = 0;
197
+ this._stales = 0;
171
198
  }
172
199
  /** Number of entries currently stored. */
173
200
  get size() {
@@ -177,6 +204,32 @@ var MemoryCache = class {
177
204
  keys() {
178
205
  return [...this.store.keys()];
179
206
  }
207
+ /**
208
+ * Get cache performance statistics.
209
+ *
210
+ * @example
211
+ * ```ts
212
+ * const cache = new MemoryCache();
213
+ * // ... after some usage ...
214
+ * console.log(cache.stats);
215
+ * // { hits: 42, misses: 8, stales: 0, hitRate: 0.84 }
216
+ * ```
217
+ */
218
+ get stats() {
219
+ const total = this._hits + this._misses + this._stales;
220
+ return {
221
+ hits: this._hits,
222
+ misses: this._misses,
223
+ stales: this._stales,
224
+ hitRate: total === 0 ? Number.NaN : this._hits / total
225
+ };
226
+ }
227
+ /** Reset cache statistics without clearing stored data. */
228
+ resetStats() {
229
+ this._hits = 0;
230
+ this._misses = 0;
231
+ this._stales = 0;
232
+ }
180
233
  /**
181
234
  * Remove all entries whose key matches the given pattern.
182
235
  *
@@ -199,6 +252,10 @@ var MemoryCache = class {
199
252
 
200
253
  // src/errors/index.ts
201
254
  var AniListError = class _AniListError extends Error {
255
+ /** HTTP status code returned by the API */
256
+ status;
257
+ /** Raw error body from the API response */
258
+ errors;
202
259
  constructor(message, status, errors = []) {
203
260
  super(message);
204
261
  this.name = "AniListError";
@@ -212,6 +269,32 @@ var AniListError = class _AniListError extends Error {
212
269
  };
213
270
 
214
271
  // src/queries/fragments.ts
272
+ var MEDIA_FIELDS_LIGHT = `
273
+ id
274
+ idMal
275
+ title { romaji english native userPreferred }
276
+ type
277
+ format
278
+ status
279
+ coverImage { large medium color }
280
+ bannerImage
281
+ genres
282
+ averageScore
283
+ popularity
284
+ favourites
285
+ isAdult
286
+ siteUrl
287
+ season
288
+ seasonYear
289
+ episodes
290
+ chapters
291
+ nextAiringEpisode {
292
+ id
293
+ airingAt
294
+ episode
295
+ timeUntilAiring
296
+ }
297
+ `;
215
298
  var MEDIA_FIELDS_BASE = `
216
299
  id
217
300
  idMal
@@ -631,6 +714,12 @@ query ($season: MediaSeason!, $seasonYear: Int!, $type: MediaType, $sort: [Media
631
714
  }
632
715
  }
633
716
  }`;
717
+ var QUERY_MEDIA_BY_MAL_ID = `
718
+ query ($idMal: Int!, $type: MediaType) {
719
+ Media(idMal: $idMal, type: $type) {
720
+ ${MEDIA_FIELDS}
721
+ }
722
+ }`;
634
723
  var QUERY_RECOMMENDATIONS = `
635
724
  query ($mediaId: Int!, $page: Int, $perPage: Int, $sort: [RecommendationSort]) {
636
725
  Media(id: $mediaId) {
@@ -769,6 +858,63 @@ query ($name: String!) {
769
858
  ${USER_FAVORITES_FIELDS}
770
859
  }
771
860
  }`;
861
+ function buildUserFavoritesQuery(idOrName, perPage = 25) {
862
+ const pp = clampPerPage(perPage);
863
+ const varDecl = idOrName === "id" ? "$id: Int!" : "$name: String!";
864
+ const selector = idOrName === "id" ? "id: $id" : "name: $name";
865
+ return `
866
+ query (${varDecl}) {
867
+ User(${selector}) {
868
+ id
869
+ name
870
+ favourites {
871
+ anime(perPage: ${pp}) {
872
+ nodes {
873
+ id
874
+ title { romaji english native userPreferred }
875
+ coverImage { large medium }
876
+ type
877
+ format
878
+ siteUrl
879
+ }
880
+ }
881
+ manga(perPage: ${pp}) {
882
+ nodes {
883
+ id
884
+ title { romaji english native userPreferred }
885
+ coverImage { large medium }
886
+ type
887
+ format
888
+ siteUrl
889
+ }
890
+ }
891
+ characters(perPage: ${pp}) {
892
+ nodes {
893
+ id
894
+ name { full native }
895
+ image { large medium }
896
+ siteUrl
897
+ }
898
+ }
899
+ staff(perPage: ${pp}) {
900
+ nodes {
901
+ id
902
+ name { full native }
903
+ image { large medium }
904
+ siteUrl
905
+ }
906
+ }
907
+ studios(perPage: ${pp}) {
908
+ nodes {
909
+ id
910
+ name
911
+ siteUrl
912
+ }
913
+ }
914
+ }
915
+ }
916
+ }`;
917
+ }
772
918
 
773
919
  // src/queries/studio.ts
774
920
  var QUERY_STUDIO_BY_ID = `
@@ -777,6 +923,26 @@ query ($id: Int!) {
777
923
  ${STUDIO_FIELDS}
778
924
  }
779
925
  }`;
926
+ function buildStudioByIdQuery(mediaPerPage) {
927
+ if (mediaPerPage === void 0) return QUERY_STUDIO_BY_ID;
928
+ const pp = clampPerPage(mediaPerPage);
929
+ return `
930
+ query ($id: Int!) {
931
+ Studio(id: $id) {
932
+ id
933
+ name
934
+ isAnimationStudio
935
+ siteUrl
936
+ favourites
937
+ media(page: 1, perPage: ${pp}, sort: POPULARITY_DESC) {
938
+ pageInfo { total perPage currentPage lastPage hasNextPage }
939
+ nodes {
940
+ ${MEDIA_FIELDS_LIGHT}
941
+ }
942
+ }
943
+ }
944
+ }`;
945
+ }
780
946
  var QUERY_STUDIO_SEARCH = `
781
947
  query ($search: String, $sort: [StudioSort], $page: Int, $perPage: Int) {
782
948
  Page(page: $page, perPage: $perPage) {
@@ -927,11 +1093,21 @@ query ($search: String, $mediaCategoryId: Int, $categoryId: Int, $sort: [ThreadS
927
1093
 
928
1094
  // src/rate-limiter/index.ts
929
1095
  var RateLimiter = class {
1096
+ maxRequests;
1097
+ windowMs;
1098
+ maxRetries;
1099
+ retryDelayMs;
1100
+ enabled;
1101
+ timeoutMs;
1102
+ retryOnNetworkError;
1103
+ retryStrategy;
1104
+ /** @internal — sliding window: circular buffer of timestamps */
1105
+ timestamps;
1106
+ head = 0;
1107
+ count = 0;
1108
+ /** @internal — active sleep timers for cleanup */
1109
+ activeTimers = /* @__PURE__ */ new Set();
930
1110
  constructor(options = {}) {
931
- this.head = 0;
932
- this.count = 0;
933
- /** @internal — active sleep timers for cleanup */
934
- this.activeTimers = /* @__PURE__ */ new Set();
935
1111
  this.maxRequests = options.maxRequests ?? 85;
936
1112
  this.windowMs = options.windowMs ?? 6e4;
937
1113
  this.maxRetries = options.maxRetries ?? 3;
@@ -1326,6 +1502,14 @@ async function getMedia(client, id, include) {
1326
1502
  const data = await client.request(query, { id });
1327
1503
  return data.Media;
1328
1504
  }
1505
+ async function getMediaByMalId(client, malId, type) {
1506
+ validateId(malId, "malId");
1507
+ const data = await client.request(QUERY_MEDIA_BY_MAL_ID, {
1508
+ idMal: malId,
1509
+ type
1510
+ });
1511
+ return data.Media;
1512
+ }
1329
1513
  async function searchMedia(client, options = {}) {
1330
1514
  const { query: search, page = 1, perPage = 20, genres, tags, genresExclude, tagsExclude, ...filters } = options;
1331
1515
  return client.pagedRequest(
@@ -1446,7 +1630,7 @@ async function getWeeklySchedule(client, date = /* @__PURE__ */ new Date()) {
1446
1630
  for await (const episode of iterator) {
1447
1631
  const epDate = new Date(episode.airingAt * 1e3);
1448
1632
  const dayName = names[epDate.getUTCDay()];
1449
- schedule[dayName].push(episode);
1633
+ if (dayName) schedule[dayName].push(episode);
1450
1634
  }
1451
1635
  return schedule;
1452
1636
  }
@@ -1472,8 +1656,14 @@ async function searchStaff(client, options = {}) {
1472
1656
  }
1473
1657
 
1474
1658
  // src/client/studio.ts
1475
- async function getStudio(client, id) {
1659
+ async function getStudio(client, id, include) {
1476
1660
  validateId(id, "studioId");
1661
+ if (include?.media) {
1662
+ const perPage = typeof include.media === "object" ? include.media.perPage : void 0;
1663
+ const query = buildStudioByIdQuery(perPage);
1664
+ const data2 = await client.request(query, { id });
1665
+ return data2.Studio;
1666
+ }
1477
1667
  const data = await client.request(QUERY_STUDIO_BY_ID, { id });
1478
1668
  return data.Studio;
1479
1669
  }
@@ -1548,15 +1738,18 @@ async function getUserMediaList(client, options) {
1548
1738
  "mediaList"
1549
1739
  );
1550
1740
  }
1551
- async function getUserFavorites(client, idOrName) {
1741
+ async function getUserFavorites(client, idOrName, options) {
1742
+ const useBuilder = options?.perPage !== void 0;
1552
1743
  if (typeof idOrName === "number") {
1553
1744
  validateId(idOrName, "userId");
1554
- const data2 = await client.request(QUERY_USER_FAVORITES_BY_ID, {
1745
+ const query2 = useBuilder ? buildUserFavoritesQuery("id", options.perPage) : QUERY_USER_FAVORITES_BY_ID;
1746
+ const data2 = await client.request(query2, {
1555
1747
  id: idOrName
1556
1748
  });
1557
1749
  return mapFavorites(data2.User.favourites);
1558
1750
  }
1559
- const data = await client.request(QUERY_USER_FAVORITES_BY_NAME, {
1751
+ const query = useBuilder ? buildUserFavoritesQuery("name", options.perPage) : QUERY_USER_FAVORITES_BY_NAME;
1752
+ const data = await client.request(query, {
1560
1753
  name: idOrName
1561
1754
  });
1562
1755
  return mapFavorites(data.User.favourites);
@@ -1573,10 +1766,19 @@ function mapFavorites(fav) {
1573
1766
 
1574
1767
  // src/client/index.ts
1575
1768
  var DEFAULT_API_URL = "https://graphql.anilist.co";
1576
- var LIB_VERSION = "1.7.0" ;
1769
+ var LIB_VERSION = "1.8.0" ;
1577
1770
  var AniListClient = class {
1771
+ apiUrl;
1772
+ headers;
1773
+ cacheAdapter;
1774
+ rateLimiter;
1775
+ hooks;
1776
+ logger;
1777
+ signal;
1778
+ inFlight = /* @__PURE__ */ new Map();
1779
+ _rateLimitInfo;
1780
+ _lastRequestMeta;
1578
1781
  constructor(options = {}) {
1579
- this.inFlight = /* @__PURE__ */ new Map();
1580
1782
  this.apiUrl = options.apiUrl ?? DEFAULT_API_URL;
1581
1783
  this.headers = {
1582
1784
  "Content-Type": "application/json",
@@ -1589,6 +1791,7 @@ var AniListClient = class {
1589
1791
  this.cacheAdapter = options.cacheAdapter ?? new MemoryCache(options.cache);
1590
1792
  this.rateLimiter = new RateLimiter(options.rateLimit);
1591
1793
  this.hooks = options.hooks ?? {};
1794
+ this.logger = options.logger;
1592
1795
  this.signal = options.signal;
1593
1796
  }
1594
1797
  /**
@@ -1611,6 +1814,7 @@ var AniListClient = class {
1611
1814
  const cached = await this.cacheAdapter.get(cacheKey);
1612
1815
  if (cached !== void 0) {
1613
1816
  this.hooks.onCacheHit?.(cacheKey);
1817
+ this.logger?.debug("Cache hit", { cacheKey });
1614
1818
  const meta = { durationMs: 0, fromCache: true };
1615
1819
  this._lastRequestMeta = meta;
1616
1820
  this.hooks.onResponse?.(query, 0, true);
@@ -1630,6 +1834,7 @@ var AniListClient = class {
1630
1834
  async executeRequest(query, variables, cacheKey) {
1631
1835
  const start = Date.now();
1632
1836
  this.hooks.onRequest?.(query, variables);
1837
+ this.logger?.debug("API request", { variables });
1633
1838
  const minifiedQuery = normalizeQuery(query);
1634
1839
  let res;
1635
1840
  try {
@@ -1645,6 +1850,7 @@ var AniListClient = class {
1645
1850
  );
1646
1851
  } catch (err) {
1647
1852
  const error = err instanceof AniListError ? err : new AniListError(err.message ?? "Network request failed", 0, [err]);
1853
+ this.logger?.error("Request failed", { error: error.message, status: error.status });
1648
1854
  this.hooks.onError?.(error, query, variables);
1649
1855
  throw error;
1650
1856
  }
@@ -1652,6 +1858,7 @@ var AniListClient = class {
1652
1858
  if (!res.ok || json.errors) {
1653
1859
  const message = json.errors?.[0]?.message ?? `AniList API error (HTTP ${res.status})`;
1654
1860
  const error = new AniListError(message, res.status, json.errors ?? []);
1861
+ this.logger?.error("Request failed", { error: error.message, status: error.status });
1655
1862
  this.hooks.onError?.(error, query, variables);
1656
1863
  throw error;
1657
1864
  }
@@ -1670,6 +1877,7 @@ var AniListClient = class {
1670
1877
  await this.cacheAdapter.set(cacheKey, data);
1671
1878
  const meta = { durationMs, fromCache: false, rateLimitInfo: this._rateLimitInfo };
1672
1879
  this._lastRequestMeta = meta;
1880
+ this.logger?.debug("Request complete", { durationMs, rateLimitInfo: this._rateLimitInfo });
1673
1881
  this.hooks.onResponse?.(query, durationMs, false, this._rateLimitInfo);
1674
1882
  return data;
1675
1883
  }
@@ -1732,6 +1940,15 @@ var AniListClient = class {
1732
1940
  async getAiredChapters(options = {}) {
1733
1941
  return this.getRecentlyUpdatedManga(options);
1734
1942
  }
1943
+ /**
1944
+ * Fetch a media entry by its MyAnimeList (MAL) ID.
1945
+ *
1946
+ * @param malId - The MyAnimeList ID
1947
+ * @param type - Optional media type to disambiguate (some MAL IDs map to both ANIME and MANGA)
1948
+ */
1949
+ async getMediaByMalId(malId, type) {
1950
+ return getMediaByMalId(this, malId, type);
1951
+ }
1735
1952
  /** Get the detailed schedule for the current week, sorted by day. */
1736
1953
  async getWeeklySchedule(date) {
1737
1954
  return getWeeklySchedule(this, date);
@@ -1784,20 +2001,32 @@ var AniListClient = class {
1784
2001
  * Fetch a user's favorite anime, manga, characters, staff, and studios.
1785
2002
  *
1786
2003
  * @param idOrName - AniList user ID (number) or username (string)
2004
+ * @param options - Optional pagination options (perPage per category)
1787
2005
  * @returns The user's favorites grouped by category
1788
2006
  *
1789
2007
  * @example
1790
2008
  * ```typescript
1791
2009
  * const favs = await client.getUserFavorites("AniList");
1792
2010
  * favs.anime.forEach(a => console.log(a.title.romaji));
2011
+ *
2012
+ * // Fetch more results per category
2013
+ * const moreResults = await client.getUserFavorites(1, { perPage: 50 });
1793
2014
  * ```
1794
2015
  */
1795
- async getUserFavorites(idOrName) {
1796
- return getUserFavorites(this, idOrName);
2016
+ async getUserFavorites(idOrName, options) {
2017
+ return getUserFavorites(this, idOrName, options);
1797
2018
  }
1798
- /** Fetch a studio by its AniList ID. */
1799
- async getStudio(id) {
1800
- return getStudio(this, id);
2019
+ /**
2020
+ * Fetch a studio by its AniList ID.
2021
+ * Pass `include` to customise the number of media returned.
2022
+ *
2023
+ * @example
2024
+ * ```typescript
2025
+ * const studio = await client.getStudio(21, { media: { perPage: 50 } });
2026
+ * ```
2027
+ */
2028
+ async getStudio(id, include) {
2029
+ return getStudio(this, id, include);
1801
2030
  }
1802
2031
  /** Search for studios by name. */
1803
2032
  async searchStudios(options = {}) {
@@ -1847,21 +2076,24 @@ var AniListClient = class {
1847
2076
  async getMediaBatch(ids) {
1848
2077
  if (ids.length === 0) return [];
1849
2078
  validateIds(ids, "mediaId");
1850
- if (ids.length === 1) return [await this.getMedia(ids[0])];
2079
+ const [singleMediaId] = ids;
2080
+ if (ids.length === 1 && singleMediaId !== void 0) return [await this.getMedia(singleMediaId)];
1851
2081
  return this.executeBatch(ids, buildBatchMediaQuery, "m");
1852
2082
  }
1853
2083
  /** Fetch multiple characters in a single API request. */
1854
2084
  async getCharacterBatch(ids) {
1855
2085
  if (ids.length === 0) return [];
1856
2086
  validateIds(ids, "characterId");
1857
- if (ids.length === 1) return [await this.getCharacter(ids[0])];
2087
+ const [singleCharId] = ids;
2088
+ if (ids.length === 1 && singleCharId !== void 0) return [await this.getCharacter(singleCharId)];
1858
2089
  return this.executeBatch(ids, buildBatchCharacterQuery, "c");
1859
2090
  }
1860
2091
  /** Fetch multiple staff members in a single API request. */
1861
2092
  async getStaffBatch(ids) {
1862
2093
  if (ids.length === 0) return [];
1863
2094
  validateIds(ids, "staffId");
1864
- if (ids.length === 1) return [await this.getStaff(ids[0])];
2095
+ const [singleStaffId] = ids;
2096
+ if (ids.length === 1 && singleStaffId !== void 0) return [await this.getStaff(singleStaffId)];
1865
2097
  return this.executeBatch(ids, buildBatchStaffQuery, "s");
1866
2098
  }
1867
2099
  /** @internal */
@@ -1906,10 +2138,31 @@ var AniListClient = class {
1906
2138
  this.inFlight.clear();
1907
2139
  this.rateLimiter.dispose();
1908
2140
  }
2141
+ /**
2142
+ * Return a scoped view of this client where every request uses the given `AbortSignal`.
2143
+ * The returned object shares the same cache, rate limiter, and hooks.
2144
+ *
2145
+ * @example
2146
+ * ```ts
2147
+ * const controller = new AbortController();
2148
+ * const media = await client.withSignal(controller.signal).getMedia(1);
2149
+ *
2150
+ * // Cancel all in-flight requests made through the scoped view
2151
+ * controller.abort();
2152
+ * ```
2153
+ */
2154
+ withSignal(signal) {
2155
+ const scoped = Object.create(this);
2156
+ Object.defineProperty(scoped, "signal", { value: signal, configurable: true });
2157
+ return scoped;
2158
+ }
1909
2159
  };
1910
2160
 
1911
2161
  // src/cache/redis.ts
1912
2162
  var RedisCache = class {
2163
+ client;
2164
+ prefix;
2165
+ ttl;
1913
2166
  constructor(options) {
1914
2167
  this.client = options.client;
1915
2168
  this.prefix = options.prefix ?? "ani:";