feat(metadata): field-level episode metadata, titleZh and refresh?full=1 (MD-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:28:16 +08:00
1 parent fc1856c2e3
commit 9cd5a28943
12 files changed
+1104 -27

No files matched your search

+79 -2
View File
@@ -1,5 +1,5 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { TmdbError } from "@umt/adapters";
import { fetchRichDetail, fetchRichSeason, TmdbError, type TmdbRichDetail } from "@umt/adapters";
import {
Api,
type ApiScope,
@@ -19,6 +19,8 @@ import {
type TmdbMediaDetail,
type TmdbMediaParams,
type TmdbMediaQuery,
type TmdbSeasonDetailView,
type TmdbSeasonParams,
} from "@umt/contracts";
import type { SiteRow } from "@umt/db";
import type { Context } from "hono";
@@ -26,6 +28,7 @@ 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 } 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";
@@ -159,6 +162,23 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
}
};
/** 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),
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];
@@ -314,7 +334,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
const p = params as TmdbMediaParams;
const inc = (query as TmdbMediaQuery).include;
const m = await tmdbDetails(p.type, p.id);
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
@@ -356,8 +376,65 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
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 = rich.tagline;
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),
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 encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url");