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 +317 -296
- package/dist/index.d.mts +170 -11
- package/dist/index.d.ts +170 -11
- package/dist/index.js +347 -115
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +347 -116
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -15
package/README.md
CHANGED
|
@@ -1,9 +1,52 @@
|
|
|
1
1
|
# ani-client
|
|
2
2
|
|
|
3
|
+
[](https://github.com/gonzyui/ani-client/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/ani-client)
|
|
5
|
+
[](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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
### `new AniListClient(options?)`
|
|
98
|
+
</details>
|
|
57
99
|
|
|
58
|
-
|
|
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
|
-
|
|
102
|
+
```ts
|
|
103
|
+
const client = new AniListClient({
|
|
104
|
+
// AniList OAuth bearer token (optional)
|
|
105
|
+
token: "your-token",
|
|
66
106
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
144
|
+
### Media
|
|
98
145
|
|
|
99
|
-
| Method
|
|
100
|
-
|
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
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
|
-
|
|
154
|
+
```ts
|
|
155
|
+
const anime = await client.getMedia(1);
|
|
106
156
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
###
|
|
166
|
+
### Characters
|
|
113
167
|
|
|
114
|
-
| Method
|
|
115
|
-
|
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
168
|
+
| Method | Description |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `getCharacter(id)` | Fetch a character by ID |
|
|
171
|
+
| `searchCharacters(options?)` | Search characters by name |
|
|
118
172
|
|
|
119
|
-
|
|
173
|
+
```ts
|
|
174
|
+
const spike = await client.getCharacter(1);
|
|
175
|
+
const results = await client.searchCharacters({ query: "Luffy", perPage: 5 });
|
|
176
|
+
```
|
|
120
177
|
|
|
121
|
-
|
|
122
|
-
| ----------------------------------- | ------------------------------------ |
|
|
123
|
-
| `raw<T>(query, variables?)` | Execute any GraphQL query |
|
|
178
|
+
### Staff
|
|
124
179
|
|
|
125
|
-
|
|
180
|
+
| Method | Description |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `getStaff(id)` | Fetch a staff member by ID |
|
|
183
|
+
| `searchStaff(options?)` | Search for staff members |
|
|
126
184
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
185
|
+
```ts
|
|
186
|
+
const staff = await client.getStaff(95001);
|
|
187
|
+
const results = await client.searchStaff({ query: "Miyazaki" });
|
|
188
|
+
```
|
|
130
189
|
|
|
131
|
-
###
|
|
190
|
+
### Users
|
|
132
191
|
|
|
133
|
-
| Method
|
|
134
|
-
|
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
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
|
-
|
|
198
|
+
### Airing, Chapters & Planning
|
|
139
199
|
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
//
|
|
152
|
-
client.
|
|
153
|
-
|
|
213
|
+
// Most anticipated upcoming anime
|
|
214
|
+
const upcoming = await client.getPlanning({
|
|
215
|
+
type: MediaType.ANIME,
|
|
216
|
+
perPage: 10,
|
|
217
|
+
});
|
|
154
218
|
```
|
|
155
219
|
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
###
|
|
241
|
+
### User media lists
|
|
177
242
|
|
|
178
|
-
|
|
243
|
+
```ts
|
|
244
|
+
import { MediaType, MediaListStatus } from "ani-client";
|
|
179
245
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
import { AniListClient } from "ani-client";
|
|
256
|
+
Provide either `userId` or `userName`. The `type` field is required.
|
|
190
257
|
|
|
191
|
-
|
|
258
|
+
### Recommendations
|
|
192
259
|
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
console.log(`${
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
console.log(`${
|
|
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
|
-
|
|
278
|
+
Available types: `ADAPTATION`, `PREQUEL`, `SEQUEL`, `PARENT`, `SIDE_STORY`, `CHARACTER`, `SUMMARY`, `ALTERNATIVE`, `SPIN_OFF`, `OTHER`, `SOURCE`, `COMPILATION`, `CONTAINS`.
|
|
223
279
|
|
|
224
|
-
|
|
280
|
+
### Studios
|
|
225
281
|
|
|
226
|
-
|
|
|
227
|
-
|
|
|
228
|
-
| `
|
|
229
|
-
| `
|
|
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
|
-
|
|
288
|
+
const studio = await client.getStudio(44); // Bones
|
|
289
|
+
const results = await client.searchStudios({ query: "MAPPA" });
|
|
290
|
+
```
|
|
235
291
|
|
|
236
|
-
|
|
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
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
294
|
+
```ts
|
|
295
|
+
const genres = await client.getGenres();
|
|
296
|
+
// ["Action", "Adventure", "Comedy", ...]
|
|
243
297
|
|
|
244
|
-
|
|
298
|
+
const tags = await client.getTags();
|
|
299
|
+
// [{ id, name, description, category, isAdult }, ...]
|
|
300
|
+
```
|
|
245
301
|
|
|
246
|
-
|
|
302
|
+
### Batch queries
|
|
247
303
|
|
|
248
|
-
|
|
304
|
+
Fetch multiple IDs in a single GraphQL request (up to 50 per call, auto-chunked).
|
|
249
305
|
|
|
250
|
-
|
|
|
251
|
-
|
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
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
|
-
|
|
313
|
+
const [bebop, naruto, aot] = await client.getMediaBatch([1, 20, 16498]);
|
|
314
|
+
```
|
|
261
315
|
|
|
262
|
-
|
|
316
|
+
### Raw queries
|
|
263
317
|
|
|
264
|
-
|
|
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
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
Fetch a user's anime or manga list, optionally filtered by status.
|
|
283
|
-
|
|
284
|
-
### `getUserMediaList(options)`
|
|
326
|
+
### Auto-pagination
|
|
285
327
|
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
+
## Caching
|
|
300
340
|
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
355
|
+
### Redis cache
|
|
326
356
|
|
|
327
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
const client = new AniListClient();
|
|
360
|
+
import Redis from "ioredis";
|
|
361
|
+
import { AniListClient, RedisCache } from "ani-client";
|
|
341
362
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
372
|
+
### Custom adapter
|
|
353
373
|
|
|
354
|
-
|
|
374
|
+
Implement the `CacheAdapter` interface to bring your own storage:
|
|
355
375
|
|
|
356
376
|
```ts
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
)
|
|
361
|
-
|
|
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
|
-
|
|
391
|
+
### Cache invalidation
|
|
365
392
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
### `getStudio(id)`
|
|
393
|
+
```ts
|
|
394
|
+
// Clear everything
|
|
395
|
+
await client.clearCache();
|
|
371
396
|
|
|
372
|
-
|
|
397
|
+
// Remove entries matching a pattern
|
|
398
|
+
const removed = await client.invalidateCache(/Media/);
|
|
399
|
+
console.log(`Removed ${removed} entries`);
|
|
373
400
|
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
405
|
+
## Rate limiting
|
|
382
406
|
|
|
383
|
-
|
|
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
|
|
391
|
-
|
|
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
|
-
##
|
|
423
|
+
## Request deduplication
|
|
395
424
|
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
##
|
|
435
|
+
## Event hooks
|
|
407
436
|
|
|
408
|
-
|
|
437
|
+
Monitor every request lifecycle event for logging, metrics, or debugging.
|
|
409
438
|
|
|
410
439
|
```ts
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
)
|
|
415
|
-
console.log(
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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 {
|
|
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
|
|
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
|
|
471
|
+
All types and enums are exported:
|
|
450
472
|
|
|
451
473
|
```ts
|
|
452
474
|
import type {
|
|
453
|
-
Media,
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|