Merge branch 'md1'
# Conflicts: # packages/contracts/src/settings.ts
This commit is contained in:
commit
b633eb5379
30 files changed
+3240
-222
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");
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
OkResult,
|
||||
ReleaseIdentityAssign,
|
||||
type TitleAlias,
|
||||
UnitMetadataField,
|
||||
} from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
updateExternalIdentity,
|
||||
} from "../service/identity-admin.ts";
|
||||
import { clearFieldOverride, setFieldOverride } from "../service/metadata.ts";
|
||||
import { clearUnitFieldOverride, setUnitFieldOverride } from "../service/metadata-rich.ts";
|
||||
|
||||
// 身份与元数据 API:作品(列表/详情/纠错/合并/字段覆盖)、内容单元、别名、外部身份、待识别池。
|
||||
|
||||
@@ -43,6 +45,12 @@ const T_ENT = ["media-entities"];
|
||||
const T_ID = ["identity"];
|
||||
const Accepted = z.object({ taskId: Id });
|
||||
const FieldParam = z.object({ id: Id, field: EntityMetadataField });
|
||||
const UnitFieldParam = z.object({ id: Id, field: UnitMetadataField });
|
||||
const UnitFieldResult = z.object({
|
||||
contentUnitId: Id,
|
||||
changedFields: z.array(z.string()),
|
||||
lockedFields: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** 列表带作品标题:按 media_entity_id 批量取;只挂在内容单元上的行以 `cu:<id>` 为键取所属作品标题 */
|
||||
async function entityTitles(
|
||||
@@ -221,18 +229,24 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
method: "post",
|
||||
path: "/media-entities/{id}/refresh",
|
||||
scope: "organize.execute",
|
||||
summary: "刷新元数据(字段级;用户锁优先,不动路径)",
|
||||
summary:
|
||||
"刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息",
|
||||
tags: T_ENT,
|
||||
params: IdParam,
|
||||
query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }),
|
||||
response: Accepted,
|
||||
},
|
||||
async ({ params }) => {
|
||||
async ({ params, query }) => {
|
||||
const id = await resolveEntityId(db(), params.id);
|
||||
if (!id) throw new ApiException("not_found", "作品不存在");
|
||||
const full = query.full === "1" || query.full === "true";
|
||||
const r = await rt().ctx.engine.enqueue(
|
||||
"refreshMetadata",
|
||||
{ mediaEntityId: id },
|
||||
{ idempotencyKey: `refreshMetadata:${id}:${Math.floor(Date.now() / 60_000)}`, traceId: randomUUID() },
|
||||
{ mediaEntityId: id, ...(full ? { full: true } : {}) },
|
||||
{
|
||||
idempotencyKey: `refreshMetadata:${id}:${full ? "full:" : ""}${Math.floor(Date.now() / 60_000)}`,
|
||||
traceId: randomUUID(),
|
||||
},
|
||||
);
|
||||
return { taskId: r.runId };
|
||||
},
|
||||
@@ -336,6 +350,33 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
);
|
||||
return toApi(row);
|
||||
});
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "put",
|
||||
path: "/content-units/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "覆盖季 / 集字段并锁定(元数据刷新不再改写)",
|
||||
tags: T_ENT,
|
||||
params: UnitFieldParam,
|
||||
body: FieldOverrideInput,
|
||||
response: UnitFieldResult,
|
||||
},
|
||||
async ({ params, body, actor }) => setUnitFieldOverride(rt(), params.id, params.field, body, actor),
|
||||
);
|
||||
route(
|
||||
app,
|
||||
{
|
||||
method: "delete",
|
||||
path: "/content-units/{id}/fields/{field}",
|
||||
scope: "organize.execute",
|
||||
summary: "取消季 / 集字段覆盖与锁定",
|
||||
tags: T_ENT,
|
||||
params: UnitFieldParam,
|
||||
response: UnitFieldResult,
|
||||
},
|
||||
async ({ params, actor }) => clearUnitFieldOverride(rt(), params.id, params.field, actor),
|
||||
);
|
||||
|
||||
// ── 别名 ──
|
||||
route(app, D.titleAliases.list, async ({ query }) => {
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
DEFAULT_METADATA_LANGUAGES,
|
||||
scriptMatches,
|
||||
type TmdbRichDetail,
|
||||
type TmdbRichEpisode,
|
||||
type TmdbRichSeasonDetail,
|
||||
} from "@umt/adapters";
|
||||
import type {
|
||||
EntityMetadataField,
|
||||
FieldOverrideInput,
|
||||
FinalFieldValue,
|
||||
MetadataSource,
|
||||
UnitMetadataField,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import { ensureSeason } from "./metadata-totals.ts";
|
||||
|
||||
// MD-1 语言回退链 + 每集元数据(REQ §7.3 字段级):
|
||||
// - 作品:TMDB 富详情(fetchRichDetail)按设置 metadata.languages 取标题 / 简介 / 标语,另存中文名 titleZh、类型、评分、
|
||||
// 状态、时长、季数、主创、外部 ID;来源值写 metadata_field(language = 实际取到的语言),同字段换语言时删旧行;
|
||||
// - 别名:translations / alternative_titles 写 title_alias(来源 tmdb,标语言);已有同文本别名只补空的语言;
|
||||
// - 季 / 集:fetchRichSeason 的每集 name / overview / airDate / runtime / stillPath / vote 写 metadata_field(content_unit_id),
|
||||
// 投影到 content_unit.metadata(字段级,可锁),name → title 列、airDate → air_date 列;只写变化的字段,
|
||||
// 不改编号、不碰路径绑定与资产覆盖。集单元不存在时按 TMDB 建(confirmed,挂季单元)。
|
||||
|
||||
type FieldValue = { value: unknown; language: string | null };
|
||||
|
||||
/** 设置 metadata.languages(缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US) */
|
||||
export async function metadataLanguages(ctx: ServerContext): Promise<string[]> {
|
||||
const m = await ctx.settings.get("metadata").catch(() => null);
|
||||
const langs = (m as { languages?: string[] } | null)?.languages;
|
||||
return langs?.length ? langs : [...DEFAULT_METADATA_LANGUAGES];
|
||||
}
|
||||
|
||||
/** 富详情 → 作品字段(值 + 语言);海报 / 背景图没有时不写(保留旧值) */
|
||||
export function richEntityFields(r: TmdbRichDetail): Partial<Record<EntityMetadataField, FieldValue>> {
|
||||
const v = (value: unknown, language: string | null = null): FieldValue => ({ value, language });
|
||||
return {
|
||||
title: v(r.title, r.titleLanguage),
|
||||
...(r.titleZh ? { titleZh: v(r.titleZh, r.titleZhLanguage) } : {}),
|
||||
originalTitle: v(r.originalTitle, r.originalLanguage),
|
||||
year: v(r.year),
|
||||
overview: v(r.overview, r.overviewLanguage),
|
||||
tagline: v(r.tagline),
|
||||
releaseDate: v(r.releaseDate),
|
||||
...(r.entityType === "tv" ? { lastAirDate: v(r.lastAirDate) } : {}),
|
||||
originalLanguage: v(r.originalLanguage),
|
||||
originCountry: v(r.originCountry),
|
||||
...(r.posterPath ? { posterPath: v(r.posterPath) } : {}),
|
||||
...(r.backdropPath ? { backdropPath: v(r.backdropPath) } : {}),
|
||||
...(r.genres.length ? { genres: v(r.genres) } : {}),
|
||||
...(r.networks.length ? { platform: v(r.networks.join(" / ")) } : {}),
|
||||
...(r.entityType === "tv" && r.episodeCount !== null ? { episodeCount: v(r.episodeCount) } : {}),
|
||||
...(r.entityType === "tv" ? { seasonCount: v(r.seasonCount) } : {}),
|
||||
voteAverage: v(r.voteAverage),
|
||||
voteCount: v(r.voteCount),
|
||||
airStatus: v(r.status),
|
||||
runtime: v(r.runtime),
|
||||
cast: v(r.cast),
|
||||
crew: v(r.crew),
|
||||
externalIds: v(r.externalIds),
|
||||
};
|
||||
}
|
||||
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
/**
|
||||
* 写一组 tmdb 来源字段(作品或内容单元):值和语言都没变的跳过;语言变了删掉该字段其它语言的 tmdb 行再写。
|
||||
* 返回写入(新增 / 变化)的字段名。
|
||||
*/
|
||||
async function writeSourceFields(
|
||||
exec: Exec,
|
||||
target: { mediaEntityId: string } | { contentUnitId: string },
|
||||
fields: Record<string, FieldValue>,
|
||||
existing: readonly { field: string; value: unknown; language: string | null }[],
|
||||
sourceRevision: string,
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
const entityId = "mediaEntityId" in target ? target.mediaEntityId : null;
|
||||
const unitId = "contentUnitId" in target ? target.contentUnitId : null;
|
||||
const written: string[] = [];
|
||||
for (const [field, fv] of Object.entries(fields)) {
|
||||
const rows = existing.filter((x) => x.field === field);
|
||||
if (rows.length === 1 && rows[0]!.language === fv.language && same(rows[0]!.value, fv.value)) continue;
|
||||
if (rows.some((x) => x.language !== fv.language)) {
|
||||
let q = exec.deleteFrom("metadata_field").where("field", "=", field).where("source", "=", "tmdb");
|
||||
q = entityId
|
||||
? q.where("media_entity_id", "=", entityId).where("content_unit_id", "is", null)
|
||||
: q.where("content_unit_id", "=", unitId!).where("media_entity_id", "is", null);
|
||||
q =
|
||||
fv.language === null
|
||||
? q.where("language", "is not", null)
|
||||
: q.where(sql<boolean>`language is distinct from ${fv.language}`);
|
||||
await q.execute();
|
||||
}
|
||||
await sql`insert into metadata_field (media_entity_id, content_unit_id, field, value, language, source, source_revision, fetched_at)
|
||||
values (${entityId}, ${unitId}, ${field}, ${jsonb(fv.value)}::jsonb, ${fv.language}, 'tmdb', ${sourceRevision}, ${now})
|
||||
on conflict (media_entity_id, content_unit_id, field, source, language) do update set
|
||||
value = excluded.value, source_revision = excluded.source_revision, fetched_at = excluded.fetched_at`.execute(
|
||||
exec,
|
||||
);
|
||||
written.push(field);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/** 富详情写作品的字段来源表;调用方随后 project() 投影 */
|
||||
export async function writeRichEntityFields(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
rich: TmdbRichDetail,
|
||||
sourceRevision: string,
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
const existing = await exec
|
||||
.selectFrom("metadata_field")
|
||||
.select(["field", "value", "language"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.where("source", "=", "tmdb")
|
||||
.execute();
|
||||
return writeSourceFields(
|
||||
exec,
|
||||
{ mediaEntityId: entityId },
|
||||
richEntityFields(rich) as Record<string, FieldValue>,
|
||||
existing,
|
||||
sourceRevision,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
/** 别名的地区 → 语言:只在文字相符时标(CN 地区的英文别名不标 zh-CN) */
|
||||
function altLanguage(region: string | null, text: string): string | null {
|
||||
if (!region) return null;
|
||||
if (["CN", "SG", "TW", "HK", "MO"].includes(region)) return scriptMatches("zh", text) ? `zh-${region}` : null;
|
||||
if (region === "JP") return scriptMatches("ja", text) ? "ja-JP" : null;
|
||||
if (region === "KR") return scriptMatches("ko", text) ? "ko-KR" : null;
|
||||
if (["US", "GB", "CA", "AU", "NZ", "IE"].includes(region)) return scriptMatches("en", text) ? `en-${region}` : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* translations(译名,kind translated)与 alternative_titles(别名,kind other)写 title_alias,来源 tmdb、标语言。
|
||||
* 同作品已有同文本别名时不再新增,只给来源 tmdb 且语言为空的那行补语言。最多 80 条。
|
||||
*/
|
||||
export async function tagTitleAliases(exec: Exec, entityId: string, rich: TmdbRichDetail): Promise<number> {
|
||||
const want: { text: string; kind: "translated" | "other"; language: string | null }[] = [];
|
||||
if (rich.titleZh) want.push({ text: rich.titleZh, kind: "translated", language: rich.titleZhLanguage });
|
||||
for (const t of rich.translations) if (t.title) want.push({ text: t.title, kind: "translated", language: t.lang });
|
||||
for (const a of rich.alternativeTitles)
|
||||
want.push({ text: a.title, kind: "other", language: altLanguage(a.region, a.title) });
|
||||
const rows = await exec
|
||||
.selectFrom("title_alias")
|
||||
.select(["id", "normalized", "language", "source"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.execute();
|
||||
const byNorm = new Map<string, (typeof rows)[number][]>();
|
||||
for (const r of rows) byNorm.set(r.normalized, [...(byNorm.get(r.normalized) ?? []), r]);
|
||||
let n = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const w of want.slice(0, 120)) {
|
||||
const norm = normalizeTitle(w.text);
|
||||
if (!norm || seen.has(norm)) continue;
|
||||
seen.add(norm);
|
||||
const hit = byNorm.get(norm);
|
||||
if (hit?.length) {
|
||||
const blank = hit.find((r) => r.source === "tmdb" && r.language === null);
|
||||
if (w.language && blank && !hit.some((r) => r.language)) {
|
||||
await exec.updateTable("title_alias").set({ language: w.language }).where("id", "=", blank.id).execute();
|
||||
n++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (n >= 80) break;
|
||||
await sql`insert into title_alias (media_entity_id, content_unit_id, alias, normalized, language, kind, source, locked)
|
||||
values (${entityId}, null, ${w.text}, ${norm}, ${w.language}, ${w.kind}, 'tmdb', false)
|
||||
on conflict (media_entity_id, content_unit_id, normalized, kind) do nothing`.execute(exec);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── 内容单元字段 ──
|
||||
|
||||
const UNIT_COLUMN: Partial<Record<UnitMetadataField, "title" | "air_date">> = { name: "title", airDate: "air_date" };
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* 重新投影内容单元的最终字段:覆盖(锁)> field_locks 已锁字段保持 > tmdb 来源值。
|
||||
* name → title 列、airDate → air_date 列(来源没给日期时不清空列);编号不动。只在有变化时写。
|
||||
*/
|
||||
export async function projectUnit(
|
||||
exec: Exec,
|
||||
unitId: string,
|
||||
now: Date,
|
||||
): Promise<{ changedFields: string[]; lockedFields: string[] }> {
|
||||
const u = await exec
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "metadata", "field_locks", "title", sql<string | null>`to_char(air_date, 'YYYY-MM-DD')`.as("air")])
|
||||
.where("id", "=", unitId)
|
||||
.forUpdate()
|
||||
.executeTakeFirstOrThrow();
|
||||
const [overrides, sources] = await Promise.all([
|
||||
exec.selectFrom("field_override").selectAll().where("content_unit_id", "=", unitId).execute(),
|
||||
exec
|
||||
.selectFrom("metadata_field")
|
||||
.selectAll()
|
||||
.where("content_unit_id", "=", unitId)
|
||||
.orderBy("fetched_at", "desc")
|
||||
.execute(),
|
||||
]);
|
||||
const meta = { ...((u.metadata ?? {}) as Record<string, FinalFieldValue>) };
|
||||
const locks = new Set(u.field_locks);
|
||||
const changed: string[] = [];
|
||||
const lockedFields: string[] = [];
|
||||
const colPatch: { title?: string; air_date?: string } = {};
|
||||
const names = new Set([...sources.map((s) => s.field), ...overrides.map((o) => o.field)]);
|
||||
for (const field of names) {
|
||||
const ov = overrides.find((o) => o.field === field);
|
||||
let next: FinalFieldValue;
|
||||
if (ov) {
|
||||
next = {
|
||||
value: ov.value,
|
||||
source: ov.source as MetadataSource,
|
||||
language: null,
|
||||
at: ov.updated_at.toISOString(),
|
||||
locked: ov.locked,
|
||||
};
|
||||
if (ov.locked) lockedFields.push(field);
|
||||
} else if (locks.has(field)) {
|
||||
lockedFields.push(field);
|
||||
continue;
|
||||
} else {
|
||||
const src =
|
||||
sources.find((s) => s.field === field && s.source === "tmdb") ?? sources.find((s) => s.field === field);
|
||||
if (!src) continue;
|
||||
next = {
|
||||
value: src.value,
|
||||
source: src.source as MetadataSource,
|
||||
language: src.language,
|
||||
at: src.fetched_at.toISOString(),
|
||||
locked: false,
|
||||
};
|
||||
}
|
||||
const prev = meta[field];
|
||||
if (
|
||||
!prev ||
|
||||
!same(prev.value, next.value) ||
|
||||
prev.source !== next.source ||
|
||||
prev.language !== next.language ||
|
||||
prev.locked !== next.locked
|
||||
) {
|
||||
changed.push(field);
|
||||
meta[field] = next;
|
||||
}
|
||||
const col = UNIT_COLUMN[field as UnitMetadataField];
|
||||
if (col === "title" && typeof next.value === "string" && next.value && next.value !== u.title)
|
||||
colPatch.title = next.value;
|
||||
if (col === "air_date" && typeof next.value === "string" && DATE_RE.test(next.value) && next.value !== u.air)
|
||||
colPatch.air_date = next.value;
|
||||
}
|
||||
if (changed.length || Object.keys(colPatch).length) {
|
||||
await exec
|
||||
.updateTable("content_unit")
|
||||
.set({ ...colPatch, metadata: jsonb(meta), updated_at: now })
|
||||
.where("id", "=", unitId)
|
||||
.execute();
|
||||
}
|
||||
return { changedFields: changed, lockedFields };
|
||||
}
|
||||
|
||||
function episodeFields(e: TmdbRichEpisode): Record<string, FieldValue> {
|
||||
const air = e.airDate && DATE_RE.test(e.airDate.slice(0, 10)) ? e.airDate.slice(0, 10) : null;
|
||||
return {
|
||||
name: { value: e.name, language: e.nameLanguage },
|
||||
overview: { value: e.overview, language: e.overviewLanguage },
|
||||
airDate: { value: air, language: null },
|
||||
runtime: { value: e.runtime, language: null },
|
||||
stillPath: { value: e.stillPath, language: null },
|
||||
voteAverage: { value: e.voteAverage, language: null },
|
||||
voteCount: { value: e.voteCount, language: null },
|
||||
};
|
||||
}
|
||||
|
||||
export interface SeasonMetadataResult {
|
||||
season: number;
|
||||
episodes: number;
|
||||
/** 新建的集单元数 */
|
||||
created: number;
|
||||
/** 字段有变化(投影改写)的内容单元数(含季单元) */
|
||||
changedUnits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 季详情 → 季单元与集单元的字段级元数据。集单元按(作品, 季, 集号)找最早建的未退役单元,没有时:
|
||||
* create = true(full 刷新 / 追踪中的季)建 confirmed 单元;否则只更新已有单元(集单元仍由发布 / 文件懒建)。
|
||||
* 只写变化的来源字段,只投影有变化的单元。
|
||||
*/
|
||||
export async function applySeasonMetadata(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
sd: TmdbRichSeasonDetail,
|
||||
sourceRevision: string,
|
||||
now: Date,
|
||||
opts: { create?: boolean } = {},
|
||||
): Promise<SeasonMetadataResult> {
|
||||
const create = opts.create ?? true;
|
||||
const out: SeasonMetadataResult = { season: sd.seasonNumber, episodes: 0, created: 0, changedUnits: 0 };
|
||||
if (sd.seasonNumber < 1) return out;
|
||||
const seasonUnit = await ensureSeason(exec, entityId, sd.seasonNumber, sd.name);
|
||||
const units = await exec
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "episode_number", "metadata"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("kind", "=", "episode")
|
||||
.where("season_number", "=", sd.seasonNumber)
|
||||
.where("status", "!=", "retired")
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
const unitOf = new Map<number, { id: string; metadata: unknown }>();
|
||||
for (const u of units)
|
||||
if (u.episode_number !== null && !unitOf.has(u.episode_number)) unitOf.set(u.episode_number, u);
|
||||
const eps = sd.episodes.filter((e) => e.episodeNumber >= 1).slice(0, 500);
|
||||
const targets: { unitId: string; fields: Record<string, FieldValue>; fresh: boolean }[] = [];
|
||||
for (const e of eps) {
|
||||
let hit = unitOf.get(e.episodeNumber);
|
||||
let fresh = false;
|
||||
if (!hit && !create) continue;
|
||||
if (!hit) {
|
||||
const row = await exec
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
parent_unit_id: seasonUnit,
|
||||
kind: "episode",
|
||||
season_number: sd.seasonNumber,
|
||||
episode_number: e.episodeNumber,
|
||||
status: "confirmed",
|
||||
display_order: e.episodeNumber,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
hit = { id: row.id, metadata: {} };
|
||||
unitOf.set(e.episodeNumber, hit);
|
||||
out.created++;
|
||||
fresh = true;
|
||||
}
|
||||
targets.push({ unitId: hit.id, fields: episodeFields(e), fresh });
|
||||
}
|
||||
const seasonFields: Record<string, FieldValue> = {
|
||||
name: { value: sd.name, language: sd.nameLanguage },
|
||||
overview: { value: sd.overview, language: sd.overviewLanguage },
|
||||
airDate: { value: sd.airDate && DATE_RE.test(sd.airDate) ? sd.airDate : null, language: null },
|
||||
...(sd.posterPath ? { posterPath: { value: sd.posterPath, language: null } } : {}),
|
||||
};
|
||||
targets.push({ unitId: seasonUnit, fields: seasonFields, fresh: false });
|
||||
const ids = targets.map((t) => t.unitId);
|
||||
const existing = ids.length
|
||||
? await exec
|
||||
.selectFrom("metadata_field")
|
||||
.select(["content_unit_id", "field", "value", "language"])
|
||||
.where("content_unit_id", "in", ids)
|
||||
.where("source", "=", "tmdb")
|
||||
.execute()
|
||||
: [];
|
||||
for (const t of targets) {
|
||||
const mine = existing.filter((x) => x.content_unit_id === t.unitId);
|
||||
const written = await writeSourceFields(exec, { contentUnitId: t.unitId }, t.fields, mine, sourceRevision, now);
|
||||
if (written.length || t.fresh) {
|
||||
const r = await projectUnit(exec, t.unitId, now);
|
||||
if (r.changedFields.length) out.changedUnits++;
|
||||
}
|
||||
}
|
||||
out.episodes = eps.length;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 该作品已有集单元(或整季资产覆盖)的季号:默认刷新时这些季也拉每集信息 */
|
||||
export async function seasonsInLibrary(exec: Exec, entityId: string): Promise<number[]> {
|
||||
const r = await sql<{ season_number: number }>`
|
||||
select distinct season_number from content_unit
|
||||
where media_entity_id = ${entityId} and kind = 'episode' and status <> 'retired' and season_number >= 1
|
||||
order by season_number`.execute(exec);
|
||||
return r.rows.map((x) => x.season_number);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次刷新要拉每集信息的季:full = 全部正片季(最多 60);否则 = 追踪中的季 ∪ 库里已有集单元的季,取最近 8 季
|
||||
* (没有追踪订阅、库里也没有集单元时不拉季详情,定时刷新不额外请求 TMDB)。
|
||||
*/
|
||||
export function seasonsToRefresh(
|
||||
known: readonly number[],
|
||||
opts: { full: boolean; tracked: readonly number[]; inLibrary: readonly number[] },
|
||||
): number[] {
|
||||
const all = [...new Set(known.filter((n) => n >= 1))].sort((a, b) => a - b);
|
||||
if (opts.full) return all.slice(0, 60);
|
||||
const want = new Set<number>([...opts.tracked, ...opts.inLibrary].filter((n) => all.includes(n)));
|
||||
return [...want].sort((a, b) => a - b).slice(-8);
|
||||
}
|
||||
|
||||
// ── 内容单元字段覆盖 / 锁 ──
|
||||
|
||||
function validateUnitValue(field: UnitMetadataField, v: unknown): void {
|
||||
const bad = (m: string) => {
|
||||
throw new ApiException("bad_request", m);
|
||||
};
|
||||
if (v === null) {
|
||||
if (field === "name") bad("name 必须是非空字符串");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(field === "name" || field === "overview" || field === "stillPath" || field === "posterPath") &&
|
||||
typeof v !== "string"
|
||||
)
|
||||
bad(`${field} 必须是字符串`);
|
||||
if (field === "name" && !(v as string).trim()) bad("name 必须是非空字符串");
|
||||
if (field === "airDate" && (typeof v !== "string" || !DATE_RE.test(v))) bad("airDate 必须是 YYYY-MM-DD");
|
||||
if ((field === "runtime" || field === "voteCount") && (typeof v !== "number" || !Number.isInteger(v) || v < 0))
|
||||
bad(`${field} 必须是非负整数`);
|
||||
if (field === "voteAverage" && (typeof v !== "number" || v < 0 || v > 10)) bad("voteAverage 必须在 0–10 之间");
|
||||
}
|
||||
|
||||
export async function setUnitFieldOverride(
|
||||
rt: SourcesRuntime,
|
||||
unitId: string,
|
||||
field: UnitMetadataField,
|
||||
input: FieldOverrideInput,
|
||||
actor: Actor,
|
||||
) {
|
||||
validateUnitValue(field, input.value);
|
||||
return rt.ctx.db.transaction().execute(async (trx) => {
|
||||
const u = await trx
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "field_locks", "media_entity_id"])
|
||||
.where("id", "=", unitId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("not_found", "内容单元不存在");
|
||||
await sql`insert into field_override (media_entity_id, content_unit_id, field, value, locked, source, reason, actor_type, actor_id)
|
||||
values (null, ${u.id}, ${field}, ${jsonb(input.value)}::jsonb, ${input.lock}, 'user', ${input.reason ?? null}, ${actor.type}, ${actor.id})
|
||||
on conflict (media_entity_id, content_unit_id, field) do update set
|
||||
value = excluded.value, locked = excluded.locked, reason = excluded.reason,
|
||||
actor_type = excluded.actor_type, actor_id = excluded.actor_id`.execute(trx);
|
||||
const locks = new Set(u.field_locks);
|
||||
if (input.lock) locks.add(field);
|
||||
else locks.delete(field);
|
||||
await trx
|
||||
.updateTable("content_unit")
|
||||
.set({ field_locks: [...locks].sort() })
|
||||
.where("id", "=", u.id)
|
||||
.execute();
|
||||
const r = await projectUnit(trx, u.id, rt.now());
|
||||
await audit(
|
||||
trx,
|
||||
actor,
|
||||
"metadata.unit_field_override",
|
||||
{ type: "content_unit", id: u.id },
|
||||
{ field, lock: input.lock, mediaEntityId: u.media_entity_id },
|
||||
);
|
||||
return { contentUnitId: u.id, ...r };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearUnitFieldOverride(
|
||||
rt: SourcesRuntime,
|
||||
unitId: string,
|
||||
field: UnitMetadataField,
|
||||
actor: Actor,
|
||||
) {
|
||||
return rt.ctx.db.transaction().execute(async (trx) => {
|
||||
const u = await trx
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "field_locks", "metadata"])
|
||||
.where("id", "=", unitId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("not_found", "内容单元不存在");
|
||||
await trx.deleteFrom("field_override").where("content_unit_id", "=", u.id).where("field", "=", field).execute();
|
||||
// 最终值里去掉覆盖留下的值,重新按来源投影(没有来源值时该字段消失)
|
||||
const meta = { ...((u.metadata ?? {}) as Record<string, unknown>) };
|
||||
delete meta[field];
|
||||
await trx
|
||||
.updateTable("content_unit")
|
||||
.set({ field_locks: u.field_locks.filter((f) => f !== field), metadata: jsonb(meta) })
|
||||
.where("id", "=", u.id)
|
||||
.execute();
|
||||
const r = await projectUnit(trx, u.id, rt.now());
|
||||
await audit(trx, actor, "metadata.unit_field_override_cleared", { type: "content_unit", id: u.id }, { field });
|
||||
return { contentUnitId: u.id, ...r };
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,8 @@ export interface SeasonTotalsResult {
|
||||
activated: number;
|
||||
}
|
||||
|
||||
async function ensureSeason(exec: Exec, entityId: string, season: number, name: string | null): Promise<string> {
|
||||
/** 季单元:按(作品, 季号)找最早建的未退役季单元,没有就建(confirmed) */
|
||||
export async function ensureSeason(exec: Exec, entityId: string, season: number, name: string | null): Promise<string> {
|
||||
const hit = await exec
|
||||
.selectFrom("content_unit")
|
||||
.select("id")
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type MetadataCandidate, TmdbError, type TmdbSeasonDetail } from "@umt/adapters";
|
||||
import {
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
type MetadataCandidate,
|
||||
TmdbError,
|
||||
type TmdbRichDetail,
|
||||
type TmdbRichSeasonDetail,
|
||||
type TmdbSeasonDetail,
|
||||
} from "@umt/adapters";
|
||||
import type {
|
||||
EntityMetadataField,
|
||||
FieldEvidence,
|
||||
@@ -12,6 +20,15 @@ import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { type Actor, audit, type Exec } from "./common.ts";
|
||||
import { resolveEntityId, seedAliases } from "./entities.ts";
|
||||
import {
|
||||
applySeasonMetadata,
|
||||
metadataLanguages,
|
||||
type SeasonMetadataResult,
|
||||
seasonsInLibrary,
|
||||
seasonsToRefresh,
|
||||
tagTitleAliases,
|
||||
writeRichEntityFields,
|
||||
} from "./metadata-rich.ts";
|
||||
import {
|
||||
applyEpisodeAirDates,
|
||||
applySeasonTotals,
|
||||
@@ -103,9 +120,22 @@ export interface RefreshResult {
|
||||
lockedFields: string[];
|
||||
/** 剧集:季总集数账本刷新结果(W5-C) */
|
||||
seasons?: SeasonTotalsResult["seasons"];
|
||||
/** MD-1:本次拉了每集信息的季 */
|
||||
episodes?: SeasonMetadataResult[];
|
||||
/** MD-1:回退链取到的标题 / 中文名(语言) */
|
||||
titles?: { title: string; language: string | null; titleZh: string | null };
|
||||
}
|
||||
|
||||
export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string): Promise<RefreshResult> {
|
||||
export interface RefreshOptions {
|
||||
/** true = 拉全部正片季的每集信息(POST /media-entities/{id}/refresh?full=1);默认只拉追踪中 / 库里已有 / 最新的季 */
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
export async function refreshMetadata(
|
||||
rt: SourcesRuntime,
|
||||
mediaEntityId: string,
|
||||
opts: RefreshOptions = {},
|
||||
): Promise<RefreshResult> {
|
||||
const { ctx } = rt;
|
||||
const id = await resolveEntityId(ctx.db, mediaEntityId);
|
||||
if (!id) return { status: "skipped", reason: "not_found", changedFields: [], lockedFields: [] };
|
||||
@@ -144,9 +174,41 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
// MD-1 富详情(语言回退链、类型、评分、主创…)与每集信息;注入的简化客户端没有 getJson 时走旧路径
|
||||
const rich = typeof tmdb.getJson === "function";
|
||||
const langs = rich ? await metadataLanguages(ctx) : [];
|
||||
let richDetail: TmdbRichDetail | null = null;
|
||||
const richSeasons: TmdbRichSeasonDetail[] = [];
|
||||
// 可以新建集单元的季:full 刷新的全部季、追踪中订阅的季;其余季只更新已有集单元
|
||||
let creatable = new Set<number>();
|
||||
if (rich) {
|
||||
try {
|
||||
richDetail = await fetchRichDetail(tmdb, ident.entity_type, ident.external_id, langs);
|
||||
} catch (e) {
|
||||
if (!(e instanceof TmdbError)) throw e;
|
||||
ctx.logger.debug({ entityId: id, err: { message: e.message } }, "TMDB 富详情获取失败,按基础详情刷新");
|
||||
}
|
||||
}
|
||||
if (rich && ident.entity_type === "tv" && detail.seasons?.length) {
|
||||
const known = detail.seasons.map((x) => x.seasonNumber);
|
||||
const tracked = await trackedSeasonsForAirDates(ctx.db, id, known);
|
||||
const want = seasonsToRefresh(known, {
|
||||
full: !!opts.full,
|
||||
tracked,
|
||||
inLibrary: await seasonsInLibrary(ctx.db, id),
|
||||
});
|
||||
creatable = new Set(opts.full ? want : tracked);
|
||||
for (const n of want) {
|
||||
try {
|
||||
richSeasons.push(await fetchRichSeason(tmdb, ident.external_id, n, langs, { now: rt.now() }));
|
||||
} catch (e) {
|
||||
ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新
|
||||
const airSeasons: TmdbSeasonDetail[] = [];
|
||||
if (ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
|
||||
if (!rich && ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
|
||||
const want = await trackedSeasonsForAirDates(
|
||||
ctx.db,
|
||||
id,
|
||||
@@ -161,8 +223,10 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
}
|
||||
}
|
||||
const now = rt.now();
|
||||
const rev = `tmdb:${ident.entity_type}/${ident.external_id}`;
|
||||
return ctx.db.transaction().execute(async (trx) => {
|
||||
await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now);
|
||||
if (richDetail) await writeRichEntityFields(trx, id, richDetail, rev, now);
|
||||
else await writeTmdbFields(trx, id, detail, rev, now);
|
||||
await trx
|
||||
.updateTable("external_identity")
|
||||
.set({ verify_status: "ok", verified_at: now })
|
||||
@@ -171,6 +235,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
.where("status", "=", "accepted")
|
||||
.execute();
|
||||
await seedAliases(trx, id, detail);
|
||||
if (richDetail) await tagTitleAliases(trx, id, richDetail);
|
||||
const r = await project(trx, id, now);
|
||||
// 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此)
|
||||
const totals =
|
||||
@@ -178,8 +243,19 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
|
||||
? await applySeasonTotals(trx, id, detail.seasons, now)
|
||||
: null;
|
||||
for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd);
|
||||
const episodes: SeasonMetadataResult[] = [];
|
||||
for (const sd of richSeasons)
|
||||
episodes.push(await applySeasonMetadata(trx, id, sd, rev, now, { create: creatable.has(sd.seasonNumber) }));
|
||||
await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute();
|
||||
return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) };
|
||||
return {
|
||||
status: "refreshed" as const,
|
||||
...r,
|
||||
...(totals ? { seasons: totals.seasons } : {}),
|
||||
...(richSeasons.length ? { episodes } : {}),
|
||||
...(richDetail
|
||||
? { titles: { title: richDetail.title, language: richDetail.titleLanguage, titleZh: richDetail.titleZh } }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,10 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
|
||||
defineTask({
|
||||
name: "refreshMetadata",
|
||||
version: 1,
|
||||
inputSchema: z.object({ mediaEntityId: id }),
|
||||
inputSchema: z.object({ mediaEntityId: id, full: z.boolean().optional() }),
|
||||
retryPolicy: NETWORK_RETRY,
|
||||
handler: (t) => t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId)),
|
||||
handler: (t) =>
|
||||
t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full })),
|
||||
}),
|
||||
defineTask({
|
||||
name: "searchSubscription",
|
||||
|
||||
@@ -346,6 +346,33 @@ describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
||||
expect(counts()).toEqual(before);
|
||||
});
|
||||
|
||||
it("MD-1 GET /media/tmdb/tv/{id}(语言回退链)与 /season/{n}(每集信息)", async () => {
|
||||
const d = await req(`/media/tmdb/tv/95396`);
|
||||
expect(d.status).toBe(200);
|
||||
expect(d.body.data).toMatchObject({
|
||||
title: "人生切割术",
|
||||
titleZh: "人生切割术",
|
||||
overview: "故事发生在一家名为卢蒙的超级大公司内。",
|
||||
genres: ["剧情", "悬疑", "Sci-Fi & Fantasy"],
|
||||
voteAverage: 8.4,
|
||||
airStatus: "Returning Series",
|
||||
platform: "Apple TV",
|
||||
backdropPath: "/severance-bg.jpg",
|
||||
});
|
||||
expect(d.body.data.cast[0]).toMatchObject({ name: "亚当·斯科特", role: "Mark Scout", profilePath: "/adam.jpg" });
|
||||
expect(d.body.data.seasons[0]).toMatchObject({ seasonNumber: 1, posterPath: "/sev-s1.jpg" });
|
||||
|
||||
const s1 = await req(`/media/tmdb/tv/95396/season/1`);
|
||||
expect(s1.status).toBe(200);
|
||||
expect(s1.body.data.episodes.map((e: Loose) => [e.episodeNumber, e.name, e.nameLanguage])).toEqual([
|
||||
[1, "有关地狱的好消息", "zh-CN"],
|
||||
[2, "半循環", "zh-TW"],
|
||||
[3, "In Perpetuity", "en-US"],
|
||||
]);
|
||||
expect(s1.body.data.episodes[0]).toMatchObject({ runtime: 59, stillPath: "/sev-101.jpg", voteAverage: 8.2 });
|
||||
expect((await req(`/media/tmdb/tv/95396/season/9`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /inventory/media-servers 可按 TMDB 条目查(未建档时 mediaEntityId = null)", async () => {
|
||||
const r = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=872585`);
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { seasonsToRefresh } from "../../src/modules/sources-identity/service/metadata-rich.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable } from "./harness.ts";
|
||||
|
||||
const up = await pgAvailable();
|
||||
type Final = { value: unknown; source: string; language: string | null; locked: boolean };
|
||||
|
||||
describe("seasonsToRefresh", () => {
|
||||
it("full 拉全部正片季;默认 = 追踪 ∪ 库里已有,最多 8 季;都没有时不拉", () => {
|
||||
expect(seasonsToRefresh([0, 1, 2, 3], { full: true, tracked: [], inLibrary: [] })).toEqual([1, 2, 3]);
|
||||
expect(seasonsToRefresh([0, 1, 2, 3], { full: false, tracked: [], inLibrary: [] })).toEqual([]);
|
||||
expect(seasonsToRefresh([1, 2, 3, 4], { full: false, tracked: [2], inLibrary: [1, 9] })).toEqual([1, 2]);
|
||||
const many = Array.from({ length: 20 }, (_, i) => i + 1);
|
||||
expect(seasonsToRefresh(many, { full: false, tracked: [], inLibrary: many })).toEqual(many.slice(-8));
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("MD-1 语言回退链 + 每集元数据", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let id: string;
|
||||
const actor = { type: "user" as const, id: "tester" };
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb();
|
||||
h = await createHarness("md1", { tmdbUrl: tmdb.url, now: () => new Date("2026-01-01T00:00:00Z") });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
const entity = () => h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const episodes = () =>
|
||||
h.db
|
||||
.selectFrom("content_unit")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("kind", "=", "episode")
|
||||
.orderBy("season_number")
|
||||
.orderBy("episode_number")
|
||||
.execute();
|
||||
|
||||
it("建档后 full 刷新:标题取 CN 别名「人生切割术」,中文名 / 简介带语言,别名标语言", async () => {
|
||||
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId: "95396", kind: "tv" }, actor);
|
||||
id = r.mediaEntityId;
|
||||
// details() 的 zh-CN name 是原名(TMDB 缺 zh-CN 译名时回退)
|
||||
expect((await entity()).title).toBe("Severance");
|
||||
const before = await episodes();
|
||||
expect(before.map((u) => [u.season_number, u.episode_number, u.status])).toEqual([
|
||||
[1, 1, "provisional"],
|
||||
[1, 2, "provisional"],
|
||||
[1, 3, "provisional"],
|
||||
[2, 1, "provisional"],
|
||||
]);
|
||||
|
||||
const acc = await json(await h.req(`/media-entities/${id}/refresh?full=1`, { method: "POST" }));
|
||||
expect(acc.status).toBe(200);
|
||||
expect(h.engine.enqueued.at(-1)).toMatchObject({
|
||||
task: "refreshMetadata",
|
||||
input: { mediaEntityId: id, full: true },
|
||||
});
|
||||
|
||||
const res = await refreshMetadata(h.rt, id, { full: true });
|
||||
expect(res.status).toBe("refreshed");
|
||||
expect(res.titles).toEqual({ title: "人生切割术", language: "zh-CN", titleZh: "人生切割术" });
|
||||
const e = await entity();
|
||||
expect(e.title).toBe("人生切割术");
|
||||
const meta = e.metadata as Record<string, Final>;
|
||||
expect(meta.title).toMatchObject({ value: "人生切割术", language: "zh-CN", source: "tmdb" });
|
||||
expect(meta.titleZh).toMatchObject({ value: "人生切割术", language: "zh-CN" });
|
||||
expect(meta.overview).toMatchObject({ value: "故事发生在一家名为卢蒙的超级大公司内。", language: "zh-CN" });
|
||||
expect(meta.genres?.value).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]);
|
||||
expect(meta.voteAverage?.value).toBe(8.4);
|
||||
expect(meta.airStatus?.value).toBe("Returning Series");
|
||||
expect(meta.platform?.value).toBe("Apple TV");
|
||||
expect(meta.backdropPath?.value).toBe("/severance-bg.jpg");
|
||||
expect((meta.cast?.value as { name: string }[] | undefined)?.[0]?.name).toBe("亚当·斯科特");
|
||||
expect(meta.externalIds?.value).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null });
|
||||
// 同字段只留一行 tmdb 来源(语言变了删旧行)
|
||||
const titleRows = await h.db
|
||||
.selectFrom("metadata_field")
|
||||
.select(["language"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("field", "=", "title")
|
||||
.execute();
|
||||
expect(titleRows).toEqual([{ language: "zh-CN" }]);
|
||||
|
||||
const aliases = await h.db
|
||||
.selectFrom("title_alias")
|
||||
.select(["alias", "language", "source", "kind"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.execute();
|
||||
const lang = (a: string) => aliases.find((x) => x.alias === a)?.language;
|
||||
expect(lang("人生切割术")).toBe("zh-CN");
|
||||
// 繁简归一化后与「人生切割术」同键,不重复登记
|
||||
expect(aliases.filter((a) => a.alias === "人生切割術")).toHaveLength(0);
|
||||
expect(lang("生活割離術")).toBe("zh-HK");
|
||||
expect(aliases.every((a) => a.source === "tmdb")).toBe(true);
|
||||
});
|
||||
|
||||
it("每集元数据:字段级写入 content_unit,编号与已有单元不变,名称按链回退", async () => {
|
||||
const units = await episodes();
|
||||
expect(units).toHaveLength(4);
|
||||
const [e1, e2, e3, s2e1] = units;
|
||||
const m = (u: (typeof units)[number]) => u.metadata as Record<string, Final>;
|
||||
expect(e1!.title).toBe("有关地狱的好消息");
|
||||
expect(e1!.air_date).toBeTruthy();
|
||||
expect(m(e1!).name).toMatchObject({ value: "有关地狱的好消息", language: "zh-CN", source: "tmdb" });
|
||||
expect(m(e1!).overview?.value).toBe("马克升职成为部门主管。");
|
||||
expect(m(e1!).runtime?.value).toBe(59);
|
||||
expect(m(e1!).stillPath?.value).toBe("/sev-101.jpg");
|
||||
expect(m(e1!).voteAverage?.value).toBe(8.2);
|
||||
expect(m(e2!).name).toMatchObject({ value: "半循環", language: "zh-TW" });
|
||||
expect(m(e3!).name).toMatchObject({ value: "In Perpetuity", language: "en-US" });
|
||||
expect(m(e3!).overview?.value).toBeNull();
|
||||
// 未播出:占位名,语言未知
|
||||
expect(m(s2e1!).name).toMatchObject({ value: "第 1 集", language: null });
|
||||
// 编号 / 状态不被元数据刷新改动(provisional 仍是 provisional)
|
||||
expect(units.map((u) => [u.season_number, u.episode_number, u.status, u.numbering_locked])).toEqual([
|
||||
[1, 1, "provisional", false],
|
||||
[1, 2, "provisional", false],
|
||||
[1, 3, "provisional", false],
|
||||
[2, 1, "provisional", false],
|
||||
]);
|
||||
const s1 = await h.db
|
||||
.selectFrom("content_unit")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("kind", "=", "season")
|
||||
.where("season_number", "=", 1)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((s1.metadata as Record<string, Final>).posterPath?.value).toBe("/sev-s1.jpg");
|
||||
expect((s1.metadata as Record<string, Final>).overview?.value).toContain("卢蒙");
|
||||
});
|
||||
|
||||
it("增量:再刷新没有变化的单元不改写;锁定集名后刷新不覆盖,解锁回到来源值", async () => {
|
||||
const at = async () =>
|
||||
(
|
||||
await h.db
|
||||
.selectFrom("metadata_field")
|
||||
.select(["fetched_at"])
|
||||
.where("content_unit_id", "is not", null)
|
||||
.execute()
|
||||
)
|
||||
.map((x) => x.fetched_at.getTime())
|
||||
.sort();
|
||||
const t0 = await at();
|
||||
const again = await refreshMetadata(h.rt, id, { full: true });
|
||||
expect(again.episodes?.every((s) => s.changedUnits === 0 && s.created === 0)).toBe(true);
|
||||
expect(await at()).toEqual(t0);
|
||||
|
||||
const e1 = (await episodes())[0]!;
|
||||
const put = await json(
|
||||
await h.req(`/content-units/${e1.id}/fields/name`, { method: "PUT", body: { value: "自定义标题" } }),
|
||||
);
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.data).toMatchObject({ contentUnitId: e1.id, lockedFields: ["name"] });
|
||||
const bad = await json(
|
||||
await h.req(`/content-units/${e1.id}/fields/airDate`, { method: "PUT", body: { value: "x" } }),
|
||||
);
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
await refreshMetadata(h.rt, id, { full: true });
|
||||
let u = (await episodes())[0]!;
|
||||
expect(u.title).toBe("自定义标题");
|
||||
expect(u.field_locks).toEqual(["name"]);
|
||||
expect((u.metadata as Record<string, Final>).name).toMatchObject({
|
||||
value: "自定义标题",
|
||||
source: "user",
|
||||
locked: true,
|
||||
});
|
||||
|
||||
const del = await json(await h.req(`/content-units/${e1.id}/fields/name`, { method: "DELETE" }));
|
||||
expect(del.status).toBe(200);
|
||||
u = (await episodes())[0]!;
|
||||
expect(u.title).toBe("有关地狱的好消息");
|
||||
expect(u.field_locks).toEqual([]);
|
||||
});
|
||||
|
||||
it("设置 metadata.languages 换链:en-US 优先时标题取英文,中文名仍是 zh-CN 值", async () => {
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "metadata", value: JSON.stringify({ languages: ["en-US", "zh-CN"] }) } as never)
|
||||
.execute();
|
||||
const r = await refreshMetadata(h.rt, id);
|
||||
expect(r.titles).toEqual({ title: "Severance", language: "en-US", titleZh: "人生切割术" });
|
||||
const e = await entity();
|
||||
expect(e.title).toBe("Severance");
|
||||
expect((e.metadata as Record<string, Final>).overview).toMatchObject({
|
||||
value: "Mark leads a team of office workers.",
|
||||
language: "en-US",
|
||||
});
|
||||
await h.db.deleteFrom("settings").where("key", "=", "metadata").execute();
|
||||
await refreshMetadata(h.rt, id);
|
||||
expect((await entity()).title).toBe("人生切割术");
|
||||
});
|
||||
|
||||
it("作品详情 API 带内容单元元数据;作品级字段来源不含集字段", async () => {
|
||||
const d = await json(await h.req(`/media-entities/${id}/detail`));
|
||||
expect(d.status).toBe(200);
|
||||
const ep = d.body.data.contentUnits.find(
|
||||
(u: { kind: string; episodeNumber: number | null; seasonNumber: number | null }) =>
|
||||
u.kind === "episode" && u.seasonNumber === 1 && u.episodeNumber === 2,
|
||||
);
|
||||
expect(ep.metadata.name.value).toBe("半循環");
|
||||
expect(d.body.data.metadataFields.some((f: { field: string }) => f.field === "stillPath")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user