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:
1 parent
fc1856c2e3
commit
9cd5a28943
12 files changed
+1104
-27
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user