- adapters 新增 simplifiedChineseTagline:取值语言为繁中 / 非中文、或文字是繁体特有字时不显示;原语言为中文(zh / cn)的作品按简中处理 - TMDB 富详情带回 taglineLanguage;发现页详情的 tagline 按上述口径给出 - 媒体库作品详情新增 displayTagline(存储的标语语言为 null,只能按文字判断);metadata.tagline 存储不变 - 补 adapters 单测与作品详情接口测试 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
522 lines
21 KiB
TypeScript
522 lines
21 KiB
TypeScript
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
|
|
import {
|
|
fetchRichDetail,
|
|
fetchRichSeason,
|
|
type MetadataCandidate,
|
|
simplifiedChineseTagline,
|
|
TmdbError,
|
|
type TmdbRichDetail,
|
|
} from "@umt/adapters";
|
|
import {
|
|
Api,
|
|
type ApiScope,
|
|
apiResponse,
|
|
DEFAULT_SETTINGS,
|
|
type DiscoverBangumiQuery,
|
|
type DiscoverDoubanQuery,
|
|
type DiscoverMatchRequest,
|
|
type DiscoverTmdbQuery,
|
|
type EndpointDef,
|
|
type InventoryEntry,
|
|
type InventoryResponse,
|
|
type ManualAcquisitionResult,
|
|
type MediaServerInventory,
|
|
type ReleaseSearchPage,
|
|
type ReleaseSearchQuery,
|
|
type SearchQuery,
|
|
type SearchReleaseParams,
|
|
type SearchReleasePlanRequest,
|
|
type SearchResponse,
|
|
type TmdbMediaDetail,
|
|
type TmdbMediaParams,
|
|
type TmdbMediaQuery,
|
|
type TmdbSeasonDetailView,
|
|
type TmdbSeasonParams,
|
|
} from "@umt/contracts";
|
|
import type { SiteRow } from "@umt/db";
|
|
import type { Context } from "hono";
|
|
import type { z } from "zod";
|
|
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
|
import { ApiException } from "../../http/errors.ts";
|
|
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
|
import { metadataLanguages, metadataTextOptions } from "../sources-identity/service/metadata-rich.ts";
|
|
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
|
import { matchActions } from "./actions.ts";
|
|
import { annotate, bangumiCalendar, bangumiList, doubanList, matchTmdb, tmdbList } from "./discover.ts";
|
|
import { InternalDispatcher } from "./dispatch.ts";
|
|
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
|
|
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
|
|
import { localWorkTarget, splitByWork, tmdbWorkTarget, type WorkTarget } from "./work-match.ts";
|
|
import {
|
|
buildWorks,
|
|
detailSeasons,
|
|
localInfo,
|
|
localSeasonHave,
|
|
searchTerms,
|
|
slotCountsFor,
|
|
workInventory,
|
|
} from "./works.ts";
|
|
|
|
export interface SearchDeps {
|
|
runtime: () => SourcesRuntime;
|
|
siteTimeoutMs: number;
|
|
siteConcurrency: number;
|
|
cacheTtlMs: number;
|
|
tmdbCacheTtlMs: number;
|
|
now: () => number;
|
|
}
|
|
|
|
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
|
|
interface Input<D extends EndpointDef> {
|
|
params: Infer<D["params"]>;
|
|
query: Infer<D["query"]>;
|
|
body: Infer<D["body"]>;
|
|
c: Context<AuthEnv>;
|
|
}
|
|
|
|
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: Input<D>) => Promise<unknown>) {
|
|
const request: RouteConfig["request"] = {};
|
|
if (def.params) request.params = def.params as never;
|
|
if (def.query) request.query = def.query as never;
|
|
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
|
|
const r = createRoute({
|
|
method: def.method,
|
|
path: def.path,
|
|
summary: def.summary,
|
|
tags: ["search"],
|
|
middleware: [requireScope(def.scope as ApiScope)] as never,
|
|
request,
|
|
responses: {
|
|
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
|
|
},
|
|
});
|
|
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
|
|
app.openapi(r, (async (c: any) => {
|
|
const input = {
|
|
params: def.params ? c.req.valid("param") : {},
|
|
query: def.query ? c.req.valid("query") : {},
|
|
body: def.body ? c.req.valid("json") : {},
|
|
c,
|
|
} as Input<D>;
|
|
const data = def.response.parse(await handler(input));
|
|
return c.json({ ok: true, data }, 200);
|
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
|
}) as any);
|
|
}
|
|
|
|
const since = (t0: number) => Math.round(performance.now() - t0);
|
|
|
|
export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: SearchDeps) {
|
|
const fanout = new ReleaseFanout(d.runtime, {
|
|
timeoutMs: d.siteTimeoutMs,
|
|
concurrency: d.siteConcurrency,
|
|
ttlMs: d.cacheTtlMs,
|
|
now: d.now,
|
|
});
|
|
const tmdb = new TmdbSearch(d.runtime, { ttlMs: d.tmdbCacheTtlMs, timeoutMs: d.siteTimeoutMs, now: d.now });
|
|
const dispatch = new InternalDispatcher(app);
|
|
|
|
/** 库存叠加:转调 GET /inventory(每次最多 200 个作品) */
|
|
const inventory =
|
|
(c: Context<AuthEnv>) =>
|
|
async (ids: string[]): Promise<Map<string, InventoryEntry>> => {
|
|
const out = new Map<string, InventoryEntry>();
|
|
for (let i = 0; i < ids.length; i += 200) {
|
|
const chunk = ids.slice(i, i + 200);
|
|
const r = await dispatch.call<InventoryResponse>(
|
|
c,
|
|
"GET",
|
|
`/inventory?entityIds=${encodeURIComponent(chunk.join(","))}`,
|
|
);
|
|
for (const e of r.items) out.set(e.entityId, e);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const csvIds = (s: string | undefined) =>
|
|
(s ?? "")
|
|
.split(",")
|
|
.map((x) => x.trim())
|
|
.filter(Boolean);
|
|
|
|
/** 参与搜索的站点:给了 sites 只查这些(仍须启用);否则按 settings.search.defaultSiteMode */
|
|
const resolveSites = async (sites: string | undefined): Promise<SiteRow[]> => {
|
|
const all = await fanout.enabledSites();
|
|
const ids = csvIds(sites);
|
|
if (ids.length) {
|
|
const set = new Set(ids);
|
|
return all.filter((s) => set.has(s.id));
|
|
}
|
|
const cfg = {
|
|
...DEFAULT_SETTINGS.search,
|
|
...(await ctx.settings.get("search").catch(() => DEFAULT_SETTINGS.search)),
|
|
};
|
|
if (cfg.defaultSiteMode === "all") return all;
|
|
if (cfg.defaultSiteMode === "custom") {
|
|
const set = new Set(cfg.siteIds);
|
|
return all.filter((s) => set.has(s.id));
|
|
}
|
|
return all.filter((s) => s.search_default);
|
|
};
|
|
|
|
const tmdbDetails = async (type: "movie" | "tv", id: string) => {
|
|
const client = await d.runtime().tmdb();
|
|
if (!client) throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key");
|
|
try {
|
|
return await raceAbort(client.details(type, id), AbortSignal.timeout(d.siteTimeoutMs), "TMDB 查询超时");
|
|
} catch (e) {
|
|
if (e instanceof TmdbError && e.kind === "notfound")
|
|
throw new ApiException("not_found", `TMDB 没有 ${type}/${id}`);
|
|
throw new ApiException(
|
|
"upstream_unavailable",
|
|
`TMDB 查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
/** 按作品搜资源时判定归属用的 TMDB 详情(季列表、别名):按 TMDB 缓存时长复用,失败返回 null */
|
|
const detailCache = new Map<string, { expires: number; value: Promise<MetadataCandidate | null> }>();
|
|
const cachedDetails = (type: "movie" | "tv", id: string): Promise<MetadataCandidate | null> => {
|
|
const key = `${type}:${id}`;
|
|
const hit = detailCache.get(key);
|
|
if (hit && hit.expires > d.now()) return hit.value;
|
|
// 失败不缓存:下次重试
|
|
const value = tmdbDetails(type, id).catch(() => {
|
|
detailCache.delete(key);
|
|
return null;
|
|
});
|
|
for (const [k, v] of detailCache) if (v.expires <= d.now()) detailCache.delete(k);
|
|
detailCache.set(key, { expires: d.now() + d.tmdbCacheTtlMs, value });
|
|
return value;
|
|
};
|
|
|
|
/** 按作品搜(entityId / tmdb)时的目标作品;关键词搜索为 null(不判定归属) */
|
|
const workTarget = async (q: ReleaseSearchQuery): Promise<WorkTarget | null> => {
|
|
if (q.entityId) return localWorkTarget(ctx, q.entityId, cachedDetails);
|
|
if (!q.tmdb) return null;
|
|
const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string];
|
|
const local = (await localEntities(ctx, [{ entityType: type, externalId: id }])).get(q.tmdb);
|
|
if (local) return localWorkTarget(ctx, local, cachedDetails);
|
|
const m = await cachedDetails(type, id);
|
|
return m ? tmdbWorkTarget(m) : null;
|
|
};
|
|
|
|
/** MD-1 富详情(语言回退链、类型、评分、主创);失败返回 null,详情页退回基础字段 */
|
|
const tmdbRich = async (type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null> => {
|
|
const client = await d.runtime().tmdb();
|
|
if (!client || typeof client.getJson !== "function") return null;
|
|
try {
|
|
const langs = await metadataLanguages(ctx);
|
|
return await raceAbort(
|
|
fetchRichDetail(client, type, id, langs, await metadataTextOptions(ctx)),
|
|
AbortSignal.timeout(d.siteTimeoutMs),
|
|
"TMDB 查询超时",
|
|
);
|
|
} catch (e) {
|
|
ctx.logger.debug({ type, id, err: { message: (e as Error).message } }, "TMDB 富详情获取失败");
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/** 查询词:给了 q 就只用 q;按作品搜时由作品名 / 原名 / 年份生成 */
|
|
const releaseTerms = async (q: ReleaseSearchQuery): Promise<string[]> => {
|
|
if (q.q) return [q.q];
|
|
if (q.entityId) {
|
|
const e = await ctx.db
|
|
.selectFrom("media_entity")
|
|
.select(["title", "original_title", "year", "kind"])
|
|
.where("id", "=", q.entityId)
|
|
.executeTakeFirst();
|
|
if (!e) throw new ApiException("not_found", "作品不存在");
|
|
return searchTerms({ title: e.title, originalTitle: e.original_title, year: e.year, movie: e.kind === "movie" });
|
|
}
|
|
if (q.tmdb) {
|
|
const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string];
|
|
const m = (await cachedDetails(type, id)) ?? (await tmdbDetails(type, id));
|
|
return searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: type === "movie" });
|
|
}
|
|
throw new ApiException("validation_failed", "q、entityId、tmdb 至少给一个");
|
|
};
|
|
|
|
route(app, Api.search.all, async ({ query, c }) => {
|
|
const t0 = performance.now();
|
|
const q = query as SearchQuery;
|
|
const want = (s: SearchQuery["scope"]) => q.scope === s || (q.scope === "all" && s !== "releases");
|
|
const loadInv = inventory(c);
|
|
|
|
const media = want("media")
|
|
? Promise.all([
|
|
(async () => {
|
|
const t = performance.now();
|
|
const hits = await searchLocalMedia(ctx, q.q, q.limit);
|
|
return { hits, tookMs: since(t) };
|
|
})(),
|
|
(async () => {
|
|
const t = performance.now();
|
|
const r = await tmdb.search(q.q, q.limit);
|
|
return { ...r, tookMs: since(t) };
|
|
})(),
|
|
]).then(async ([local, tm]) => {
|
|
// 库存对比:本地命中 + TMDB 候选里已建档的作品一起查一次
|
|
const ids = [
|
|
...new Set([
|
|
...local.hits.map((h) => h.entity.id),
|
|
...tm.items.map((c) => c.localEntityId).filter((x): x is string => !!x),
|
|
]),
|
|
];
|
|
const [inv, info, counts] = ids.length
|
|
? await Promise.all([loadInv(ids), localInfo(ctx, ids), slotCountsFor(ctx, ids)])
|
|
: [new Map(), new Map(), new Map()];
|
|
return {
|
|
local: { items: withInventory(local.hits, inv), tookMs: local.tookMs },
|
|
tmdb: tm,
|
|
works: buildWorks(local.hits, tm.items, info, inv, counts),
|
|
};
|
|
})
|
|
: Promise.resolve(null);
|
|
|
|
// SEARCH-2:资源只在 scope=releases 时查(all 不再自动查站点),站点按 settings.search 的默认集合
|
|
const releases =
|
|
q.scope === "releases"
|
|
? (async () => {
|
|
const t = performance.now();
|
|
const hit = await fanout.search([q.q], await resolveSites(undefined));
|
|
const sorted = sortReleases(hit.fanout.items, "default", "desc");
|
|
const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv);
|
|
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
|
|
return {
|
|
items,
|
|
total: sorted.length,
|
|
partial: failed.length > 0,
|
|
sites: hit.fanout.sites,
|
|
failedSites: failed,
|
|
cached: hit.cached,
|
|
coalesced: hit.coalesced,
|
|
fetchedAt: hit.fanout.fetchedAt,
|
|
tookMs: since(t),
|
|
};
|
|
})()
|
|
: Promise.resolve(null);
|
|
|
|
const actions = want("actions")
|
|
? (() => {
|
|
const t = performance.now();
|
|
const items = matchActions(q.q, q.limit);
|
|
return { items, tookMs: since(t) };
|
|
})()
|
|
: null;
|
|
|
|
const [m, r] = await Promise.all([media, releases]);
|
|
const out: SearchResponse = { q: q.q, scope: q.scope, media: m, releases: r, actions, tookMs: since(t0) };
|
|
return out;
|
|
});
|
|
|
|
route(app, Api.search.releases, async ({ query, c }) => {
|
|
const t0 = performance.now();
|
|
const q = query as ReleaseSearchQuery;
|
|
const terms = await releaseTerms(q);
|
|
const [hit, target] = await Promise.all([
|
|
fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" }),
|
|
workTarget(q),
|
|
]);
|
|
// 按作品搜:不属于这部作品的不参与筛选与分面,第一页另外给出(列表最后折叠展示)
|
|
const split = target ? await splitByWork(ctx, target, hit.fanout.items) : null;
|
|
const all = split ? split.matched : hit.fanout.items;
|
|
const quick = quickFilter(all, q, d.now());
|
|
const filtered = sortReleases(quick.items, q.sort, q.order);
|
|
const offset = decodeCursor(q.cursor);
|
|
const pageItems = filtered.slice(offset, offset + q.limit);
|
|
const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null;
|
|
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
|
|
const others =
|
|
split && offset === 0 && split.unmatched.length
|
|
? await decorate(
|
|
d.runtime(),
|
|
sortReleases(split.unmatched, q.sort, q.order).slice(0, UNMATCHED_MAX),
|
|
inventory(c),
|
|
)
|
|
: [];
|
|
const out: ReleaseSearchPage = {
|
|
q: q.q ?? terms[0]!,
|
|
queries: terms,
|
|
items: await decorate(d.runtime(), pageItems, inventory(c)),
|
|
...(split ? { unmatched: others, unmatchedTotal: split.unmatched.length } : {}),
|
|
nextCursor: next,
|
|
total: filtered.length,
|
|
unfilteredTotal: all.length,
|
|
facets: { ...facets(all), ...quick.facets },
|
|
partial: failed.length > 0,
|
|
sites: hit.fanout.sites,
|
|
failedSites: failed,
|
|
cached: hit.cached,
|
|
coalesced: hit.coalesced,
|
|
fetchedAt: hit.fanout.fetchedAt,
|
|
tookMs: since(t0),
|
|
};
|
|
return out;
|
|
});
|
|
|
|
route(app, Api.search.plan, async ({ params, body, c }) => {
|
|
const p = params as SearchReleaseParams;
|
|
const rt = d.runtime();
|
|
const find = () =>
|
|
rt.ctx.db
|
|
.selectFrom("source_release")
|
|
.select(["id"])
|
|
.where("site_id", "=", p.siteId)
|
|
.where("site_release_id", "=", p.siteReleaseId)
|
|
.executeTakeFirst();
|
|
let row = await find();
|
|
if (!row) {
|
|
// 搜索时落库失败的兜底:按缓存里的站点结果补写
|
|
const cached = fanout.findCached(p.siteId, p.siteReleaseId);
|
|
const site = cached
|
|
? await rt.ctx.db.selectFrom("site").select(["id"]).where("id", "=", p.siteId).executeTakeFirst()
|
|
: undefined;
|
|
if (cached && site) {
|
|
await rt.ctx.db.transaction().execute((trx) => upsertSiteRelease(rt, trx, site, cached.raw));
|
|
row = await find();
|
|
}
|
|
}
|
|
if (!row) throw new ApiException("not_found", "资源不在搜索结果里,重新搜索后再下载");
|
|
return dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
|
|
...(body as SearchReleasePlanRequest),
|
|
sourceReleaseId: row.id,
|
|
});
|
|
});
|
|
|
|
route(app, Api.search.discoverCalendar, ({ c }) => bangumiCalendar(d.runtime(), inventory(c)));
|
|
const withLocal =
|
|
(c: Context<AuthEnv>) =>
|
|
async <P extends { items: Parameters<typeof annotate>[1] }>(p: P): Promise<P> => {
|
|
await annotate(d.runtime(), p.items, inventory(c));
|
|
return p;
|
|
};
|
|
route(app, Api.search.discoverTmdb, async ({ query, c }) =>
|
|
withLocal(c)(await tmdbList(d.runtime(), query as DiscoverTmdbQuery)),
|
|
);
|
|
route(app, Api.search.discoverDouban, async ({ query, c }) =>
|
|
withLocal(c)(await doubanList(d.runtime(), query as DiscoverDoubanQuery)),
|
|
);
|
|
route(app, Api.search.discoverBangumi, async ({ query, c }) =>
|
|
withLocal(c)(await bangumiList(d.runtime(), query as DiscoverBangumiQuery)),
|
|
);
|
|
route(app, Api.search.discoverMatch, ({ body }) => matchTmdb(d.runtime(), body as DiscoverMatchRequest));
|
|
|
|
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
|
|
const p = params as TmdbMediaParams;
|
|
const inc = (query as TmdbMediaQuery).include;
|
|
const [m, rich] = await Promise.all([tmdbDetails(p.type, p.id), tmdbRich(p.type, p.id)]);
|
|
const localId =
|
|
(await localEntities(ctx, [{ entityType: p.type, externalId: p.id }])).get(`${p.type}:${p.id}`) ?? null;
|
|
const [inv, counts] = localId
|
|
? await Promise.all([inventory(c)([localId]), slotCountsFor(ctx, [localId])])
|
|
: [new Map<string, InventoryEntry>(), new Map()];
|
|
const have =
|
|
p.type === "tv" && localId
|
|
? await localSeasonHave(
|
|
ctx,
|
|
localId,
|
|
(m.seasons ?? []).map((s) => ({ seasonNumber: s.seasonNumber, episodeCount: s.episodeCount })),
|
|
)
|
|
: null;
|
|
const mediaServers =
|
|
inc === "mediaServers"
|
|
? await dispatch
|
|
.call<MediaServerInventory>(c, "GET", `/inventory/media-servers?tmdbType=${p.type}&tmdbId=${p.id}`)
|
|
.catch(() => null)
|
|
: null;
|
|
const out: TmdbMediaDetail = {
|
|
provider: "tmdb",
|
|
entityType: p.type,
|
|
externalId: p.id,
|
|
kind: p.type === "movie" ? "movie" : "series",
|
|
title: m.title,
|
|
originalTitle: m.originalTitle,
|
|
originalLanguage: m.originalLanguage,
|
|
year: m.year,
|
|
releaseDate: m.releaseDate,
|
|
overview: m.overview,
|
|
posterPath: m.posterPath,
|
|
backdropPath: m.backdropPath ?? null,
|
|
originCountry: m.originCountry,
|
|
aliases: [...new Set(m.aliases)].filter((x) => x !== m.title).slice(0, 20),
|
|
externalIds: { imdb: m.externalIds?.imdb ?? null, tvdb: m.externalIds?.tvdb ?? null },
|
|
seasons: detailSeasons(m, have),
|
|
localEntityId: localId,
|
|
inventory: workInventory(localId ? (inv.get(localId) ?? null) : null, localId ? counts.get(localId) : undefined),
|
|
mediaServers,
|
|
queries: searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: p.type === "movie" }),
|
|
};
|
|
if (rich) {
|
|
// 标题 / 简介按语言回退链;季名 / 简介 / 海报取富详情
|
|
out.title = rich.title || out.title;
|
|
out.overview = rich.overview ?? out.overview;
|
|
out.titleZh = rich.titleZh;
|
|
out.tagline = simplifiedChineseTagline(rich.tagline, rich.taglineLanguage, rich.originalLanguage);
|
|
out.genres = rich.genres;
|
|
out.voteAverage = rich.voteAverage;
|
|
out.voteCount = rich.voteCount;
|
|
out.airStatus = rich.status;
|
|
out.runtime = rich.runtime;
|
|
out.episodeCount = rich.episodeCount;
|
|
out.lastAirDate = rich.lastAirDate;
|
|
out.platform = rich.networks.join(" / ") || null;
|
|
out.cast = rich.cast;
|
|
out.crew = rich.crew;
|
|
out.backdropPath = out.backdropPath ?? rich.backdropPath;
|
|
out.aliases = [
|
|
...new Set([rich.titleZh, ...out.aliases].filter((x): x is string => !!x && x !== rich.title)),
|
|
].slice(0, 20);
|
|
out.seasons = out.seasons.map((s) => {
|
|
const r = rich.seasons.find((x) => x.seasonNumber === s.seasonNumber);
|
|
return r ? { ...s, overview: r.overview, posterPath: r.posterPath } : s;
|
|
});
|
|
}
|
|
return out;
|
|
});
|
|
|
|
route(app, Api.search.tmdbSeason, async ({ params }) => {
|
|
const p = params as TmdbSeasonParams;
|
|
const client = await d.runtime().tmdb();
|
|
if (!client || typeof client.getJson !== "function")
|
|
throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key");
|
|
try {
|
|
const langs = await metadataLanguages(ctx);
|
|
const sd = await raceAbort(
|
|
fetchRichSeason(client, p.id, p.season, langs, await metadataTextOptions(ctx)),
|
|
AbortSignal.timeout(d.siteTimeoutMs * 2),
|
|
"TMDB 查询超时",
|
|
);
|
|
const out: TmdbSeasonDetailView = {
|
|
tvId: sd.tvId,
|
|
seasonNumber: sd.seasonNumber,
|
|
name: sd.name,
|
|
overview: sd.overview,
|
|
airDate: sd.airDate,
|
|
posterPath: sd.posterPath,
|
|
episodes: sd.episodes,
|
|
};
|
|
return out;
|
|
} catch (e) {
|
|
if (e instanceof TmdbError && e.kind === "notfound")
|
|
throw new ApiException("not_found", `TMDB 没有 tv/${p.id} 第 ${p.season} 季`);
|
|
throw new ApiException(
|
|
"upstream_unavailable",
|
|
`TMDB 查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** 「可能不是这部作品」最多给多少条 */
|
|
const UNMATCHED_MAX = 200;
|
|
|
|
const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url");
|
|
function decodeCursor(cursor: string | undefined): number {
|
|
if (!cursor) return 0;
|
|
const n = Number(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
return Number.isSafeInteger(n) && n >= 0 ? n : 0;
|
|
}
|