@animegarden/client
Version:
Anime Garden API client and utils
708 lines (693 loc) • 22 kB
JavaScript
import { z } from 'zod';
import { serialize } from 'ohash';
import { fullToHalf, tradToSimple } from 'simptrad';
const DefaultBaseURL = "https://api.animes.garden/";
const SupportProviders = ["dmhy", "moe", "ani"];
const CollectionSchema = z.object({
hash: z.string().optional(),
name: z.coerce.string().default(""),
authorization: z.string(),
filters: z.array(
z.object({
name: z.coerce.string().default(""),
searchParams: z.string(),
// filters
provider: z.enum(SupportProviders).optional(),
duplicate: z.boolean().optional(),
types: z.array(z.string()).optional(),
after: z.coerce.date().optional(),
before: z.coerce.date().optional(),
fansubs: z.array(z.string()).optional(),
publishers: z.array(z.string()).optional(),
subjects: z.array(z.number()).optional(),
search: z.array(z.string()).optional(),
include: z.array(z.string()).optional(),
keywords: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional()
}).passthrough()
).min(1).max(50)
});
function parseCollection(collection) {
const parsed = CollectionSchema.safeParse(collection);
if (parsed.success) {
return parsed.data;
} else {
return void 0;
}
}
async function hashCollection(collection) {
const sorted = [...collection.filters];
sorted.sort((lhs, rhs) => lhs.searchParams.localeCompare(rhs.searchParams));
const filters = sorted.map((f) => {
const r = { ...f };
delete r.name;
delete r.searchParams;
delete r.resources;
delete r.complete;
return r;
});
const body = serialize(filters);
const encoder = new TextEncoder();
const data = encoder.encode(body);
const digest = await crypto.subtle.digest("SHA-1", data);
const hashArray = Array.from(new Uint8Array(digest));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
return hashHex;
}
function normalizeTitle(title) {
return fullToHalf(tradToSimple(title), { punctuation: true });
}
function makeResourcesFilter(filter) {
const conds = [];
const {
provider,
include,
keywords,
exclude,
subjects,
search,
publishers,
fansubs,
types,
before,
after
} = filter;
if (provider) {
conds.push((r) => r.provider === provider);
}
if (include && include.length > 0 || keywords && keywords.length > 0 || exclude && exclude.length > 0) {
conds.push((r) => {
const title = normalizeTitle(r.title).toLowerCase();
return (include?.map((i) => normalizeTitle(i).toLocaleLowerCase()).some((i) => title.indexOf(i.toLocaleLowerCase()) !== -1) ?? true) && (keywords?.map((i) => normalizeTitle(i).toLocaleLowerCase()).every((i) => title.indexOf(i) !== -1) ?? true) && (exclude?.map((i) => normalizeTitle(i).toLocaleLowerCase()).every((i) => title.indexOf(i) === -1) ?? true);
});
}
if (subjects && subjects.length > 0) {
conds.push((r) => subjects.some((s) => r.subjectId === s));
}
if (search && search.length > 0) ;
if (publishers && publishers.length > 0 || fansubs && fansubs.length > 0) {
conds.push(
(r) => (publishers?.some((p) => r.publisher.name === p) ?? false) || (fansubs?.some((p) => r.fansub?.name === p) ?? false)
);
}
if (types && types.length > 0) {
conds.push((r) => types.some((t) => r.type === t));
}
if (before) {
const t = before.getTime();
conds.push((r) => r.createdAt.getTime() <= t);
}
if (after) {
const t = after.getTime();
conds.push((r) => r.createdAt.getTime() >= t);
}
return (res) => {
return conds.every((c) => c(res));
};
}
const dateLike = z.union([
z.null(),
z.undefined(),
z.coerce.number().transform((n) => new Date(n)),
z.coerce.date()
]);
const stringArray = z.union([z.string().transform((s) => [s]), z.array(z.string())]);
const providerEnum = z.enum(SupportProviders);
const UrlSearchSchema = {
provider: providerEnum.optional(),
duplicate: z.union([z.null(), z.undefined(), z.coerce.boolean()]).optional(),
page: z.union([z.null(), z.undefined(), z.coerce.number()]).optional(),
pageSize: z.union([z.null(), z.undefined(), z.coerce.number()]).optional(),
fansub: z.string().array().optional(),
publisher: z.string().array().optional(),
type: z.string().array().optional(),
before: dateLike.optional(),
after: dateLike.optional(),
subject: z.coerce.number().array().optional(),
search: z.string().array().optional(),
include: z.string().array().optional(),
keyword: z.string().array().optional(),
exclude: z.string().array().optional()
};
const BodySchema = {
provider: providerEnum.optional(),
duplicate: z.union([z.null(), z.undefined(), z.coerce.boolean()]).optional(),
page: z.coerce.number().optional(),
pageSize: z.coerce.number().optional(),
fansub: z.string().optional(),
fansubs: z.string().array().optional(),
publisher: z.string().optional(),
publishers: z.string().array().optional(),
type: z.string().optional(),
types: z.string().array().optional(),
before: dateLike.optional(),
after: dateLike.optional(),
subject: z.coerce.number().optional(),
subjects: z.coerce.number().array().optional(),
search: stringArray.optional(),
include: stringArray.optional(),
keywords: stringArray.optional(),
exclude: stringArray.optional()
};
function parseURLSearch(params, body) {
const res1 = params ? {
provider: UrlSearchSchema.provider.safeParse(params.get("provider")).data,
duplicate: UrlSearchSchema.duplicate.safeParse(params.get("duplicate")).data,
page: UrlSearchSchema.page.safeParse(params.get("page")).data,
pageSize: UrlSearchSchema.pageSize.safeParse(params.get("pageSize")).data,
fansub: UrlSearchSchema.fansub.safeParse(params.getAll("fansub")).data,
publisher: UrlSearchSchema.publisher.safeParse(params.getAll("publisher")).data,
type: UrlSearchSchema.type.safeParse(params.getAll("type")).data,
before: UrlSearchSchema.before.safeParse(params.get("before")).data,
after: UrlSearchSchema.after.safeParse(params.get("after")).data,
subject: UrlSearchSchema.subject.safeParse(params.getAll("subject")).data,
search: UrlSearchSchema.search.safeParse(params.getAll("search")).data,
include: UrlSearchSchema.include.safeParse(params.getAll("include")).data,
keyword: UrlSearchSchema.keyword.safeParse(params.getAll("keyword")).data,
exclude: UrlSearchSchema.exclude.safeParse(params.getAll("exclude")).data
} : void 0;
const res2 = body ? {
provider: BodySchema.provider.safeParse(body.provider).data,
duplicate: BodySchema.duplicate.safeParse(body.duplicate).data,
page: BodySchema.page.safeParse(body.page).data,
pageSize: BodySchema.pageSize.safeParse(body.pageSize).data,
fansub: BodySchema.fansub.safeParse(body.fansub).data,
fansubs: BodySchema.fansubs.safeParse(body.fansubs).data,
publisher: BodySchema.publisher.safeParse(body.publisher).data,
publishers: BodySchema.publishers.safeParse(body.publishers).data,
type: BodySchema.type.safeParse(body.type).data,
types: BodySchema.types.safeParse(body.types).data,
before: BodySchema.before.safeParse(body.before).data,
after: BodySchema.after.safeParse(body.after).data,
subject: BodySchema.subject.safeParse(body.subject).data,
subjects: BodySchema.subjects.safeParse(body.subjects).data,
search: BodySchema.search.safeParse(body.search).data,
include: BodySchema.include.safeParse(body.include).data,
keywords: BodySchema.keywords.safeParse(body.keywords).data,
exclude: BodySchema.exclude.safeParse(body.exclude).data
} : void 0;
const filter = {
page: res1?.page ?? res2?.page ?? 1,
pageSize: res1?.pageSize ?? res2?.pageSize ?? 100
};
const isNaN = (d) => d === void 0 || d === null || Number.isNaN(d);
if (isNaN(filter.page) || filter.page < 1) {
filter.page = 1;
} else {
filter.page = Math.round(filter.page);
}
if (isNaN(filter.pageSize) || filter.pageSize < 1 || filter.pageSize > 1e3) {
filter.pageSize = 100;
} else {
filter.pageSize = Math.round(filter.pageSize);
}
if (res2?.provider) {
filter.duplicate = res1?.duplicate ?? res2?.duplicate ?? true;
filter.provider = res2.provider;
} else if (res1?.provider) {
filter.provider = res1.provider;
filter.duplicate = res2?.duplicate ?? res1?.duplicate ?? true;
}
if (res2?.fansub) {
filter.fansubs = [res2.fansub];
} else if (res2?.fansubs && res2.fansubs.length > 0) {
filter.fansubs = res2.fansubs;
} else if (res1?.fansub && res1.fansub.length > 0) {
filter.fansubs = res1.fansub;
}
if (filter.fansubs) {
filter.fansubs = [...new Set(filter.fansubs)];
}
if (res2?.publisher) {
filter.publishers = [res2.publisher];
} else if (res2?.publishers && res2.publishers.length > 0) {
filter.publishers = res2.publishers;
} else if (res1?.publisher && res1.publisher.length > 0) {
filter.publishers = res1.publisher;
}
if (res2?.type) {
filter.types = [res2.type];
} else if (res2?.types && res2.types.length > 0) {
filter.types = res2.types;
} else if (res1?.type && res1.type.length > 0) {
filter.types = res1.type;
}
if (filter.types) {
filter.types = [...new Set(filter.types)];
}
if (res2?.before || res1?.before) {
filter.before = res2?.before || res1?.before || void 0;
}
if (res2?.after || res1?.after) {
filter.after = res2?.after || res1?.after || void 0;
}
if (res2?.subject) {
filter.subjects = [res2.subject];
} else if (res2?.subjects && res2?.subjects.length > 0) {
filter.subjects = res2.subjects;
} else if (res1?.subject && res1.subject.length > 0) {
filter.subjects = res1.subject;
}
if (res2?.search && res2.search.length > 0) {
filter.search = res2.search;
} else if (res1?.search && res1.search.length > 0) {
filter.search = res1.search;
}
if (filter.search) {
filter.search = [...new Set(filter.search)];
}
if (res2?.include && res2.include.length > 0) {
filter.include = res2.include;
} else if (res1?.include && res1.include.length > 0) {
filter.include = res1.include;
}
if (filter.include) {
filter.include = [...new Set(filter.include)];
}
if (res2?.keywords && res2.keywords.length > 0) {
filter.keywords = res2.keywords;
} else if (res1?.keyword && res1.keyword.length > 0) {
filter.keywords = res1.keyword;
}
if (filter.keywords) {
filter.keywords = [...new Set(filter.keywords)];
}
if (res2?.exclude && res2.exclude.length > 0) {
filter.exclude = res2.exclude;
} else if (res1?.exclude && res1.exclude.length > 0) {
filter.exclude = res1.exclude;
}
if (filter.exclude) {
filter.exclude = [...new Set(filter.exclude)];
}
if (filter.search) {
delete filter.include;
}
return filter;
}
function stringifyURLSearch(options) {
const params = new URLSearchParams();
const { page, pageSize, duplicate, after, before } = options;
if (page) {
params.set("page", "" + page);
}
if (pageSize) {
params.set("pageSize", "" + pageSize);
}
if (duplicate) {
params.set("duplicate", "true");
}
if (after) {
params.set("after", "" + after.getTime());
}
if (before) {
params.set("before", "" + before.getTime());
}
const { provider } = options;
if (provider) {
params.set("provider", provider);
}
const { search, include, keywords, exclude, subject, subjects } = options;
if (subject) {
params.set("subject", "" + subject);
} else if (subjects) {
for (const subject2 of new Set(subjects)) {
params.append("subject", "" + subject2);
}
}
if (search && search.length > 0) {
for (const word of new Set(search)) {
params.append("search", word);
}
for (const word of keywords ? new Set(keywords) : []) {
params.append("keyword", word);
}
for (const word of exclude ? new Set(exclude) : []) {
params.append("exclude", word);
}
} else if (include && include.length > 0) {
for (const word of include ? new Set(include) : []) {
params.append("include", word);
}
for (const word of keywords ? new Set(keywords) : []) {
params.append("keyword", word);
}
for (const word of exclude ? new Set(exclude) : []) {
params.append("exclude", word);
}
} else {
for (const word of keywords ? new Set(keywords) : []) {
params.append("keyword", word);
}
for (const word of exclude ? new Set(exclude) : []) {
params.append("exclude", word);
}
}
const { type, types } = options;
if (type) {
params.set("type", type);
} else if (types) {
for (const type2 of new Set(types)) {
params.append("type", type2);
}
}
const { fansub, fansubs } = options;
if (fansub) {
params.set("fansub", fansub);
} else if (fansubs) {
for (const fansub2 of new Set(fansubs)) {
params.append("fansub", fansub2);
}
}
const { publisher, publishers } = options;
if (publisher) {
params.set("publisher", publisher);
} else if (publishers) {
for (const publisher2 of new Set(publishers)) {
params.append("publisher", publisher2);
}
}
params.sort();
return params;
}
function transformResourceHref(provider, href) {
if (!href) return void 0;
switch (provider) {
case "dmhy":
return `https://share.dmhy.org/topics/view/${href}`;
case "moe":
return `https://bangumi.moe/torrent/${href}`;
case "ani":
return href;
default:
return void 0;
}
}
function transformPublisherHref(provider, publisherId) {
if (!publisherId) return void 0;
switch (provider) {
case "dmhy":
return `https://share.dmhy.org/topics/list/user_id/${publisherId}`;
case "moe":
return `https://bangumi.moe/tag/${publisherId}`;
case "ani":
return "https://aniopen.an-i.workers.dev/";
default:
return void 0;
}
}
function transformFansubHref(provider, fansubId) {
if (!fansubId) return void 0;
switch (provider) {
case "dmhy":
return `https://share.dmhy.org/topics/list/team_id/${fansubId}`;
case "moe":
return `https://bangumi.moe/tag/${fansubId}`;
case "ani":
return "https://aniopen.an-i.workers.dev/";
default:
return void 0;
}
}
async function retryFn(fn, count, signal) {
if (count < 0) {
count = Number.MAX_SAFE_INTEGER;
}
let e;
for (let i = 0; i <= count; i++) {
try {
return await fn();
} catch (err) {
e = err;
if (signal?.aborted) {
break;
}
}
}
throw e;
}
function sleep(timeout) {
return new Promise((res) => {
setTimeout(() => res(), timeout);
});
}
const version = "0.5.2";
async function fetchAPI(path, init = void 0, options = {}) {
const { fetch = globalThis.fetch, baseURL = DefaultBaseURL, retry = 0 } = options;
const url = new URL(path.replace(/^\/+/g, ""), baseURL);
const headers = new Headers(options.headers);
headers.set(`x-trace-id`, crypto.randomUUID());
if (!headers.get("user-agent")) {
headers.set(`user-agent`, `animegarden@${version}`);
}
return await retryFn(
async () => {
const signal = options.timeout && options.timeout > 0 ? options.signal ? AbortSignal.any([AbortSignal.timeout(options.timeout), options.signal]) : AbortSignal.timeout(options.timeout) : options.signal;
const payload = {
...init,
headers,
signal
};
if (options?.hooks?.prefetch) {
await options.hooks.prefetch(url.toString(), payload);
}
let error;
const resp = await fetch(url.toString(), payload).catch((_error) => {
error = _error;
return void 0;
});
if (resp) {
if (options?.hooks?.postfetch) {
await options.hooks.postfetch(url.toString(), payload, resp);
}
if (resp.ok) {
return await resp.json();
} else {
if (resp.status === 429) {
await sleep(16 * 1e3);
}
throw new Error(`${resp.status} ${resp.statusText} ${url.toString()}`, { cause: resp });
}
} else {
if (error?.name === "AbortError") {
throw error;
}
if (error?.name === "TimeoutError") {
await (options.hooks?.timeout ? options.hooks?.timeout() : sleep(100));
throw error;
}
if (error instanceof Error) {
throw error;
} else {
throw new Error(error);
}
}
},
retry,
options.signal
);
}
async function fetchStatus(options = {}) {
const resp = await fetchAPI("/", void 0, options).catch(() => void 0);
if (resp) {
return {
ok: true,
timestamp: resp.timestamp,
providers: resp.providers
};
} else {
return {
ok: false,
timestamp: void 0,
providers: void 0
};
}
}
async function generateCollection(collection, options = {}) {
const body = JSON.stringify({
...collection,
filters: collection.filters.map((f) => ({ ...f, resources: void 0, complete: void 0 }))
});
const resp = await fetchAPI(
"collection",
{
method: "PUT",
body
},
options
).catch((_err) => {
return void 0;
});
if (resp) {
return {
ok: true,
...collection,
createdAt: resp.createdAt,
hash: resp.hash,
timestamp: new Date(resp.timestamp)
};
}
return void 0;
}
async function fetchCollection(hash, options = {}) {
const resp = await fetchAPI(
`collection/${hash}`,
{
method: "GET"
},
options
).catch((_err) => {
return void 0;
});
if (resp) {
return {
ok: true,
...resp,
timestamp: new Date(resp.timestamp)
};
}
return void 0;
}
async function fetchResourceDetail(provider, href, options = {}) {
const resp = await fetchAPI(`detail/${provider}/${href}`, void 0, options).catch(
(_err) => {
return void 0;
}
);
return {
ok: resp && resp.resource !== void 0 && resp.timestamp !== void 0,
resource: resp?.resource,
detail: resp?.detail,
timestamp: resp?.timestamp ? new Date(resp.timestamp) : void 0
};
}
async function fetchResources(options = {}) {
const searchParams = stringifyURLSearch(options);
if (options.tracker) {
searchParams.set("tracker", "true");
}
if (options.metadata) {
searchParams.set("metadata", "true");
}
if (options.count !== void 0 && options.count !== null) {
searchParams.set("pageSize", "1000");
const count = options.count < 0 ? Number.MAX_SAFE_INTEGER : options.count;
const map = /* @__PURE__ */ new Map();
let aborted = false;
let timestamp;
let complete = false;
let filter = void 0;
for (let page = 1; map.size < count && !complete; page++) {
try {
if (options.signal?.aborted) {
aborted = true;
break;
}
const resp = await fetchPage(page);
if (!resp) {
aborted = true;
break;
}
complete = resp.complete;
if (!timestamp) {
timestamp = resp.timestamp;
}
if (resp.filter) {
filter = resp.filter;
}
if (resp.resources.length === 0) {
break;
}
const newRes = [];
for (const r of resp.resources) {
if (!map.has(r.href)) {
map.set(r.href, r);
newRes.push(r);
}
}
await options.progress?.(newRes, {
url: searchParams.toString(),
searchParams,
page
});
} catch (error) {
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) {
aborted = true;
break;
} else {
throw error;
}
}
}
if (filter) {
delete filter["page"];
delete filter["pageSize"];
}
return {
ok: !aborted,
resources: uniq([...map.values()]),
complete: aborted ? false : complete,
filter,
timestamp
};
} else {
const resp = await fetchPage(options.page ?? 1);
if (!resp) {
return {
ok: false,
resources: [],
complete: false,
filter: void 0,
timestamp: void 0
};
}
const resources = uniq(resp.resources);
await options.progress?.(resources, {
url: searchParams.toString(),
searchParams,
page: 1
});
return {
ok: true,
resources,
complete: resp.complete ?? false,
filter: resp.filter,
timestamp: resp.timestamp
};
}
async function fetchPage(page) {
searchParams.set("page", "" + page);
const r = await fetchAPI("resources?" + searchParams.toString(), void 0, options);
const timestamp = new Date(r.timestamp);
if (!isNaN(timestamp.getTime())) {
for (const res of r.resources) {
res.createdAt = new Date(res.createdAt);
res.fetchedAt = new Date(res.fetchedAt);
}
if (r.filter.before) {
r.filter.before = new Date(r.filter.before);
}
if (r.filter.after) {
r.filter.after = new Date(r.filter.after);
}
return {
resources: r.resources,
complete: r.complete,
filter: r.filter,
timestamp
};
} else {
throw new Error(`Invalid response /resource?${searchParams.toString()}`);
}
}
function uniq(resources) {
const map = /* @__PURE__ */ new Map();
for (const r of resources) {
if (!map.has(r.href)) {
map.set(r.href, r);
}
}
return [...map.values()].sort((lhs, rhs) => rhs.createdAt.getTime() - lhs.createdAt.getTime());
}
}
export { DefaultBaseURL, SupportProviders, fetchAPI, fetchCollection, fetchResourceDetail, fetchResources, fetchStatus, generateCollection, hashCollection, makeResourcesFilter, normalizeTitle, parseCollection, parseURLSearch, stringifyURLSearch, transformFansubHref, transformPublisherHref, transformResourceHref };