feat(metadata): apply language chain and queue full refresh on new entities (INT-3)

Entities created from TMDB candidates (subscription, search/TMDB preview, organize wizard)
and manual assignment to an unregistered TMDB identity take title/overview from the
metadata.languages chain at creation and enqueue refreshMetadata full=1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:51:04 +08:00
1 parent fe05138c9c
commit ed868f166e
5 files changed
+83 -8

No files matched your search

@@ -1,6 +1,11 @@
import type { FilesEntityFromCandidateResult, FilesExternalCandidate } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { CandidateError, createEntityFromTmdbCandidate } from "../sources-identity/service/entity-from-candidate.ts";
import {
CandidateError,
createEntityFromTmdbCandidate,
enqueueNewEntityRefresh,
} from "../sources-identity/service/entity-from-candidate.ts";
import { metadataLanguages } from "../sources-identity/service/metadata-rich.ts";
import { actorOfP, FilesError, type FmDeps } from "./common.ts";
import { audit } from "./ledger.ts";
@@ -18,8 +23,13 @@ export async function entityFromCandidate(
ref: FilesExternalCandidate,
): Promise<FilesEntityFromCandidateResult> {
try {
const r = await createEntityFromTmdbCandidate(d.ctx.db, d.tmdb, ref, actorOf(principal));
if (r.created) await audit(d, principal, "entity_from_candidate", { ...ref, mediaEntityId: r.mediaEntityId });
const languages = await metadataLanguages(d.ctx);
const r = await createEntityFromTmdbCandidate(d.ctx.db, d.tmdb, ref, actorOf(principal), { languages });
if (r.created) {
await audit(d, principal, "entity_from_candidate", { ...ref, mediaEntityId: r.mediaEntityId });
// 订阅建档 / 搜索建档 / 整理向导都走这里:建档即入队 full 刷新(每集信息、富字段)
await enqueueNewEntityRefresh(d.ctx, r.mediaEntityId);
}
return r;
} catch (e) {
if (e instanceof CandidateError)
@@ -1,7 +1,8 @@
import { type TmdbClient, TmdbError } from "@umt/adapters";
import { fetchRichDetail, type MetadataCandidate, type TmdbClient, TmdbError } from "@umt/adapters";
import type { MediaKind } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db";
import type { ParsedRelease } from "@umt/domain";
import type { ServerContext } from "../../types.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import { createEntityFromTmdb, ensureUnits, entityByExternal, IdentityConflictError } from "./entities.ts";
@@ -17,6 +18,44 @@ export class CandidateError extends Error {
}
}
/**
* INT-3(MD-1 遗留):建档时就按设置 metadata.languages 回退链取标题 / 简介,不等首次刷新。
* 富详情失败(或注入的简化客户端没有 getJson)沿用基础详情。
*/
export async function withLanguageChain(
client: TmdbClient,
detail: MetadataCandidate,
languages: readonly string[] | undefined,
): Promise<MetadataCandidate> {
if (!languages?.length || typeof client.getJson !== "function") return detail;
if (detail.entityType !== "movie" && detail.entityType !== "tv") return detail;
try {
const rich = await fetchRichDetail(client, detail.entityType, detail.externalId, languages);
return { ...detail, title: rich.title || detail.title, overview: rich.overview ?? detail.overview };
} catch (e) {
if (e instanceof TmdbError) return detail;
throw e;
}
}
/** 新建档后入队一次 full 刷新(富字段 + 全部季的集单元),用户不必手动刷新;入队失败只记日志 */
export async function enqueueNewEntityRefresh(ctx: ServerContext, entityId: string): Promise<void> {
await ctx.engine
.enqueue(
"refreshMetadata",
{ mediaEntityId: entityId, full: true },
{ idempotencyKey: `refreshMetadata:created:${entityId}`, traceId: entityId },
)
.then(
() => {},
(err: unknown) =>
ctx.logger.debug(
{ entityId, err: { message: err instanceof Error ? err.message : String(err) } },
"新建档元数据刷新入队失败",
),
);
}
export interface EntityFromCandidate {
mediaEntityId: string;
created: boolean;
@@ -35,6 +74,7 @@ export async function createEntityFromTmdbCandidate(
tmdb: () => Promise<TmdbClient | null>,
ref: { externalId: string; kind: "movie" | "tv" },
actor: Actor,
opts: { languages?: readonly string[] } = {},
): Promise<EntityFromCandidate> {
const extRef = { provider: "tmdb", entityType: ref.kind, externalId: ref.externalId };
const existing = await entityByExternal(db, extRef);
@@ -51,6 +91,7 @@ export async function createEntityFromTmdbCandidate(
if (e instanceof TmdbError) throw new CandidateError("upstream", `TMDB 查询失败:${e.kind}`);
throw e;
}
detail = await withLanguageChain(client, detail, opts.languages);
for (let attempt = 0; ; attempt++) {
try {
@@ -22,7 +22,9 @@ import {
looksLikeVariety,
resolveEntityId,
} from "./entities.ts";
import { enqueueNewEntityRefresh, withLanguageChain } from "./entity-from-candidate.ts";
import { detachFromIdentityDecisions } from "./identity-decisions.ts";
import { metadataLanguages } from "./metadata-rich.ts";
import { recordAttempt } from "./resolve.ts";
import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts";
@@ -32,9 +34,15 @@ import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts";
const actorName = (a: Actor) => `${a.type}:${a.id ?? ""}`;
/** 外部身份 → 本地作品;未登记且提供方为 TMDB 时按详情建档(TMDB 失败给 502,不当作不存在) */
async function entityForExternal(rt: SourcesRuntime, exec: Exec, ref: ExternalRef, actor: Actor, variety: boolean) {
async function entityForExternal(
rt: SourcesRuntime,
exec: Exec,
ref: ExternalRef,
actor: Actor,
variety: boolean,
): Promise<{ entityId: string; created: boolean }> {
const hit = await entityByExternal(exec, ref);
if (hit) return hit;
if (hit) return { entityId: hit, created: false };
if (ref.provider !== "tmdb")
throw new ApiException("bad_request", `未登记的外部身份 ${ref.provider}:${ref.externalId},只支持按 TMDB 建档`);
const tmdb = await rt.tmdb();
@@ -48,6 +56,7 @@ async function entityForExternal(rt: SourcesRuntime, exec: Exec, ref: ExternalRe
if (e instanceof TmdbError) throw new ApiException("upstream_unavailable", `TMDB 查询失败:${e.kind}`);
throw e;
}
detail = await withLanguageChain(tmdb, detail, await metadataLanguages(rt.ctx));
const r = await createEntityFromTmdb(exec, detail, {
level: "manual",
evidence: { by: actorName(actor) },
@@ -55,7 +64,7 @@ async function entityForExternal(rt: SourcesRuntime, exec: Exec, ref: ExternalRe
variety,
locked: true,
});
return r.entityId;
return r;
}
export async function assignReleaseIdentity(
@@ -86,6 +95,7 @@ export async function assignReleaseIdentity(
}
for (let attempt = 0; ; attempt++) {
let created = false;
try {
const done = await ctx.db.transaction().execute(async (trx) => {
let entityId: string;
@@ -94,7 +104,9 @@ export async function assignReleaseIdentity(
if (!r) throw new ApiException("not_found", "本地作品不存在");
entityId = r;
} else {
entityId = await entityForExternal(rt, trx, ref!, actor, variety);
const r = await entityForExternal(rt, trx, ref!, actor, variety);
entityId = r.entityId;
created = r.created;
}
const entity = await trx
.selectFrom("media_entity")
@@ -193,6 +205,7 @@ export async function assignReleaseIdentity(
return { mediaEntityId: entityId, contentUnitIds: unitIds };
});
await backfillRecognizeCache(rt, parsed, done.mediaEntityId);
if (created) await enqueueNewEntityRefresh(ctx, done.mediaEntityId);
return done;
} catch (e) {
if (e instanceof IdentityConflictError && attempt < 2) continue;
+9
View File
@@ -72,6 +72,15 @@ describe.skipIf(skip)("files v2(W9-H)", () => {
.execute();
expect(units.filter((u) => u.kind === "season")).toHaveLength(3);
expect(units.filter((u) => u.kind === "episode")).toHaveLength(28);
// INT-3:建档即入队 full 刷新(每集信息、富字段)
expect(
h.engine.enqueued.filter(
(e) =>
e.task === "refreshMetadata" &&
(e.input as Loose).mediaEntityId === d.mediaEntityId &&
(e.input as Loose).full === true,
),
).toHaveLength(1);
const again = await h.api(
"POST",
+2
View File
@@ -16,7 +16,9 @@
|---|---|---|
| `POST /media-entities/{id}/refresh`(定时追踪刷新同) | 追踪中订阅的季 ∪ 库里已有集单元的季(最近 8 季) | 只在追踪中的季 |
| `POST /media-entities/{id}/refresh?full=1`(详情页「刷新元数据」「更新每集信息」) | 全部正片季(最多 60) | 是 |
| 新建档:按 TMDB 候选建作品(订阅建档、搜索 / TMDB 预览页建档、整理向导)、手动指定未登记的 TMDB 身份 | 同 `full=1`(幂等键 `refreshMetadata:created:{id}`) | 是 |
- 新建档时标题 / 简介当场按语言链取(富详情失败沿用基础详情),每集信息由随后入队的 full 刷新补齐,不用手动刷新。自动识别(RSS)新建的作品不入队 full,仍按追踪刷新。
- 刷新是后台任务 `refreshMetadata`(输入 `{mediaEntityId, full?}`),结果里 `titles` 是本次取到的标题 / 语言 / 中文名,`episodes[]` 是各季写入统计(`created` 新建单元数、`changedUnits` 有变化的单元数)。
- 增量:来源值和语言都没变的字段不写;只投影有变化的单元。不改编号、状态、路径绑定和资产覆盖。
- 现有作品补齐中文名 / 每集信息:对作品跑一次 `full=1`。批量: