ani-client 1.2.0 → 1.3.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,52 @@
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 18, 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
+ - [Characters](#characters)
26
+ - [Staff](#staff)
27
+ - [Users](#users)
28
+ - [Airing, Chapters & Planning](#airing-chapters--planning)
29
+ - [Season charts](#season-charts)
30
+ - [User media lists](#user-media-lists)
31
+ - [Recommendations](#recommendations)
32
+ - [Relations](#relations)
33
+ - [Studios](#studios)
34
+ - [Genres & Tags](#genres--tags)
35
+ - [Batch queries](#batch-queries)
36
+ - [Raw queries](#raw-queries)
37
+ - [Auto-pagination](#auto-pagination)
38
+ - [Caching](#caching)
39
+ - [Memory cache](#memory-cache)
40
+ - [Redis cache](#redis-cache)
41
+ - [Custom adapter](#custom-adapter)
42
+ - [Cache invalidation](#cache-invalidation)
43
+ - [Rate limiting](#rate-limiting)
44
+ - [Request deduplication](#request-deduplication)
45
+ - [Event hooks](#event-hooks)
46
+ - [Error handling](#error-handling)
47
+ - [Types](#types)
48
+ - [Contributing](#contributing)
49
+ - [License](#license)
7
50
 
8
51
  ## Install
9
52
 
@@ -42,7 +85,8 @@ const trending = await client.getTrending(MediaType.ANIME);
42
85
  console.log(trending.results[0].title.romaji);
43
86
  ```
44
87
 
45
- ### CommonJS
88
+ <details>
89
+ <summary>CommonJS</summary>
46
90
 
47
91
  ```js
48
92
  const { AniListClient } = require("ani-client");
@@ -51,427 +95,404 @@ const client = new AniListClient();
51
95
  client.getMedia(1).then((anime) => console.log(anime.title.romaji));
52
96
  ```
53
97
 
54
- ## API
55
-
56
- ### `new AniListClient(options?)`
98
+ </details>
57
99
 
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 |
100
+ ## Client options
64
101
 
65
- ### Media
102
+ ```ts
103
+ const client = new AniListClient({
104
+ // AniList OAuth bearer token (optional)
105
+ token: "your-token",
66
106
 
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 |
107
+ // Custom API endpoint
108
+ apiUrl: "https://graphql.anilist.co",
74
109
 
75
- ### Characters
110
+ // In-memory cache settings
111
+ cache: {
112
+ ttl: 86_400_000, // 24 hours (ms)
113
+ maxSize: 500, // max entries (LRU eviction)
114
+ enabled: true,
115
+ },
76
116
 
77
- | Method | Description |
78
- | ----------------------------------- | ------------------------------------ |
79
- | `getCharacter(id: number)` | Fetch a character by ID |
80
- | `searchCharacters(options?)` | Search characters by name |
117
+ // Or bring your own adapter (takes precedence over `cache`)
118
+ cacheAdapter: new RedisCache({ client: redisClient }),
81
119
 
82
- ### Staff
83
-
84
- | Method | Description |
85
- | ----------------------------------- | ------------------------------------ |
86
- | `getStaff(id: number)` | Fetch a staff member by ID |
87
- | `searchStaff(options?)` | Search for staff members |
120
+ // Rate limiter
121
+ rateLimit: {
122
+ maxRequests: 85, // per window (AniList allows 90)
123
+ windowMs: 60_000, // 1 minute
124
+ maxRetries: 3, // retries on 429
125
+ retryDelayMs: 2_000, // delay between retries
126
+ timeoutMs: 30_000, // per-request timeout (0 = none)
127
+ retryOnNetworkError: true, // retry ECONNRESET, ETIMEDOUT, etc.
128
+ enabled: true,
129
+ },
88
130
 
89
- ### Users
131
+ // Event hooks
132
+ hooks: {
133
+ onRequest: (query, variables) => {},
134
+ onResponse: (query, durationMs, fromCache) => {},
135
+ onCacheHit: (key) => {},
136
+ onRateLimit: (retryAfterMs) => {},
137
+ onRetry: (attempt, reason, delayMs) => {},
138
+ },
139
+ });
140
+ ```
90
141
 
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 |
142
+ ## API reference
96
143
 
97
- ### Airing, Chapters & Planning
144
+ ### Media
98
145
 
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 |
146
+ | Method | Description |
147
+ | --- | --- |
148
+ | `getMedia(id)` | Fetch a single anime / manga by ID |
149
+ | `searchMedia(options?)` | Search & filter anime / manga |
150
+ | `getTrending(type?, page?, perPage?)` | Currently trending entries |
151
+ | `getMediaBySeason(options)` | Anime/manga for a given season & year |
152
+ | `getRecommendations(mediaId, options?)` | User recommendations for a media |
104
153
 
105
- ### Studios
154
+ ```ts
155
+ const anime = await client.getMedia(1);
106
156
 
107
- | Method | Description |
108
- | ----------------------------------- | ------------------------------------ |
109
- | `getStudio(id: number)` | Fetch a studio by ID |
110
- | `searchStudios(options?)` | Search studios by name |
157
+ const results = await client.searchMedia({
158
+ query: "Naruto",
159
+ type: MediaType.ANIME,
160
+ format: MediaFormat.TV,
161
+ genre: "Action",
162
+ perPage: 10,
163
+ });
164
+ ```
111
165
 
112
- ### Genres & Tags
166
+ ### Characters
113
167
 
114
- | Method | Description |
115
- | ----------------------------------- | ------------------------------------ |
116
- | `getGenres()` | List all available genres |
117
- | `getTags()` | List all available media tags |
168
+ | Method | Description |
169
+ | --- | --- |
170
+ | `getCharacter(id)` | Fetch a character by ID |
171
+ | `searchCharacters(options?)` | Search characters by name |
118
172
 
119
- ### Raw queries
173
+ ```ts
174
+ const spike = await client.getCharacter(1);
175
+ const results = await client.searchCharacters({ query: "Luffy", perPage: 5 });
176
+ ```
120
177
 
121
- | Method | Description |
122
- | ----------------------------------- | ------------------------------------ |
123
- | `raw<T>(query, variables?)` | Execute any GraphQL query |
178
+ ### Staff
124
179
 
125
- ### Pagination helper
180
+ | Method | Description |
181
+ | --- | --- |
182
+ | `getStaff(id)` | Fetch a staff member by ID |
183
+ | `searchStaff(options?)` | Search for staff members |
126
184
 
127
- | Method | Description |
128
- | ----------------------------------- | ------------------------------------ |
129
- | `paginate<T>(fetchPage, maxPages?)` | Auto-paginating async iterator |
185
+ ```ts
186
+ const staff = await client.getStaff(95001);
187
+ const results = await client.searchStaff({ query: "Miyazaki" });
188
+ ```
130
189
 
131
- ### Cache management
190
+ ### Users
132
191
 
133
- | Method / Property | Description |
134
- | ----------------------------------- | ------------------------------------ |
135
- | `clearCache()` | Clear the entire response cache |
136
- | `cacheSize` | Number of entries currently cached |
192
+ | Method | Description |
193
+ | --- | --- |
194
+ | `getUser(id)` | Fetch a user by ID |
195
+ | `getUserByName(name)` | Fetch a user by username |
196
+ | `getUserMediaList(options)` | Get a user's anime or manga list |
137
197
 
138
- ## Caching
198
+ ### Airing, Chapters & Planning
139
199
 
140
- All responses are cached in-memory by default (**24 hours TTL**, max 500 entries). Configure it per-client:
200
+ | Method | Description |
201
+ | --- | --- |
202
+ | `getAiredEpisodes(options?)` | Recently aired anime episodes (last 24 h by default) |
203
+ | `getAiredChapters(options?)` | Recently updated releasing manga |
204
+ | `getPlanning(options?)` | Upcoming not-yet-released anime / manga |
141
205
 
142
206
  ```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
- },
207
+ // Episodes aired in the last 7 days
208
+ const week = await client.getAiredEpisodes({
209
+ airingAtGreater: Math.floor(Date.now() / 1000) - 7 * 24 * 3600,
210
+ perPage: 50,
149
211
  });
150
212
 
151
- // Manual cache control
152
- client.clearCache();
153
- console.log(client.cacheSize); // 0
213
+ // Most anticipated upcoming anime
214
+ const upcoming = await client.getPlanning({
215
+ type: MediaType.ANIME,
216
+ perPage: 10,
217
+ });
154
218
  ```
155
219
 
156
- ## Rate limiting
157
-
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).
220
+ ### Season charts
159
221
 
160
222
  ```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
- },
223
+ import { MediaSeason } from "ani-client";
224
+
225
+ const winter2026 = await client.getMediaBySeason({
226
+ season: MediaSeason.WINTER,
227
+ seasonYear: 2026,
228
+ perPage: 25,
169
229
  });
170
230
  ```
171
231
 
172
- ## Airing, Chapters & Planning
173
-
174
- Fetch recently released content or see what's coming up next.
232
+ | Option | Type | Default | Description |
233
+ | --- | --- | --- | --- |
234
+ | `season` | `MediaSeason` | **(required)** | WINTER, SPRING, SUMMER, or FALL |
235
+ | `seasonYear` | `number` | **(required)** | The year |
236
+ | `type` | `MediaType` | `ANIME` | Filter by ANIME or MANGA |
237
+ | `sort` | `MediaSort[]` | `["POPULARITY_DESC"]` | Sort order |
238
+ | `page` | `number` | `1` | Page number |
239
+ | `perPage` | `number` | `20` | Results per page (max 50) |
175
240
 
176
- ### `getAiredEpisodes(options?)`
241
+ ### User media lists
177
242
 
178
- Returns anime episodes that recently aired (defaults to the **last 24 hours**).
243
+ ```ts
244
+ import { MediaType, MediaListStatus } from "ani-client";
179
245
 
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) |
246
+ const list = await client.getUserMediaList({
247
+ userName: "AniList",
248
+ type: MediaType.ANIME,
249
+ status: MediaListStatus.COMPLETED,
250
+ });
251
+ list.results.forEach((entry) =>
252
+ console.log(`${entry.media.title.romaji} ${entry.score}/100`)
253
+ );
254
+ ```
187
255
 
188
- ```ts
189
- import { AniListClient } from "ani-client";
256
+ Provide either `userId` or `userName`. The `type` field is required.
190
257
 
191
- const client = new AniListClient();
258
+ ### Recommendations
192
259
 
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}`)
260
+ ```ts
261
+ const recs = await client.getRecommendations(1); // Cowboy Bebop
262
+ recs.results.forEach((r) =>
263
+ console.log(`${r.mediaRecommendation.title.romaji} (rating: ${r.rating})`)
197
264
  );
198
-
199
- // Episodes aired in the last 7 days
200
- const week = await client.getAiredEpisodes({
201
- airingAtGreater: Math.floor(Date.now() / 1000) - 7 * 24 * 3600,
202
- perPage: 50,
203
- });
204
265
  ```
205
266
 
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).
267
+ ### Relations
209
268
 
210
- | Option | Type | Default | Description |
211
- | --------- | -------- | ------- | ----------------------- |
212
- | `page` | `number` | `1` | Page number |
213
- | `perPage` | `number` | `20` | Results per page (max 50) |
269
+ Every media object includes a `relations` field with sequels, prequels, spin-offs, etc.
214
270
 
215
271
  ```ts
216
- const chapters = await client.getAiredChapters({ perPage: 10 });
217
- chapters.results.forEach((m) =>
218
- console.log(`${m.title.romaji} ${m.chapters ?? "?"} chapters`)
272
+ const anime = await client.getMedia(1);
273
+ anime.relations?.edges.forEach((edge) =>
274
+ console.log(`${edge.relationType}: ${edge.node.title.romaji}`)
219
275
  );
220
276
  ```
221
277
 
222
- ### `getPlanning(options?)`
278
+ Available types: `ADAPTATION`, `PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `CHARACTER`, `SUMMARY`, `ALTERNATIVE`, `SPIN_OFF`, `OTHER`, `SOURCE`, `COMPILATION`, `CONTAINS`.
223
279
 
224
- Returns anime and/or manga that are **not yet released**, sorted by popularity.
280
+ ### Studios
225
281
 
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) |
282
+ | Method | Description |
283
+ | --- | --- |
284
+ | `getStudio(id)` | Fetch a studio with its productions |
285
+ | `searchStudios(options?)` | Search studios by name |
232
286
 
233
287
  ```ts
234
- import { MediaType } from "ani-client";
288
+ const studio = await client.getStudio(44); // Bones
289
+ const results = await client.searchStudios({ query: "MAPPA" });
290
+ ```
235
291
 
236
- // 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));
292
+ ### Genres & Tags
239
293
 
240
- // All upcoming media (anime + manga)
241
- const all = await client.getPlanning();
242
- ```
294
+ ```ts
295
+ const genres = await client.getGenres();
296
+ // ["Action", "Adventure", "Comedy", ...]
243
297
 
244
- ## Season charts
298
+ const tags = await client.getTags();
299
+ // [{ id, name, description, category, isAdult }, ...]
300
+ ```
245
301
 
246
- Fetch all anime (or manga) for a specific season and year.
302
+ ### Batch queries
247
303
 
248
- ### `getMediaBySeason(options)`
304
+ Fetch multiple IDs in a single GraphQL request (up to 50 per call, auto-chunked).
249
305
 
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) |
306
+ | Method | Description |
307
+ | --- | --- |
308
+ | `getMediaBatch(ids)` | Fetch multiple anime / manga |
309
+ | `getCharacterBatch(ids)` | Fetch multiple characters |
310
+ | `getStaffBatch(ids)` | Fetch multiple staff members |
258
311
 
259
312
  ```ts
260
- import { AniListClient, MediaSeason } from "ani-client";
313
+ const [bebop, naruto, aot] = await client.getMediaBatch([1, 20, 16498]);
314
+ ```
261
315
 
262
- const client = new AniListClient();
316
+ ### Raw queries
263
317
 
264
- // All anime from Winter 2026
265
- const winter2026 = await client.getMediaBySeason({
266
- season: MediaSeason.WINTER,
267
- seasonYear: 2026,
268
- perPage: 25,
269
- });
270
- winter2026.results.forEach((m) => console.log(m.title.romaji));
318
+ Execute any GraphQL query against the AniList API.
271
319
 
272
- // Spring 2025 manga
273
- const spring = await client.getMediaBySeason({
274
- season: MediaSeason.SPRING,
275
- seasonYear: 2025,
276
- type: MediaType.MANGA,
277
- });
320
+ ```ts
321
+ const data = await client.raw<{ Media: { id: number; title: { romaji: string } } }>(
322
+ "query { Media(id: 1) { id title { romaji } } }",
323
+ );
278
324
  ```
279
325
 
280
- ## User media lists
281
-
282
- Fetch a user's anime or manga list, optionally filtered by status.
283
-
284
- ### `getUserMediaList(options)`
326
+ ### Auto-pagination
285
327
 
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) |
328
+ `paginate()` returns an async iterator that fetches pages on demand.
295
329
 
296
330
  ```ts
297
- import { AniListClient, MediaType, MediaListStatus } from "ani-client";
331
+ for await (const anime of client.paginate(
332
+ (page) => client.searchMedia({ query: "Gundam", page, perPage: 10 }),
333
+ 3, // max 3 pages
334
+ )) {
335
+ console.log(anime.title.romaji);
336
+ }
337
+ ```
298
338
 
299
- const client = new AniListClient();
339
+ ## Caching
300
340
 
301
- // All anime on a user's list
302
- const list = await client.getUserMediaList({
303
- userName: "AniList",
304
- type: MediaType.ANIME,
305
- perPage: 10,
306
- });
307
- list.results.forEach((entry) =>
308
- console.log(`${entry.media.title.romaji} — ${entry.score}/100`)
309
- );
341
+ ### Memory cache
310
342
 
311
- // Only completed anime
312
- const completed = await client.getUserMediaList({
313
- userName: "AniList",
314
- type: MediaType.ANIME,
315
- status: MediaListStatus.COMPLETED,
316
- });
343
+ The default in-memory cache uses **LRU eviction** (24 h TTL, 500 entries max).
317
344
 
318
- // By user ID
319
- const byId = await client.getUserMediaList({
320
- userId: 1,
321
- type: MediaType.MANGA,
345
+ ```ts
346
+ const client = new AniListClient({
347
+ cache: {
348
+ ttl: 1000 * 60 * 60, // 1 hour
349
+ maxSize: 200,
350
+ enabled: true, // false to disable
351
+ },
322
352
  });
323
353
  ```
324
354
 
325
- ## Recommendations
355
+ ### Redis cache
326
356
 
327
- Get user-submitted recommendations for a specific anime or manga.
328
-
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 |
357
+ 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+.
336
358
 
337
359
  ```ts
338
- import { AniListClient } from "ani-client";
339
-
340
- const client = new AniListClient();
360
+ import Redis from "ioredis";
361
+ import { AniListClient, RedisCache } from "ani-client";
341
362
 
342
- // Recommendations for Cowboy Bebop
343
- const recs = await client.getRecommendations(1);
344
- recs.results.forEach((r) =>
345
- console.log(`${r.mediaRecommendation.title.romaji} (rating: ${r.rating})`)
346
- );
347
-
348
- // With pagination
349
- const page2 = await client.getRecommendations(20, { page: 2, perPage: 10 });
363
+ const client = new AniListClient({
364
+ cacheAdapter: new RedisCache({
365
+ client: new Redis(),
366
+ prefix: "ani:", // key prefix (default)
367
+ ttl: 86_400, // seconds (default: 24 h)
368
+ }),
369
+ });
350
370
  ```
351
371
 
352
- ## Relations
372
+ ### Custom adapter
353
373
 
354
- All media objects now include a `relations` field with sequels, prequels, spin-offs, etc.
374
+ Implement the `CacheAdapter` interface to bring your own storage:
355
375
 
356
376
  ```ts
357
- const anime = await client.getMedia(1); // Cowboy Bebop
358
- anime.relations?.edges.forEach((edge) =>
359
- console.log(`${edge.relationType}: ${edge.node.title.romaji}`)
360
- );
361
- // SIDE_STORY: Cowboy Bebop: Tengoku no Tobira
377
+ import type { CacheAdapter } from "ani-client";
378
+
379
+ class MyCache implements CacheAdapter {
380
+ get<T>(key: string): T | undefined | Promise<T | undefined> { /* ... */ }
381
+ set<T>(key: string, data: T): void | Promise<void> { /* ... */ }
382
+ delete(key: string): boolean | Promise<boolean> { /* ... */ }
383
+ clear(): void | Promise<void> { /* ... */ }
384
+ get size(): number { return -1; } // return -1 if unknown
385
+ keys(): string[] | Promise<string[]> { /* ... */ }
386
+ // Optional — the client provides a fallback if omitted
387
+ invalidate?(pattern: string | RegExp): number | Promise<number> { /* ... */ }
388
+ }
362
389
  ```
363
390
 
364
- Available relation types: `ADAPTATION`, `PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `CHARACTER`, `SUMMARY`, `ALTERNATIVE`, `SPIN_OFF`, `OTHER`, `SOURCE`, `COMPILATION`, `CONTAINS`.
391
+ ### Cache invalidation
365
392
 
366
- ## Studios
367
-
368
- Fetch or search for animation studios.
369
-
370
- ### `getStudio(id)`
393
+ ```ts
394
+ // Clear everything
395
+ await client.clearCache();
371
396
 
372
- Returns the studio with its most popular productions.
397
+ // Remove entries matching a pattern
398
+ const removed = await client.invalidateCache(/Media/);
399
+ console.log(`Removed ${removed} entries`);
373
400
 
374
- ```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));
401
+ // Current cache size
402
+ console.log(client.cacheSize);
379
403
  ```
380
404
 
381
- ### `searchStudios(options?)`
405
+ ## Rate limiting
382
406
 
383
- | Option | Type | Default | Description |
384
- | --------- | -------- | ------- | ----------------------- |
385
- | `query` | `string` | — | Search term |
386
- | `page` | `number` | `1` | Page number |
387
- | `perPage` | `number` | `20` | Results per page |
407
+ 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.
388
408
 
389
409
  ```ts
390
- const result = await client.searchStudios({ query: "MAPPA" });
391
- result.results.forEach((s) => console.log(s.name));
410
+ const client = new AniListClient({
411
+ rateLimit: {
412
+ maxRequests: 60,
413
+ windowMs: 60_000,
414
+ maxRetries: 5,
415
+ retryDelayMs: 3_000,
416
+ timeoutMs: 30_000, // abort after 30 s
417
+ retryOnNetworkError: true, // retry ECONNRESET, ETIMEDOUT, etc.
418
+ enabled: true,
419
+ },
420
+ });
392
421
  ```
393
422
 
394
- ## Genres & Tags
423
+ ## Request deduplication
395
424
 
396
- List all genres and tags available on AniList.
425
+ When multiple callers request the same data at the same time, only one API call is made. All callers receive the same response.
397
426
 
398
427
  ```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})`));
428
+ // Only 1 HTTP request is sent
429
+ const [a, b] = await Promise.all([
430
+ client.getMedia(1),
431
+ client.getMedia(1),
432
+ ]);
404
433
  ```
405
434
 
406
- ## Auto-pagination
435
+ ## Event hooks
407
436
 
408
- Use `paginate()` to iterate across all pages automatically with an async iterator.
437
+ Monitor every request lifecycle event for logging, metrics, or debugging.
409
438
 
410
439
  ```ts
411
- // Iterate over all search results
412
- for await (const anime of client.paginate((page) =>
413
- client.searchMedia({ query: "Gundam", page, perPage: 10 })
414
- )) {
415
- console.log(anime.title.romaji);
416
- }
417
-
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);
424
- }
440
+ const client = new AniListClient({
441
+ hooks: {
442
+ onRequest: (query, variables) => console.log("", query.slice(0, 40)),
443
+ onResponse: (query, durationMs, fromCache) => console.log(`← ${durationMs}ms (cache: ${fromCache})`),
444
+ onCacheHit: (key) => console.log("Cache hit:", key.slice(0, 30)),
445
+ onRateLimit: (retryAfterMs) => console.warn(`Rate limited, waiting ${retryAfterMs}ms`),
446
+ onRetry: (attempt, reason, delayMs) => console.warn(`Retry #${attempt}: ${reason}`),
447
+ },
448
+ });
425
449
  ```
426
450
 
427
451
  ## Error handling
428
452
 
429
- All API errors throw an `AniListError` with:
430
-
431
- - `message` — Human-readable error message
432
- - `status` — HTTP status code
433
- - `errors` — Raw error array from the API
453
+ All API errors throw an `AniListError` with `message`, `status` and `errors`:
434
454
 
435
455
  ```ts
436
- import { AniListClient, AniListError } from "ani-client";
456
+ import { AniListError } from "ani-client";
437
457
 
438
458
  try {
439
459
  await client.getMedia(999999999);
440
460
  } catch (err) {
441
461
  if (err instanceof AniListError) {
442
- console.error(err.message, err.status);
462
+ console.error(err.message); // "Not Found."
463
+ console.error(err.status); // 404
464
+ console.error(err.errors); // raw API error array
443
465
  }
444
466
  }
445
467
  ```
446
468
 
447
469
  ## Types
448
470
 
449
- All types are exported and documented:
471
+ All types and enums are exported:
450
472
 
451
473
  ```ts
452
474
  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,
475
+ Media, Character, Staff, User,
476
+ AiringSchedule, MediaListEntry, Recommendation, StudioDetail,
477
+ MediaEdge, MediaConnection, PageInfo, PagedResult,
478
+ CacheAdapter, AniListHooks, AniListClientOptions,
479
+ SearchMediaOptions, SearchCharacterOptions, SearchStaffOptions,
480
+ SearchStudioOptions, GetAiringOptions, GetRecentChaptersOptions,
481
+ GetPlanningOptions, GetSeasonOptions, GetUserMediaListOptions,
471
482
  GetRecommendationsOptions,
472
483
  } from "ani-client";
484
+
485
+ import {
486
+ MediaType, MediaFormat, MediaStatus, MediaSeason, MediaSort,
487
+ CharacterSort, AiringSort, RecommendationSort,
488
+ MediaRelationType, MediaListStatus, MediaListSort,
489
+ } from "ani-client";
473
490
  ```
474
491
 
492
+ ## Contributing
493
+
494
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and how to submit changes.
495
+
475
496
  ## License
476
497
 
477
498
  [MIT](LICENSE) © gonzyui