diff --git a/apps/server/src/modules/file-manager/candidate.ts b/apps/server/src/modules/file-manager/candidate.ts index 536d686d..8957557c 100644 --- a/apps/server/src/modules/file-manager/candidate.ts +++ b/apps/server/src/modules/file-manager/candidate.ts @@ -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 { 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) diff --git a/apps/server/src/modules/sources-identity/service/entity-from-candidate.ts b/apps/server/src/modules/sources-identity/service/entity-from-candidate.ts index 27c9c5b0..6e65dfa2 100644 --- a/apps/server/src/modules/sources-identity/service/entity-from-candidate.ts +++ b/apps/server/src/modules/sources-identity/service/entity-from-candidate.ts @@ -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 { + 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 { + 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, ref: { externalId: string; kind: "movie" | "tv" }, actor: Actor, + opts: { languages?: readonly string[] } = {}, ): Promise { 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 { diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index d8bc7000..76ffec59 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -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; diff --git a/apps/server/test/file-manager/v2.test.ts b/apps/server/test/file-manager/v2.test.ts index d4e97c80..6fe5bb2d 100644 --- a/apps/server/test/file-manager/v2.test.ts +++ b/apps/server/test/file-manager/v2.test.ts @@ -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", diff --git a/docs/runbook/metadata.md b/docs/runbook/metadata.md index dce51d3e..810675a5 100644 --- a/docs/runbook/metadata.md +++ b/docs/runbook/metadata.md @@ -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`。批量: