@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/README.md
CHANGED
|
@@ -1,3 +1,269 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AniMap
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The JavaScript client for AniMap, the anime id mapping API. AniMap maps
|
|
4
|
+
anime-database entries (Kitsu, MyAnimeList, AniList, AniDB: one entry per season
|
|
5
|
+
or part) to series databases (TVDB, TMDB, IMDb), with season and episode ranges,
|
|
6
|
+
and serves whole titles in Cinemeta format:
|
|
7
|
+
|
|
8
|
+
kitsu:44607 episode 7 ⇄ tvdb:342329 S1E85 ⇄ tt8948436 S1E85
|
|
9
|
+
|
|
10
|
+
The client can also fill titles with data from TMDB, TVDB and Fanart.tv using
|
|
11
|
+
**your own keys, inside your own process**. It has no dependencies, ships as ESM
|
|
12
|
+
with TypeScript types, and runs on Node 18+ and in browsers.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install @vanomarb/animap
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## API key
|
|
21
|
+
|
|
22
|
+
Every request to the API needs a key (`amk_live_…`). Ask the operator of the
|
|
23
|
+
server you use; an admin creates keys on the web UI's Admin page or with
|
|
24
|
+
`POST /v1/keys`. The secret is shown once.
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import { Client } from '@vanomarb/animap';
|
|
30
|
+
|
|
31
|
+
const client = new Client({ apiKey: process.env.ANIMAP_KEY, baseUrl: 'https://animap.example.com' });
|
|
32
|
+
|
|
33
|
+
await client.toSeries('kitsu:44607', 7, { target: 'imdb' });
|
|
34
|
+
// { provider: 'imdb', id: 'tt8948436', season: 1, episode: 85, span: 1, source: 'anibridge', confidence: 'inferred' }
|
|
35
|
+
|
|
36
|
+
const title = await client.title('kitsu:44607'); // the whole show, id "tt8948436"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`AniMap` and `AniMapError` are the same classes as `Client` and
|
|
40
|
+
`ClientError`; use whichever reads better:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { AniMap } from '@vanomarb/animap';
|
|
44
|
+
const animap = new AniMap({ apiKey: process.env.ANIMAP_KEY, baseUrl: 'https://animap.example.com' });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Options
|
|
48
|
+
|
|
49
|
+
| Option | Default | |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `apiKey` | required | Sent as `X-API-Key`. |
|
|
52
|
+
| `baseUrl` | required | API origin; a trailing `/v1` is fine. |
|
|
53
|
+
| `timeoutMs` | `10000` | Per API attempt. |
|
|
54
|
+
| `retry` | `{ max: 1, maxWaitMs: 10000 }` | Retries network errors, 429 (after `Retry-After`, at most `maxWaitMs`) and 502–504. `false`: one attempt. |
|
|
55
|
+
| `breaker` | `{ threshold: 5, cooldownMs: 30000 }` | Per API key. See [Failures](#failures-and-fallbacks). `false` turns it off. |
|
|
56
|
+
| `cache` | 1 h hits, 5 min misses, 5000 items | `{ ttlMs, missTtlMs, max }`, your own store (below), or `false`. |
|
|
57
|
+
| `fetch` | global `fetch` | Used for the API and every provider. |
|
|
58
|
+
| `providers` | none | Provider keys and keyless providers, see [BYOK](#bring-your-own-keys-byok). |
|
|
59
|
+
| `providerCache` | in memory | Where provider results are kept (7 days). |
|
|
60
|
+
| `providerTimeoutMs` | `8000` | Per provider request. |
|
|
61
|
+
| `onProviderError` | none | `(error) => void` for each provider failure. |
|
|
62
|
+
|
|
63
|
+
A cache store is any object with `get(key)` and `set(key, value, ttlMs)`, and
|
|
64
|
+
optionally `delete(key)` and `clear()`. The methods can be synchronous or return
|
|
65
|
+
promises, so a Redis adapter works. Values are JSON-serialisable.
|
|
66
|
+
|
|
67
|
+
## Failures and fallbacks
|
|
68
|
+
|
|
69
|
+
`null` (or `[]`) means **the API answered and does not know it**. If the API
|
|
70
|
+
**could not answer**, the call throws a `ClientError` with code `unavailable` and
|
|
71
|
+
a `reason`: `network_error`, `timeout`, `rate_limited`, `server_error` or
|
|
72
|
+
`circuit_open`. Use the second case to fall back to another source:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { ClientError } from '@vanomarb/animap';
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const entry = await client.entry('kitsu:44607'); // null: unknown entry
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof ClientError && err.code === 'unavailable') { /* use your own data */ }
|
|
81
|
+
else throw err;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Circuit breaker.** After `threshold` consecutive failures for one API key
|
|
86
|
+
(network errors, timeouts, 5xx, 429), the client stops calling the API for
|
|
87
|
+
`cooldownMs`. During that time calls throw at once with reason `circuit_open`
|
|
88
|
+
and `retryAfterMs`. A 429 opens the circuit straight away, for its
|
|
89
|
+
`Retry-After`. Pauses never last longer than 5 minutes. `client.pausedFor`
|
|
90
|
+
gives the milliseconds remaining.
|
|
91
|
+
|
|
92
|
+
The other codes are the server's (`unauthorized`, `forbidden`, `not_found`,
|
|
93
|
+
`bad_request`, with `details` on validation errors) and client-side ones
|
|
94
|
+
(`missing_api_key`, `missing_base_url`, `invalid_ref`, `invalid_argument`,
|
|
95
|
+
`invalid_response`). The client checks refs before sending anything.
|
|
96
|
+
|
|
97
|
+
## One client, many API keys
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const forUser = client.withApiKey(userKey); // shares cache, providers and breaker map
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use this when your users bring their own API key. Breaker state is kept per key.
|
|
104
|
+
|
|
105
|
+
## Refs
|
|
106
|
+
|
|
107
|
+
`kitsu:44607`, `mal:42496`, `anilist:129347`, `anidb:16201`, `tvdb:342329`,
|
|
108
|
+
`tmdb:76572`, and IMDb ids bare (`tt8948436`; `imdb:tt8948436` also works).
|
|
109
|
+
Title methods also take a title id and `tmdb:movie:<id>`.
|
|
110
|
+
|
|
111
|
+
## Entries and mapping
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
await client.entry('kitsu:44607'); // Entry | null
|
|
115
|
+
await client.lookup(['kitsu:44607', 'mal:1']); // Map<ref as given, Entry | null>, batches of 100
|
|
116
|
+
await client.toSeries('kitsu:44607', 7, { target: 'tvdb' });
|
|
117
|
+
// { provider, id, season, episode, span, source, confidence } | null
|
|
118
|
+
await client.fromSeries('tt8948436', 1, 85); // [{ entry, ids, episode, source, confidence }]
|
|
119
|
+
await client.series('tvdb:342329', { season: 1 }); // { series, title, images, seasons: [{ season, length, rows, issues }] }
|
|
120
|
+
await client.search('soul land', { limit: 5 }); // EntrySummary[]
|
|
121
|
+
await client.issues({ entry: 'kitsu:44607', type: 'count' }); // { results: Issue[], next }
|
|
122
|
+
await client.history('anilist:129347'); // Revision[], newest first
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
An Entry has `id`, `ids`, `title`, `titles`, `format`, `episodes`, `year`,
|
|
126
|
+
`tags`, `score`, `series` (`tvdb`/`tmdb`/`imdb`), `images` and `ranges`. A range
|
|
127
|
+
maps entry episodes `from`–`to` to `targetFrom`–`targetTo` of `season` (`0` =
|
|
128
|
+
specials, `null` = absolute numbering). `ratio` is the number of target episodes
|
|
129
|
+
per entry episode.
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
import { alignToEntry } from '@vanomarb/animap';
|
|
133
|
+
alignToEntry(entry.ranges, 'cinemeta'); // { season: 1, offset: 78, end: 91, target: 'imdb' } | null
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`alignToEntry` gives where a 1:1 entry sits in a series: series episode = entry
|
|
137
|
+
episode + `offset`. A target with no 1:1 range of its own borrows another
|
|
138
|
+
target's.
|
|
139
|
+
|
|
140
|
+
## Titles
|
|
141
|
+
|
|
142
|
+
A title is a whole show or film, with a Cinemeta-format id: `tt…`, else
|
|
143
|
+
`kitsu:<id>`, `tmdb:<id>`, `tvdb:<id>`.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
await client.title('kitsu:44607'); // Title | null (enriched when you set provider keys)
|
|
147
|
+
await client.title('tt8948436', { enrich: false });
|
|
148
|
+
await client.titleEpisodes('tt8948436', { season: 1 });
|
|
149
|
+
// { season: 1, seasons: [1, 0], episodes: [{ season, episode, name, overview, released, thumbnail, runtime, rating, mapped }] }
|
|
150
|
+
await client.titles({ q: 'soul land', type: 'series', genre: 'Action', sort: 'popular', limit: 24 });
|
|
151
|
+
// { results: TitleSummary[], total, next } — pass `next` back as `cursor`
|
|
152
|
+
await client.trending({ limit: 12 }); // TitleSummary[]
|
|
153
|
+
await client.activity({ limit: 20 }); // recent mapping edits
|
|
154
|
+
await client.stats(); // { titles, entries, mappedEpisodes, ranges, sources, importedAt }
|
|
155
|
+
await client.genres(); // [{ name, count }]
|
|
156
|
+
await client.meta(); // dataset build and import info
|
|
157
|
+
await client.me(); // the API key in use
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A Title has `name`, `originalName`, `description`, `genres`, `year`,
|
|
161
|
+
`releaseInfo`, `released`, `status`, `runtime`, `country`, `certification`,
|
|
162
|
+
`poster`, `background`, `logo`, `ratings` (one per source, never merged),
|
|
163
|
+
`cast`, `directors`, `writers`, `seasons`, `entries` (each with its best range),
|
|
164
|
+
`coverage`, `similar` and `provenance` (which source filled each field).
|
|
165
|
+
|
|
166
|
+
## Bring your own keys (BYOK)
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
const client = new Client({
|
|
170
|
+
apiKey, baseUrl,
|
|
171
|
+
providers: {
|
|
172
|
+
tmdb: { key: process.env.TMDB_KEY }, // v3 key or v4 read token
|
|
173
|
+
tvdb: { key: process.env.TVDB_KEY, pin: process.env.TVDB_PIN },
|
|
174
|
+
fanart: { key: process.env.FANART_KEY },
|
|
175
|
+
},
|
|
176
|
+
providerCache: localStorage, // in a browser; in memory by default
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Your keys stay in your process.** The client calls TMDB, TVDB and Fanart.tv
|
|
181
|
+
itself, from your server or the user's browser. A key goes only to its own
|
|
182
|
+
provider, never to the mapping API, and never into logs, error messages or the
|
|
183
|
+
cache. Without provider keys the client behaves exactly as if BYOK did not exist.
|
|
184
|
+
|
|
185
|
+
`title()` and `titleEpisodes()` fill **only what the API left empty**:
|
|
186
|
+
|
|
187
|
+
| Field | Filled from |
|
|
188
|
+
|---|---|
|
|
189
|
+
| description, genres, runtime, status, country, certification, directors, writers | TMDB, then TVDB |
|
|
190
|
+
| poster, background, logo | TMDB, then TVDB, then Fanart.tv |
|
|
191
|
+
| `ratings.tmdb` / `ratings.tvdb` | TMDB's own rating, TVDB's own score (each provider only its own card) |
|
|
192
|
+
| cast | TMDB, then TVDB, when the API has none; otherwise only missing photos |
|
|
193
|
+
| season posters | TMDB, then TVDB |
|
|
194
|
+
| `similar` | TMDB recommendations that exist in the catalogue, after the API's own |
|
|
195
|
+
| episode names (placeholders such as "Episode 5"), overviews, stills, air dates | TMDB, then TVDB |
|
|
196
|
+
|
|
197
|
+
`provenance` names the provider of every filled field. Results are cached for
|
|
198
|
+
7 days per title, keyed by which providers have keys, never by the keys
|
|
199
|
+
themselves. Failures that may pass on their own (network, rate limit) are not
|
|
200
|
+
cached. A provider failure never fails the call: it is passed to
|
|
201
|
+
`onProviderError`, and `enrichTitle()` returns it in `errors`.
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
const { title, filled, errors } = await client.enrichTitle(apiTitle);
|
|
205
|
+
const { episodes, filledBy } = await client.enrichEpisodes(apiTitle, 1, rows);
|
|
206
|
+
await client.checkProvider('tvdb'); // { state: 'connected' | 'invalid' | 'rate_limited' | 'error' | 'missing', message }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Provider clients
|
|
210
|
+
|
|
211
|
+
To work with raw provider data, use the provider clients. Each returns the
|
|
212
|
+
provider's own JSON. The last argument of every method is an optional
|
|
213
|
+
`AbortSignal`. A provider without a key is `null`.
|
|
214
|
+
|
|
215
|
+
```js
|
|
216
|
+
client.providers.tmdb.series('76572'); // /tv/{id} + aggregate_credits, recommendations, external_ids, content_ratings, images
|
|
217
|
+
client.providers.tmdb.movie('129'); // /movie/{id} + credits, recommendations, external_ids, release_dates, images
|
|
218
|
+
client.providers.tmdb.season('76572', 1); // /tv/{id}/season/{n}
|
|
219
|
+
client.providers.tmdb.findByImdb('tt8948436'); // /find/{imdb}
|
|
220
|
+
client.providers.tmdb.search('Soul Land', { type: 'tv', year: 2018 });
|
|
221
|
+
|
|
222
|
+
client.providers.tvdb.series('342329'); // /series/{id}/extended (translations)
|
|
223
|
+
client.providers.tvdb.movie('1'); // /movies/{id}/extended
|
|
224
|
+
client.providers.tvdb.allEpisodes('342329'); // every page, English names, absoluteNumber kept
|
|
225
|
+
client.providers.tvdb.episodes('342329', 1); // one season, first page
|
|
226
|
+
client.providers.tvdb.translation('342329'); // English name, overview, aliases
|
|
227
|
+
client.providers.tvdb.artworks('342329');
|
|
228
|
+
client.providers.tvdb.searchRemoteId('tt8948436');
|
|
229
|
+
|
|
230
|
+
client.providers.fanart.series('342329'); // by TVDB id
|
|
231
|
+
client.providers.fanart.movie('129'); // by TMDB id
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
TVDB logs in once per key and PIN and keeps the token in memory. If a token
|
|
235
|
+
expires (401), the client logs in again and retries.
|
|
236
|
+
|
|
237
|
+
The **keyless** Kitsu and Cinemeta clients are on by default. They go through
|
|
238
|
+
the response cache. Turn one off with `providers: { kitsu: false }`.
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
await client.providers.kitsu.anime('44607');
|
|
242
|
+
// { id, titles: { canonical, en, romaji, native, synonyms, abbreviated }, status, startDate, endDate,
|
|
243
|
+
// episodeCount, subtype, posterImage, coverImage, averageRating (0–100), rating (0–10), userCount, categories, … } | null
|
|
244
|
+
await client.providers.kitsu.episodes('44607'); // [{ number, seasonNumber, title, airdate, thumbnail, … }]
|
|
245
|
+
await client.providers.kitsu.relationships('42037'); // [{ role: 'prequel' | 'sequel' | 'side_story' | …, id, subtype, … }]
|
|
246
|
+
await client.providers.kitsu.search('soul land');
|
|
247
|
+
await client.providers.cinemeta.meta('tt8948436', 'series'); // raw Cinemeta meta with videos, or null
|
|
248
|
+
await client.providers.cinemeta.find('tt0119698'); // { type, meta }, trying the other type too
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Some Kitsu quirks are handled for you:
|
|
252
|
+
- Genres come from Kitsu's categories; its `genres` field is always empty.
|
|
253
|
+
- Missing titles do not break anything.
|
|
254
|
+
- `airdate` stays `null` until Kitsu knows the real date. Kitsu's `createdAt`, set when an episode is only planned, is never used as an air date.
|
|
255
|
+
|
|
256
|
+
### Building blocks
|
|
257
|
+
|
|
258
|
+
The `@vanomarb/animap/byok` entry exports the pieces behind all this, for
|
|
259
|
+
apps that manage their own state, such as React hooks:
|
|
260
|
+
- clients: `createTmdb`, `createTvdb`, `createFanart`
|
|
261
|
+
- normalizers: `normalizeTmdb`, `normalizeTvdb`, `normalizeFanart`, `tmdbTvPeople`, `pickTvdbArt`, `bestFanart`, `episodeAbsoluteMap`
|
|
262
|
+
- merge: `mergeTitle`, `mergeEpisodes`
|
|
263
|
+
- fetching and key checks: `fetchSources`, `fetchEpisodeFills`, `checkKey`
|
|
264
|
+
- cache: `createByokCache`, `memoryStorage`
|
|
265
|
+
- errors: `ByokError`
|
|
266
|
+
|
|
267
|
+
## Licence
|
|
268
|
+
|
|
269
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanomarb/animap",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AniMap client: map Kitsu, MAL, AniList and AniDB entries to TVDB, TMDB and IMDb seasons and episodes, read titles and episodes, and enrich them in-process with your own TMDB, TVDB and Fanart.tv keys.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"types": "src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./byok": {
|
|
14
|
+
"types": "./src/byok/index.d.ts",
|
|
15
|
+
"default": "./src/byok/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test \"test/**/*.test.js\""
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"anidb",
|
|
31
|
+
"anilist",
|
|
32
|
+
"animap",
|
|
33
|
+
"anime",
|
|
34
|
+
"cinemeta",
|
|
35
|
+
"fanart",
|
|
36
|
+
"imdb",
|
|
37
|
+
"kitsu",
|
|
38
|
+
"mapping",
|
|
39
|
+
"myanimelist",
|
|
40
|
+
"stremio",
|
|
41
|
+
"tmdb",
|
|
42
|
+
"tvdb"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": false
|
|
48
|
+
}
|
package/src/align.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Where an entry sits inside a series, for 1:1 numbering: series episode = entry episode +
|
|
2
|
+
// offset, in `season`, up to `end`. Pure.
|
|
3
|
+
//
|
|
4
|
+
// A target without a 1:1 range borrows another's slice, because aligning episode N to E N
|
|
5
|
+
// and running the tail to the season's end is worse: Soul Land part 3 has TVDB and IMDb
|
|
6
|
+
// ranges but no TMDB one, and would otherwise come out as all 264 episodes. Cinemeta
|
|
7
|
+
// numbers like IMDb. Where numberings truly drift apart a caller should check air dates.
|
|
8
|
+
|
|
9
|
+
const BORROW_ORDER = {
|
|
10
|
+
imdb: ['imdb', 'tvdb', 'tmdb'],
|
|
11
|
+
cinemeta: ['imdb', 'tvdb', 'tmdb'],
|
|
12
|
+
tvdb: ['tvdb', 'imdb', 'tmdb'],
|
|
13
|
+
tmdb: ['tmdb', 'tvdb', 'imdb'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Manual ranges for a target replace that target's imported ones (contract). */
|
|
17
|
+
function effective(ranges) {
|
|
18
|
+
const manual = new Set(ranges.filter((r) => r.source === 'manual').map((r) => r.target));
|
|
19
|
+
return manual.size ? ranges.filter((r) => r.source === 'manual' || !manual.has(r.target)) : ranges;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Per target, the first 1:1 range by entry episode → { season, offset, end, from, to }. */
|
|
23
|
+
export function slicesFromRanges(ranges) {
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const r of [...effective(ranges ?? [])].sort((a, b) => a.from - b.from)) {
|
|
26
|
+
if (out[r.target] || Number(r.ratio) !== 1) continue;
|
|
27
|
+
out[r.target] = { season: r.season ?? null, offset: r.targetFrom - r.from, end: r.targetTo, from: r.from, to: r.to };
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ranges: an Entry's ranges. target: 'tvdb' | 'tmdb' | 'imdb' | 'cinemeta'.
|
|
34
|
+
* Returns { season, offset, end, target } (target = the slice actually used, which may be
|
|
35
|
+
* borrowed), or null when the entry has no 1:1 range at all.
|
|
36
|
+
*/
|
|
37
|
+
export function alignToEntry(ranges, target) {
|
|
38
|
+
const order = BORROW_ORDER[target];
|
|
39
|
+
if (!order) throw new TypeError(`target must be one of ${Object.keys(BORROW_ORDER).join(', ')}`);
|
|
40
|
+
const slices = slicesFromRanges(ranges);
|
|
41
|
+
for (const t of order) {
|
|
42
|
+
const s = slices[t];
|
|
43
|
+
if (s) return { season: s.season, offset: s.offset, end: s.end, target: t };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
package/src/breaker.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Circuit breaker per API key: after `threshold` consecutive failures (network errors,
|
|
2
|
+
// timeouts, 5xx, 429) the client stops calling the API for a cooldown and fails fast with
|
|
3
|
+
// code "unavailable", reason "circuit_open". A 429 opens it at once for its Retry-After.
|
|
4
|
+
// The cooldown is at least `cooldownMs`, honours Retry-After, and is capped at 5 minutes.
|
|
5
|
+
|
|
6
|
+
export const MAX_COOLDOWN_MS = 5 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
export function createBreakers({ threshold = 5, cooldownMs = 30_000, maxCooldownMs = MAX_COOLDOWN_MS, now = () => Date.now() } = {}) {
|
|
9
|
+
const states = new Map();
|
|
10
|
+
const state = (key) => {
|
|
11
|
+
if (!states.has(key)) states.set(key, { failures: 0, openUntil: 0 });
|
|
12
|
+
return states.get(key);
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
/** Milliseconds until the API may be called again for this key; 0 when closed. */
|
|
16
|
+
openFor(key) {
|
|
17
|
+
const s = states.get(key);
|
|
18
|
+
return s ? Math.max(0, s.openUntil - now()) : 0;
|
|
19
|
+
},
|
|
20
|
+
success(key) {
|
|
21
|
+
states.delete(key);
|
|
22
|
+
},
|
|
23
|
+
/** `retryAfterMs` comes from a 429; it opens the breaker immediately. */
|
|
24
|
+
failure(key, retryAfterMs) {
|
|
25
|
+
const s = state(key);
|
|
26
|
+
s.failures++;
|
|
27
|
+
if (retryAfterMs !== undefined && retryAfterMs !== null) {
|
|
28
|
+
s.openUntil = now() + Math.min(Math.max(retryAfterMs, 1000), maxCooldownMs);
|
|
29
|
+
} else if (s.failures >= threshold) {
|
|
30
|
+
s.openUntil = now() + Math.min(cooldownMs, maxCooldownMs);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function disabledBreakers() {
|
|
37
|
+
return { openFor: () => 0, success() {}, failure() {} };
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Cache of what the provider keys fetched (TMDB/TVDB/Fanart.tv), so a quota is spent once
|
|
2
|
+
// a week per title. Keyed by title and by WHICH providers have keys, never by the key
|
|
3
|
+
// itself. Storage is pluggable: anything with getItem/setItem/removeItem, synchronous
|
|
4
|
+
// (localStorage) or returning promises (a Redis or file adapter). Clock is injected.
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PREFIX = 'byok.v1';
|
|
7
|
+
export const TTL_MS = 7 * 24 * 3600 * 1000;
|
|
8
|
+
|
|
9
|
+
/** "tmdb+tvdb" from the keys that are set; '' when none. */
|
|
10
|
+
export function keySignature(keys = {}) {
|
|
11
|
+
return ['tmdb', 'tvdb', 'fanart'].filter((p) => String(keys[p] ?? '').trim()).join('+');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A Storage-like object in memory, bounded so a long-running process does not grow forever. */
|
|
15
|
+
export function memoryStorage({ maxItems = 2000 } = {}) {
|
|
16
|
+
const map = new Map();
|
|
17
|
+
return {
|
|
18
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
19
|
+
setItem: (k, v) => {
|
|
20
|
+
map.delete(k);
|
|
21
|
+
map.set(k, String(v));
|
|
22
|
+
if (map.size > maxItems) map.delete(map.keys().next().value);
|
|
23
|
+
},
|
|
24
|
+
removeItem: (k) => { map.delete(k); },
|
|
25
|
+
clear: () => map.clear(),
|
|
26
|
+
get length() { return map.size; },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isPromise = (v) => v && typeof v.then === 'function';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* get() and set() return plain values for a synchronous storage and promises for an
|
|
34
|
+
* asynchronous one, so `await` works either way. Storage failures never throw.
|
|
35
|
+
*/
|
|
36
|
+
export function createByokCache({ storage = memoryStorage(), prefix = DEFAULT_PREFIX, now = () => Date.now(), ttl = TTL_MS } = {}) {
|
|
37
|
+
const slot = (kind, id, sig) => `${prefix}:${kind}:${id}:${sig}`;
|
|
38
|
+
const read = (key, raw) => {
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
const v = JSON.parse(raw);
|
|
41
|
+
if (!v || now() - v.at > ttl) {
|
|
42
|
+
try { const r = storage.removeItem(key); if (isPromise(r)) r.catch(() => {}); } catch { /* ignore */ }
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return v.data;
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
get(kind, id, sig) {
|
|
49
|
+
const key = slot(kind, id, sig);
|
|
50
|
+
try {
|
|
51
|
+
const raw = storage.getItem(key);
|
|
52
|
+
if (isPromise(raw)) return raw.then((r) => read(key, r)).catch(() => null);
|
|
53
|
+
return read(key, raw);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
set(kind, id, sig, data) {
|
|
59
|
+
try {
|
|
60
|
+
const r = storage.setItem(slot(kind, id, sig), JSON.stringify({ at: now(), data }));
|
|
61
|
+
if (isPromise(r)) return r.catch(() => {});
|
|
62
|
+
} catch { /* storage full: skip caching, the caller still works */ }
|
|
63
|
+
return undefined;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Fetch what the caller's own provider keys can add to a title. Dependencies are injected
|
|
2
|
+
// (fetch for the providers, findTitle for the mapping API) so tests can drive it.
|
|
3
|
+
import { ByokError } from './http.js';
|
|
4
|
+
import { createTmdb, normalizeTmdb, normalizeTmdbSeason } from './tmdb.js';
|
|
5
|
+
import { createTvdb, normalizeTvdb, normalizeTvdbEpisodes } from './tvdb.js';
|
|
6
|
+
import { createFanart, fanartTarget, normalizeFanart } from './fanart.js';
|
|
7
|
+
|
|
8
|
+
const has = (v) => String(v ?? '').trim().length > 0;
|
|
9
|
+
|
|
10
|
+
function errorOf(e, provider) {
|
|
11
|
+
if (e instanceof ByokError) return { provider: e.provider, code: e.code, message: e.message };
|
|
12
|
+
return { provider, code: 'bad_response', message: 'Unexpected response.' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* keys: { tmdb, tvdb, tvdbPin, fanart }.
|
|
17
|
+
* findTitle(ref) -> TitleSummary | null, resolving a provider id to one of our titles.
|
|
18
|
+
* Returns { sources: { tmdb, tvdb, fanart, similar }, errors: [] } with normalized payloads.
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchSources(title, keys, { fetch, findTitle, signal } = {}) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
const sources = { tmdb: null, tvdb: null, fanart: null, similar: [] };
|
|
23
|
+
const movie = title.type === 'movie';
|
|
24
|
+
const ids = { ...(title.ids ?? {}) };
|
|
25
|
+
|
|
26
|
+
if (has(keys.tmdb)) {
|
|
27
|
+
try {
|
|
28
|
+
const tmdb = createTmdb({ key: keys.tmdb, fetch });
|
|
29
|
+
let id = ids.tmdb;
|
|
30
|
+
if (!id && ids.imdb) {
|
|
31
|
+
const found = await tmdb.findByImdb(ids.imdb, signal);
|
|
32
|
+
id = String((movie ? found.movie_results : found.tv_results)?.[0]?.id ?? '') || null;
|
|
33
|
+
}
|
|
34
|
+
if (id) {
|
|
35
|
+
sources.tmdb = normalizeTmdb(await (movie ? tmdb.movie(id, signal) : tmdb.tv(id, signal)), movie ? 'movie' : 'tv');
|
|
36
|
+
ids.tmdb = id;
|
|
37
|
+
if (!ids.tvdb && sources.tmdb?.tvdbId) ids.tvdb = sources.tmdb.tvdbId;
|
|
38
|
+
}
|
|
39
|
+
} catch (e) { errors.push(errorOf(e, 'tmdb')); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const later = [];
|
|
43
|
+
if (has(keys.tvdb) && !movie && ids.tvdb) {
|
|
44
|
+
later.push((async () => {
|
|
45
|
+
try {
|
|
46
|
+
const tvdb = createTvdb({ key: keys.tvdb, pin: keys.tvdbPin, fetch });
|
|
47
|
+
sources.tvdb = normalizeTvdb(await tvdb.seriesExtended(ids.tvdb, signal));
|
|
48
|
+
} catch (e) { errors.push(errorOf(e, 'tvdb')); }
|
|
49
|
+
})());
|
|
50
|
+
}
|
|
51
|
+
const target = fanartTarget({ ...title, ids });
|
|
52
|
+
if (has(keys.fanart) && target) {
|
|
53
|
+
later.push((async () => {
|
|
54
|
+
try {
|
|
55
|
+
const fan = createFanart({ key: keys.fanart, fetch });
|
|
56
|
+
sources.fanart = normalizeFanart(await (target.kind === 'movie' ? fan.movie(target.id, signal) : fan.tv(target.id, signal)), target.kind);
|
|
57
|
+
} catch (e) { errors.push(errorOf(e, 'fanart')); }
|
|
58
|
+
})());
|
|
59
|
+
}
|
|
60
|
+
if (findTitle && sources.tmdb?.recommendations?.length) {
|
|
61
|
+
later.push((async () => {
|
|
62
|
+
const recs = sources.tmdb.recommendations.slice(0, 8);
|
|
63
|
+
const hits = await Promise.all(recs.map((r) => findTitle(`tmdb:${r.tmdbId}`).catch(() => null)));
|
|
64
|
+
sources.similar = hits.filter(Boolean);
|
|
65
|
+
})());
|
|
66
|
+
}
|
|
67
|
+
await Promise.all(later);
|
|
68
|
+
sources.ids = ids;
|
|
69
|
+
return { sources, errors };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Episode fills for one season: TMDB first, then TVDB. */
|
|
73
|
+
export async function fetchEpisodeFills(title, season, keys, { fetch, signal, ids: knownIds } = {}) {
|
|
74
|
+
const ids = { ...(title.ids ?? {}), ...(knownIds ?? {}) };
|
|
75
|
+
const fills = [];
|
|
76
|
+
const errors = [];
|
|
77
|
+
if (has(keys.tmdb) && ids.tmdb && title.type !== 'movie') {
|
|
78
|
+
try { fills.push(['tmdb', normalizeTmdbSeason(await createTmdb({ key: keys.tmdb, fetch }).season(ids.tmdb, season, signal))]); } catch (e) { errors.push(errorOf(e, 'tmdb')); }
|
|
79
|
+
}
|
|
80
|
+
if (has(keys.tvdb) && ids.tvdb && title.type !== 'movie') {
|
|
81
|
+
try { fills.push(['tvdb', normalizeTvdbEpisodes(await createTvdb({ key: keys.tvdb, pin: keys.tvdbPin, fetch }).episodes(ids.tvdb, season, signal), season)]); } catch (e) { errors.push(errorOf(e, 'tvdb')); }
|
|
82
|
+
}
|
|
83
|
+
return { fills, errors };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Settings "Check": one cheap call to the provider itself. -> { state, message } */
|
|
87
|
+
export async function checkKey(provider, keys, { fetch, signal } = {}) {
|
|
88
|
+
try {
|
|
89
|
+
if (provider === 'tmdb') await createTmdb({ key: keys.tmdb, fetch }).check(signal);
|
|
90
|
+
else if (provider === 'tvdb') await createTvdb({ key: keys.tvdb, pin: keys.tvdbPin, fetch }).check(signal);
|
|
91
|
+
else if (provider === 'fanart') await createFanart({ key: keys.fanart, fetch }).check(signal);
|
|
92
|
+
return { state: 'connected', message: null };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
const err = errorOf(e, provider);
|
|
95
|
+
if (err.code === 'invalid_key') return { state: 'invalid', message: err.message };
|
|
96
|
+
if (err.code === 'rate_limited') return { state: 'rate_limited', message: err.message };
|
|
97
|
+
return { state: 'error', message: err.message };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Fanart.tv v3, called from the caller's process. The key travels as ?api_key=. Series are looked up by
|
|
2
|
+
// their TVDB id, films by their TMDB id.
|
|
3
|
+
import { ByokError, providerFetch, qs } from './http.js';
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://webservice.fanart.tv/v3';
|
|
6
|
+
// A title Fanart.tv certainly knows (TVDB 81189, Breaking Bad), for checking a key.
|
|
7
|
+
export const FANART_CHECK_TVDB_ID = '81189';
|
|
8
|
+
|
|
9
|
+
export function createFanart({ key, fetch: f = globalThis.fetch.bind(globalThis), timeoutMs } = {}) {
|
|
10
|
+
const k = String(key ?? '').trim();
|
|
11
|
+
if (!k) throw new ByokError('fanart', 'invalid_key', 'No Fanart.tv key set.');
|
|
12
|
+
const get = (path, signal) => providerFetch('fanart', `${BASE}${path}${qs({ api_key: k })}`, { fetch: f, signal, timeoutMs });
|
|
13
|
+
return {
|
|
14
|
+
check: (signal) => get(`/tv/${FANART_CHECK_TVDB_ID}`, signal),
|
|
15
|
+
tv: (tvdbId, signal) => get(`/tv/${encodeURIComponent(tvdbId)}`, signal),
|
|
16
|
+
movie: (tmdbId, signal) => get(`/movies/${encodeURIComponent(tmdbId)}`, signal),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Which Fanart.tv lookup a title needs: TVDB id for series, TMDB id for films. */
|
|
21
|
+
export function fanartTarget(title) {
|
|
22
|
+
if (title?.type === 'movie') return title.ids?.tmdb ? { kind: 'movie', id: title.ids.tmdb } : null;
|
|
23
|
+
return title?.ids?.tvdb ? { kind: 'tv', id: title.ids.tvdb } : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The best image of one kind: English first, then textless ("00" or empty), then anything
|
|
28
|
+
* for posters and backgrounds. Logos are English or textless only: a logo is the title as
|
|
29
|
+
* text. Ties go to the most-liked image. Pure.
|
|
30
|
+
*/
|
|
31
|
+
export function bestFanart(images, { textOnly = false } = {}) {
|
|
32
|
+
const rank = (img) => {
|
|
33
|
+
const lang = img.lang || '';
|
|
34
|
+
if (lang === 'en') return 0;
|
|
35
|
+
if (lang === '00' || lang === '') return 1;
|
|
36
|
+
return textOnly ? Infinity : 2;
|
|
37
|
+
};
|
|
38
|
+
return (images ?? [])
|
|
39
|
+
.filter((img) => img?.url && Number.isFinite(rank(img)))
|
|
40
|
+
.sort((a, b) => (rank(a) - rank(b)) || ((parseInt(b.likes, 10) || 0) - (parseInt(a.likes, 10) || 0)))[0]?.url ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fanart.tv response -> poster, background and logo (HD logos and plain logos pooled). Pure. */
|
|
44
|
+
export function normalizeFanart(json, kind) {
|
|
45
|
+
if (!json) return null;
|
|
46
|
+
return kind === 'movie'
|
|
47
|
+
? { poster: bestFanart(json.movieposter), background: bestFanart(json.moviebackground), logo: bestFanart([...(json.hdmovielogo ?? []), ...(json.movielogo ?? [])], { textOnly: true }) }
|
|
48
|
+
: { poster: bestFanart(json.tvposter), background: bestFanart(json.showbackground), logo: bestFanart([...(json.hdtvlogo ?? []), ...(json.clearlogo ?? [])], { textOnly: true }) };
|
|
49
|
+
}
|