Files
UHD_Media_Tool/apps/server/src/modules/sources-identity/tasks.ts
T
rootandClaude Opus 5.5 da33cad81d feat(worker): IO Worker 分道(WK-1)
io worker 进程内按任务声明的 lane 分文件 / 网络 / 维护三条道,各自并发(缺省 2/4/1)、
各自排队;维护道在文件道或网络道有排队/运行时让路(连续 10 分钟放行一个),调度器内联的
清理/回填/保留期裁剪也走维护道。并发在 设置 → 自动化 → 高级 热生效,env 覆盖;旧
WORKER_CONCURRENCY 映射到网络道。运行状态页按道显示运行中/并发/排队。归属表写进
docs/runbook/dev.md,ci 与注册表逐项核对。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:24:21 +08:00

258 lines
10 KiB
TypeScript

import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks";
import { z } from "zod";
import type { SourcesRuntime } from "./runtime.ts";
import { refreshSiteAccount } from "./service/account.ts";
import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts";
import { ingestFeed } from "./service/feeds.ts";
import { refreshMetadata } from "./service/metadata.ts";
import { resolveRelease } from "./service/resolve.ts";
import { snapshotSite } from "./service/snapshot.ts";
import { liveSearch } from "./service/workbench.ts";
// 持久任务:输入只放业务 ID,处理器读业务表。外部请求失败的退避/熔断由适配器状态与业务表承担,
// 凭据失效返回正常结果(不重试);只有意外错误(DB、TMDB 网络)抛出交给引擎重试。
const NETWORK_RETRY = {
maximumAttempts: 5,
initialInterval: "1s",
backoffCoefficient: 2,
maximumInterval: "60s",
} as const;
const id = z.string().uuid();
/** Feed 疑似缺口时的补漏页数预算 */
export const BACKFILL_MAX_PAGES = 3;
export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRuntime): void {
registry.register(
defineTask({
name: "ingestFeed",
version: 1,
lane: "network",
inputSchema: z.object({ feedSourceId: id, manual: z.boolean().optional() }),
retryPolicy: NETWORK_RETRY,
handler: (t) => t.step.run("ingest", () => ingestFeed(rt(), t.input.feedSourceId, { manual: !!t.input.manual })),
}),
defineTask({
name: "resolveObservation",
version: 1,
lane: "network",
inputSchema: z
.object({
observationId: id.nullish(),
sourceReleaseId: id.nullish(),
force: z.boolean().optional(),
// 惰性识别(DEC-1):manual / search 总是查 TMDB;缺省按 force 视为 review / observed
trigger: z.enum(["observed", "review", "manual", "search"]).optional(),
})
.refine((v) => !!v.observationId || !!v.sourceReleaseId, { message: "需要 observationId 或 sourceReleaseId" }),
retryPolicy: NETWORK_RETRY,
handler: (t) =>
t.step.run("resolve", () =>
resolveRelease(rt(), {
observationId: t.input.observationId ?? undefined,
sourceReleaseId: t.input.sourceReleaseId ?? undefined,
force: t.input.force,
trigger: t.input.trigger,
}),
),
}),
// META-1 自动元数据补全(每个池条目一次,只出建议):经能力注册中心调用 metadata.enrich
defineTask({
name: AUTO_ENRICH_TASK,
version: 1,
lane: "network",
inputSchema: z.object({ poolEntryId: id }),
retryPolicy: { maximumAttempts: 1 },
handler: (t) => t.step.run("enrich", () => runAutoEnrich(rt().ctx, t.input.poolEntryId, t.runId, t.abortSignal)),
}),
defineTask({
name: "refreshMetadata",
version: 1,
lane: "network",
inputSchema: z.object({ mediaEntityId: id, full: z.boolean().optional() }),
retryPolicy: NETWORK_RETRY,
handler: (t) =>
t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full })),
}),
defineTask({
name: "searchSubscription",
version: 1,
lane: "network",
inputSchema: z.object({ subscriptionId: id, manual: z.boolean().optional() }),
retryPolicy: NETWORK_RETRY,
handler: (t) =>
t.step.run("search", () => searchSubscription(rt(), t.input.subscriptionId, { manual: !!t.input.manual })),
}),
defineTask({
name: "snapshotSite",
version: 1,
lane: "network",
inputSchema: z.object({
siteId: id,
kind: z.enum(["periodic", "backfill", "manual"]).default("periodic"),
maxPages: z.number().int().min(1).max(20).optional(),
}),
retryPolicy: NETWORK_RETRY,
handler: (t) =>
t.step.run("snapshot", () =>
snapshotSite(rt(), { siteId: t.input.siteId, kind: t.input.kind, maxPages: t.input.maxPages }),
),
}),
defineTask({
name: "refreshSiteAccount",
version: 1,
lane: "network",
inputSchema: z.object({ accountId: id, mode: z.enum(["login", "userData"]).default("login") }),
// 网络失败只记 last_error 不抛错;不自动重试(站点登录频繁重试有封号风险)
retryPolicy: { maximumAttempts: 1 },
handler: (t) =>
t.step.run("refresh", async () => {
const exists = await rt()
.ctx.db.selectFrom("site_account")
.select("id")
.where("id", "=", t.input.accountId)
.executeTakeFirst();
if (!exists) return { skipped: "not_found" };
return refreshSiteAccount(rt(), t.input.accountId, { mode: t.input.mode });
}),
}),
);
}
// ── 订阅主动搜索(W5-C,对照 MP SubscribeSearchOwner):按作品标题/原名/别名在已开启站点搜索 ──
/** 主动搜索周期缺省 24h;subscription.params.searchIntervalHours 覆盖(0 = 关闭周期搜索) */
export const DEFAULT_SEARCH_INTERVAL_HOURS = 24;
/** 每次最多用几个检索词(标题、原名、别名),控制站点请求量 */
export const SEARCH_MAX_KEYWORDS = 3;
export interface SubscriptionSearchResult {
status: "ok" | "skipped";
reason?: string;
keywords: string[];
sites: string[];
searches: number;
errors: { siteId: string; keyword: string; error: string }[];
nextSearchAt: string | null;
}
/**
* 主动搜索:对订阅站点范围内已开启且健康的站点,按标题/原名/别名经站点适配器 search(共享限流器、实时补查合并与失败退避,
* 记 site_snapshot kind=live_search);新结果 upsert 为 source_release 并发 release.observed → 识别 → release.resolved →
* evaluateAcquisition(与 RSS 同一路径);已识别的旧结果由本任务末尾入队的订阅重评覆盖。
*/
export async function searchSubscription(
rt: SourcesRuntime,
subscriptionId: string,
opts: { manual?: boolean } = {},
): Promise<SubscriptionSearchResult> {
const { db } = rt.ctx;
const none = { keywords: [], sites: [], searches: 0, errors: [], nextSearchAt: null };
const sub = await db.selectFrom("subscription").selectAll().where("id", "=", subscriptionId).executeTakeFirst();
if (!sub) return { status: "skipped", reason: "not_found", ...none };
if (!opts.manual && sub.status !== "active" && sub.status !== "pending")
return { status: "skipped", reason: `subscription_${sub.status}`, ...none };
if (!sub.media_entity_id) return { status: "skipped", reason: "no_entity", ...none };
const automation = await rt.ctx.settings.get("automation");
if (!opts.manual && (!automation.enabled || automation.readOnlyReconcile))
return { status: "skipped", reason: "automation_blocked", ...none };
const entity = await db
.selectFrom("media_entity")
.select(["id", "title", "original_title"])
.where("id", "=", sub.media_entity_id)
.executeTakeFirst();
if (!entity) return { status: "skipped", reason: "entity_not_found", ...none };
const aliases = await db
.selectFrom("title_alias")
.select(["alias", "kind"])
.where("media_entity_id", "=", entity.id)
.where("content_unit_id", "is", null)
.orderBy("created_at")
.limit(20)
.execute();
const seen = new Set<string>();
const keywords: string[] = [];
for (const k of [entity.title, entity.original_title, ...aliases.map((a) => a.alias)]) {
const v = (k ?? "").trim();
const norm = v.toLowerCase();
if (!v || seen.has(norm)) continue;
seen.add(norm);
keywords.push(v);
if (keywords.length >= SEARCH_MAX_KEYWORDS) break;
}
const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number; keywords?: string[] };
// 订阅的搜索关键词(MP keyword)追加在标题/别名之后,不占 SEARCH_MAX_KEYWORDS;结果同样经识别 → 评估
// (订阅私有规则组的包含 / 排除在评估时过滤,与 RSS 来的资源同一口径)
for (const k of params.keywords ?? []) {
const v = typeof k === "string" ? k.trim() : "";
if (!v || seen.has(v.toLowerCase())) continue;
seen.add(v.toLowerCase());
keywords.push(v);
}
let sq = db.selectFrom("site").selectAll().where("enabled", "=", true).where("health_status", "!=", "failing");
if (params.siteIds?.length) sq = sq.where("id", "in", params.siteIds);
const sites = await sq.orderBy("name").execute();
const errors: SubscriptionSearchResult["errors"] = [];
let searches = 0;
for (const site of sites) {
for (const kw of keywords) {
searches++;
const r = await liveSearch(rt, site, kw).catch((e) => ({ error: (e as Error).message, coalesced: false }));
if (r.error) errors.push({ siteId: site.id, keyword: kw, error: r.error });
}
}
const hours = params.searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS;
const now = rt.now();
const next = hours > 0 ? new Date(now.getTime() + hours * 3600_000) : null;
await db
.updateTable("subscription")
.set({ last_searched_at: now, next_search_at: next })
.where("id", "=", sub.id)
.execute();
// 已识别的旧结果:按订阅重评(新结果走 release.observed → resolved → evaluate)
await rt.ctx.engine
.enqueue(
"evaluateAcquisition",
{ subscriptionId: sub.id },
{ idempotencyKey: `search-eval:${sub.id}:${now.getTime()}`, traceId: sub.id },
)
.catch(() => {});
return {
status: "ok",
keywords,
sites: sites.map((s) => s.id),
searches,
errors,
nextSearchAt: next ? next.toISOString() : null,
};
}
type P = Record<string, unknown>;
export const sourcesOutboxRoutes: Record<string, OutboxRouteFn> = {
"feed.due": (ev) => {
const p = ev.payload as P;
return { task: "ingestFeed", input: { feedSourceId: p.feedId, manual: p.reason === "manual" } };
},
"release.observed": (ev) => {
const p = ev.payload as P;
// 标题更新(isNew=false):按新标题重新解析并重新评估,已识别的也不短路(ID-2);触发来源仍是 observed(DEC-1 不变)
const retitled = p.isNew === false;
return {
task: "resolveObservation",
input: {
sourceReleaseId: p.sourceReleaseId,
observationId: p.observationId,
...(retitled ? { force: true, trigger: "observed" } : {}),
},
};
},
"feed.gap_suspected": (ev) => {
const p = ev.payload as P;
if (!p.siteId) return null;
return { task: "snapshotSite", input: { siteId: p.siteId, kind: "backfill", maxPages: BACKFILL_MAX_PAGES } };
},
};