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:
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`。批量:
|
||||
|
||||
Reference in new issue
Block a user