@vanomarb/animap 0.0.0-stage → 1.0.1
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 +269 -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 +578 -0
- package/src/errors.js +32 -0
- package/src/index.d.ts +635 -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/index.d.ts
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
export type AnimeProvider = 'kitsu' | 'mal' | 'anilist' | 'anidb';
|
|
2
|
+
export type SeriesProvider = 'tvdb' | 'tmdb' | 'imdb';
|
|
3
|
+
export type Format = 'tv' | 'ona' | 'ova' | 'movie' | 'special' | 'music' | 'unknown';
|
|
4
|
+
export type RangeSource = 'manual' | 'anibridge' | 'scudlee' | 'fribb' | 'derived';
|
|
5
|
+
export type Confidence = 'verified' | 'inferred' | 'derived';
|
|
6
|
+
|
|
7
|
+
export interface Range {
|
|
8
|
+
target: SeriesProvider;
|
|
9
|
+
targetId: string;
|
|
10
|
+
/** 0 = specials, null = absolute numbering. */
|
|
11
|
+
season: number | null;
|
|
12
|
+
/** Entry episodes, 1-based, inclusive. */
|
|
13
|
+
from: number;
|
|
14
|
+
to: number;
|
|
15
|
+
/** Target episodes in `season`, inclusive. */
|
|
16
|
+
targetFrom: number;
|
|
17
|
+
targetTo: number;
|
|
18
|
+
/** Target episodes per entry episode. */
|
|
19
|
+
ratio: number;
|
|
20
|
+
source: RangeSource;
|
|
21
|
+
confidence: Confidence;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Entry {
|
|
25
|
+
/** First present of anilist, mal, anidb, kitsu, as a ref. */
|
|
26
|
+
id: string;
|
|
27
|
+
ids: Record<AnimeProvider, string | null>;
|
|
28
|
+
title: string;
|
|
29
|
+
titles: string[];
|
|
30
|
+
format: Format;
|
|
31
|
+
episodes: number | null;
|
|
32
|
+
year: number | null;
|
|
33
|
+
/** Up to 12 lower-case genre-like tags, genres first. Optional: older datasets lack it. */
|
|
34
|
+
tags?: string[];
|
|
35
|
+
/** 0–10, one decimal; null when unknown. Optional: older datasets lack it. */
|
|
36
|
+
score?: number | null;
|
|
37
|
+
series: { tvdb: string | null; tmdb: string | null; imdb: string | null };
|
|
38
|
+
images: Images;
|
|
39
|
+
ranges: Range[];
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Artwork URLs, each null when unknown. `cover` may 404; fall back to the poster. */
|
|
44
|
+
export interface Images {
|
|
45
|
+
poster: string | null;
|
|
46
|
+
thumb: string | null;
|
|
47
|
+
cover: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type EntrySummary = Omit<Entry, 'ranges'> & {
|
|
51
|
+
names: Names;
|
|
52
|
+
/** On search() results: the Cinemeta-format id of the entry's title, null when none is cached. */
|
|
53
|
+
titleId?: string | null;
|
|
54
|
+
rangeCount: number;
|
|
55
|
+
issueCount: number;
|
|
56
|
+
/** Targets the entry has effective ranges for, in the order tvdb, tmdb, imdb. */
|
|
57
|
+
targets: SeriesProvider[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface Issue {
|
|
61
|
+
id: string;
|
|
62
|
+
type: 'overlap' | 'gap' | 'count' | 'unmapped' | 'conflict';
|
|
63
|
+
target?: SeriesProvider;
|
|
64
|
+
targetId?: string;
|
|
65
|
+
season?: number | null;
|
|
66
|
+
from?: number;
|
|
67
|
+
to?: number;
|
|
68
|
+
entries: string[];
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SeriesResponse {
|
|
73
|
+
series: { provider: SeriesProvider; id: string };
|
|
74
|
+
/** Most common title among the series' entries. */
|
|
75
|
+
title: string | null;
|
|
76
|
+
images: Images | null;
|
|
77
|
+
/** Ordered 1, 2, …, then 0 (specials), then null (absolute). */
|
|
78
|
+
seasons: Array<{
|
|
79
|
+
season: number | null;
|
|
80
|
+
/** Known season length, or null. */
|
|
81
|
+
length: number | null;
|
|
82
|
+
rows: Array<{ entry: EntrySummary; range: Range }>;
|
|
83
|
+
issues: Issue[];
|
|
84
|
+
}>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SeriesEpisode {
|
|
88
|
+
provider: SeriesProvider;
|
|
89
|
+
id: string;
|
|
90
|
+
season: number | null;
|
|
91
|
+
episode: number;
|
|
92
|
+
/** Target episodes covered by the entry episode (ratio > 1). */
|
|
93
|
+
span: number;
|
|
94
|
+
source: RangeSource;
|
|
95
|
+
confidence: Confidence;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface EntryEpisode {
|
|
99
|
+
/** Entry id, e.g. "anilist:129347". */
|
|
100
|
+
entry: string;
|
|
101
|
+
ids: Record<AnimeProvider, string | null>;
|
|
102
|
+
episode: number;
|
|
103
|
+
source: RangeSource;
|
|
104
|
+
confidence: Confidence;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Meta {
|
|
108
|
+
builtAt: string | null;
|
|
109
|
+
importedAt: string | null;
|
|
110
|
+
counts: { entries: number; ranges: number; issues: number };
|
|
111
|
+
sources: Record<string, { version: string | null; fetchedAt: string | null; count: number }>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface Revision {
|
|
115
|
+
id: string;
|
|
116
|
+
at: string;
|
|
117
|
+
keyName: string;
|
|
118
|
+
kind: 'ranges' | 'ids';
|
|
119
|
+
target?: SeriesProvider | null;
|
|
120
|
+
before: unknown;
|
|
121
|
+
after: unknown;
|
|
122
|
+
note?: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ApiKeyInfo {
|
|
126
|
+
id: string;
|
|
127
|
+
name: string;
|
|
128
|
+
role: 'read' | 'admin';
|
|
129
|
+
rateLimit: number;
|
|
130
|
+
createdAt: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Titles (Cinemeta-format ids) -------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export type TitleType = 'series' | 'movie';
|
|
136
|
+
|
|
137
|
+
export interface Rating {
|
|
138
|
+
/** 0–10, or null (TVDB has no user rating; its popularity count is `score`). */
|
|
139
|
+
value: number | null;
|
|
140
|
+
votes: number | null;
|
|
141
|
+
score?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface CastMember {
|
|
145
|
+
name: string;
|
|
146
|
+
character: string | null;
|
|
147
|
+
photo: string | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface TitleSeason {
|
|
151
|
+
season: number | null;
|
|
152
|
+
episodes: number | null;
|
|
153
|
+
mappedEpisodes: number | null;
|
|
154
|
+
poster: string | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A title's name in three forms; each null when unknown. */
|
|
158
|
+
export interface Names {
|
|
159
|
+
/** English only (the `name` rule). */
|
|
160
|
+
english: string | null;
|
|
161
|
+
/** Kitsu's romanisation (en_jp, en_cn or en_kr). */
|
|
162
|
+
romaji: string | null;
|
|
163
|
+
/** ja_jp, zh_cn or ko_kr. */
|
|
164
|
+
native: string | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface TitleSummary {
|
|
168
|
+
/** Cinemeta-format id: "tt…", else "kitsu:<id>", "tmdb:<id>", "tvdb:<id>", then "mal:", "anilist:" or "anidb:". */
|
|
169
|
+
id: string;
|
|
170
|
+
type: TitleType;
|
|
171
|
+
/** names.english, else names.romaji. */
|
|
172
|
+
name: string | null;
|
|
173
|
+
names: Names;
|
|
174
|
+
year: string | null;
|
|
175
|
+
format: Format;
|
|
176
|
+
poster: string | null;
|
|
177
|
+
background: string | null;
|
|
178
|
+
genres: string[];
|
|
179
|
+
episodes: number | null;
|
|
180
|
+
mappedEpisodes: number | null;
|
|
181
|
+
providers: Partial<Record<AnimeProvider | SeriesProvider, boolean>>;
|
|
182
|
+
entryCount: number;
|
|
183
|
+
issueCount: number;
|
|
184
|
+
rating: number | null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface Title {
|
|
188
|
+
id: string;
|
|
189
|
+
type: TitleType;
|
|
190
|
+
ids: Record<SeriesProvider | AnimeProvider, string | null>;
|
|
191
|
+
/** names.english, else names.romaji. */
|
|
192
|
+
name: string | null;
|
|
193
|
+
names: Names;
|
|
194
|
+
originalName: string | null;
|
|
195
|
+
description: string | null;
|
|
196
|
+
genres: string[];
|
|
197
|
+
year: string | null;
|
|
198
|
+
releaseInfo: string | null;
|
|
199
|
+
released: string | null;
|
|
200
|
+
status: string | null;
|
|
201
|
+
runtime: string | null;
|
|
202
|
+
country: string | null;
|
|
203
|
+
certification: string | null;
|
|
204
|
+
format: Format;
|
|
205
|
+
poster: string | null;
|
|
206
|
+
background: string | null;
|
|
207
|
+
logo: string | null;
|
|
208
|
+
/** Each source keeps its own rating; never merged. */
|
|
209
|
+
ratings: Partial<Record<'imdb' | 'tmdb' | 'tvdb' | 'kitsu', Rating | null>>;
|
|
210
|
+
cast: CastMember[];
|
|
211
|
+
directors: string[];
|
|
212
|
+
writers: string[];
|
|
213
|
+
seasons: TitleSeason[];
|
|
214
|
+
entries: Array<EntrySummary & { range: Range | null }>;
|
|
215
|
+
coverage: { episodes: number | null; mappedEpisodes: number | null };
|
|
216
|
+
similar: TitleSummary[];
|
|
217
|
+
/** Field → source that filled it (cinemeta, kitsu, aniscraper, and tmdb/tvdb/fanart when enriched in-process). */
|
|
218
|
+
provenance: Record<string, string>;
|
|
219
|
+
layers: Record<string, string>;
|
|
220
|
+
enrichedAt: string | null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface TitleEpisode {
|
|
224
|
+
season: number;
|
|
225
|
+
episode: number;
|
|
226
|
+
name: string | null;
|
|
227
|
+
overview: string | null;
|
|
228
|
+
released: string | null;
|
|
229
|
+
thumbnail: string | null;
|
|
230
|
+
runtime: string | null;
|
|
231
|
+
rating: number | null;
|
|
232
|
+
mapped: EntryEpisode[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface TitleEpisodes {
|
|
236
|
+
season: number | null;
|
|
237
|
+
/** Every season with episodes, ordered 1, 2, …, then 0. */
|
|
238
|
+
seasons: number[];
|
|
239
|
+
episodes: TitleEpisode[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface TitlesQuery {
|
|
243
|
+
/** Names and aliases; an id (tt…, tmdb:N, kitsu:N, …) is a lookup of that id. */
|
|
244
|
+
q?: string;
|
|
245
|
+
type?: TitleType;
|
|
246
|
+
format?: Format;
|
|
247
|
+
year?: number;
|
|
248
|
+
genre?: string;
|
|
249
|
+
mapped?: 'full' | 'partial' | 'none';
|
|
250
|
+
/** Only titles with mapping issues. */
|
|
251
|
+
issues?: boolean;
|
|
252
|
+
sort?: 'popular' | 'updated' | 'title' | 'year';
|
|
253
|
+
/** `next` from the previous page. */
|
|
254
|
+
cursor?: string;
|
|
255
|
+
limit?: number;
|
|
256
|
+
/** false: the server answers from its cache only, with no upstream call. */
|
|
257
|
+
live?: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface TitlesPage {
|
|
261
|
+
results: TitleSummary[];
|
|
262
|
+
total: number;
|
|
263
|
+
next: string | null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface ActivityItem {
|
|
267
|
+
at: string;
|
|
268
|
+
kind: 'ranges' | 'ids';
|
|
269
|
+
title: TitleSummary | null;
|
|
270
|
+
entry: string;
|
|
271
|
+
/** e.g. "Mapped episodes 1–13 to TVDB S1 E79–91". */
|
|
272
|
+
summary: string;
|
|
273
|
+
keyName: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface Stats {
|
|
277
|
+
titles: number;
|
|
278
|
+
entries: number;
|
|
279
|
+
mappedEpisodes: number;
|
|
280
|
+
ranges: number;
|
|
281
|
+
sources: string[];
|
|
282
|
+
importedAt: string | null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- BYOK ---------------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
export type ByokProvider = 'tmdb' | 'tvdb' | 'fanart';
|
|
288
|
+
|
|
289
|
+
export interface ProviderKeys {
|
|
290
|
+
/** TMDB v3 API key or v4 read access token (detected by shape). */
|
|
291
|
+
tmdb?: string | { key: string };
|
|
292
|
+
/** TVDB v4 key, with the subscriber PIN when the key needs one. */
|
|
293
|
+
tvdb?: string | { key: string; pin?: string };
|
|
294
|
+
fanart?: string | { key: string };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Anything Storage-like; methods may be synchronous (localStorage) or return promises. */
|
|
298
|
+
export interface CacheStorage {
|
|
299
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
300
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
301
|
+
removeItem(key: string): void | Promise<void>;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type ByokErrorCode = 'invalid_key' | 'rate_limited' | 'network' | 'timeout' | 'not_found' | 'bad_response' | 'aborted';
|
|
305
|
+
|
|
306
|
+
export class ByokError extends Error {
|
|
307
|
+
constructor(provider: ByokProvider | string, code: ByokErrorCode, message: string, options?: { status?: number | null; retryAfter?: number | null });
|
|
308
|
+
name: 'ByokError';
|
|
309
|
+
provider: ByokProvider | string;
|
|
310
|
+
code: ByokErrorCode;
|
|
311
|
+
status: number | null;
|
|
312
|
+
/** Seconds, on rate_limited. */
|
|
313
|
+
retryAfter: number | null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface ProviderErrorInfo {
|
|
317
|
+
provider: ByokProvider;
|
|
318
|
+
code: ByokErrorCode;
|
|
319
|
+
message: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface KeyCheck {
|
|
323
|
+
state: 'connected' | 'invalid' | 'rate_limited' | 'error' | 'missing';
|
|
324
|
+
message: string | null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Raw provider calls (provider JSON, not normalized). Every method takes an optional AbortSignal last. */
|
|
328
|
+
export interface Providers {
|
|
329
|
+
tmdb: null | {
|
|
330
|
+
/** 'v3' (api_key) or 'v4' (Bearer token), from the key's shape. */
|
|
331
|
+
kind: 'v3' | 'v4';
|
|
332
|
+
check(signal?: AbortSignal): Promise<unknown>;
|
|
333
|
+
/** GET /find/{imdbId}?external_source=imdb_id */
|
|
334
|
+
findByImdb(imdbId: string, signal?: AbortSignal): Promise<any>;
|
|
335
|
+
/** GET /tv/{id} with aggregate_credits, recommendations, external_ids, content_ratings. */
|
|
336
|
+
series(tmdbId: string, signal?: AbortSignal): Promise<any>;
|
|
337
|
+
/** GET /movie/{id} with credits, recommendations, external_ids, release_dates. */
|
|
338
|
+
movie(tmdbId: string, signal?: AbortSignal): Promise<any>;
|
|
339
|
+
/** GET /tv/{id}/season/{n} */
|
|
340
|
+
season(tmdbId: string, season: number, signal?: AbortSignal): Promise<any>;
|
|
341
|
+
/** GET /search/tv or /search/movie. */
|
|
342
|
+
search(query: string, options?: { type?: 'tv' | 'movie'; year?: number }, signal?: AbortSignal): Promise<any>;
|
|
343
|
+
};
|
|
344
|
+
tvdb: null | {
|
|
345
|
+
/** Logs in (POST /login); resolves when the key works. */
|
|
346
|
+
check(signal?: AbortSignal): Promise<{ ok: true }>;
|
|
347
|
+
/** GET /series/{id}/extended?meta=translations */
|
|
348
|
+
series(tvdbId: string, signal?: AbortSignal): Promise<any>;
|
|
349
|
+
/** GET /movies/{id}/extended?meta=translations */
|
|
350
|
+
movie(tvdbId: string, signal?: AbortSignal): Promise<any>;
|
|
351
|
+
/** GET /series/{id}/episodes/default/eng?season={n} (first page) */
|
|
352
|
+
episodes(tvdbId: string, season: number, signal?: AbortSignal): Promise<any>;
|
|
353
|
+
/** Every page of /series/{id}/episodes/{order}/{lang}: names and overviews translated, absoluteNumber kept. */
|
|
354
|
+
allEpisodes(tvdbId: string, options?: { order?: 'official' | 'default' | 'absolute' | 'dvd'; lang?: string; signal?: AbortSignal }): Promise<any[]>;
|
|
355
|
+
/** GET /series|movies/{id}/translations/{lang} (English by default). */
|
|
356
|
+
translation(tvdbId: string, options?: { lang?: string; kind?: 'series' | 'movie' }, signal?: AbortSignal): Promise<any>;
|
|
357
|
+
/** GET /series/{id}/artworks; pick with pickTvdbArt from the "/byok" entry. */
|
|
358
|
+
artworks(tvdbId: string, signal?: AbortSignal): Promise<any>;
|
|
359
|
+
/** GET /search/remoteid/{id} (an IMDb id → TVDB hits). */
|
|
360
|
+
searchRemoteId(remoteId: string, signal?: AbortSignal): Promise<any>;
|
|
361
|
+
};
|
|
362
|
+
fanart: null | {
|
|
363
|
+
check(signal?: AbortSignal): Promise<unknown>;
|
|
364
|
+
/** GET /tv/{tvdbId} */
|
|
365
|
+
series(tvdbId: string, signal?: AbortSignal): Promise<any>;
|
|
366
|
+
/** GET /movies/{tmdbId} */
|
|
367
|
+
movie(tmdbId: string, signal?: AbortSignal): Promise<any>;
|
|
368
|
+
};
|
|
369
|
+
/** Keyless; null when turned off. Results go through the response cache. */
|
|
370
|
+
kitsu: null | {
|
|
371
|
+
/** One anime with labelled titles and categories; null when Kitsu has no such id. */
|
|
372
|
+
anime(kitsuId: string, signal?: AbortSignal): Promise<KitsuAnime | null>;
|
|
373
|
+
/** Every episode sorted by number (default at most 500). */
|
|
374
|
+
episodes(kitsuId: string, options?: { max?: number; signal?: AbortSignal }): Promise<KitsuEpisode[]>;
|
|
375
|
+
/** Media relationships with the destination's subtype. */
|
|
376
|
+
relationships(kitsuId: string, signal?: AbortSignal): Promise<KitsuRelationship[]>;
|
|
377
|
+
/** Text search in Kitsu's relevance order (limit 1–20, default 5). */
|
|
378
|
+
search(text: string, options?: { limit?: number; signal?: AbortSignal }): Promise<KitsuAnime[]>;
|
|
379
|
+
};
|
|
380
|
+
/** Keyless; null when turned off. Results go through the response cache. */
|
|
381
|
+
cinemeta: null | {
|
|
382
|
+
/** The raw Cinemeta meta (videos included), or null when Cinemeta has none of that type. */
|
|
383
|
+
meta(imdbId: string, type?: TitleType, signal?: AbortSignal): Promise<any | null>;
|
|
384
|
+
/** `type` first, then the other: { type, meta } or null. */
|
|
385
|
+
find(imdbId: string, type?: TitleType, signal?: AbortSignal): Promise<{ type: TitleType; meta: any } | null>;
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface KitsuAnime {
|
|
390
|
+
id: string;
|
|
391
|
+
slug: string | null;
|
|
392
|
+
titles: {
|
|
393
|
+
canonical: string | null;
|
|
394
|
+
/** en, else en_us */
|
|
395
|
+
en: string | null;
|
|
396
|
+
/** en_jp, else en_cn, else en_kr */
|
|
397
|
+
romaji: string | null;
|
|
398
|
+
/** ja_jp, else zh_cn, else ko_kr */
|
|
399
|
+
native: string | null;
|
|
400
|
+
/** Every other titles.* value. */
|
|
401
|
+
synonyms: string[];
|
|
402
|
+
/** abbreviatedTitles */
|
|
403
|
+
abbreviated: string[];
|
|
404
|
+
};
|
|
405
|
+
synopsis: string | null;
|
|
406
|
+
/** TV, ONA, OVA, movie, special, music */
|
|
407
|
+
subtype: string | null;
|
|
408
|
+
showType: string | null;
|
|
409
|
+
/** current, finished, tba, unreleased, upcoming */
|
|
410
|
+
status: string | null;
|
|
411
|
+
startDate: string | null;
|
|
412
|
+
endDate: string | null;
|
|
413
|
+
nextRelease: string | null;
|
|
414
|
+
episodeCount: number | null;
|
|
415
|
+
episodeLength: number | null;
|
|
416
|
+
ageRating: string | null;
|
|
417
|
+
nsfw: boolean | null;
|
|
418
|
+
posterImage: Record<string, any> | null;
|
|
419
|
+
coverImage: Record<string, any> | null;
|
|
420
|
+
youtubeVideoId: string | null;
|
|
421
|
+
/** Kitsu's 0–100 average. */
|
|
422
|
+
averageRating: number | null;
|
|
423
|
+
/** 0–10, one decimal. */
|
|
424
|
+
rating: number | null;
|
|
425
|
+
userCount: number | null;
|
|
426
|
+
popularityRank: number | null;
|
|
427
|
+
/** Kitsu's genres (the `genres` attribute is always empty). */
|
|
428
|
+
categories: string[];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface KitsuEpisode {
|
|
432
|
+
number: number;
|
|
433
|
+
seasonNumber: number;
|
|
434
|
+
relativeNumber: number | null;
|
|
435
|
+
title: string | null;
|
|
436
|
+
titles: Record<string, string>;
|
|
437
|
+
synopsis: string | null;
|
|
438
|
+
/** The real air date, or null; Kitsu's createdAt (set when an episode is only planned) is never used. */
|
|
439
|
+
airdate: string | null;
|
|
440
|
+
length: number | null;
|
|
441
|
+
thumbnail: string | null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export interface KitsuRelationship {
|
|
445
|
+
/** prequel, sequel, side_story, parent_story, alternative_setting, spinoff, summary, full_story, character, other, … */
|
|
446
|
+
role: string | null;
|
|
447
|
+
id: string;
|
|
448
|
+
type: string;
|
|
449
|
+
subtype: string | null;
|
|
450
|
+
title: string | null;
|
|
451
|
+
titles: Record<string, string>;
|
|
452
|
+
episodeCount: number | null;
|
|
453
|
+
status: string | null;
|
|
454
|
+
startDate: string | null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Your own store for API answers; sync or async. Values are JSON-serialisable. */
|
|
458
|
+
export interface ResponseStore {
|
|
459
|
+
get(key: string): unknown | Promise<unknown>;
|
|
460
|
+
set(key: string, value: unknown, ttlMs: number): void | Promise<void>;
|
|
461
|
+
delete?(key: string): void | Promise<void>;
|
|
462
|
+
clear?(): void | Promise<void>;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export interface ClientOptions {
|
|
466
|
+
apiKey: string;
|
|
467
|
+
/** API origin, e.g. "https://animap.example.com"; a trailing "/v1" is accepted. */
|
|
468
|
+
baseUrl: string;
|
|
469
|
+
/**
|
|
470
|
+
* Cache for API answers and keyless provider results. Default: in memory, 1 h for hits,
|
|
471
|
+
* 5 min for misses, 5000 items. false turns it off; a store replaces the memory one.
|
|
472
|
+
*/
|
|
473
|
+
cache?: false | { ttlMs?: number; missTtlMs?: number; max?: number; store?: ResponseStore } | ResponseStore;
|
|
474
|
+
/** @deprecated Use cache: { ttlMs }. */
|
|
475
|
+
cacheTtlMs?: number | false;
|
|
476
|
+
/** Retries of network errors, 429 (after Retry-After, at most maxWaitMs) and 502–504. Default { max: 1, maxWaitMs: 10000 }. */
|
|
477
|
+
retry?: false | { max?: number; maxWaitMs?: number };
|
|
478
|
+
/** Per API key: after `threshold` consecutive failures, pause `cooldownMs` (a 429 pauses for its Retry-After; cap 5 min). Default { threshold: 5, cooldownMs: 30000 }. */
|
|
479
|
+
breaker?: false | { threshold?: number; cooldownMs?: number };
|
|
480
|
+
/** fetch implementation, for the API and the providers; defaults to the global fetch. */
|
|
481
|
+
fetch?: typeof fetch;
|
|
482
|
+
/** Per-attempt API timeout in ms (default 10000). */
|
|
483
|
+
timeoutMs?: number;
|
|
484
|
+
/**
|
|
485
|
+
* Your own provider keys (only ever sent to that provider), and the keyless Kitsu and
|
|
486
|
+
* Cinemeta clients (on by default; false turns one off).
|
|
487
|
+
*/
|
|
488
|
+
providers?: ProviderKeys & {
|
|
489
|
+
kitsu?: boolean | { baseUrl?: string; timeoutMs?: number };
|
|
490
|
+
cinemeta?: boolean | { baseUrl?: string; timeoutMs?: number };
|
|
491
|
+
};
|
|
492
|
+
/** Where provider results are cached for 7 days (default: in memory). */
|
|
493
|
+
providerCache?: CacheStorage;
|
|
494
|
+
/** Provider request timeout in ms (default 8000). */
|
|
495
|
+
providerTimeoutMs?: number;
|
|
496
|
+
/** Called for every provider failure; the call that triggered it still succeeds. */
|
|
497
|
+
onProviderError?: (error: ByokError) => void;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Same as ClientOptions. */
|
|
501
|
+
export type AniMapOptions = ClientOptions;
|
|
502
|
+
|
|
503
|
+
export type ErrorCode =
|
|
504
|
+
| 'unauthorized' | 'forbidden' | 'not_found' | 'bad_request'
|
|
505
|
+
/** The API could not answer: see `reason`. Fall back to another source. */
|
|
506
|
+
| 'unavailable'
|
|
507
|
+
| 'missing_api_key' | 'missing_base_url' | 'invalid_ref' | 'invalid_argument' | 'invalid_response';
|
|
508
|
+
|
|
509
|
+
export type UnavailableReason = 'network_error' | 'timeout' | 'rate_limited' | 'server_error' | 'circuit_open';
|
|
510
|
+
|
|
511
|
+
export class ClientError extends Error {
|
|
512
|
+
constructor(code: ErrorCode | string, message: string, status?: number | null, extra?: { details?: unknown; reason?: UnavailableReason; retryAfterMs?: number });
|
|
513
|
+
name: 'ClientError';
|
|
514
|
+
code: ErrorCode | string;
|
|
515
|
+
/** HTTP status, or null when there was no response. */
|
|
516
|
+
status: number | null;
|
|
517
|
+
/** `error.details` from the server, when present. */
|
|
518
|
+
details?: unknown;
|
|
519
|
+
/** With code "unavailable". */
|
|
520
|
+
reason?: UnavailableReason;
|
|
521
|
+
/** With reason "rate_limited" or "circuit_open": how long to wait. */
|
|
522
|
+
retryAfterMs?: number;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export interface ParsedRef {
|
|
526
|
+
kind: 'anime' | 'series';
|
|
527
|
+
provider: AnimeProvider | SeriesProvider;
|
|
528
|
+
id: string;
|
|
529
|
+
/** Normalised ref: "kitsu:44607", "tvdb:342329", "tt8948436". */
|
|
530
|
+
ref: string;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Parse and normalise a ref; throws ClientError "invalid_ref". */
|
|
534
|
+
export function parseRef(ref: string): ParsedRef;
|
|
535
|
+
|
|
536
|
+
/** Normalise a title id or ref ("tt…", "kitsu:44607", "tvdb:342329", "tmdb:movie:129"); throws "invalid_ref". */
|
|
537
|
+
export function parseTitleRef(ref: string): string;
|
|
538
|
+
|
|
539
|
+
/** Retry-After header → milliseconds to wait, capped at 10 s. */
|
|
540
|
+
export function retryAfterMs(header: string | null | undefined, now?: number): number;
|
|
541
|
+
|
|
542
|
+
export { createByokCache, memoryStorage, mergeTitle, mergeEpisodes } from './byok/index.js';
|
|
543
|
+
|
|
544
|
+
/** In-memory response store with per-item expiry. */
|
|
545
|
+
export function memoryStore(options?: { max?: number; now?: () => number }): ResponseStore & { readonly size: number };
|
|
546
|
+
|
|
547
|
+
export interface Slice {
|
|
548
|
+
season: number | null;
|
|
549
|
+
/** series episode = entry episode + offset */
|
|
550
|
+
offset: number;
|
|
551
|
+
/** Last series episode of the slice. */
|
|
552
|
+
end: number;
|
|
553
|
+
/** The target whose range was used (may be borrowed). */
|
|
554
|
+
target: SeriesProvider;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Where an entry sits in a series for 1:1 numbering. A target without a 1:1 range borrows
|
|
559
|
+
* another's (imdb/cinemeta: imdb, tvdb, tmdb; tvdb: tvdb, imdb, tmdb; tmdb: tmdb, tvdb, imdb).
|
|
560
|
+
*/
|
|
561
|
+
export function alignToEntry(ranges: Range[], target: SeriesProvider | 'cinemeta'): Slice | null;
|
|
562
|
+
/** Per target, the first 1:1 range by entry episode. */
|
|
563
|
+
export function slicesFromRanges(ranges: Range[]): Partial<Record<SeriesProvider, { season: number | null; offset: number; end: number; from: number; to: number }>>;
|
|
564
|
+
|
|
565
|
+
export class Client {
|
|
566
|
+
constructor(options: ClientOptions);
|
|
567
|
+
|
|
568
|
+
/** A client for another API key that shares this one's cache, providers and breaker map (the breaker state is per key). */
|
|
569
|
+
withApiKey(apiKey: string): Client;
|
|
570
|
+
/** Milliseconds until the API is called again for this key after repeated failures; 0 when available. */
|
|
571
|
+
readonly pausedFor: number;
|
|
572
|
+
|
|
573
|
+
/** Provider clients: tmdb/tvdb/fanart with your keys (null without one), keyless kitsu/cinemeta (null when off). */
|
|
574
|
+
readonly providers: Providers;
|
|
575
|
+
/** Providers with a key, e.g. ['tmdb', 'tvdb']. */
|
|
576
|
+
readonly enrichment: ByokProvider[];
|
|
577
|
+
|
|
578
|
+
/** Entry for an anime ref ("kitsu:44607"), or null when unknown. */
|
|
579
|
+
entry(ref: string): Promise<Entry | null>;
|
|
580
|
+
/** Entries for many anime refs (sent in batches of 100), keyed by the refs as given. */
|
|
581
|
+
lookup(refs: string[]): Promise<Map<string, Entry | null>>;
|
|
582
|
+
/** Series episode for an entry episode, or null when unmapped. */
|
|
583
|
+
toSeries(ref: string, episode: number, options?: { target?: SeriesProvider }): Promise<SeriesEpisode | null>;
|
|
584
|
+
/** Entry episodes for a series episode ("tt8948436", 1, 85). */
|
|
585
|
+
fromSeries(ref: string, season: number, episode: number): Promise<EntryEpisode[]>;
|
|
586
|
+
/** Every mapped entry of a series ("tvdb:342329"), grouped by season. */
|
|
587
|
+
series(ref: string, options?: { season?: number }): Promise<SeriesResponse>;
|
|
588
|
+
/** Entry search by title, alias or id. */
|
|
589
|
+
search(query: string, options?: { limit?: number; /** false: the server's cache only, no upstream call. */ live?: boolean }): Promise<EntrySummary[]>;
|
|
590
|
+
/** Mapping issues, optionally for one entry and one type. */
|
|
591
|
+
issues(options?: { type?: Issue['type']; entry?: string; cursor?: string; limit?: number }): Promise<{ results: Issue[]; next: string | null }>;
|
|
592
|
+
/** Edit history of an entry, newest first. */
|
|
593
|
+
history(ref: string): Promise<Revision[]>;
|
|
594
|
+
|
|
595
|
+
/** A title by id or any ref; enriched from your provider keys unless `enrich: false`. */
|
|
596
|
+
title(ref: string, options?: { enrich?: boolean; signal?: AbortSignal }): Promise<Title | null>;
|
|
597
|
+
/** Fill a Title from the configured providers (never overwrites what the API filled). */
|
|
598
|
+
enrichTitle(title: Title, options?: { signal?: AbortSignal }): Promise<{
|
|
599
|
+
title: Title;
|
|
600
|
+
filled: Record<string, ByokProvider>;
|
|
601
|
+
errors: ProviderErrorInfo[];
|
|
602
|
+
ids: Title['ids'] | null;
|
|
603
|
+
}>;
|
|
604
|
+
/** Episodes of one season (the first when omitted); enriched from TMDB/TVDB unless `enrich: false`. */
|
|
605
|
+
titleEpisodes(ref: string, options?: { season?: number; enrich?: boolean; signal?: AbortSignal }): Promise<TitleEpisodes | null>;
|
|
606
|
+
/** Fill episode rows of one season from TMDB, then TVDB. */
|
|
607
|
+
enrichEpisodes(title: Title, season: number, episodes: TitleEpisode[], options?: { signal?: AbortSignal }): Promise<{
|
|
608
|
+
episodes: TitleEpisode[];
|
|
609
|
+
filledBy: ByokProvider[];
|
|
610
|
+
errors: ProviderErrorInfo[];
|
|
611
|
+
}>;
|
|
612
|
+
/** Browse or search titles. */
|
|
613
|
+
titles(query?: TitlesQuery): Promise<TitlesPage>;
|
|
614
|
+
/** Kitsu's trending anime, resolved to titles. */
|
|
615
|
+
trending(options?: { limit?: number }): Promise<TitleSummary[]>;
|
|
616
|
+
/** Recent mapping edits. */
|
|
617
|
+
activity(options?: { limit?: number }): Promise<ActivityItem[]>;
|
|
618
|
+
/** Catalogue counts. */
|
|
619
|
+
stats(): Promise<Stats>;
|
|
620
|
+
/** The most common genres. */
|
|
621
|
+
genres(): Promise<Array<{ name: string; count: number }>>;
|
|
622
|
+
|
|
623
|
+
/** Dataset build/import times, counts and source versions. */
|
|
624
|
+
meta(): Promise<Meta>;
|
|
625
|
+
/** The API key in use. */
|
|
626
|
+
me(): Promise<ApiKeyInfo>;
|
|
627
|
+
/** Check a provider key against the provider itself. */
|
|
628
|
+
checkProvider(provider: ByokProvider): Promise<KeyCheck>;
|
|
629
|
+
/** Forget every cached API response (provider results stay in the provider cache). */
|
|
630
|
+
clearCache(): Promise<void>;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** AniMap is Client; AniMapError is ClientError. */
|
|
634
|
+
export { Client as AniMap, ClientError as AniMapError };
|
|
635
|
+
export default Client;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// AniMap client: the anime id mapping API, with optional in-process provider enrichment.
|
|
2
|
+
// Zero dependencies; runs on Node >= 18 and in browsers (global fetch).
|
|
3
|
+
|
|
4
|
+
import { Client } from './client.js';
|
|
5
|
+
import { ClientError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
export { Client, retryAfterMs } from './client.js';
|
|
8
|
+
export { ClientError } from './errors.js';
|
|
9
|
+
export { parseRef, parseTitleRef } from './refs.js';
|
|
10
|
+
export { alignToEntry, slicesFromRanges } from './align.js';
|
|
11
|
+
export { memoryStore } from './cache.js';
|
|
12
|
+
export { ByokError } from './byok/http.js';
|
|
13
|
+
export { createByokCache, memoryStorage } from './byok/cache.js';
|
|
14
|
+
export { mergeEpisodes, mergeTitle } from './byok/merge.js';
|
|
15
|
+
|
|
16
|
+
// Brand-named aliases of the same classes.
|
|
17
|
+
export { Client as AniMap, ClientError as AniMapError };
|
|
18
|
+
export default Client;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Cinemeta (keyless): Stremio's IMDb-based catalogue. Returns metas exactly as Cinemeta
|
|
2
|
+
// serves them (videos included). A meta without a name means "not this type".
|
|
3
|
+
import { providerFetch } from '../byok/http.js';
|
|
4
|
+
|
|
5
|
+
export const CINEMETA_API = 'https://v3-cinemeta.strem.io';
|
|
6
|
+
|
|
7
|
+
export function createCinemeta({ fetch, timeoutMs = 10_000, baseUrl = CINEMETA_API } = {}) {
|
|
8
|
+
async function meta(imdbId, type = 'series', signal) {
|
|
9
|
+
if (!/^tt\d+$/.test(String(imdbId))) return null;
|
|
10
|
+
if (type !== 'series' && type !== 'movie') throw new TypeError('type must be "series" or "movie"');
|
|
11
|
+
try {
|
|
12
|
+
const body = await providerFetch('cinemeta', `${baseUrl}/meta/${type}/${imdbId}.json`, { fetch, signal, timeoutMs });
|
|
13
|
+
return body?.meta?.name ? body.meta : null;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
if (e.code === 'not_found') return null;
|
|
16
|
+
throw e;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
/** The raw meta for one type, or null. */
|
|
22
|
+
meta,
|
|
23
|
+
/** Try `type` first, then the other one: { type, meta } or null. */
|
|
24
|
+
async find(imdbId, type = 'series', signal) {
|
|
25
|
+
for (const t of type === 'movie' ? ['movie', 'series'] : ['series', 'movie']) {
|
|
26
|
+
const m = await meta(imdbId, t, signal);
|
|
27
|
+
if (m) return { type: t, meta: m };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|