@vanomarb/animap 0.0.0-stage → 1.0.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 +268 -2
- package/package.json +46 -4
- package/src/align.js +46 -0
- package/src/breaker.js +38 -0
- package/src/byok/cache.js +66 -0
- package/src/byok/enrich.js +99 -0
- package/src/byok/fanart.js +49 -0
- package/src/byok/http.js +82 -0
- package/src/byok/index.d.ts +80 -0
- package/src/byok/index.js +13 -0
- package/src/byok/merge.js +107 -0
- package/src/byok/tmdb.js +123 -0
- package/src/byok/tvdb.js +165 -0
- package/src/cache.js +82 -0
- package/src/client.js +573 -0
- package/src/errors.js +32 -0
- package/src/index.d.ts +616 -0
- package/src/index.js +18 -0
- package/src/providers/cinemeta.js +32 -0
- package/src/providers/kitsu.js +175 -0
- package/src/refs.js +58 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// The API client. One shared core (response cache, breaker states, provider clients,
|
|
2
|
+
// provider cache) per `new Client()`; `withApiKey()` makes light clients on the same core.
|
|
3
|
+
|
|
4
|
+
import { ClientError, unavailable } from './errors.js';
|
|
5
|
+
import { SERIES_PROVIDERS, animeRef, checkInt, optionalInt, parseTitleRef, seriesRef } from './refs.js';
|
|
6
|
+
import { createResponseCache } from './cache.js';
|
|
7
|
+
import { createBreakers, disabledBreakers } from './breaker.js';
|
|
8
|
+
import { ByokError } from './byok/http.js';
|
|
9
|
+
import { createByokCache, keySignature } from './byok/cache.js';
|
|
10
|
+
import { createTmdb } from './byok/tmdb.js';
|
|
11
|
+
import { createTvdb } from './byok/tvdb.js';
|
|
12
|
+
import { createFanart } from './byok/fanart.js';
|
|
13
|
+
import { mergeEpisodes, mergeTitle } from './byok/merge.js';
|
|
14
|
+
import { checkKey, fetchEpisodeFills, fetchSources } from './byok/enrich.js';
|
|
15
|
+
import { isTransient } from './byok/index.js';
|
|
16
|
+
import { createKitsu } from './providers/kitsu.js';
|
|
17
|
+
import { createCinemeta } from './providers/cinemeta.js';
|
|
18
|
+
|
|
19
|
+
const BYOK_PROVIDERS = ['tmdb', 'tvdb', 'fanart'];
|
|
20
|
+
const LOOKUP_BATCH = 100;
|
|
21
|
+
const MAX_RETRY_AFTER_MS = 10_000;
|
|
22
|
+
const NETWORK_RETRY_DELAY_MS = 250;
|
|
23
|
+
const RETRYABLE_5XX = new Set([502, 503, 504]);
|
|
24
|
+
const CORE = Symbol('core');
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
|
|
28
|
+
/** Retry-After (seconds or HTTP date) → ms, uncapped; 1 s when absent or unreadable. */
|
|
29
|
+
function parseRetryAfter(header, now = Date.now()) {
|
|
30
|
+
if (header === null || header === undefined || header === '') return 1000;
|
|
31
|
+
const seconds = Number(header);
|
|
32
|
+
const ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - now;
|
|
33
|
+
return Number.isFinite(ms) && ms >= 0 ? ms : 1000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Retry-After header (seconds or HTTP date) → milliseconds, capped at 10 s. */
|
|
37
|
+
export function retryAfterMs(header, now = Date.now()) {
|
|
38
|
+
if (header === null || header === undefined || header === '') return 1000;
|
|
39
|
+
const seconds = Number(header);
|
|
40
|
+
let ms = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - now;
|
|
41
|
+
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
|
42
|
+
return Math.min(ms, MAX_RETRY_AFTER_MS);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** { tmdb: 'k' } or { tmdb: { key: 'k' } }, { tvdb: { key, pin } } → flat keys, '' when unset. */
|
|
46
|
+
function providerKeys(providers = {}) {
|
|
47
|
+
const keyOf = (v) => String((typeof v === 'string' ? v : v?.key) ?? '').trim();
|
|
48
|
+
return {
|
|
49
|
+
tmdb: keyOf(providers.tmdb),
|
|
50
|
+
tvdb: keyOf(providers.tvdb),
|
|
51
|
+
tvdbPin: String((typeof providers.tvdb === 'object' ? providers.tvdb?.pin : '') ?? '').trim(),
|
|
52
|
+
fanart: keyOf(providers.fanart),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function codeForStatus(status) {
|
|
57
|
+
if (status === 400) return 'bad_request';
|
|
58
|
+
if (status === 401) return 'unauthorized';
|
|
59
|
+
if (status === 403) return 'forbidden';
|
|
60
|
+
if (status === 404) return 'not_found';
|
|
61
|
+
return 'bad_request';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkApiKey(apiKey) {
|
|
65
|
+
if (typeof apiKey !== 'string' || !apiKey.trim()) {
|
|
66
|
+
throw new ClientError('missing_api_key', 'An API key is required: new Client({ apiKey, baseUrl })');
|
|
67
|
+
}
|
|
68
|
+
return apiKey.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildCore(options) {
|
|
72
|
+
const {
|
|
73
|
+
baseUrl, cache, cacheTtlMs, fetch, timeoutMs = 10_000, retry, breaker,
|
|
74
|
+
providers = {}, providerCache, providerTimeoutMs = 8000, onProviderError,
|
|
75
|
+
} = options;
|
|
76
|
+
if (typeof baseUrl !== 'string' || !/^https?:\/\//i.test(baseUrl)) {
|
|
77
|
+
throw new ClientError('missing_base_url', 'baseUrl must be the http(s) URL of the API');
|
|
78
|
+
}
|
|
79
|
+
// Wrapped so a browser's window.fetch is never called with the wrong `this`.
|
|
80
|
+
const f = fetch ? (...args) => fetch(...args) : (...args) => globalThis.fetch(...args);
|
|
81
|
+
const keys = providerKeys(providers);
|
|
82
|
+
const t = providerTimeoutMs;
|
|
83
|
+
const tmdb = keys.tmdb ? createTmdb({ key: keys.tmdb, fetch: f, timeoutMs: t }) : null;
|
|
84
|
+
const tvdb = keys.tvdb ? createTvdb({ key: keys.tvdb, pin: keys.tvdbPin, fetch: f, timeoutMs: t }) : null;
|
|
85
|
+
const fanart = keys.fanart ? createFanart({ key: keys.fanart, fetch: f, timeoutMs: t }) : null;
|
|
86
|
+
const keyless = (opt) => (opt === false ? null : typeof opt === 'object' && opt ? opt : {});
|
|
87
|
+
const kitsuOpt = keyless(providers.kitsu);
|
|
88
|
+
const cinemetaOpt = keyless(providers.cinemeta);
|
|
89
|
+
|
|
90
|
+
const core = {
|
|
91
|
+
baseUrl: baseUrl.replace(/\/+$/, '').replace(/\/v1$/, ''),
|
|
92
|
+
fetch: f,
|
|
93
|
+
timeoutMs,
|
|
94
|
+
retry: retry === false ? null : { max: retry?.max ?? 1, maxWaitMs: retry?.maxWaitMs ?? MAX_RETRY_AFTER_MS },
|
|
95
|
+
cache: createResponseCache(cache, { cacheTtlMs }),
|
|
96
|
+
breakers: breaker === false ? disabledBreakers() : createBreakers(breaker ?? {}),
|
|
97
|
+
keys,
|
|
98
|
+
sig: keySignature(keys),
|
|
99
|
+
providerCache: createByokCache(providerCache ? { storage: providerCache } : {}),
|
|
100
|
+
onProviderError: typeof onProviderError === 'function' ? onProviderError : null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const cached = (key, load) => cachedIn(core.cache, key, load);
|
|
104
|
+
const kitsu = kitsuOpt && createKitsu({ fetch: f, timeoutMs: kitsuOpt.timeoutMs ?? 10_000, baseUrl: kitsuOpt.baseUrl });
|
|
105
|
+
const cinemeta = cinemetaOpt && createCinemeta({ fetch: f, timeoutMs: cinemetaOpt.timeoutMs ?? 10_000, baseUrl: cinemetaOpt.baseUrl });
|
|
106
|
+
|
|
107
|
+
core.providers = Object.freeze({
|
|
108
|
+
tmdb: tmdb && Object.freeze({
|
|
109
|
+
kind: tmdb.kind,
|
|
110
|
+
check: tmdb.check,
|
|
111
|
+
findByImdb: tmdb.findByImdb,
|
|
112
|
+
series: tmdb.tv,
|
|
113
|
+
movie: tmdb.movie,
|
|
114
|
+
season: tmdb.season,
|
|
115
|
+
search: tmdb.search,
|
|
116
|
+
}),
|
|
117
|
+
tvdb: tvdb && Object.freeze({
|
|
118
|
+
check: tvdb.check,
|
|
119
|
+
series: tvdb.seriesExtended,
|
|
120
|
+
movie: tvdb.movieExtended,
|
|
121
|
+
episodes: tvdb.episodes,
|
|
122
|
+
allEpisodes: tvdb.allEpisodes,
|
|
123
|
+
translation: tvdb.translation,
|
|
124
|
+
artworks: tvdb.artworks,
|
|
125
|
+
searchRemoteId: tvdb.searchRemoteId,
|
|
126
|
+
}),
|
|
127
|
+
fanart: fanart && Object.freeze({
|
|
128
|
+
check: fanart.check,
|
|
129
|
+
series: fanart.tv,
|
|
130
|
+
movie: fanart.movie,
|
|
131
|
+
}),
|
|
132
|
+
// Keyless ones go through the response cache: they are called often and change rarely.
|
|
133
|
+
kitsu: kitsu && Object.freeze({
|
|
134
|
+
anime: (id, signal) => cached(`kitsu:anime:${id}`, () => kitsu.anime(id, signal)),
|
|
135
|
+
episodes: (id, { max, signal } = {}) => cached(`kitsu:episodes:${id}:${max ?? ''}`, () => kitsu.episodes(id, { max, signal })),
|
|
136
|
+
relationships: (id, signal) => cached(`kitsu:relationships:${id}`, () => kitsu.relationships(id, signal)),
|
|
137
|
+
search: (text, { limit, signal } = {}) => cached(`kitsu:search:${String(text).toLowerCase()}:${limit ?? ''}`, () => kitsu.search(text, { limit, signal })),
|
|
138
|
+
}),
|
|
139
|
+
cinemeta: cinemeta && Object.freeze({
|
|
140
|
+
meta: (imdbId, type = 'series', signal) => cached(`cinemeta:${type}:${imdbId}`, () => cinemeta.meta(imdbId, type, signal)),
|
|
141
|
+
find: (imdbId, type = 'series', signal) => cached(`cinemeta:find:${type}:${imdbId}`, () => cinemeta.find(imdbId, type, signal)),
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
return core;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function cachedIn(cache, key, load) {
|
|
148
|
+
const hit = await cache.get(key);
|
|
149
|
+
if (hit) return hit.value;
|
|
150
|
+
const value = await load();
|
|
151
|
+
await cache.set(key, value);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class Client {
|
|
156
|
+
#core;
|
|
157
|
+
#apiKey;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {object} options see ClientOptions in index.d.ts
|
|
161
|
+
*/
|
|
162
|
+
constructor(options = {}, internal = undefined) {
|
|
163
|
+
this.#apiKey = checkApiKey(options.apiKey);
|
|
164
|
+
this.#core = internal?.[CORE] ?? buildCore(options);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A client for another API key, sharing this one's cache, providers and breaker map. */
|
|
168
|
+
withApiKey(apiKey) {
|
|
169
|
+
return new Client({ apiKey }, { [CORE]: this.#core });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Provider clients: tmdb, tvdb, fanart (null without a key; raw provider JSON) and the
|
|
174
|
+
* keyless kitsu and cinemeta (null when disabled; normalized / raw Cinemeta metas).
|
|
175
|
+
*/
|
|
176
|
+
get providers() {
|
|
177
|
+
return this.#core.providers;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Providers with a key, e.g. ['tmdb', 'tvdb']. */
|
|
181
|
+
get enrichment() {
|
|
182
|
+
return BYOK_PROVIDERS.filter((p) => this.#core.keys[p]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- Entries and mapping ------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** The entry for an anime ref, or null when the API does not know it. */
|
|
188
|
+
async entry(ref) {
|
|
189
|
+
const p = animeRef(ref);
|
|
190
|
+
return this.#cached(`entry:${p.ref}`, () => this.#orNull(this.#request('GET', `/v1/entries/${p.provider}/${p.id}`), (b) => b.entry));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Several entries at once, in batches of 100.
|
|
195
|
+
* Returns a Map keyed by the refs as given, each value an Entry or null.
|
|
196
|
+
*/
|
|
197
|
+
async lookup(refs) {
|
|
198
|
+
if (!Array.isArray(refs)) throw new ClientError('invalid_argument', 'lookup() takes an array of anime refs');
|
|
199
|
+
const parsed = refs.map((r) => [r, animeRef(r)]);
|
|
200
|
+
const found = new Map();
|
|
201
|
+
const missing = [];
|
|
202
|
+
for (const [, p] of parsed) {
|
|
203
|
+
if (found.has(p.ref) || missing.includes(p.ref)) continue;
|
|
204
|
+
const hit = await this.#core.cache.get(`entry:${p.ref}`);
|
|
205
|
+
if (hit) found.set(p.ref, hit.value);
|
|
206
|
+
else missing.push(p.ref);
|
|
207
|
+
}
|
|
208
|
+
for (let i = 0; i < missing.length; i += LOOKUP_BATCH) {
|
|
209
|
+
const batch = missing.slice(i, i + LOOKUP_BATCH);
|
|
210
|
+
const body = await this.#request('POST', '/v1/entries/lookup', { refs: batch });
|
|
211
|
+
for (const ref of batch) {
|
|
212
|
+
const value = body.results?.[ref] ?? null;
|
|
213
|
+
found.set(ref, value);
|
|
214
|
+
await this.#core.cache.set(`entry:${ref}`, value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return new Map(parsed.map(([input, p]) => [input, found.get(p.ref) ?? null]));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Translate an entry episode to a series episode.
|
|
222
|
+
* `target` picks tvdb, tmdb or imdb; without it the best match is returned.
|
|
223
|
+
* Returns { provider, id, season, episode, span, source, confidence } or null.
|
|
224
|
+
*/
|
|
225
|
+
async toSeries(ref, episode, { target } = {}) {
|
|
226
|
+
const p = animeRef(ref);
|
|
227
|
+
checkInt(episode, 'episode', 1);
|
|
228
|
+
if (target !== undefined && !SERIES_PROVIDERS.includes(target)) {
|
|
229
|
+
throw new ClientError('invalid_argument', `target must be one of ${SERIES_PROVIDERS.join(', ')}`);
|
|
230
|
+
}
|
|
231
|
+
const body = await this.#episodes(`${p.ref}:${episode}`);
|
|
232
|
+
const hit = (body?.results ?? []).find((r) => !target || r.provider === target);
|
|
233
|
+
if (!hit) return null;
|
|
234
|
+
const { provider, id, season, episode: ep, span, source, confidence } = hit;
|
|
235
|
+
return { provider, id, season, episode: ep, span, source, confidence };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Translate a series episode back to the entries that contain it.
|
|
240
|
+
* Returns [{ entry, ids, episode, source, confidence }] (empty when none).
|
|
241
|
+
*/
|
|
242
|
+
async fromSeries(ref, season, episode) {
|
|
243
|
+
const p = seriesRef(ref);
|
|
244
|
+
checkInt(season, 'season', 0);
|
|
245
|
+
checkInt(episode, 'episode', 1);
|
|
246
|
+
const body = await this.#episodes(`${p.ref}:${season}:${episode}`);
|
|
247
|
+
return body?.results ?? [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Every mapped entry of a series, grouped by season. `season` narrows it to one. */
|
|
251
|
+
async series(ref, { season } = {}) {
|
|
252
|
+
const p = seriesRef(ref);
|
|
253
|
+
optionalInt(season, 'season', 0);
|
|
254
|
+
const qs = season === undefined || season === null ? '' : `?season=${season}`;
|
|
255
|
+
return this.#cached(`series:${p.ref}${qs}`, () => this.#request('GET', `/v1/series/${p.provider}/${p.id}${qs}`));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Entry search by title, alias or id. Returns EntrySummary[]. */
|
|
259
|
+
async search(query, { limit } = {}) {
|
|
260
|
+
if (typeof query !== 'string' || !query.trim()) throw new ClientError('invalid_argument', 'search() needs a non-empty query');
|
|
261
|
+
optionalInt(limit, 'limit', 1);
|
|
262
|
+
const params = new URLSearchParams({ q: query.trim() });
|
|
263
|
+
if (limit !== undefined) params.set('limit', String(limit));
|
|
264
|
+
const body = await this.#cached(`search:${params}`, () => this.#request('GET', `/v1/search?${params}`));
|
|
265
|
+
return body.results ?? [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Mapping issues; `entry` (an entry id or anime ref) and `type` filter them. */
|
|
269
|
+
async issues({ type, entry, cursor, limit } = {}) {
|
|
270
|
+
optionalInt(limit, 'limit', 1);
|
|
271
|
+
const params = new URLSearchParams();
|
|
272
|
+
if (type) params.set('type', type);
|
|
273
|
+
if (entry) params.set('entry', animeRef(entry).ref);
|
|
274
|
+
if (cursor) params.set('cursor', cursor);
|
|
275
|
+
if (limit !== undefined) params.set('limit', String(limit));
|
|
276
|
+
const qs = params.toString() ? `?${params}` : '';
|
|
277
|
+
return this.#cached(`issues:${qs}`, () => this.#request('GET', `/v1/issues${qs}`));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Edit history of an entry, newest first ([] when the entry is unknown). */
|
|
281
|
+
async history(ref) {
|
|
282
|
+
const p = animeRef(ref);
|
|
283
|
+
const body = await this.#orNull(this.#request('GET', `/v1/entries/${p.provider}/${p.id}/history`));
|
|
284
|
+
return body?.results ?? [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- Titles -------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A whole show or film, by title id or any ref ("tt8948436", "kitsu:44607", "tvdb:342329").
|
|
291
|
+
* With provider keys, fields the API left empty are filled from TMDB, TVDB and Fanart.tv.
|
|
292
|
+
* Returns Title | null.
|
|
293
|
+
*/
|
|
294
|
+
async title(ref, { enrich = true, signal } = {}) {
|
|
295
|
+
const server = await this.#serverTitle(ref);
|
|
296
|
+
if (!server || !enrich || !this.#core.sig) return server;
|
|
297
|
+
return (await this.enrichTitle(server, { signal })).title;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Fill a Title from the configured providers. Never overwrites a field the API filled.
|
|
302
|
+
* Returns { title, filled: { field: provider }, errors: [{ provider, code, message }], ids }.
|
|
303
|
+
* `title.provenance` also names the provider of every filled field.
|
|
304
|
+
*/
|
|
305
|
+
async enrichTitle(server, { signal } = {}) {
|
|
306
|
+
if (!server || !this.#core.sig) return { title: server, filled: {}, errors: [], ids: server?.ids ?? null };
|
|
307
|
+
const { sources, errors } = await this.#sources(server, signal);
|
|
308
|
+
const { title, filled } = mergeTitle(server, sources);
|
|
309
|
+
if (Object.keys(filled).length) title.provenance = { ...(server.provenance ?? {}), ...filled };
|
|
310
|
+
return { title, filled, errors, ids: sources.ids ?? server.ids ?? null };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Episodes of one season of a title (the first season when `season` is omitted).
|
|
315
|
+
* With TMDB/TVDB keys, placeholder names and missing overviews, stills and air dates are
|
|
316
|
+
* filled. Returns { season, seasons, episodes } | null.
|
|
317
|
+
*/
|
|
318
|
+
async titleEpisodes(ref, { season, enrich = true, signal } = {}) {
|
|
319
|
+
const id = parseTitleRef(ref);
|
|
320
|
+
optionalInt(season, 'season', 0);
|
|
321
|
+
const qs = season === undefined || season === null ? '' : `?season=${season}`;
|
|
322
|
+
const body = await this.#cached(`title-episodes:${id}${qs}`, () => this.#orNull(this.#request('GET', `/v1/titles/${id}/episodes${qs}`)));
|
|
323
|
+
const keys = this.#core.keys;
|
|
324
|
+
if (!body || !enrich || !(keys.tmdb || keys.tvdb) || body.season === null || !body.episodes?.length) return body;
|
|
325
|
+
const server = await this.#serverTitle(id);
|
|
326
|
+
if (!server) return body;
|
|
327
|
+
const { episodes } = await this.enrichEpisodes(server, body.season, body.episodes, { signal });
|
|
328
|
+
return { ...body, episodes };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Fill episode rows of one season from TMDB, then TVDB.
|
|
333
|
+
* Returns { episodes, filledBy: provider[], errors }.
|
|
334
|
+
*/
|
|
335
|
+
async enrichEpisodes(title, season, episodes, { signal } = {}) {
|
|
336
|
+
const keys = this.#core.keys;
|
|
337
|
+
const sig = keySignature({ tmdb: keys.tmdb, tvdb: keys.tvdb });
|
|
338
|
+
if (!sig || !title || title.type === 'movie') return { episodes, filledBy: [], errors: [] };
|
|
339
|
+
const key = `${title.id}:${season}`;
|
|
340
|
+
let fills = await this.#core.providerCache.get('episodes', key, sig);
|
|
341
|
+
let errors = [];
|
|
342
|
+
if (!fills) {
|
|
343
|
+
// Ids found through TMDB (a TVDB id behind an IMDb-only title) come from the title sources.
|
|
344
|
+
const { sources } = await this.#sources(title, signal);
|
|
345
|
+
const res = await fetchEpisodeFills(title, season, keys, { fetch: this.#core.fetch, signal, ids: sources.ids });
|
|
346
|
+
fills = res.fills;
|
|
347
|
+
errors = res.errors;
|
|
348
|
+
this.#report(errors);
|
|
349
|
+
if (!errors.some(isTransient)) await this.#core.providerCache.set('episodes', key, sig, fills);
|
|
350
|
+
}
|
|
351
|
+
const merged = mergeEpisodes(episodes, fills);
|
|
352
|
+
return { episodes: merged.episodes, filledBy: [...merged.filledBy], errors };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Browse or search titles. `q` matches names, aliases and any id.
|
|
357
|
+
* Returns { results: TitleSummary[], total, next }; pass `next` back as `cursor`.
|
|
358
|
+
*/
|
|
359
|
+
async titles({ q, type, format, year, genre, mapped, issues, sort, cursor, limit } = {}) {
|
|
360
|
+
optionalInt(year, 'year', 0);
|
|
361
|
+
optionalInt(limit, 'limit', 1);
|
|
362
|
+
const params = new URLSearchParams();
|
|
363
|
+
for (const [k, v] of Object.entries({ q, type, format, year, genre, mapped, sort, cursor, limit })) {
|
|
364
|
+
if (v !== undefined && v !== null && v !== '') params.set(k, String(v).trim());
|
|
365
|
+
}
|
|
366
|
+
if (issues) params.set('issues', '1');
|
|
367
|
+
const qs = params.toString() ? `?${params}` : '';
|
|
368
|
+
return this.#cached(`titles:${qs}`, () => this.#request('GET', `/v1/titles${qs}`));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Kitsu's trending anime, resolved to titles. */
|
|
372
|
+
async trending({ limit } = {}) {
|
|
373
|
+
optionalInt(limit, 'limit', 1);
|
|
374
|
+
const qs = limit === undefined ? '' : `?limit=${limit}`;
|
|
375
|
+
const body = await this.#cached(`trending:${qs}`, () => this.#request('GET', `/v1/trending${qs}`));
|
|
376
|
+
return body.results ?? [];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Recent mapping edits. */
|
|
380
|
+
async activity({ limit } = {}) {
|
|
381
|
+
optionalInt(limit, 'limit', 1);
|
|
382
|
+
const qs = limit === undefined ? '' : `?limit=${limit}`;
|
|
383
|
+
const body = await this.#cached(`activity:${qs}`, () => this.#request('GET', `/v1/activity${qs}`));
|
|
384
|
+
return body.results ?? [];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Catalogue counts: { titles, entries, mappedEpisodes, ranges, sources, importedAt }. */
|
|
388
|
+
async stats() {
|
|
389
|
+
return this.#cached('stats', () => this.#request('GET', '/v1/stats'));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The most common genres: [{ name, count }]. */
|
|
393
|
+
async genres() {
|
|
394
|
+
const body = await this.#cached('genres', () => this.#request('GET', '/v1/genres'));
|
|
395
|
+
return body.results ?? [];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- Service ------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
/** Dataset build and import times, counts and source versions. Not cached. */
|
|
401
|
+
async meta() {
|
|
402
|
+
return this.#request('GET', '/v1/meta');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The API key in use: { id, name, role, rateLimit, createdAt }. Not cached. */
|
|
406
|
+
async me() {
|
|
407
|
+
return (await this.#request('GET', '/v1/me')).key;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Check a provider key against the provider itself.
|
|
412
|
+
* Returns { state: 'connected' | 'invalid' | 'rate_limited' | 'error' | 'missing', message }.
|
|
413
|
+
*/
|
|
414
|
+
async checkProvider(provider) {
|
|
415
|
+
if (!BYOK_PROVIDERS.includes(provider)) throw new ClientError('invalid_argument', `provider must be one of ${BYOK_PROVIDERS.join(', ')}`);
|
|
416
|
+
if (!this.#core.keys[provider]) return { state: 'missing', message: 'No key configured.' };
|
|
417
|
+
return checkKey(provider, this.#core.keys, { fetch: this.#core.fetch });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Milliseconds until the API is called again for this key after repeated failures; 0 when available. */
|
|
421
|
+
get pausedFor() {
|
|
422
|
+
return this.#core.breakers.openFor(this.#apiKey);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Forget every cached API response (provider results stay in the provider cache). */
|
|
426
|
+
async clearCache() {
|
|
427
|
+
await this.#core.cache.clear();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---- Internals ----------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
#serverTitle(ref) {
|
|
433
|
+
const id = parseTitleRef(ref);
|
|
434
|
+
return this.#cached(`title:${id}`, () => this.#orNull(this.#request('GET', `/v1/titles/${id}`), (b) => b.title));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Normalized provider payloads for a title, cached per title and key signature. */
|
|
438
|
+
async #sources(server, signal) {
|
|
439
|
+
const core = this.#core;
|
|
440
|
+
const cached = await core.providerCache.get('title', server.id, core.sig);
|
|
441
|
+
if (cached) return { sources: cached, errors: [] };
|
|
442
|
+
const findTitle = async (ref) => (await this.titles({ q: ref, limit: 1 })).results?.[0] ?? null;
|
|
443
|
+
const { sources, errors } = await fetchSources(server, core.keys, { fetch: core.fetch, findTitle, signal });
|
|
444
|
+
this.#report(errors);
|
|
445
|
+
if (!errors.some(isTransient) && !errors.some((e) => e.code === 'aborted')) {
|
|
446
|
+
await core.providerCache.set('title', server.id, core.sig, sources);
|
|
447
|
+
}
|
|
448
|
+
return { sources, errors };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#report(errors) {
|
|
452
|
+
const handler = this.#core.onProviderError;
|
|
453
|
+
if (!handler) return;
|
|
454
|
+
for (const e of errors) {
|
|
455
|
+
try { handler(new ByokError(e.provider, e.code, e.message)); } catch { /* caller's handler */ }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 404 means the ref is unknown: no translation.
|
|
460
|
+
#episodes(episodeRef) {
|
|
461
|
+
return this.#cached(`episodes:${episodeRef}`, () => this.#orNull(this.#request('GET', `/v1/episodes/${episodeRef}`)));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async #orNull(promise, pick = (b) => b) {
|
|
465
|
+
try {
|
|
466
|
+
return pick(await promise);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if (err.status === 404 && err.code === 'not_found') return null;
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#cached(key, load) {
|
|
474
|
+
return cachedIn(this.#core.cache, key, load);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async #request(method, path, body) {
|
|
478
|
+
const core = this.#core;
|
|
479
|
+
const apiKey = this.#apiKey;
|
|
480
|
+
const paused = core.breakers.openFor(apiKey);
|
|
481
|
+
if (paused > 0) {
|
|
482
|
+
throw unavailable('circuit_open', `The API failed repeatedly; calls resume in ${Math.ceil(paused / 1000)} s`, null, paused);
|
|
483
|
+
}
|
|
484
|
+
// Only these headers ever reach the API: provider keys stay with the provider clients.
|
|
485
|
+
const headers = { Accept: 'application/json', 'X-API-Key': apiKey };
|
|
486
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
487
|
+
const init = { method, headers, body: body === undefined ? undefined : JSON.stringify(body) };
|
|
488
|
+
const url = core.baseUrl + path;
|
|
489
|
+
const maxRetries = core.retry ? core.retry.max : 0;
|
|
490
|
+
|
|
491
|
+
for (let attempt = 0; ; attempt++) {
|
|
492
|
+
let res;
|
|
493
|
+
let text;
|
|
494
|
+
try {
|
|
495
|
+
({ res, text } = await this.#attempt(url, init));
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (!err.timedOut && attempt < maxRetries) {
|
|
498
|
+
await sleep(NETWORK_RETRY_DELAY_MS * (attempt + 1));
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
core.breakers.failure(apiKey);
|
|
502
|
+
throw err.timedOut
|
|
503
|
+
? unavailable('timeout', `Request to ${url} timed out after ${core.timeoutMs} ms`)
|
|
504
|
+
: unavailable('network_error', `Request to ${url} failed: ${err?.message ?? err}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (res.status === 429) {
|
|
508
|
+
const wait = parseRetryAfter(res.headers.get('retry-after'));
|
|
509
|
+
if (attempt < maxRetries) {
|
|
510
|
+
await sleep(Math.min(wait, core.retry.maxWaitMs));
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
core.breakers.failure(apiKey, wait);
|
|
514
|
+
throw unavailable('rate_limited', this.#message(text, `Rate limited by ${url}`), 429, wait);
|
|
515
|
+
}
|
|
516
|
+
if (res.status >= 500) {
|
|
517
|
+
if (RETRYABLE_5XX.has(res.status) && attempt < maxRetries) {
|
|
518
|
+
await sleep(NETWORK_RETRY_DELAY_MS * (attempt + 1));
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
core.breakers.failure(apiKey);
|
|
522
|
+
throw unavailable('server_error', this.#message(text, `HTTP ${res.status} from ${url}`), res.status);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let data = null;
|
|
526
|
+
if (text) {
|
|
527
|
+
try {
|
|
528
|
+
data = JSON.parse(text);
|
|
529
|
+
} catch {
|
|
530
|
+
if (res.ok) {
|
|
531
|
+
core.breakers.failure(apiKey);
|
|
532
|
+
throw new ClientError('invalid_response', `Expected JSON from ${url}`, res.status);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
core.breakers.success(apiKey);
|
|
537
|
+
if (res.ok) return data;
|
|
538
|
+
const err = data?.error ?? {};
|
|
539
|
+
throw new ClientError(
|
|
540
|
+
typeof err.code === 'string' ? err.code : codeForStatus(res.status),
|
|
541
|
+
typeof err.message === 'string' ? err.message : `HTTP ${res.status} from ${url}`,
|
|
542
|
+
res.status,
|
|
543
|
+
{ details: err.details },
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#message(text, fallback) {
|
|
549
|
+
try {
|
|
550
|
+
const m = JSON.parse(text)?.error?.message;
|
|
551
|
+
return typeof m === 'string' ? m : fallback;
|
|
552
|
+
} catch {
|
|
553
|
+
return fallback;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async #attempt(url, init) {
|
|
558
|
+
const ms = this.#core.timeoutMs;
|
|
559
|
+
const controller = new AbortController();
|
|
560
|
+
let timedOut = false;
|
|
561
|
+
const timer = ms > 0 ? setTimeout(() => { timedOut = true; controller.abort(); }, ms) : null;
|
|
562
|
+
try {
|
|
563
|
+
// The body is read here too, so the timeout covers a stalled download.
|
|
564
|
+
const res = await this.#core.fetch(url, { ...init, signal: controller.signal });
|
|
565
|
+
return { res, text: await res.text() };
|
|
566
|
+
} catch (err) {
|
|
567
|
+
if (timedOut) throw Object.assign(new Error('timeout'), { timedOut: true });
|
|
568
|
+
throw err;
|
|
569
|
+
} finally {
|
|
570
|
+
if (timer) clearTimeout(timer);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class ClientError extends Error {
|
|
2
|
+
/**
|
|
3
|
+
* @param {string} code server error code (unauthorized, forbidden, not_found, bad_request)
|
|
4
|
+
* or a client one: unavailable (the API could not answer: see
|
|
5
|
+
* `reason`), missing_api_key, missing_base_url, invalid_ref,
|
|
6
|
+
* invalid_argument, invalid_response
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @param {number|null} status HTTP status, null when no response
|
|
9
|
+
* @param {object} [extra] { details, reason, retryAfterMs }
|
|
10
|
+
*/
|
|
11
|
+
constructor(code, message, status = null, extra = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'ClientError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.status = status;
|
|
16
|
+
// Older call sites passed `details` as the fourth argument.
|
|
17
|
+
const opts = extra && typeof extra === 'object' && !Array.isArray(extra) && ('details' in extra || 'reason' in extra || 'retryAfterMs' in extra)
|
|
18
|
+
? extra
|
|
19
|
+
: { details: extra };
|
|
20
|
+
if (opts.details !== undefined) this.details = opts.details;
|
|
21
|
+
if (opts.reason !== undefined) this.reason = opts.reason;
|
|
22
|
+
if (opts.retryAfterMs !== undefined) this.retryAfterMs = opts.retryAfterMs;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The API could not answer (as opposed to answering "not found"): `reason` is
|
|
28
|
+
* network_error | timeout | rate_limited | server_error | circuit_open.
|
|
29
|
+
*/
|
|
30
|
+
export function unavailable(reason, message, status = null, retryAfterMs) {
|
|
31
|
+
return new ClientError('unavailable', message, status, { reason, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
32
|
+
}
|