feat(metadata): one-shot metadata.backfill_languages maintenance job (FIX-5)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 19:37:11 +08:00
1 parent a51dc1590f
commit 2c9e5a1d00
7 files changed
+281 -8

No files matched your search

@@ -9,6 +9,7 @@ import { workbenchRoutes } from "./routes/workbench.ts";
import { type RuntimeOverrides, SourcesRuntime } from "./runtime.ts";
import { readOnlyReconcile, tickFeeds, tickReviews, tickSnapshots } from "./scheduler.ts";
import { tickSiteHealth, tickSiteUserData } from "./service/account.ts";
import { BACKFILL_LANGUAGES_JOB, backfillLanguages } from "./service/backfill-languages.ts";
import { registerSourcesTasks, sourcesOutboxRoutes } from "./tasks.ts";
/**
@@ -89,6 +90,23 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
},
run: async () => ({ summary: `${await tickSiteHealth(rt)} 个站点` }),
});
// FIX-5 存量元数据回刷:部署后首次启动即运行,跑完自动停用;可在计划页手动再执行
registerScheduledJob(ctx, {
key: BACKFILL_LANGUAGES_JOB,
defaults: {
name: "元数据语言回刷",
description:
"对已确认 TMDB 身份的作品按语言回退链完整刷新一次(标题、简介、每集信息),TMDB 每秒最多 2 次请求;跑完自动停用,可手动再执行",
group: "maintain",
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
firstRun: "now",
},
run: async ({ runId, jobId }) => {
if (await readOnlyReconcile(rt)) return { status: "skipped", summary: "只读对账模式" };
const r = await backfillLanguages(rt, { runId, jobId, disableWhenDone: true });
return { status: r.status, summary: r.summary };
},
});
},
};
}
@@ -79,6 +79,38 @@ export class SourcesRuntime {
if (this.tmdbClient === undefined) this.tmdbClient = this.overrides.tmdb();
return this.tmdbClient;
}
const { sig, cfg } = await this.tmdbConfig();
if (this.tmdbClient !== undefined && this.tmdbSig === sig) return this.tmdbClient;
this.tmdbSig = sig;
this.tmdbClient = cfg ? createTmdbClient(cfg) : null;
return this.tmdbClient;
}
/**
* 批量任务用的独立 TMDB 客户端(FIX-5 元数据回刷):串行、网络请求间隔 ≥ minIntervalMs(缓存命中不占额度),
* 同样走 PG 请求缓存;requests() 返回实际发出的网络请求数。测试注入的客户端原样返回(requests 为 null)。
*/
async tmdbForBatch(opts: {
minIntervalMs: number;
}): Promise<{ client: TmdbClient; requests: () => number | null } | null> {
if (this.overrides.tmdb) {
const client = await this.tmdb();
return client ? { client, requests: () => null } : null;
}
const { cfg } = await this.tmdbConfig();
if (!cfg) return null;
let n = 0;
const base = cfg.fetch ?? globalThis.fetch;
const counted: typeof fetch = (input, init) => {
n++;
return base(input, init);
};
const client = createTmdbClient({ ...cfg, fetch: counted, maxConcurrent: 1, minIntervalMs: opts.minIntervalMs });
return { client, requests: () => n };
}
/** TMDB 客户端配置(未配置凭证时 cfg 为 null)与签名(设置变化时重建客户端) */
private async tmdbConfig(): Promise<{ sig: string; cfg: Parameters<typeof createTmdbClient>[0] | null }> {
const meta = await this.ctx.settings.get("metadata").catch(() => null);
const policy = await tmdbCachePolicy(this.ctx);
const env = (k: string) => Deno.env.get(k);
@@ -90,7 +122,6 @@ export class SourcesRuntime {
policy,
ref ? null : [!!env("TMDB_API_KEY"), !!env("TMDB_ACCESS_TOKEN")],
]);
if (this.tmdbClient !== undefined && this.tmdbSig === sig) return this.tmdbClient;
let apiKey: string | undefined;
let accessToken: string | undefined;
if (ref) {
@@ -106,10 +137,9 @@ export class SourcesRuntime {
apiKey = env("TMDB_API_KEY");
accessToken = env("TMDB_ACCESS_TOKEN");
}
this.tmdbSig = sig;
this.tmdbClient =
const cfg =
apiKey || accessToken
? createTmdbClient({
? {
...(apiKey ? { apiKey } : {}),
...(accessToken ? { accessToken } : {}),
...(baseUrl ? { baseUrl } : {}),
@@ -121,9 +151,9 @@ export class SourcesRuntime {
emptyTtlMs: policy.emptyTtl * 1000,
},
sharedCache: tmdbSharedCache(this.ctx, policy),
})
}
: null;
return this.tmdbClient;
return { sig, cfg };
}
/** 站点默认(settings.sites),读库并刷新缓存;读失败用上次值 */
@@ -0,0 +1,90 @@
import { sql } from "@umt/db";
import type { SourcesRuntime } from "../runtime.ts";
import { refreshMetadata } from "./metadata.ts";
// FIX-5 存量元数据回刷(维护任务 metadata.backfill_languages):MD-1 语言回退链上线前建档的作品,标题 / 简介 / 每集信息
// 仍是旧语言。对所有已确认 TMDB 身份的作品按当前语言链跑一次 full 刷新(同 POST /media-entities/{id}/refresh?full=1)。
// - 限速:独立 TMDB 客户端串行、网络请求间隔 ≥ 500 ms(每秒最多 2 次);请求走 PG 请求缓存(对照 MP @cached),
// 命中不发请求、不占额度,中断后重跑已取过的作品几乎不再请求 TMDB。
// - 进度写本次运行记录的摘要(scheduled_job_run.summary),结束时写最终统计。
// - 跑完(不论个别失败)把任务停用(customized,之后同步定义不会再打开);手动「立即执行」可再跑一次。
export const BACKFILL_LANGUAGES_JOB = "metadata.backfill_languages";
/** TMDB 网络请求最小间隔:每秒最多 2 次 */
export const BACKFILL_MIN_INTERVAL_MS = 500;
const PROGRESS_EVERY_MS = 5_000;
export interface BackfillResult {
status: "ok" | "skipped";
total: number;
refreshed: number;
skipped: number;
failed: number;
requests: number | null;
errors: { mediaEntityId: string; title: string; error: string }[];
summary: string;
}
export async function backfillLanguages(
rt: SourcesRuntime,
opts: { runId?: string; jobId?: string; minIntervalMs?: number; disableWhenDone?: boolean } = {},
): Promise<BackfillResult> {
const { ctx } = rt;
const empty = { total: 0, refreshed: 0, skipped: 0, failed: 0, requests: null, errors: [] };
const batch = await rt.tmdbForBatch({ minIntervalMs: opts.minIntervalMs ?? BACKFILL_MIN_INTERVAL_MS });
if (!batch) return { status: "skipped", ...empty, summary: "TMDB 未配置,未执行(配置后可手动执行)" };
const rows = await sql<{ id: string; title: string }>`
select e.id, e.title from media_entity e
where e.status in ('active', 'provisional')
and exists (select 1 from external_identity x where x.media_entity_id = e.id and x.provider = 'tmdb'
and x.status = 'accepted' and x.entity_type in ('movie', 'tv'))
order by e.created_at, e.id`.execute(ctx.db);
const total = rows.rows.length;
const r: BackfillResult = { status: "ok", ...empty, total, summary: "" };
let lastProgress = Date.now();
const writeProgress = async (done: number) => {
if (!opts.runId) return;
await ctx.db
.updateTable("scheduled_job_run")
.set({ summary: `进度 ${done}/${total}:刷新 ${r.refreshed},跳过 ${r.skipped},失败 ${r.failed}` })
.where("id", "=", opts.runId)
.execute()
.catch(() => undefined);
};
for (const [i, e] of rows.rows.entries()) {
try {
const res = await refreshMetadata(rt, e.id, { full: true, tmdb: batch.client });
if (res.status === "refreshed") r.refreshed++;
else if (res.status === "skipped") r.skipped++;
else {
r.failed++;
r.errors.push({ mediaEntityId: e.id, title: e.title, error: res.reason ?? "failed" });
}
} catch (err) {
r.failed++;
r.errors.push({ mediaEntityId: e.id, title: e.title, error: (err as Error).message.slice(0, 300) });
ctx.logger.warn({ entityId: e.id, err: { message: (err as Error).message } }, "元数据回刷失败");
}
if (Date.now() - lastProgress >= PROGRESS_EVERY_MS) {
lastProgress = Date.now();
await writeProgress(i + 1);
}
}
r.requests = batch.requests();
const failedList = r.errors
.slice(0, 5)
.map((x) => `「${x.title}」${x.error}`)
.join(";");
r.summary =
`${total} 部作品:刷新 ${r.refreshed},跳过 ${r.skipped},失败 ${r.failed}` +
(r.requests !== null ? `;TMDB 请求 ${r.requests} 次` : "") +
(failedList ? `。失败:${failedList}${r.errors.length > 5 ? " 等" : ""}` : "");
if (opts.disableWhenDone && opts.jobId) {
await ctx.db
.updateTable("scheduled_job")
.set({ enabled: false, customized: true })
.where("id", "=", opts.jobId)
.execute();
}
return r;
}
@@ -2,6 +2,7 @@ import {
fetchRichDetail,
fetchRichSeason,
type MetadataCandidate,
type TmdbClient,
TmdbError,
type TmdbRichDetail,
type TmdbRichSeasonDetail,
@@ -129,6 +130,8 @@ export interface RefreshResult {
export interface RefreshOptions {
/** true = 拉全部正片季的每集信息(POST /media-entities/{id}/refresh?full=1);默认只拉追踪中 / 库里已有 / 最新的季 */
full?: boolean;
/** 指定 TMDB 客户端(批量回刷用限速客户端);缺省 rt.tmdb() */
tmdb?: TmdbClient;
}
export async function refreshMetadata(
@@ -146,7 +149,7 @@ export async function refreshMetadata(
.where("provider", "=", "tmdb")
.where("status", "=", "accepted")
.executeTakeFirst();
const tmdb = await rt.tmdb();
const tmdb = opts.tmdb ?? (await rt.tmdb());
if (!ident || !tmdb || (ident.entity_type !== "movie" && ident.entity_type !== "tv")) {
return {
status: "skipped",
@@ -0,0 +1,125 @@
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { registerScheduledJob, scheduleRegistry } from "../../src/modules/schedules/registry.ts";
import { syncDefinitions } from "../../src/modules/schedules/store.ts";
import { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
import {
BACKFILL_LANGUAGES_JOB,
backfillLanguages,
} from "../../src/modules/sources-identity/service/backfill-languages.ts";
import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts";
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
// FIX-5 存量元数据回刷(metadata.backfill_languages)
const up = await pgAvailable();
describe.skipIf(!up)("元数据语言回刷", () => {
let h: Harness;
let tmdb: MockTmdb;
const ids: string[] = [];
const actor = { type: "user" as const, id: "tester" };
beforeAll(async () => {
tmdb = await createMockTmdb();
h = await createHarness("backfill", { tmdbUrl: tmdb.url });
for (const [externalId, kind] of [
["95396", "tv"],
["872585", "movie"],
] as const) {
const r = await createEntityFromTmdbCandidate(h.db, () => h.rt.tmdb(), { externalId, kind }, actor);
ids.push(r.mediaEntityId);
}
// 没有 TMDB 身份的作品不参与
await h.db
.insertInto("media_entity")
.values({ kind: "series", title: "本地短剧", status: "provisional" } as never)
.execute();
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
it("登记为维护组任务,首次登记即到期;跑完按新语言链刷新并自动停用", async () => {
registerScheduledJob(h.ctx, {
key: BACKFILL_LANGUAGES_JOB,
defaults: {
name: "元数据语言回刷",
group: "maintain",
schedule: { type: "interval", everyMs: 86_400_000 },
firstRun: "now",
},
run: async () => undefined,
});
const now = new Date();
await syncDefinitions(h.db, scheduleRegistry(h.ctx).list(), now);
const before = await h.db
.selectFrom("scheduled_job")
.selectAll()
.where("id", "=", BACKFILL_LANGUAGES_JOB)
.executeTakeFirstOrThrow();
expect(before).toMatchObject({ enabled: true, board_group: "maintain" });
expect(before.next_run_at!.getTime()).toBeLessThanOrEqual(now.getTime());
const r = await backfillLanguages(h.rt, { jobId: BACKFILL_LANGUAGES_JOB, disableWhenDone: true, minIntervalMs: 0 });
expect(r).toMatchObject({ status: "ok", total: 2, refreshed: 2, failed: 0 });
expect(r.summary).toContain("2 部作品:刷新 2");
const tv = await h.db.selectFrom("media_entity").selectAll().where("id", "=", ids[0]!).executeTakeFirstOrThrow();
expect(tv.title).toBe("人生切割术");
const eps = await h.db
.selectFrom("content_unit")
.select(["episode_number", "title"])
.where("media_entity_id", "=", ids[0]!)
.where("kind", "=", "episode")
.where("season_number", "=", 1)
.orderBy("episode_number")
.execute();
expect(eps.length).toBeGreaterThan(0);
const after = await h.db
.selectFrom("scheduled_job")
.selectAll()
.where("id", "=", BACKFILL_LANGUAGES_JOB)
.executeTakeFirstOrThrow();
expect(after).toMatchObject({ enabled: false, customized: true });
// 重启后同步定义不会重新打开
await syncDefinitions(h.db, scheduleRegistry(h.ctx).list(), new Date());
const synced = await h.db
.selectFrom("scheduled_job")
.select(["enabled"])
.where("id", "=", BACKFILL_LANGUAGES_JOB)
.executeTakeFirstOrThrow();
expect(synced.enabled).toBe(false);
});
it("按设置建的客户端:网络请求限速,走 PG 请求缓存,第二次不再请求 TMDB", async () => {
await h.db
.insertInto("settings")
.values({ key: "metadata", value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) } as never)
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) }))
.execute();
const prev = Deno.env.get("TMDB_API_KEY");
Deno.env.set("TMDB_API_KEY", "test-tmdb-key");
try {
const rt = new SourcesRuntime(h.ctx, {});
const t0 = performance.now();
const first = await backfillLanguages(rt, { minIntervalMs: 100 });
const elapsed = performance.now() - t0;
expect(first).toMatchObject({ status: "ok", total: 2, refreshed: 2 });
expect(first.requests).toBeGreaterThan(2);
expect(elapsed).toBeGreaterThanOrEqual((first.requests! - 1) * 90);
const second = await backfillLanguages(rt, { minIntervalMs: 100 });
expect(second).toMatchObject({ refreshed: 2, requests: 0 });
} finally {
if (prev === undefined) Deno.env.delete("TMDB_API_KEY");
else Deno.env.set("TMDB_API_KEY", prev);
}
});
it("TMDB 未配置:跳过,不停用", async () => {
const rt = new SourcesRuntime(h.ctx, { tmdb: () => null });
const r = await backfillLanguages(rt, { jobId: "x", disableWhenDone: true });
expect(r.status).toBe("skipped");
});
});
+7 -1
View File
@@ -21,7 +21,13 @@
- 新建档时标题 / 简介当场按语言链取(富详情失败沿用基础详情),每集信息由随后入队的 full 刷新补齐,不用手动刷新。自动识别(RSS)新建的作品不入队 full,仍按追踪刷新。
- 刷新是后台任务 `refreshMetadata`(输入 `{mediaEntityId, full?}`),结果里 `titles` 是本次取到的标题 / 语言 / 中文名,`episodes[]` 是各季写入统计(`created` 新建单元数、`changedUnits` 有变化的单元数)。
- 增量:来源值和语言都没变的字段不写;只投影有变化的单元。不改编号、状态、路径绑定和资产覆盖。
- 现有作品补齐中文名 / 每集信息:对作品跑一次 `full=1`。批量:
- 存量回刷(FIX-5):维护任务 `metadata.backfill_languages`「元数据语言回刷」在部署后首次启动即运行,对所有已确认 TMDB 身份
(active / provisional)的作品逐个跑 `full` 刷新。用独立的 TMDB 客户端串行请求,网络请求间隔 ≥ 500 ms(每秒最多 2 次),
请求照常走 `tmdb.request` PG 缓存(对照 MP `@cached`),中断后重跑时已取过的不再请求。进度每 5 秒写进本次运行记录的摘要,
结束写「N 部作品:刷新 / 跳过 / 失败;TMDB 请求 X 次」和前 5 条失败原因。跑完(有个别失败也算)任务自动停用;
计划页「立即执行」可再跑一次。TMDB 未配置时记为跳过、保持启用,第二天再试。只读对账模式下跳过。
代码:`apps/server/src/modules/sources-identity/service/backfill-languages.ts`。
- 单部作品补齐中文名 / 每集信息:对作品跑一次 `full=1`。手动批量:
```sh
# 所有有 TMDB 身份的作品各跑一次(TMDB 请求走缓存;数量多时分批)
+1
View File
@@ -36,6 +36,7 @@
| `cache.images_lru` | 图片缓存容量控制 | 维护 | 30 分钟 | 超过 `storage.budgets.imagesGb` 按最久未访问删到 90% |
| `storage.retention.*` | 保留清理(观察 / SSE / 任务运行 / Agent 事件 / 审计 / 能力统计 / 待识别池归档) | 维护 | 1–24 小时 | 分批删除超过保留期的流水数据,见 [storage.md](storage.md) |
| `storage.analyze` | 统计信息更新 | 维护 | 7 天 | 全库 `ANALYZE` |
| `metadata.backfill_languages` | 元数据语言回刷 | 维护 | 24 小时,跑完自动停用 | 部署后首次启动即运行:对已确认 TMDB 身份的作品按语言回退链 full 刷新一次(TMDB 每秒最多 2 次,走请求缓存),进度写运行记录;跑完把自己停用,可手动再执行。见 [metadata.md](metadata.md) |
| `maintain.backup` | 备份 | 维护 | 每天 03:30(占位) | 未配置。备份目前由宿主机上的 `scripts/backup.sh` 执行 |
| `check.pending` | 待处理巡检 | Agent | 6 小时,默认关闭 | 只读。有未关闭事项时才会运行 |
| `check.downloads` | 下载诊断 | Agent | 2 小时,默认关闭 | 默认只读。打开 `allowRecover` 后,在授权范围内重试或重新校验 |