@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.
@@ -0,0 +1,82 @@
1
+ // Shared plumbing for the provider clients (TMDB, TVDB, Fanart.tv), which run in the
2
+ // caller's own process: a browser tab or a Node server. Keys go to the provider and
3
+ // nowhere else: never to the mapping API, never into a log line, never into an error
4
+ // message. Callers pass `fetch` so tests can drive the clients.
5
+
6
+ export class ByokError extends Error {
7
+ /**
8
+ * code: 'invalid_key' | 'rate_limited' | 'network' | 'timeout' | 'not_found' | 'bad_response' | 'aborted'
9
+ */
10
+ constructor(provider, code, message, { status = null, retryAfter = null } = {}) {
11
+ super(message);
12
+ this.name = 'ByokError';
13
+ this.provider = provider;
14
+ this.code = code;
15
+ this.status = status;
16
+ this.retryAfter = retryAfter;
17
+ }
18
+ }
19
+
20
+ const NAMES = { tmdb: 'TMDB', tvdb: 'TVDB', fanart: 'Fanart.tv', kitsu: 'Kitsu', cinemeta: 'Cinemeta' };
21
+
22
+ /** Combine the caller's abort signal (navigation) with a timeout. */
23
+ function withTimeout(signal, ms) {
24
+ const ac = new AbortController();
25
+ const timer = setTimeout(() => ac.abort(new DOMException('timeout', 'TimeoutError')), ms);
26
+ const onAbort = () => ac.abort(signal.reason);
27
+ if (signal) {
28
+ if (signal.aborted) ac.abort(signal.reason);
29
+ else signal.addEventListener('abort', onAbort, { once: true });
30
+ }
31
+ return {
32
+ signal: ac.signal,
33
+ done: () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); },
34
+ timedOut: () => ac.signal.aborted && ac.signal.reason?.name === 'TimeoutError',
35
+ };
36
+ }
37
+
38
+ /**
39
+ * One provider request. Maps failures to ByokError codes. The URL (which may hold an
40
+ * api_key) is never put in an error or logged.
41
+ */
42
+ export async function providerFetch(provider, url, { fetch: f, signal, timeoutMs = 8000, method = 'GET', headers = {}, body } = {}) {
43
+ const name = NAMES[provider] ?? provider;
44
+ const t = withTimeout(signal, timeoutMs);
45
+ let res;
46
+ try {
47
+ res = await f(url, {
48
+ method,
49
+ headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...headers },
50
+ body: body === undefined ? undefined : JSON.stringify(body),
51
+ signal: t.signal,
52
+ referrerPolicy: 'no-referrer',
53
+ credentials: 'omit',
54
+ });
55
+ } catch (e) {
56
+ if (t.timedOut()) throw new ByokError(provider, 'timeout', `${name} did not answer in time.`);
57
+ if (signal?.aborted) throw new ByokError(provider, 'aborted', `${name} request cancelled.`);
58
+ throw new ByokError(provider, 'network', `Could not reach ${name}.`);
59
+ } finally {
60
+ t.done();
61
+ }
62
+ if (res.status === 401 || res.status === 403) throw new ByokError(provider, 'invalid_key', `${name} rejected the key.`, { status: res.status });
63
+ if (res.status === 429) {
64
+ const retryAfter = Number(res.headers?.get?.('Retry-After')) || null;
65
+ throw new ByokError(provider, 'rate_limited', `${name} rate limit reached. Try again ${retryAfter ? `in ${retryAfter} seconds` : 'in a minute'}.`, { status: 429, retryAfter });
66
+ }
67
+ if (res.status === 404) throw new ByokError(provider, 'not_found', `${name} has no record for this title.`, { status: 404 });
68
+ if (!res.ok) throw new ByokError(provider, 'bad_response', `${name} answered ${res.status}.`, { status: res.status });
69
+ try {
70
+ return await res.json();
71
+ } catch {
72
+ throw new ByokError(provider, 'bad_response', `${name} sent something that is not JSON.`, { status: res.status });
73
+ }
74
+ }
75
+
76
+ /** Query string from an object, skipping empty values. */
77
+ export function qs(params) {
78
+ const s = new URLSearchParams();
79
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') s.set(k, String(v));
80
+ const out = s.toString();
81
+ return out ? `?${out}` : '';
82
+ }
@@ -0,0 +1,80 @@
1
+ import type { ByokError, ByokProvider, CacheStorage, KeyCheck, ProviderErrorInfo, Title, TitleEpisode, TitleSummary } from '../index.js';
2
+
3
+ export { ByokError } from '../index.js';
4
+
5
+ export function providerFetch(provider: ByokProvider | string, url: string, options?: {
6
+ fetch?: typeof fetch; signal?: AbortSignal; timeoutMs?: number; method?: string; headers?: Record<string, string>; body?: unknown;
7
+ }): Promise<any>;
8
+ export function qs(params: Record<string, unknown>): string;
9
+
10
+ export const DEFAULT_PREFIX: string;
11
+ export const TTL_MS: number;
12
+ export function keySignature(keys?: { tmdb?: string; tvdb?: string; fanart?: string }): string;
13
+ export function memoryStorage(options?: { maxItems?: number }): CacheStorage & { clear(): void; readonly length: number };
14
+ export interface ByokCache {
15
+ get(kind: string, id: string, signature: string): any | Promise<any>;
16
+ set(kind: string, id: string, signature: string, data: unknown): void | Promise<void>;
17
+ }
18
+ export function createByokCache(options?: { storage?: CacheStorage; prefix?: string; now?: () => number; ttl?: number }): ByokCache;
19
+
20
+ export interface ByokKeys { tmdb?: string; tvdb?: string; tvdbPin?: string; fanart?: string }
21
+
22
+ export const TMDB_IMAGE: string;
23
+ export function isV4Token(key: string): boolean;
24
+ export function createTmdb(options: { key: string; fetch?: typeof fetch; timeoutMs?: number }): {
25
+ kind: 'v3' | 'v4';
26
+ check(signal?: AbortSignal): Promise<any>;
27
+ findByImdb(imdbId: string, signal?: AbortSignal): Promise<any>;
28
+ tv(id: string, signal?: AbortSignal): Promise<any>;
29
+ movie(id: string, signal?: AbortSignal): Promise<any>;
30
+ season(id: string, season: number, signal?: AbortSignal): Promise<any>;
31
+ search(query: string, options?: { type?: 'tv' | 'movie'; year?: number }, signal?: AbortSignal): Promise<any>;
32
+ };
33
+ export function normalizeTmdb(json: any, type: 'tv' | 'movie'): any;
34
+ export function tmdbTvPeople(data: any): { cast: Array<{ name: string; character: string | null; profile_path: string | null }>; directors: string[]; writers: string[] };
35
+ export function tmdbMoviePeople(data: any): { cast: Array<{ name: string; character: string | null; profile_path: string | null }>; directors: string[]; writers: string[] };
36
+ export function normalizeTmdbSeason(json: any): Record<number, { name: string | null; overview: string | null; thumbnail: string | null; released: string | null }>;
37
+
38
+ export function forgetTvdbTokens(): void;
39
+ export function createTvdb(options: { key: string; pin?: string; fetch?: typeof fetch; timeoutMs?: number }): {
40
+ check(signal?: AbortSignal): Promise<{ ok: true }>;
41
+ seriesExtended(id: string, signal?: AbortSignal): Promise<any>;
42
+ movieExtended(id: string, signal?: AbortSignal): Promise<any>;
43
+ episodes(id: string, season: number, signal?: AbortSignal): Promise<any>;
44
+ allEpisodes(id: string, options?: { order?: string; lang?: string; signal?: AbortSignal }): Promise<any[]>;
45
+ translation(id: string, options?: { lang?: string; kind?: 'series' | 'movie' }, signal?: AbortSignal): Promise<any>;
46
+ artworks(id: string, signal?: AbortSignal): Promise<any>;
47
+ searchRemoteId(remoteId: string, signal?: AbortSignal): Promise<any>;
48
+ };
49
+ /** "S{season}E{number}" → TVDB absolute number. */
50
+ export function episodeAbsoluteMap(episodes: any[]): Record<string, number>;
51
+ /** Best poster (English, textless, score), background (textless first) and logo (English or textless only). */
52
+ export function pickTvdbArt(artworks: any[], options?: { movie?: boolean; fallbackPoster?: string | null }): { poster: string | null; background: string | null; logo: string | null };
53
+ export function normalizeTvdb(json: any): any;
54
+ export function normalizeTvdbEpisodes(json: any, season: number): Record<number, { name: string | null; overview: string | null; thumbnail: string | null; released: string | null }>;
55
+
56
+ export const FANART_CHECK_TVDB_ID: string;
57
+ export function createFanart(options: { key: string; fetch?: typeof fetch; timeoutMs?: number }): {
58
+ check(signal?: AbortSignal): Promise<any>;
59
+ tv(tvdbId: string, signal?: AbortSignal): Promise<any>;
60
+ movie(tmdbId: string, signal?: AbortSignal): Promise<any>;
61
+ };
62
+ /** English, then textless, then (unless textOnly) anything; ties by likes. */
63
+ export function bestFanart(images: Array<{ url: string; lang?: string; likes?: string }>, options?: { textOnly?: boolean }): string | null;
64
+ export function fanartTarget(title: Pick<Title, 'type' | 'ids'>): { kind: 'tv' | 'movie'; id: string } | null;
65
+ export function normalizeFanart(json: any, kind: 'tv' | 'movie'): { poster: string | null; background: string | null; logo: string | null } | null;
66
+
67
+ export interface ByokSources { tmdb: any; tvdb: any; fanart: any; similar: TitleSummary[]; ids?: Title['ids'] }
68
+ export function isGenericEpisodeName(name: string | null | undefined, number?: number): boolean;
69
+ export function mergeTitle(server: Title, sources?: Partial<ByokSources>): { title: Title; filled: Record<string, ByokProvider> };
70
+ export function mergeEpisodes(episodes: TitleEpisode[], sources: Array<[ByokProvider, Record<number, any>]>): { episodes: TitleEpisode[]; filledBy: Set<ByokProvider> };
71
+ export function providerList(filled: Record<string, string>): string;
72
+
73
+ export function fetchSources(title: Pick<Title, 'id' | 'type' | 'ids'>, keys: ByokKeys, options?: {
74
+ fetch?: typeof fetch; findTitle?: (ref: string) => Promise<TitleSummary | null>; signal?: AbortSignal;
75
+ }): Promise<{ sources: ByokSources; errors: ProviderErrorInfo[] }>;
76
+ export function fetchEpisodeFills(title: Pick<Title, 'type' | 'ids'>, season: number, keys: ByokKeys, options?: {
77
+ fetch?: typeof fetch; signal?: AbortSignal; ids?: Partial<Title['ids']>;
78
+ }): Promise<{ fills: Array<[ByokProvider, Record<number, any>]>; errors: ProviderErrorInfo[] }>;
79
+ export function checkKey(provider: ByokProvider, keys: ByokKeys, options?: { fetch?: typeof fetch; signal?: AbortSignal }): Promise<Omit<KeyCheck, 'state'> & { state: 'connected' | 'invalid' | 'rate_limited' | 'error' }>;
80
+ export function isTransient(error: { code?: string } | null | undefined): boolean;
@@ -0,0 +1,13 @@
1
+ // Low-level BYOK building blocks: provider clients, normalizers, the merge and the cache.
2
+ // The Client uses these when it is given provider keys; apps that drive their own UI state
3
+ // (the web app's React hooks) use them directly.
4
+ export { ByokError, providerFetch, qs } from './http.js';
5
+ export { createByokCache, keySignature, memoryStorage, DEFAULT_PREFIX, TTL_MS } from './cache.js';
6
+ export { createTmdb, isV4Token, normalizeTmdb, normalizeTmdbSeason, tmdbMoviePeople, tmdbTvPeople, TMDB_IMAGE } from './tmdb.js';
7
+ export { createTvdb, episodeAbsoluteMap, forgetTvdbTokens, normalizeTvdb, normalizeTvdbEpisodes, pickTvdbArt } from './tvdb.js';
8
+ export { bestFanart, createFanart, fanartTarget, normalizeFanart, FANART_CHECK_TVDB_ID } from './fanart.js';
9
+ export { isGenericEpisodeName, mergeEpisodes, mergeTitle, providerList } from './merge.js';
10
+ export { checkKey, fetchEpisodeFills, fetchSources } from './enrich.js';
11
+
12
+ /** Errors that may pass on their own; results fetched around them are not cached. */
13
+ export const isTransient = (e) => ['network', 'timeout', 'rate_limited', 'bad_response'].includes(e?.code);
@@ -0,0 +1,107 @@
1
+ // Merge provider data fetched with the caller's own keys into the API's Title. Pure.
2
+ // Rules (contract, BYOK): never overwrite a field the server filled; fill gaps in the order
3
+ // TMDB -> TVDB, artwork TMDB -> TVDB -> Fanart.tv; ratings are never merged, each provider
4
+ // only fills its own card.
5
+
6
+ const empty = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
7
+
8
+ /** "Episode 5", "Episode 085": placeholder names worth replacing with a real one. */
9
+ export function isGenericEpisodeName(name, number) {
10
+ if (empty(name)) return true;
11
+ const m = /^episode\s+0*(\d+)$/i.exec(String(name).trim());
12
+ return Boolean(m) && (number === undefined || Number(m[1]) === Number(number));
13
+ }
14
+
15
+ /**
16
+ * server: the Title from GET /v1/titles/:id.
17
+ * sources: { tmdb, tvdb, fanart } normalized payloads (any may be null); similar: TitleSummary[]
18
+ * resolved from TMDB recommendations.
19
+ * Returns { title, filled: { field: provider } }.
20
+ */
21
+ export function mergeTitle(server, { tmdb = null, tvdb = null, fanart = null, similar = [] } = {}) {
22
+ const title = {
23
+ ...server,
24
+ ratings: { ...(server.ratings ?? {}) },
25
+ cast: [...(server.cast ?? [])],
26
+ seasons: (server.seasons ?? []).map((s) => ({ ...s })),
27
+ similar: [...(server.similar ?? [])],
28
+ };
29
+ const filled = {};
30
+ const take = (field, order) => {
31
+ if (!empty(title[field])) return;
32
+ for (const [provider, src] of order) {
33
+ if (src && !empty(src[field])) { title[field] = src[field]; filled[field] = provider; return; }
34
+ }
35
+ };
36
+
37
+ for (const f of ['description', 'genres', 'runtime', 'status', 'country', 'certification', 'directors', 'writers']) take(f, [['tmdb', tmdb], ['tvdb', tvdb]]);
38
+ for (const f of ['poster', 'background', 'logo']) take(f, [['tmdb', tmdb], ['tvdb', tvdb], ['fanart', fanart]]);
39
+
40
+ // Own rating cards only.
41
+ if (empty(title.ratings.tmdb) && tmdb?.rating?.value) {
42
+ title.ratings.tmdb = { value: tmdb.rating.value, votes: tmdb.rating.votes ?? null };
43
+ filled['ratings.tmdb'] = 'tmdb';
44
+ }
45
+ if (empty(title.ratings.tvdb) && Number.isFinite(tvdb?.score)) {
46
+ title.ratings.tvdb = { value: null, votes: null, score: tvdb.score };
47
+ filled['ratings.tvdb'] = 'tvdb';
48
+ }
49
+
50
+ // Cast: take a provider's list when the server has none; otherwise only add missing photos.
51
+ if (!title.cast.length) {
52
+ const src = tmdb?.cast?.length ? ['tmdb', tmdb.cast] : tvdb?.cast?.length ? ['tvdb', tvdb.cast] : null;
53
+ if (src) { title.cast = src[1].map((c) => ({ ...c })); filled.cast = src[0]; }
54
+ } else {
55
+ const byName = new Map();
56
+ for (const [p, list] of [['tvdb', tvdb?.cast], ['tmdb', tmdb?.cast]]) for (const c of list ?? []) if (c.photo) byName.set(c.name.toLowerCase(), [p, c.photo]);
57
+ title.cast = title.cast.map((c) => {
58
+ if (c.photo || !c.name) return c;
59
+ const hit = byName.get(c.name.toLowerCase());
60
+ if (!hit) return c;
61
+ filled['cast.photo'] = filled['cast.photo'] ?? hit[0];
62
+ return { ...c, photo: hit[1] };
63
+ });
64
+ }
65
+
66
+ for (const s of title.seasons) {
67
+ if (!empty(s.poster)) continue;
68
+ const p = tmdb?.seasonPosters?.[s.season] ? ['tmdb', tmdb.seasonPosters[s.season]] : tvdb?.seasonPosters?.[s.season] ? ['tvdb', tvdb.seasonPosters[s.season]] : null;
69
+ if (p) { s.poster = p[1]; filled['seasons.poster'] = filled['seasons.poster'] ?? p[0]; }
70
+ }
71
+
72
+ // Similar: the server's own first, then TMDB recommendations that resolved to our titles.
73
+ const seen = new Set([server.id, ...title.similar.map((t) => t.id)]);
74
+ const extra = similar.filter((t) => t && !seen.has(t.id) && seen.add(t.id));
75
+ if (extra.length) { title.similar = [...title.similar, ...extra].slice(0, 12); filled.similar = 'tmdb'; }
76
+
77
+ return { title, filled };
78
+ }
79
+
80
+ /**
81
+ * Fill episode rows from provider episode maps ({ [episodeNumber]: { name, overview, thumbnail, released } }),
82
+ * in order of preference. Returns { episodes, filledBy: Set<provider> }.
83
+ */
84
+ export function mergeEpisodes(episodes, sources) {
85
+ const filledBy = new Set();
86
+ const out = episodes.map((ep) => {
87
+ const row = { ...ep };
88
+ for (const [provider, map] of sources) {
89
+ const f = map?.[ep.episode];
90
+ if (!f) continue;
91
+ if (isGenericEpisodeName(row.name, ep.episode) && !empty(f.name) && !isGenericEpisodeName(f.name, ep.episode)) { row.name = f.name; filledBy.add(provider); }
92
+ if (empty(row.overview) && !empty(f.overview)) { row.overview = f.overview; filledBy.add(provider); }
93
+ if (empty(row.thumbnail) && !empty(f.thumbnail)) { row.thumbnail = f.thumbnail; filledBy.add(provider); }
94
+ if (empty(row.released) && !empty(f.released)) { row.released = f.released; filledBy.add(provider); }
95
+ }
96
+ return row;
97
+ });
98
+ return { episodes: out, filledBy };
99
+ }
100
+
101
+ /** "TMDB and TVDB" from the providers that actually filled something. */
102
+ export function providerList(filled) {
103
+ const names = { tmdb: 'TMDB', tvdb: 'TVDB', fanart: 'Fanart.tv' };
104
+ const list = [...new Set(Object.values(filled))].map((p) => names[p] ?? p);
105
+ if (list.length <= 1) return list.join('');
106
+ return `${list.slice(0, -1).join(', ')} and ${list[list.length - 1]}`;
107
+ }
@@ -0,0 +1,123 @@
1
+ // TMDB v3, called from the caller's process. A v3 API key travels as ?api_key=; a v4 read access token
2
+ // (a JWT) travels as Authorization: Bearer. The shape of the key decides which.
3
+ import { ByokError, providerFetch, qs } from './http.js';
4
+
5
+ const BASE = 'https://api.themoviedb.org/3';
6
+ export const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
7
+
8
+ /** v4 read tokens are JWTs ("eyJ…" with two dots); v3 keys are 32 hex characters. */
9
+ export function isV4Token(key) {
10
+ const k = String(key ?? '').trim();
11
+ return k.split('.').length === 3 && k.startsWith('eyJ');
12
+ }
13
+
14
+ export function createTmdb({ key, fetch: f = globalThis.fetch.bind(globalThis), timeoutMs } = {}) {
15
+ const k = String(key ?? '').trim();
16
+ if (!k) throw new ByokError('tmdb', 'invalid_key', 'No TMDB key set.');
17
+ const v4 = isV4Token(k);
18
+
19
+ function get(path, params = {}, signal) {
20
+ const url = `${BASE}${path}${qs(v4 ? params : { ...params, api_key: k })}`;
21
+ return providerFetch('tmdb', url, { fetch: f, signal, timeoutMs, headers: v4 ? { Authorization: `Bearer ${k}` } : {} });
22
+ }
23
+
24
+ return {
25
+ kind: v4 ? 'v4' : 'v3',
26
+ /** Cheap call that succeeds only with a valid key. */
27
+ check: (signal) => get('/authentication', {}, signal),
28
+ findByImdb: (imdbId, signal) => get(`/find/${encodeURIComponent(imdbId)}`, { external_source: 'imdb_id' }, signal),
29
+ tv: (id, signal) => get(`/tv/${encodeURIComponent(id)}`, { append_to_response: 'aggregate_credits,recommendations,external_ids,content_ratings,images', include_image_language: 'en,null' }, signal),
30
+ movie: (id, signal) => get(`/movie/${encodeURIComponent(id)}`, { append_to_response: 'credits,recommendations,external_ids,release_dates,images', include_image_language: 'en,null' }, signal),
31
+ season: (id, season, signal) => get(`/tv/${encodeURIComponent(id)}/season/${encodeURIComponent(season)}`, {}, signal),
32
+ /** GET /search/tv or /search/movie; `year` narrows by first air / release year. */
33
+ search: (query, { type = 'tv', year } = {}, signal) => get(`/search/${type === 'movie' ? 'movie' : 'tv'}`, {
34
+ query, ...(year ? { [type === 'movie' ? 'year' : 'first_air_date_year']: year } : {}),
35
+ }, signal),
36
+ };
37
+ }
38
+
39
+ const uniqueNames = (list) => [...new Set(list.filter(Boolean))];
40
+ const SERIES_DIRECTOR_JOBS = new Set(['Series Director', 'Chief Director', 'Supervising Director']);
41
+
42
+ /** A film's people from `credits`. Pure. */
43
+ export function tmdbMoviePeople(data) {
44
+ const crew = data?.credits?.crew ?? [];
45
+ return {
46
+ cast: (data?.credits?.cast ?? []).map((c) => ({ name: c.name, character: c.character || null, profile_path: c.profile_path ?? null })),
47
+ directors: uniqueNames(crew.filter((c) => c.job === 'Director').map((c) => c.name)),
48
+ writers: uniqueNames(crew.filter((c) => c.department === 'Writing').map((c) => c.name)),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * A series' people from aggregate_credits (the whole run). `credits` covers only the
54
+ * latest season and never lists a TV director, since directors are credited per episode.
55
+ * Director: the series-level credit when there is one, else a "Director" credited on at
56
+ * least half the run; the one-episode directors are not the show's. Pure.
57
+ */
58
+ export function tmdbTvPeople(data) {
59
+ const agg = data?.aggregate_credits;
60
+ if (!agg) return { ...tmdbMoviePeople(data), directors: [], writers: uniqueNames((data?.created_by ?? []).map((c) => c.name)) };
61
+ const total = data.number_of_episodes || 0;
62
+ const jobsOf = (person) => (person.jobs ?? []).map((j) => ({ job: j.job, episodes: j.episode_count || 0 }));
63
+ const credited = (pred) => (agg.crew ?? [])
64
+ .flatMap((p) => jobsOf(p).filter(pred).map((j) => ({ name: p.name, episodes: j.episodes })))
65
+ .sort((a, b) => b.episodes - a.episodes);
66
+ const series = credited((j) => SERIES_DIRECTOR_JOBS.has(j.job));
67
+ const regular = total ? credited((j) => j.job === 'Director' && j.episodes * 2 >= total) : [];
68
+ return {
69
+ cast: (agg.cast ?? []).map((c) => ({ name: c.name, character: c.roles?.[0]?.character || null, profile_path: c.profile_path ?? null })),
70
+ directors: uniqueNames((series.length ? series : regular).map((d) => d.name)).slice(0, 3),
71
+ writers: uniqueNames([
72
+ ...(data.created_by ?? []).map((c) => c.name),
73
+ ...(agg.crew ?? []).filter((p) => p.department === 'Writing')
74
+ .sort((a, b) => (b.total_episode_count || 0) - (a.total_episode_count || 0)).map((p) => p.name),
75
+ ]).slice(0, 5),
76
+ };
77
+ }
78
+
79
+ const img = (path, size) => (path ? `${TMDB_IMAGE}/${size}${path}` : null);
80
+
81
+ /** TMDB tv or movie details -> the fields our Title uses. Pure. */
82
+ export function normalizeTmdb(json, type) {
83
+ if (!json) return null;
84
+ const movie = type === 'movie';
85
+ const people = movie ? tmdbMoviePeople(json) : tmdbTvPeople(json);
86
+ const cast = people.cast.slice(0, 24).map((c) => ({ name: c.name, character: c.character, photo: img(c.profile_path, 'w185') }));
87
+ const logos = json.images?.logos ?? [];
88
+ const logo = (logos.find((l) => l.iso_639_1 === 'en') ?? logos.find((l) => !l.iso_639_1))?.file_path;
89
+ const runtime = movie ? json.runtime : json.episode_run_time?.[0];
90
+ const usRating = movie
91
+ ? json.release_dates?.results?.find((r) => r.iso_3166_1 === 'US')?.release_dates?.find((d) => d.certification)?.certification
92
+ : json.content_ratings?.results?.find((r) => r.iso_3166_1 === 'US')?.rating;
93
+ return {
94
+ tmdbId: String(json.id),
95
+ imdbId: json.external_ids?.imdb_id ?? json.imdb_id ?? null,
96
+ tvdbId: json.external_ids?.tvdb_id ? String(json.external_ids.tvdb_id) : null,
97
+ name: json.name ?? json.title ?? null,
98
+ description: json.overview || null,
99
+ genres: (json.genres ?? []).map((g) => g.name),
100
+ poster: img(json.poster_path, 'w500'),
101
+ background: img(json.backdrop_path, 'w1280'),
102
+ logo: img(logo, 'w500'),
103
+ directors: people.directors,
104
+ writers: people.writers,
105
+ runtime: runtime ? `${runtime} min` : null,
106
+ status: json.status || null,
107
+ country: json.production_countries?.[0]?.name ?? json.origin_country?.[0] ?? null,
108
+ certification: usRating || null,
109
+ rating: json.vote_count ? { value: Math.round(json.vote_average * 10) / 10, votes: json.vote_count } : null,
110
+ cast,
111
+ seasonPosters: Object.fromEntries((json.seasons ?? []).filter((s) => s.poster_path).map((s) => [s.season_number, img(s.poster_path, 'w342')])),
112
+ recommendations: (json.recommendations?.results ?? []).slice(0, 10).map((r) => ({ tmdbId: String(r.id), type: r.media_type === 'movie' || movie ? 'movie' : 'tv', name: r.name ?? r.title })),
113
+ };
114
+ }
115
+
116
+ /** TMDB season -> episode fills keyed by episode number. */
117
+ export function normalizeTmdbSeason(json) {
118
+ const out = {};
119
+ for (const e of json?.episodes ?? []) {
120
+ out[e.episode_number] = { name: e.name || null, overview: e.overview || null, thumbnail: img(e.still_path, 'w300'), released: e.air_date ? `${e.air_date}T00:00:00.000Z` : null };
121
+ }
122
+ return out;
123
+ }
@@ -0,0 +1,165 @@
1
+ // TVDB v4, called from the caller's process. POST /login with the key (and optional
2
+ // subscriber PIN) returns a bearer token, kept in memory for this process (or tab) and reused; a 401 later means the token
3
+ // expired, so the client logs in once more and retries.
4
+ import { ByokError, providerFetch, qs } from './http.js';
5
+
6
+ const BASE = 'https://api4.thetvdb.com/v4';
7
+ const MAX_EPISODE_PAGES = 25; // ~12,500 episodes at TVDB's 500 per page
8
+ // hash(key, pin) -> Promise<token>; memory only, never persisted, never keyed by the raw key.
9
+ const tokens = new Map();
10
+
11
+ /** FNV-1a, enough to key the token map without holding the key itself as a map key. */
12
+ function slotOf(key, pin) {
13
+ let h = 0x811c9dc5;
14
+ for (const ch of `${key}\u0000${pin}`) {
15
+ h ^= ch.codePointAt(0);
16
+ h = Math.imul(h, 0x01000193) >>> 0;
17
+ }
18
+ return `${h.toString(36)}:${key.length}:${pin.length}`;
19
+ }
20
+
21
+ export function forgetTvdbTokens() {
22
+ tokens.clear();
23
+ }
24
+
25
+ export function createTvdb({ key, pin = '', fetch: f = globalThis.fetch.bind(globalThis), timeoutMs } = {}) {
26
+ const k = String(key ?? '').trim();
27
+ const p = String(pin ?? '').trim();
28
+ if (!k) throw new ByokError('tvdb', 'invalid_key', 'No TVDB key set.');
29
+ const slot = slotOf(k, p);
30
+
31
+ function login(signal, fresh = false) {
32
+ if (fresh) tokens.delete(slot);
33
+ if (!tokens.has(slot)) {
34
+ const pending = providerFetch('tvdb', `${BASE}/login`, {
35
+ fetch: f, signal, timeoutMs, method: 'POST', body: p ? { apikey: k, pin: p } : { apikey: k },
36
+ }).then((d) => {
37
+ const token = d?.data?.token;
38
+ if (!token) throw new ByokError('tvdb', 'bad_response', 'TVDB did not return a token.');
39
+ return token;
40
+ });
41
+ // A failed login must not be cached.
42
+ pending.catch(() => tokens.delete(slot));
43
+ tokens.set(slot, pending);
44
+ }
45
+ return tokens.get(slot);
46
+ }
47
+
48
+ async function get(path, params = {}, signal) {
49
+ const run = async (fresh) => {
50
+ const token = await login(signal, fresh);
51
+ return providerFetch('tvdb', `${BASE}${path}${qs(params)}`, { fetch: f, signal, timeoutMs, headers: { Authorization: `Bearer ${token}` } });
52
+ };
53
+ try {
54
+ return await run(false);
55
+ } catch (e) {
56
+ if (e instanceof ByokError && e.code === 'invalid_key' && e.status === 401) return run(true);
57
+ throw e;
58
+ }
59
+ }
60
+
61
+ const enc = encodeURIComponent;
62
+ return {
63
+ check: async (signal) => { await login(signal, true); return { ok: true }; },
64
+ seriesExtended: (id, signal) => get(`/series/${enc(id)}/extended`, { meta: 'translations' }, signal),
65
+ movieExtended: (id, signal) => get(`/movies/${enc(id)}/extended`, { meta: 'translations' }, signal),
66
+ episodes: (id, season, signal) => get(`/series/${enc(id)}/episodes/default/eng`, { page: 0, season }, signal),
67
+ /** English (or `lang`) name, overview and aliases of a series or movie. */
68
+ translation: (id, { lang = 'eng', kind = 'series' } = {}, signal) => get(`/${kind === 'movie' ? 'movies' : 'series'}/${enc(id)}/translations/${enc(lang)}`, {}, signal),
69
+ artworks: (id, signal) => get(`/series/${enc(id)}/artworks`, {}, signal),
70
+ /** IMDb id → TVDB search hits. */
71
+ searchRemoteId: (remoteId, signal) => get(`/search/remoteid/${enc(remoteId)}`, {}, signal),
72
+ /**
73
+ * Every episode of an order ('official', 'default', 'absolute', 'dvd'), all pages, names
74
+ * and overviews in `lang` (the language-suffixed endpoint translates in bulk; the
75
+ * /extended episode list never does). Episodes keep absoluteNumber.
76
+ */
77
+ async allEpisodes(id, { order = 'official', lang = 'eng', signal } = {}) {
78
+ const out = [];
79
+ for (let page = 0; page < MAX_EPISODE_PAGES; page++) {
80
+ const body = await get(`/series/${enc(id)}/episodes/${enc(order)}/${enc(lang)}`, { page }, signal);
81
+ const eps = body?.data?.episodes ?? [];
82
+ out.push(...eps);
83
+ if (!eps.length || !body?.links?.next) break;
84
+ }
85
+ return out;
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * "S{season}E{number}" → TVDB absolute number. TVDB's absoluteNumber already accounts for
92
+ * specials interleaved into the run, so it beats summing season lengths. Pure.
93
+ */
94
+ export function episodeAbsoluteMap(episodes) {
95
+ const out = {};
96
+ for (const ep of episodes ?? []) {
97
+ if (!ep?.seasonNumber || !ep.number || !ep.absoluteNumber) continue;
98
+ out[`S${ep.seasonNumber}E${ep.number}`] = ep.absoluteNumber;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ // Artwork type ids: series poster/background/clear logo, then the movie ones.
104
+ const ART_TYPES = { series: { poster: 2, background: 3, logo: 23 }, movie: { poster: 14, background: 15, logo: 25 } };
105
+
106
+ /**
107
+ * TVDB's best poster, background and logo. Posters prefer English, then textless, then
108
+ * the highest score; backgrounds prefer textless; logos are English or textless only (a
109
+ * logo is the title as text). Pure.
110
+ */
111
+ export function pickTvdbArt(artworks, { movie = false, fallbackPoster = null } = {}) {
112
+ const types = ART_TYPES[movie ? 'movie' : 'series'];
113
+ const list = artworks ?? [];
114
+ const best = (type, langs) => {
115
+ const rank = (a) => {
116
+ const i = langs.indexOf(a.language ?? null);
117
+ return i === -1 ? langs.length : i;
118
+ };
119
+ return list
120
+ .filter((a) => a.type === type && a.image && (langs.includes(a.language ?? null) || langs.includes('*')))
121
+ .sort((a, b) => (rank(a) - rank(b)) || ((b.score || 0) - (a.score || 0)))[0]?.image ?? null;
122
+ };
123
+ return {
124
+ poster: best(types.poster, ['eng', null, '*']) ?? fallbackPoster ?? null,
125
+ background: best(types.background, [null, 'eng', '*']),
126
+ logo: best(types.logo, ['eng', null]),
127
+ };
128
+ }
129
+
130
+ /** TVDB series extended -> the fields our Title uses. Pure. */
131
+ export function normalizeTvdb(json) {
132
+ const d = json?.data;
133
+ if (!d) return null;
134
+ const eng = (list) => list?.find((t) => t.language === 'eng');
135
+ const art = pickTvdbArt(d.artworks, { fallbackPoster: d.image ?? null });
136
+ const posters = {};
137
+ for (const s of d.seasons ?? []) if (s.type?.type === 'official' && s.image && !(s.number in posters)) posters[s.number] = s.image;
138
+ return {
139
+ tvdbId: String(d.id),
140
+ name: eng(d.translations?.nameTranslations)?.name ?? null,
141
+ description: eng(d.translations?.overviewTranslations)?.overview ?? null,
142
+ genres: (d.genres ?? []).map((g) => g.name),
143
+ poster: art.poster,
144
+ background: art.background,
145
+ logo: art.logo,
146
+ runtime: d.averageRuntime ? `${d.averageRuntime} min` : null,
147
+ status: d.status?.name ?? null,
148
+ country: d.originalCountry ? d.originalCountry.toUpperCase() : null,
149
+ // TVDB has no user rating; its "score" is a popularity count, shown as such.
150
+ score: Number.isFinite(d.score) ? d.score : null,
151
+ cast: (d.characters ?? []).filter((c) => c.peopleType === 'Actor').slice(0, 24)
152
+ .map((c) => ({ name: c.personName, character: c.name || null, photo: c.personImgURL || c.image || null })),
153
+ seasonPosters: posters,
154
+ };
155
+ }
156
+
157
+ /** TVDB episodes page -> episode fills keyed by episode number, for one season. */
158
+ export function normalizeTvdbEpisodes(json, season) {
159
+ const out = {};
160
+ for (const e of json?.data?.episodes ?? []) {
161
+ if (e.seasonNumber !== season) continue;
162
+ out[e.number] = { name: e.name || null, overview: e.overview || null, thumbnail: e.image || null, released: e.aired ? `${e.aired}T00:00:00.000Z` : null };
163
+ }
164
+ return out;
165
+ }
package/src/cache.js ADDED
@@ -0,0 +1,82 @@
1
+ // Response cache for API (and keyless provider) answers. Hits and misses are both cached;
2
+ // misses (null, [], a 404) for a shorter time so a new entry shows up soon.
3
+ //
4
+ // `cache` option:
5
+ // undefined in memory, ttlMs 1 h, missTtlMs 5 min, max 5000 items
6
+ // false | 0 off
7
+ // { ttlMs, missTtlMs, max } in memory with those limits (ttlMs 0 turns it off)
8
+ // store your own: { get(key), set(key, value, ttlMs), delete?(key), clear?() },
9
+ // sync or async. Values are JSON-serialisable; expiry is the store's job.
10
+
11
+ export const DEFAULT_TTL_MS = 60 * 60 * 1000;
12
+ export const DEFAULT_MISS_TTL_MS = 5 * 60 * 1000;
13
+ export const DEFAULT_MAX = 5000;
14
+
15
+ const isStore = (o) => o && typeof o.get === 'function' && typeof o.set === 'function';
16
+
17
+ /** Bounded in-memory store with per-item expiry (oldest evicted first). */
18
+ export function memoryStore({ max = DEFAULT_MAX, now = () => Date.now() } = {}) {
19
+ const map = new Map();
20
+ return {
21
+ get(key) {
22
+ const hit = map.get(key);
23
+ if (!hit) return undefined;
24
+ if (hit.expires <= now()) { map.delete(key); return undefined; }
25
+ return hit.value;
26
+ },
27
+ set(key, value, ttlMs) {
28
+ map.delete(key);
29
+ map.set(key, { value, expires: now() + ttlMs });
30
+ if (map.size > max) map.delete(map.keys().next().value);
31
+ },
32
+ delete(key) { map.delete(key); },
33
+ clear() { map.clear(); },
34
+ get size() { return map.size; },
35
+ };
36
+ }
37
+
38
+ /** A missing answer: cached for missTtlMs instead of ttlMs. */
39
+ export const isMiss = (v) => v === null || v === undefined || (Array.isArray(v) && v.length === 0)
40
+ || (Array.isArray(v?.results) && v.results.length === 0);
41
+
42
+ /**
43
+ * Normalise the option into { get(key) → Promise<{ value } | null>, set(key, value), clear() }.
44
+ * Values are wrapped so a cached null (a miss) differs from "not cached".
45
+ */
46
+ export function createResponseCache(option, { cacheTtlMs } = {}) {
47
+ let ttlMs = DEFAULT_TTL_MS;
48
+ let missTtlMs = DEFAULT_MISS_TTL_MS;
49
+ let store;
50
+ if (cacheTtlMs !== undefined && option === undefined) option = cacheTtlMs ? { ttlMs: Number(cacheTtlMs) } : false;
51
+ if (option === false || option === 0 || option === null) return disabled();
52
+ if (isStore(option)) {
53
+ store = option;
54
+ } else {
55
+ const o = option ?? {};
56
+ if (o.ttlMs !== undefined) ttlMs = Number(o.ttlMs);
57
+ if (o.missTtlMs !== undefined) missTtlMs = Number(o.missTtlMs);
58
+ else missTtlMs = Math.min(missTtlMs, ttlMs);
59
+ if (!ttlMs) return disabled();
60
+ store = isStore(o.store) ? o.store : memoryStore({ max: o.max ?? DEFAULT_MAX });
61
+ }
62
+ return {
63
+ async get(key) {
64
+ try {
65
+ const hit = await store.get(key);
66
+ return hit && typeof hit === 'object' && 'v' in hit ? { value: hit.v } : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ },
71
+ async set(key, value) {
72
+ try { await store.set(key, { v: value }, isMiss(value) ? missTtlMs : ttlMs); } catch { /* a cache failure never fails a call */ }
73
+ },
74
+ async clear() {
75
+ try { await store.clear?.(); } catch { /* ignore */ }
76
+ },
77
+ };
78
+ }
79
+
80
+ function disabled() {
81
+ return { get: async () => null, set: async () => {}, clear: async () => {} };
82
+ }