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;
|
||||
|
||||
Reference in new issue
Block a user