@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 CHANGED
@@ -1,3 +1,269 @@
1
- # Temporary Holding Version
1
+ # AniMap
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }