ani-client 1.2.0 → 1.4.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 CHANGED
@@ -1,9 +1,53 @@
1
1
  # ani-client
2
2
 
3
+ [![CI](https://github.com/gonzyui/ani-client/actions/workflows/ci.yml/badge.svg)](https://github.com/gonzyui/ani-client/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/ani-client)](https://www.npmjs.com/package/ani-client)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
3
7
  > A simple, typed client to fetch anime, manga, character, staff and user data from [AniList](https://anilist.co).
4
8
 
5
- Works in **Node.js ≥ 18**, **Bun**, **Deno** and modern **browsers** anywhere the `fetch` API is available.
6
- Ships ESM + CJS bundles with full **TypeScript** declarations.
9
+ - **Zero dependencies** — uses the native `fetch` API
10
+ - **Universal** Node.js 20, Bun, Deno and modern browsers
11
+ - **Dual format** — ships ESM + CJS with full TypeScript declarations
12
+ - **Built-in caching** — in-memory LRU with optional Redis adapter
13
+ - **Rate-limit aware** — auto-retry on 429, configurable timeout & network error retry
14
+ - **Request deduplication** — identical in-flight requests are coalesced
15
+ - **Event hooks** — observe every request, cache hit, retry and response
16
+ - **Batch queries** — fetch multiple IDs in a single GraphQL call
17
+
18
+ ## Table of contents
19
+
20
+ - [Install](#install)
21
+ - [Quick start](#quick-start)
22
+ - [Client options](#client-options)
23
+ - [API reference](#api-reference)
24
+ - [Media](#media)
25
+ - [Include options](#include-options)
26
+ - [Characters](#characters)
27
+ - [Staff](#staff)
28
+ - [Users](#users)
29
+ - [Airing, Chapters & Planning](#airing-chapters--planning)
30
+ - [Season charts](#season-charts)
31
+ - [User media lists](#user-media-lists)
32
+ - [Recommendations](#recommendations)
33
+ - [Relations](#relations)
34
+ - [Studios](#studios)
35
+ - [Genres & Tags](#genres--tags)
36
+ - [Batch queries](#batch-queries)
37
+ - [Raw queries](#raw-queries)
38
+ - [Auto-pagination](#auto-pagination)
39
+ - [Caching](#caching)
40
+ - [Memory cache](#memory-cache)
41
+ - [Redis cache](#redis-cache)
42
+ - [Custom adapter](#custom-adapter)
43
+ - [Cache invalidation](#cache-invalidation)
44
+ - [Rate limiting](#rate-limiting)
45
+ - [Request deduplication](#request-deduplication)
46
+ - [Event hooks](#event-hooks)
47
+ - [Error handling](#error-handling)
48
+ - [Types](#types)
49
+ - [Contributing](#contributing)
50
+ - [License](#license)
7
51
 
8
52
  ## Install
9
53
 
@@ -42,7 +86,8 @@ const trending = await client.getTrending(MediaType.ANIME);
42
86
  console.log(trending.results[0].title.romaji);
43
87
  ```
44
88
 
45
- ### CommonJS
89
+ <details>
90
+ <summary>CommonJS</summary>
46
91
 
47
92
  ```js
48
93
  const { AniListClient } = require("ani-client");
@@ -51,427 +96,472 @@ const client = new AniListClient();
51
96
  client.getMedia(1).then((anime) => console.log(anime.title.romaji));
52
97
  ```
53
98
 
54
- ## API
55
-
56
- ### `new AniListClient(options?)`
57
-
58
- | Option | Type | Default | Description |
59
- | ----------- | -------- | -------------------------------- | ---------------------------------- |
60
- | `token` | `string` | — | AniList OAuth bearer token |
61
- | `apiUrl` | `string` | `https://graphql.anilist.co` | Custom API endpoint |
62
- | `cache` | `object` | `{ ttl: 86400000, maxSize: 500, enabled: true }` | Cache configuration |
63
- | `rateLimit` | `object` | `{ maxRequests: 85, windowMs: 60000, maxRetries: 3, enabled: true }` | Rate limiter configuration |
99
+ </details>
64
100
 
65
- ### Media
101
+ ## Client options
66
102
 
67
- | Method | Description |
68
- | ----------------------------------- | ------------------------------------ |
69
- | `getMedia(id: number)` | Fetch a single anime / manga by ID |
70
- | `searchMedia(options?)` | Search & filter anime / manga |
71
- | `getTrending(type?, page?, perPage?)` | Get currently trending entries |
72
- | `getMediaBySeason(options)` | Get all anime/manga for a given season & year |
73
- | `getRecommendations(mediaId, options?)` | Get user recommendations for a media |
103
+ ```ts
104
+ const client = new AniListClient({
105
+ // AniList OAuth bearer token (optional)
106
+ token: "your-token",
74
107
 
75
- ### Characters
108
+ // Custom API endpoint
109
+ apiUrl: "https://graphql.anilist.co",
76
110
 
77
- | Method | Description |
78
- | ----------------------------------- | ------------------------------------ |
79
- | `getCharacter(id: number)` | Fetch a character by ID |
80
- | `searchCharacters(options?)` | Search characters by name |
111
+ // In-memory cache settings
112
+ cache: {
113
+ ttl: 86_400_000, // 24 hours (ms)
114
+ maxSize: 500, // max entries (LRU eviction)
115
+ enabled: true,
116
+ },
81
117
 
82
- ### Staff
118
+ // Or bring your own adapter (takes precedence over `cache`)
119
+ cacheAdapter: new RedisCache({ client: redisClient }),
83
120
 
84
- | Method | Description |
85
- | ----------------------------------- | ------------------------------------ |
86
- | `getStaff(id: number)` | Fetch a staff member by ID |
87
- | `searchStaff(options?)` | Search for staff members |
121
+ // Rate limiter
122
+ rateLimit: {
123
+ maxRequests: 85, // per window (AniList allows 90)
124
+ windowMs: 60_000, // 1 minute
125
+ maxRetries: 3, // retries on 429
126
+ retryDelayMs: 2_000, // delay between retries
127
+ timeoutMs: 30_000, // per-request timeout (0 = none)
128
+ retryOnNetworkError: true, // retry ECONNRESET, ETIMEDOUT, etc.
129
+ enabled: true,
130
+ },
88
131
 
89
- ### Users
132
+ // Event hooks
133
+ hooks: {
134
+ onRequest: (query, variables) => {},
135
+ onResponse: (query, durationMs, fromCache) => {},
136
+ onCacheHit: (key) => {},
137
+ onRateLimit: (retryAfterMs) => {},
138
+ onRetry: (attempt, reason, delayMs) => {},
139
+ },
140
+ });
141
+ ```
90
142
 
91
- | Method | Description |
92
- | ----------------------------------- | ------------------------------------ |
93
- | `getUser(id: number)` | Fetch a user by ID |
94
- | `getUserByName(name: string)` | Fetch a user by username |
95
- | `getUserMediaList(options)` | Get a user's anime or manga list |
143
+ ## API reference
96
144
 
97
- ### Airing, Chapters & Planning
145
+ ### Media
98
146
 
99
- | Method | Description |
100
- | ----------------------------------- | ------------------------------------ |
101
- | `getAiredEpisodes(options?)` | Recently aired anime episodes (last 24 h by default) |
102
- | `getAiredChapters(options?)` | Recently updated releasing manga |
103
- | `getPlanning(options?)` | Upcoming not-yet-released anime / manga |
147
+ | Method | Description |
148
+ | --- | --- |
149
+ | `getMedia(id, include?)` | Fetch a single anime / manga by ID with optional extra data |
150
+ | `searchMedia(options?)` | Search & filter anime / manga |
151
+ | `getTrending(type?, page?, perPage?)` | Currently trending entries |
152
+ | `getMediaBySeason(options)` | Anime/manga for a given season & year |
153
+ | `getRecommendations(mediaId, options?)` | User recommendations for a media |
104
154
 
105
- ### Studios
155
+ ```ts
156
+ // Simple — same as before
157
+ const anime = await client.getMedia(1);
158
+
159
+ // With extra data
160
+ const anime = await client.getMedia(1, {
161
+ characters: { perPage: 25, sort: true },
162
+ staff: true,
163
+ relations: true,
164
+ streamingEpisodes: true,
165
+ externalLinks: true,
166
+ stats: true,
167
+ recommendations: { perPage: 5 },
168
+ });
106
169
 
107
- | Method | Description |
108
- | ----------------------------------- | ------------------------------------ |
109
- | `getStudio(id: number)` | Fetch a studio by ID |
110
- | `searchStudios(options?)` | Search studios by name |
170
+ const results = await client.searchMedia({
171
+ query: "Naruto",
172
+ type: MediaType.ANIME,
173
+ format: MediaFormat.TV,
174
+ genre: "Action",
175
+ perPage: 10,
176
+ });
177
+ ```
111
178
 
112
- ### Genres & Tags
179
+ ### Include options
113
180
 
114
- | Method | Description |
115
- | ----------------------------------- | ------------------------------------ |
116
- | `getGenres()` | List all available genres |
117
- | `getTags()` | List all available media tags |
181
+ The second parameter of `getMedia()` lets you opt-in to additional data. By default, only `relations` are included for backward compatibility.
118
182
 
119
- ### Raw queries
183
+ | Option | Type | Default | Description |
184
+ | --- | --- | --- | --- |
185
+ | `characters` | `boolean \| { perPage?, sort? }` | — | Characters with their roles (MAIN, SUPPORTING, BACKGROUND) |
186
+ | `staff` | `boolean \| { perPage?, sort? }` | — | Staff members with their roles |
187
+ | `relations` | `boolean` | `true` | Sequels, prequels, adaptations, etc. Set `false` to exclude |
188
+ | `streamingEpisodes` | `boolean` | — | Streaming links (Crunchyroll, Funimation, etc.) |
189
+ | `externalLinks` | `boolean` | — | External links (MAL, official site, etc.) |
190
+ | `stats` | `boolean` | — | Score & status distribution |
191
+ | `recommendations` | `boolean \| { perPage? }` | — | User recommendations |
120
192
 
121
- | Method | Description |
122
- | ----------------------------------- | ------------------------------------ |
123
- | `raw<T>(query, variables?)` | Execute any GraphQL query |
193
+ ```ts
194
+ // Include characters sorted by role (25 per page by default)
195
+ const anime = await client.getMedia(1, { characters: true });
196
+ anime.characters?.edges.forEach((e) =>
197
+ console.log(`${e.node.name.full} (${e.role})`)
198
+ );
124
199
 
125
- ### Pagination helper
200
+ // 50 characters, no sorting
201
+ const anime = await client.getMedia(1, {
202
+ characters: { perPage: 50, sort: false },
203
+ });
126
204
 
127
- | Method | Description |
128
- | ----------------------------------- | ------------------------------------ |
129
- | `paginate<T>(fetchPage, maxPages?)` | Auto-paginating async iterator |
205
+ // Staff members
206
+ const anime = await client.getMedia(1, { staff: true });
207
+ anime.staff?.edges.forEach((e) =>
208
+ console.log(`${e.node.name.full} — ${e.role}`)
209
+ );
130
210
 
131
- ### Cache management
211
+ // Everything at once
212
+ const anime = await client.getMedia(1, {
213
+ characters: { perPage: 50 },
214
+ staff: { perPage: 25 },
215
+ relations: true,
216
+ streamingEpisodes: true,
217
+ externalLinks: true,
218
+ stats: true,
219
+ recommendations: { perPage: 10 },
220
+ });
132
221
 
133
- | Method / Property | Description |
134
- | ----------------------------------- | ------------------------------------ |
135
- | `clearCache()` | Clear the entire response cache |
136
- | `cacheSize` | Number of entries currently cached |
222
+ // Lightweight exclude relations
223
+ const anime = await client.getMedia(1, {
224
+ characters: true,
225
+ relations: false,
226
+ });
227
+ ```
137
228
 
138
- ## Caching
229
+ ### Characters
139
230
 
140
- All responses are cached in-memory by default (**24 hours TTL**, max 500 entries). Configure it per-client:
231
+ | Method | Description |
232
+ | --- | --- |
233
+ | `getCharacter(id)` | Fetch a character by ID |
234
+ | `searchCharacters(options?)` | Search characters by name |
141
235
 
142
236
  ```ts
143
- const client = new AniListClient({
144
- cache: {
145
- ttl: 1000 * 60 * 60, // 1 hour
146
- maxSize: 200, // keep at most 200 entries
147
- enabled: true, // set to false to disable
148
- },
149
- });
150
-
151
- // Manual cache control
152
- client.clearCache();
153
- console.log(client.cacheSize); // 0
237
+ const spike = await client.getCharacter(1);
238
+ const results = await client.searchCharacters({ query: "Luffy", perPage: 5 });
154
239
  ```
155
240
 
156
- ## Rate limiting
241
+ ### Staff
157
242
 
158
- The client automatically respects AniList's rate limit (90 req/min) with a conservative default of **85 req/min**. If you hit a `429 Too Many Requests`, it retries automatically (up to 3 times with backoff).
243
+ | Method | Description |
244
+ | --- | --- |
245
+ | `getStaff(id)` | Fetch a staff member by ID |
246
+ | `searchStaff(options?)` | Search for staff members |
159
247
 
160
248
  ```ts
161
- const client = new AniListClient({
162
- rateLimit: {
163
- maxRequests: 60, // be extra conservative
164
- windowMs: 60_000, // 1 minute window
165
- maxRetries: 5, // retry up to 5 times on 429
166
- retryDelayMs: 3000, // wait 3s between retries
167
- enabled: true, // set to false to disable
168
- },
169
- });
249
+ const staff = await client.getStaff(95001);
250
+ const results = await client.searchStaff({ query: "Miyazaki" });
170
251
  ```
171
252
 
172
- ## Airing, Chapters & Planning
173
-
174
- Fetch recently released content or see what's coming up next.
253
+ ### Users
175
254
 
176
- ### `getAiredEpisodes(options?)`
255
+ | Method | Description |
256
+ | --- | --- |
257
+ | `getUser(id)` | Fetch a user by ID |
258
+ | `getUserByName(name)` | Fetch a user by username |
259
+ | `getUserMediaList(options)` | Get a user's anime or manga list |
177
260
 
178
- Returns anime episodes that recently aired (defaults to the **last 24 hours**).
261
+ ### Airing, Chapters & Planning
179
262
 
180
- | Option | Type | Default | Description |
181
- | ----------------- | -------------- | -------------------- | -------------------------------------------- |
182
- | `airingAtGreater` | `number` | `now - 24h` (UNIX) | Only episodes aired after this timestamp |
183
- | `airingAtLesser` | `number` | `now` (UNIX) | Only episodes aired before this timestamp |
184
- | `sort` | `AiringSort[]` | `["TIME_DESC"]` | Sort order |
185
- | `page` | `number` | `1` | Page number |
186
- | `perPage` | `number` | `20` | Results per page (max 50) |
263
+ | Method | Description |
264
+ | --- | --- |
265
+ | `getAiredEpisodes(options?)` | Recently aired anime episodes (last 24 h by default) |
266
+ | `getAiredChapters(options?)` | Recently updated releasing manga |
267
+ | `getPlanning(options?)` | Upcoming not-yet-released anime / manga |
187
268
 
188
269
  ```ts
189
- import { AniListClient } from "ani-client";
190
-
191
- const client = new AniListClient();
192
-
193
- // Episodes aired in the last 24 hours
194
- const recent = await client.getAiredEpisodes();
195
- recent.results.forEach((ep) =>
196
- console.log(`${ep.media.title.romaji} — Episode ${ep.episode}`)
197
- );
198
-
199
270
  // Episodes aired in the last 7 days
200
271
  const week = await client.getAiredEpisodes({
201
272
  airingAtGreater: Math.floor(Date.now() / 1000) - 7 * 24 * 3600,
202
273
  perPage: 50,
203
274
  });
204
- ```
205
-
206
- ### `getAiredChapters(options?)`
207
-
208
- Returns currently releasing manga, sorted by most recently updated — the closest proxy to "recently released chapters" (AniList does not expose individual chapter schedules).
209
-
210
- | Option | Type | Default | Description |
211
- | --------- | -------- | ------- | ----------------------- |
212
- | `page` | `number` | `1` | Page number |
213
- | `perPage` | `number` | `20` | Results per page (max 50) |
214
-
215
- ```ts
216
- const chapters = await client.getAiredChapters({ perPage: 10 });
217
- chapters.results.forEach((m) =>
218
- console.log(`${m.title.romaji} — ${m.chapters ?? "?"} chapters`)
219
- );
220
- ```
221
-
222
- ### `getPlanning(options?)`
223
-
224
- Returns anime and/or manga that are **not yet released**, sorted by popularity.
225
-
226
- | Option | Type | Default | Description |
227
- | --------- | ------------- | -------------------- | ---------------------------------- |
228
- | `type` | `MediaType` | — | Filter by ANIME or MANGA (both if omitted) |
229
- | `sort` | `MediaSort[]` | `["POPULARITY_DESC"]` | Sort order |
230
- | `page` | `number` | `1` | Page number |
231
- | `perPage` | `number` | `20` | Results per page (max 50) |
232
-
233
- ```ts
234
- import { MediaType } from "ani-client";
235
275
 
236
276
  // Most anticipated upcoming anime
237
- const upcoming = await client.getPlanning({ type: MediaType.ANIME, perPage: 10 });
238
- upcoming.results.forEach((m) => console.log(m.title.romaji));
239
-
240
- // All upcoming media (anime + manga)
241
- const all = await client.getPlanning();
277
+ const upcoming = await client.getPlanning({
278
+ type: MediaType.ANIME,
279
+ perPage: 10,
280
+ });
242
281
  ```
243
282
 
244
- ## Season charts
245
-
246
- Fetch all anime (or manga) for a specific season and year.
247
-
248
- ### `getMediaBySeason(options)`
249
-
250
- | Option | Type | Default | Description |
251
- | ------------ | -------------- | -------------------- | ---------------------------------------- |
252
- | `season` | `MediaSeason` | **(required)** | WINTER, SPRING, SUMMER, or FALL |
253
- | `seasonYear` | `number` | **(required)** | The year (e.g. 2026) |
254
- | `type` | `MediaType` | `ANIME` | Filter by ANIME or MANGA |
255
- | `sort` | `MediaSort[]` | `["POPULARITY_DESC"]`| Sort order |
256
- | `page` | `number` | `1` | Page number |
257
- | `perPage` | `number` | `20` | Results per page (max 50) |
283
+ ### Season charts
258
284
 
259
285
  ```ts
260
- import { AniListClient, MediaSeason } from "ani-client";
261
-
262
- const client = new AniListClient();
286
+ import { MediaSeason } from "ani-client";
263
287
 
264
- // All anime from Winter 2026
265
288
  const winter2026 = await client.getMediaBySeason({
266
289
  season: MediaSeason.WINTER,
267
290
  seasonYear: 2026,
268
291
  perPage: 25,
269
292
  });
270
- winter2026.results.forEach((m) => console.log(m.title.romaji));
271
-
272
- // Spring 2025 manga
273
- const spring = await client.getMediaBySeason({
274
- season: MediaSeason.SPRING,
275
- seasonYear: 2025,
276
- type: MediaType.MANGA,
277
- });
278
293
  ```
279
294
 
280
- ## User media lists
295
+ | Option | Type | Default | Description |
296
+ | --- | --- | --- | --- |
297
+ | `season` | `MediaSeason` | **(required)** | WINTER, SPRING, SUMMER, or FALL |
298
+ | `seasonYear` | `number` | **(required)** | The year |
299
+ | `type` | `MediaType` | `ANIME` | Filter by ANIME or MANGA |
300
+ | `sort` | `MediaSort[]` | `["POPULARITY_DESC"]` | Sort order |
301
+ | `page` | `number` | `1` | Page number |
302
+ | `perPage` | `number` | `20` | Results per page (max 50) |
281
303
 
282
- Fetch a user's anime or manga list, optionally filtered by status.
283
-
284
- ### `getUserMediaList(options)`
285
-
286
- | Option | Type | Default | Description |
287
- | ---------- | ----------------- | -------------------- | ------------------------------------------ |
288
- | `userId` | `number` | — | User ID (provide userId **or** userName) |
289
- | `userName` | `string` | — | Username (provide userId **or** userName) |
290
- | `type` | `MediaType` | **(required)** | ANIME or MANGA |
291
- | `status` | `MediaListStatus` | — | Filter: CURRENT, COMPLETED, PLANNING, etc. |
292
- | `sort` | `MediaListSort[]` | — | Sort order |
293
- | `page` | `number` | `1` | Page number |
294
- | `perPage` | `number` | `20` | Results per page (max 50) |
304
+ ### User media lists
295
305
 
296
306
  ```ts
297
- import { AniListClient, MediaType, MediaListStatus } from "ani-client";
307
+ import { MediaType, MediaListStatus } from "ani-client";
298
308
 
299
- const client = new AniListClient();
300
-
301
- // All anime on a user's list
302
309
  const list = await client.getUserMediaList({
303
310
  userName: "AniList",
304
311
  type: MediaType.ANIME,
305
- perPage: 10,
312
+ status: MediaListStatus.COMPLETED,
306
313
  });
307
314
  list.results.forEach((entry) =>
308
315
  console.log(`${entry.media.title.romaji} — ${entry.score}/100`)
309
316
  );
310
-
311
- // Only completed anime
312
- const completed = await client.getUserMediaList({
313
- userName: "AniList",
314
- type: MediaType.ANIME,
315
- status: MediaListStatus.COMPLETED,
316
- });
317
-
318
- // By user ID
319
- const byId = await client.getUserMediaList({
320
- userId: 1,
321
- type: MediaType.MANGA,
322
- });
323
317
  ```
324
318
 
325
- ## Recommendations
326
-
327
- Get user-submitted recommendations for a specific anime or manga.
319
+ Provide either `userId` or `userName`. The `type` field is required.
328
320
 
329
- ### `getRecommendations(mediaId, options?)`
330
-
331
- | Option | Type | Default | Description |
332
- | --------- | ---------------------- | ------------------ | ----------------------- |
333
- | `sort` | `RecommendationSort[]` | `["RATING_DESC"]` | Sort order |
334
- | `page` | `number` | `1` | Page number |
335
- | `perPage` | `number` | `20` | Results per page |
321
+ ### Recommendations
336
322
 
337
323
  ```ts
338
- import { AniListClient } from "ani-client";
339
-
340
- const client = new AniListClient();
341
-
342
- // Recommendations for Cowboy Bebop
343
- const recs = await client.getRecommendations(1);
324
+ const recs = await client.getRecommendations(1); // Cowboy Bebop
344
325
  recs.results.forEach((r) =>
345
326
  console.log(`${r.mediaRecommendation.title.romaji} (rating: ${r.rating})`)
346
327
  );
347
-
348
- // With pagination
349
- const page2 = await client.getRecommendations(20, { page: 2, perPage: 10 });
350
328
  ```
351
329
 
352
- ## Relations
330
+ ### Relations
353
331
 
354
- All media objects now include a `relations` field with sequels, prequels, spin-offs, etc.
332
+ Relations (sequels, prequels, spin-offs, etc.) are included by default when using `getMedia()`. You can also explicitly request them via the `include` parameter, or exclude them with `relations: false`.
355
333
 
356
334
  ```ts
357
- const anime = await client.getMedia(1); // Cowboy Bebop
335
+ const anime = await client.getMedia(1);
358
336
  anime.relations?.edges.forEach((edge) =>
359
337
  console.log(`${edge.relationType}: ${edge.node.title.romaji}`)
360
338
  );
361
- // SIDE_STORY: Cowboy Bebop: Tengoku no Tobira
339
+
340
+ // Exclude relations for a lighter response
341
+ const anime = await client.getMedia(1, { relations: false });
362
342
  ```
363
343
 
364
- Available relation types: `ADAPTATION`, `PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `CHARACTER`, `SUMMARY`, `ALTERNATIVE`, `SPIN_OFF`, `OTHER`, `SOURCE`, `COMPILATION`, `CONTAINS`.
344
+ Available types: `ADAPTATION`, `PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `CHARACTER`, `SUMMARY`, `ALTERNATIVE`, `SPIN_OFF`, `OTHER`, `SOURCE`, `COMPILATION`, `CONTAINS`.
365
345
 
366
- ## Studios
346
+ ### Studios
367
347
 
368
- Fetch or search for animation studios.
348
+ | Method | Description |
349
+ | --- | --- |
350
+ | `getStudio(id)` | Fetch a studio with its productions |
351
+ | `searchStudios(options?)` | Search studios by name |
369
352
 
370
- ### `getStudio(id)`
353
+ ```ts
354
+ const studio = await client.getStudio(44); // Bones
355
+ const results = await client.searchStudios({ query: "MAPPA" });
356
+ ```
371
357
 
372
- Returns the studio with its most popular productions.
358
+ ### Genres & Tags
373
359
 
374
360
  ```ts
375
- const studio = await client.getStudio(44); // Bones
376
- console.log(studio.name); // "Bones"
377
- console.log(studio.isAnimationStudio); // true
378
- studio.media?.nodes.forEach((m) => console.log(m.title.romaji));
361
+ const genres = await client.getGenres();
362
+ // ["Action", "Adventure", "Comedy", ...]
363
+
364
+ const tags = await client.getTags();
365
+ // [{ id, name, description, category, isAdult }, ...]
379
366
  ```
380
367
 
381
- ### `searchStudios(options?)`
368
+ ### Batch queries
369
+
370
+ Fetch multiple IDs in a single GraphQL request (up to 50 per call, auto-chunked).
382
371
 
383
- | Option | Type | Default | Description |
384
- | --------- | -------- | ------- | ----------------------- |
385
- | `query` | `string` | — | Search term |
386
- | `page` | `number` | `1` | Page number |
387
- | `perPage` | `number` | `20` | Results per page |
372
+ | Method | Description |
373
+ | --- | --- |
374
+ | `getMediaBatch(ids)` | Fetch multiple anime / manga |
375
+ | `getCharacterBatch(ids)` | Fetch multiple characters |
376
+ | `getStaffBatch(ids)` | Fetch multiple staff members |
388
377
 
389
378
  ```ts
390
- const result = await client.searchStudios({ query: "MAPPA" });
391
- result.results.forEach((s) => console.log(s.name));
379
+ const [bebop, naruto, aot] = await client.getMediaBatch([1, 20, 16498]);
392
380
  ```
393
381
 
394
- ## Genres & Tags
382
+ ### Raw queries
395
383
 
396
- List all genres and tags available on AniList.
384
+ Execute any GraphQL query against the AniList API.
397
385
 
398
386
  ```ts
399
- const genres = await client.getGenres();
400
- console.log(genres); // ["Action", "Adventure", "Comedy", ...]
401
-
402
- const tags = await client.getTags();
403
- tags.forEach((t) => console.log(`${t.name} (${t.category})`));
387
+ const data = await client.raw<{ Media: { id: number; title: { romaji: string } } }>(
388
+ "query { Media(id: 1) { id title { romaji } } }",
389
+ );
404
390
  ```
405
391
 
406
- ## Auto-pagination
392
+ ### Auto-pagination
407
393
 
408
- Use `paginate()` to iterate across all pages automatically with an async iterator.
394
+ `paginate()` returns an async iterator that fetches pages on demand.
409
395
 
410
396
  ```ts
411
- // Iterate over all search results
412
- for await (const anime of client.paginate((page) =>
413
- client.searchMedia({ query: "Gundam", page, perPage: 10 })
397
+ for await (const anime of client.paginate(
398
+ (page) => client.searchMedia({ query: "Gundam", page, perPage: 10 }),
399
+ 3, // max 3 pages
414
400
  )) {
415
401
  console.log(anime.title.romaji);
416
402
  }
403
+ ```
417
404
 
418
- // Limit to 3 pages max
419
- for await (const anime of client.paginate(
420
- (page) => client.getTrending(MediaType.ANIME, page, 20),
421
- 3,
422
- )) {
423
- console.log(anime.title.romaji);
405
+ ## Caching
406
+
407
+ ### Memory cache
408
+
409
+ The default in-memory cache uses **LRU eviction** (24 h TTL, 500 entries max).
410
+
411
+ ```ts
412
+ const client = new AniListClient({
413
+ cache: {
414
+ ttl: 1000 * 60 * 60, // 1 hour
415
+ maxSize: 200,
416
+ enabled: true, // false to disable
417
+ },
418
+ });
419
+ ```
420
+
421
+ ### Redis cache
422
+
423
+ For distributed or persistent caching, use the built-in Redis adapter. Compatible with [ioredis](https://github.com/redis/ioredis) and [node-redis](https://github.com/redis/node-redis) v4+.
424
+
425
+ ```ts
426
+ import Redis from "ioredis";
427
+ import { AniListClient, RedisCache } from "ani-client";
428
+
429
+ const client = new AniListClient({
430
+ cacheAdapter: new RedisCache({
431
+ client: new Redis(),
432
+ prefix: "ani:", // key prefix (default)
433
+ ttl: 86_400, // seconds (default: 24 h)
434
+ }),
435
+ });
436
+ ```
437
+
438
+ ### Custom adapter
439
+
440
+ Implement the `CacheAdapter` interface to bring your own storage:
441
+
442
+ ```ts
443
+ import type { CacheAdapter } from "ani-client";
444
+
445
+ class MyCache implements CacheAdapter {
446
+ get<T>(key: string): T | undefined | Promise<T | undefined> { /* ... */ }
447
+ set<T>(key: string, data: T): void | Promise<void> { /* ... */ }
448
+ delete(key: string): boolean | Promise<boolean> { /* ... */ }
449
+ clear(): void | Promise<void> { /* ... */ }
450
+ get size(): number { return -1; } // return -1 if unknown
451
+ keys(): string[] | Promise<string[]> { /* ... */ }
452
+ // Optional — the client provides a fallback if omitted
453
+ invalidate?(pattern: string | RegExp): number | Promise<number> { /* ... */ }
424
454
  }
425
455
  ```
426
456
 
427
- ## Error handling
457
+ ### Cache invalidation
458
+
459
+ ```ts
460
+ // Clear everything
461
+ await client.clearCache();
462
+
463
+ // Remove entries matching a pattern
464
+ const removed = await client.invalidateCache(/Media/);
465
+ console.log(`Removed ${removed} entries`);
466
+
467
+ // Current cache size
468
+ console.log(client.cacheSize);
469
+ ```
470
+
471
+ ## Rate limiting
472
+
473
+ The client respects AniList's rate limit (90 req/min) with a conservative default of **85 req/min**. On HTTP 429, it retries automatically with progressive backoff.
474
+
475
+ ```ts
476
+ const client = new AniListClient({
477
+ rateLimit: {
478
+ maxRequests: 60,
479
+ windowMs: 60_000,
480
+ maxRetries: 5,
481
+ retryDelayMs: 3_000,
482
+ timeoutMs: 30_000, // abort after 30 s
483
+ retryOnNetworkError: true, // retry ECONNRESET, ETIMEDOUT, etc.
484
+ enabled: true,
485
+ },
486
+ });
487
+ ```
488
+
489
+ ## Request deduplication
490
+
491
+ When multiple callers request the same data at the same time, only one API call is made. All callers receive the same response.
492
+
493
+ ```ts
494
+ // Only 1 HTTP request is sent
495
+ const [a, b] = await Promise.all([
496
+ client.getMedia(1),
497
+ client.getMedia(1),
498
+ ]);
499
+ ```
500
+
501
+ ## Event hooks
502
+
503
+ Monitor every request lifecycle event for logging, metrics, or debugging.
428
504
 
429
- All API errors throw an `AniListError` with:
505
+ ```ts
506
+ const client = new AniListClient({
507
+ hooks: {
508
+ onRequest: (query, variables) => console.log("→", query.slice(0, 40)),
509
+ onResponse: (query, durationMs, fromCache) => console.log(`← ${durationMs}ms (cache: ${fromCache})`),
510
+ onCacheHit: (key) => console.log("Cache hit:", key.slice(0, 30)),
511
+ onRateLimit: (retryAfterMs) => console.warn(`Rate limited, waiting ${retryAfterMs}ms`),
512
+ onRetry: (attempt, reason, delayMs) => console.warn(`Retry #${attempt}: ${reason}`),
513
+ },
514
+ });
515
+ ```
430
516
 
431
- - `message` — Human-readable error message
432
- - `status` — HTTP status code
433
- - `errors` Raw error array from the API
517
+ ## Error handling
518
+
519
+ All API errors throw an `AniListError` with `message`, `status` and `errors`:
434
520
 
435
521
  ```ts
436
- import { AniListClient, AniListError } from "ani-client";
522
+ import { AniListError } from "ani-client";
437
523
 
438
524
  try {
439
525
  await client.getMedia(999999999);
440
526
  } catch (err) {
441
527
  if (err instanceof AniListError) {
442
- console.error(err.message, err.status);
528
+ console.error(err.message); // "Not Found."
529
+ console.error(err.status); // 404
530
+ console.error(err.errors); // raw API error array
443
531
  }
444
532
  }
445
533
  ```
446
534
 
447
535
  ## Types
448
536
 
449
- All types are exported and documented:
537
+ All types and enums are exported:
450
538
 
451
539
  ```ts
452
540
  import type {
453
- Media,
454
- Character,
455
- Staff,
456
- User,
457
- AiringSchedule,
458
- MediaListEntry,
459
- Recommendation,
460
- StudioDetail,
461
- MediaEdge,
462
- MediaConnection,
463
- PagedResult,
464
- SearchMediaOptions,
465
- SearchStudioOptions,
466
- GetAiringOptions,
467
- GetRecentChaptersOptions,
468
- GetPlanningOptions,
469
- GetSeasonOptions,
470
- GetUserMediaListOptions,
541
+ Media, Character, Staff, User,
542
+ AiringSchedule, MediaListEntry, Recommendation, StudioDetail,
543
+ MediaEdge, MediaConnection, MediaCharacterEdge, MediaCharacterConnection,
544
+ MediaStaffEdge, MediaStaffConnection, MediaIncludeOptions,
545
+ StreamingEpisode, ExternalLink, MediaStats, MediaRecommendationNode,
546
+ PageInfo, PagedResult,
547
+ CacheAdapter, AniListHooks, AniListClientOptions,
548
+ SearchMediaOptions, SearchCharacterOptions, SearchStaffOptions,
549
+ SearchStudioOptions, GetAiringOptions, GetRecentChaptersOptions,
550
+ GetPlanningOptions, GetSeasonOptions, GetUserMediaListOptions,
471
551
  GetRecommendationsOptions,
472
552
  } from "ani-client";
553
+
554
+ import {
555
+ MediaType, MediaFormat, MediaStatus, MediaSeason, MediaSort,
556
+ CharacterSort, CharacterRole, AiringSort, RecommendationSort,
557
+ MediaRelationType, MediaListStatus, MediaListSort,
558
+ } from "ani-client";
473
559
  ```
474
560
 
561
+ ## Contributing
562
+
563
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and how to submit changes.
564
+
475
565
  ## License
476
566
 
477
567
  [MIT](LICENSE) © gonzyui