@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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Kitsu (keyless), called from the caller's own process. Pitfalls handled here, learned
|
|
2
|
+
// in production:
|
|
3
|
+
// - genres live in the `categories` relationship; the `genres` attribute is always empty;
|
|
4
|
+
// - `abbreviatedTitles` (and `titles`) can be missing or null;
|
|
5
|
+
// - an episode's `createdAt` is set when the episode is merely planned, so it is never
|
|
6
|
+
// used as an air date: `airdate` is null until Kitsu knows the real one;
|
|
7
|
+
// - media-relationships pages at 10 by default and only one side of a prequel/sequel link
|
|
8
|
+
// may exist (a later part may name no prequel while the earlier one names it as sequel).
|
|
9
|
+
import { providerFetch, qs } from '../byok/http.js';
|
|
10
|
+
|
|
11
|
+
export const KITSU_API = 'https://kitsu.app/api/edge';
|
|
12
|
+
const PAGE = 20;
|
|
13
|
+
const MAX_EPISODES = 500;
|
|
14
|
+
const EPISODE_CONCURRENCY = 5;
|
|
15
|
+
const MAX_RELATION_PAGES = 5;
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
/** Kitsu anime record (+ included categories) → { id, titles: {…labelled}, … }. Pure. */
|
|
19
|
+
export function normalizeKitsuAnime(data, included = []) {
|
|
20
|
+
if (!data?.attributes) return null;
|
|
21
|
+
const a = data.attributes;
|
|
22
|
+
const t = a.titles ?? {};
|
|
23
|
+
const abbreviated = [...new Set((a.abbreviatedTitles ?? []).filter((x) => typeof x === 'string' && x.trim()))];
|
|
24
|
+
// Donghua and Korean entries carry their romanised and native titles under en_cn/zh_cn
|
|
25
|
+
// and en_kr/ko_kr instead of en_jp/ja_jp.
|
|
26
|
+
const en = t.en ?? t.en_us ?? null;
|
|
27
|
+
const romaji = t.en_jp ?? t.en_cn ?? t.en_kr ?? null;
|
|
28
|
+
const native = t.ja_jp ?? t.zh_cn ?? t.ko_kr ?? null;
|
|
29
|
+
const labelled = [a.canonicalTitle, en, romaji, native].filter(Boolean);
|
|
30
|
+
const synonyms = [...new Set(Object.values(t).filter((v) => typeof v === 'string' && v && !labelled.includes(v)))];
|
|
31
|
+
const categoryIds = new Set((data.relationships?.categories?.data ?? []).map((c) => c.id));
|
|
32
|
+
const categories = included
|
|
33
|
+
.filter((i) => i.type === 'categories' && (categoryIds.size === 0 || categoryIds.has(i.id)))
|
|
34
|
+
.map((i) => i.attributes?.title)
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
const avg = a.averageRating === null || a.averageRating === undefined ? null : Number(a.averageRating);
|
|
37
|
+
return {
|
|
38
|
+
id: String(data.id),
|
|
39
|
+
slug: a.slug ?? null,
|
|
40
|
+
titles: {
|
|
41
|
+
canonical: a.canonicalTitle ?? null,
|
|
42
|
+
en,
|
|
43
|
+
romaji,
|
|
44
|
+
native,
|
|
45
|
+
synonyms,
|
|
46
|
+
abbreviated,
|
|
47
|
+
},
|
|
48
|
+
synopsis: a.synopsis || a.description || null,
|
|
49
|
+
subtype: a.subtype ?? null,
|
|
50
|
+
showType: a.showType ?? null,
|
|
51
|
+
status: a.status ?? null,
|
|
52
|
+
startDate: a.startDate ?? null,
|
|
53
|
+
endDate: a.endDate ?? null,
|
|
54
|
+
nextRelease: a.nextRelease ?? null,
|
|
55
|
+
episodeCount: a.episodeCount ?? null,
|
|
56
|
+
episodeLength: a.episodeLength ?? null,
|
|
57
|
+
ageRating: a.ageRating ?? null,
|
|
58
|
+
nsfw: a.nsfw ?? null,
|
|
59
|
+
posterImage: a.posterImage ?? null,
|
|
60
|
+
coverImage: a.coverImage ?? null,
|
|
61
|
+
youtubeVideoId: a.youtubeVideoId || null,
|
|
62
|
+
/** Kitsu's 0–100 average, as a number. */
|
|
63
|
+
averageRating: Number.isFinite(avg) ? avg : null,
|
|
64
|
+
/** The same on a 0–10 scale, one decimal. */
|
|
65
|
+
rating: Number.isFinite(avg) ? Math.round(avg) / 10 : null,
|
|
66
|
+
userCount: a.userCount ?? null,
|
|
67
|
+
popularityRank: a.popularityRank ?? null,
|
|
68
|
+
categories,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Kitsu episode → { number, seasonNumber, title, … }. Pure. */
|
|
73
|
+
export function normalizeKitsuEpisode(a) {
|
|
74
|
+
return {
|
|
75
|
+
number: a.number,
|
|
76
|
+
seasonNumber: a.seasonNumber || 1,
|
|
77
|
+
relativeNumber: a.relativeNumber ?? null,
|
|
78
|
+
title: a.canonicalTitle || a.titles?.en || a.titles?.en_us || a.titles?.en_jp || null,
|
|
79
|
+
titles: a.titles ?? {},
|
|
80
|
+
synopsis: a.synopsis || a.description || null,
|
|
81
|
+
airdate: a.airdate || null,
|
|
82
|
+
length: a.length ?? null,
|
|
83
|
+
thumbnail: a.thumbnail?.original ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function mapLimit(items, limit, fn) {
|
|
88
|
+
const out = new Array(items.length);
|
|
89
|
+
let next = 0;
|
|
90
|
+
const worker = async () => {
|
|
91
|
+
while (next < items.length) {
|
|
92
|
+
const i = next++;
|
|
93
|
+
out[i] = await fn(items[i], i);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createKitsu({ fetch, timeoutMs = 10_000, baseUrl = KITSU_API } = {}) {
|
|
101
|
+
const get = (path, params, signal) => providerFetch('kitsu', `${baseUrl}${path}${qs(params ?? {})}`, {
|
|
102
|
+
fetch, signal, timeoutMs, headers: { Accept: 'application/vnd.api+json' },
|
|
103
|
+
});
|
|
104
|
+
const orNull = async (p) => {
|
|
105
|
+
try { return await p; } catch (e) { if (e.code === 'not_found') return null; throw e; }
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
/** One anime with labelled titles and categories, or null when Kitsu has no such id. */
|
|
110
|
+
async anime(id, signal) {
|
|
111
|
+
const body = await orNull(get(`/anime/${encodeURIComponent(id)}`, { include: 'categories', 'fields[categories]': 'title' }, signal));
|
|
112
|
+
return body ? normalizeKitsuAnime(body.data, body.included ?? []) : null;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every episode, sorted by number (at most `max`). The first page gives the total, the
|
|
117
|
+
* rest are fetched a few at a time; a failed page ends the list there.
|
|
118
|
+
*/
|
|
119
|
+
async episodes(id, { max = MAX_EPISODES, signal } = {}) {
|
|
120
|
+
const page = (offset) => get('/episodes', { 'filter[media_id]': id, 'page[limit]': PAGE, 'page[offset]': offset, sort: 'number' }, signal);
|
|
121
|
+
const first = await orNull(page(0));
|
|
122
|
+
if (!first) return [];
|
|
123
|
+
const pages = [first.data ?? []];
|
|
124
|
+
const total = Math.min(Number(first.meta?.count) || 0, max);
|
|
125
|
+
if (first.links?.next && total > PAGE) {
|
|
126
|
+
const offsets = [];
|
|
127
|
+
for (let o = PAGE; o < total; o += PAGE) offsets.push(o);
|
|
128
|
+
const rest = await mapLimit(offsets, EPISODE_CONCURRENCY, (o) => page(o).then((r) => r.data ?? []).catch(() => null));
|
|
129
|
+
for (const p of rest) {
|
|
130
|
+
if (!p?.length) break;
|
|
131
|
+
pages.push(p);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return pages.flat().filter((e) => e.attributes?.number).map((e) => normalizeKitsuEpisode(e.attributes)).slice(0, max);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Media relationships of an anime: [{ role: 'prequel' | 'sequel' | 'side_story' | …,
|
|
139
|
+
* id, type, subtype, title, episodeCount, status, startDate }].
|
|
140
|
+
*/
|
|
141
|
+
async relationships(id, signal) {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (let p = 0; p < MAX_RELATION_PAGES; p++) {
|
|
144
|
+
const body = await get('/media-relationships', {
|
|
145
|
+
'filter[source_id]': id, 'filter[source_type]': 'Anime', include: 'destination', 'page[limit]': PAGE, 'page[offset]': p * PAGE,
|
|
146
|
+
}, signal);
|
|
147
|
+
const included = body.included ?? [];
|
|
148
|
+
for (const link of body.data ?? []) {
|
|
149
|
+
const dest = link.relationships?.destination?.data;
|
|
150
|
+
if (!dest?.id) continue;
|
|
151
|
+
const attrs = included.find((i) => i.id === dest.id && i.type === dest.type)?.attributes ?? {};
|
|
152
|
+
out.push({
|
|
153
|
+
role: link.attributes?.role ?? null,
|
|
154
|
+
id: String(dest.id),
|
|
155
|
+
type: dest.type,
|
|
156
|
+
subtype: attrs.subtype ?? null,
|
|
157
|
+
title: attrs.canonicalTitle ?? null,
|
|
158
|
+
titles: attrs.titles ?? {},
|
|
159
|
+
episodeCount: attrs.episodeCount ?? null,
|
|
160
|
+
status: attrs.status ?? null,
|
|
161
|
+
startDate: attrs.startDate ?? null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (!body.links?.next || (body.data ?? []).length < PAGE) break;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/** Text search, Kitsu's relevance order. */
|
|
170
|
+
async search(text, { limit = 5, signal } = {}) {
|
|
171
|
+
const body = await get('/anime', { 'filter[text]': text, 'page[limit]': Math.min(Math.max(1, limit), 20) }, signal);
|
|
172
|
+
return (body.data ?? []).map((d) => normalizeKitsuAnime(d)).filter(Boolean);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/refs.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ClientError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const ANIME_PROVIDERS = ['kitsu', 'mal', 'anilist', 'anidb'];
|
|
4
|
+
export const SERIES_PROVIDERS = ['tvdb', 'tmdb', 'imdb'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse a ref. Accepts "kitsu:44607", "MAL:1", "tvdb:342329", "tt8948436" and
|
|
8
|
+
* "imdb:tt8948436" (normalised to the bare "tt8948436").
|
|
9
|
+
* Returns { kind: 'anime' | 'series', provider, id, ref }; throws invalid_ref.
|
|
10
|
+
*/
|
|
11
|
+
export function parseRef(input) {
|
|
12
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
13
|
+
const bad = () => new ClientError('invalid_ref', `"${input}" is not a ref such as kitsu:44607, tvdb:342329 or tt8948436`);
|
|
14
|
+
if (/^tt\d+$/i.test(raw)) return { kind: 'series', provider: 'imdb', id: raw.toLowerCase(), ref: raw.toLowerCase() };
|
|
15
|
+
const m = /^([a-z]+):(.+)$/i.exec(raw);
|
|
16
|
+
if (!m) throw bad();
|
|
17
|
+
const provider = m[1].toLowerCase();
|
|
18
|
+
const id = m[2];
|
|
19
|
+
if (provider === 'imdb') {
|
|
20
|
+
if (!/^tt\d+$/i.test(id)) throw bad();
|
|
21
|
+
return { kind: 'series', provider, id: id.toLowerCase(), ref: id.toLowerCase() };
|
|
22
|
+
}
|
|
23
|
+
if (!/^\d+$/.test(id) || /^0+$/.test(id)) throw bad();
|
|
24
|
+
const norm = String(BigInt(id)); // drops leading zeros without losing precision
|
|
25
|
+
if (ANIME_PROVIDERS.includes(provider)) return { kind: 'anime', provider, id: norm, ref: `${provider}:${norm}` };
|
|
26
|
+
if (SERIES_PROVIDERS.includes(provider)) return { kind: 'series', provider, id: norm, ref: `${provider}:${norm}` };
|
|
27
|
+
throw bad();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A title id or any ref that resolves to a title: "tt8948436", "kitsu:44607", "tvdb:342329", "tmdb:movie:129". */
|
|
31
|
+
export function parseTitleRef(input) {
|
|
32
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
33
|
+
const movie = /^tmdb:movie:(\d+)$/i.exec(raw);
|
|
34
|
+
if (movie && !/^0+$/.test(movie[1])) return `tmdb:movie:${String(BigInt(movie[1]))}`;
|
|
35
|
+
return parseRef(input).ref;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function animeRef(input) {
|
|
39
|
+
const p = parseRef(input);
|
|
40
|
+
if (p.kind !== 'anime') throw new ClientError('invalid_ref', `"${input}" is not an anime ref (kitsu, mal, anilist or anidb)`);
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function seriesRef(input) {
|
|
45
|
+
const p = parseRef(input);
|
|
46
|
+
if (p.kind !== 'series') throw new ClientError('invalid_ref', `"${input}" is not a series ref (tvdb, tmdb or imdb)`);
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function checkInt(value, name, min) {
|
|
51
|
+
if (!Number.isInteger(value) || value < min) {
|
|
52
|
+
throw new ClientError('invalid_argument', `${name} must be an integer >= ${min}, got ${value}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function optionalInt(value, name, min) {
|
|
57
|
+
if (value !== undefined && value !== null) checkInt(value, name, min);
|
|
58
|
+
}
|