feat: 业务节点发生命周期事件并调用链式钩子
- 订阅已建 / 已改 / 已完成 / 已取消,下载完成 / 失败 / 移除,整理结束(统一 organization.changed 终态), 刮削完成 / 失败,作品进入关注,元数据已刷新(任务调用方),季集结构切换,站点不可达 / 恢复: 都在调用方事务内写 outbox,带幂等键 - 钩子挂点:name.recognize(resolveRelease / identifyParsed)、organize.rename 与 organize.intercept(planImport)、 resource.selection(订阅重评)、subscription.completion.check(completeSubscription) - 测试:各节点事件 payload 与去重、整理改名 / 拦截 / 出错不影响整理、名称识别改写、资源选择、订阅完成拦截 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
7aa5182f3d
commit
079c3eafe9
18 files changed
+1049
-29
No files matched your search
@@ -14,6 +14,7 @@ import type { SubscriptionRow } from "@umt/db";
|
||||
import type { Context } from "hono";
|
||||
import type { AuthEnv } from "../../../http/auth.ts";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
|
||||
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { ensureSubscriptionSlot } from "../services/acquisition.ts";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
slotsOfSub,
|
||||
subscriptionCoverage,
|
||||
} from "../services/coverage.ts";
|
||||
import { emitSubscriptionEvent } from "../services/lifecycle-events.ts";
|
||||
import { loadRuleCatalog } from "../services/rules.ts";
|
||||
import { CALENDAR_MAX_DAYS, subscriptionCalendar } from "../services/subscription-calendar.ts";
|
||||
import { resetSubscription } from "../services/subscription-reset.ts";
|
||||
@@ -376,6 +378,9 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
// 订阅总有自己的收录版本:策略模板,没有就建一个默认版本(质量预设随后由 applyDerived 写入)
|
||||
await ensureSubscriptionSlot(trx, r);
|
||||
await applyDerived(trx, r, norm, now);
|
||||
// 生命周期广播(ADR-006):订阅已建;关联作品 = 作品进入关注
|
||||
await emitSubscriptionEvent(d, trx, "subscription.created", r);
|
||||
if (r.media_entity_id) await emitMediaWatched(d.ctx, trx, r.media_entity_id, "subscription", r.id);
|
||||
return r;
|
||||
});
|
||||
if (row.media_entity_id) {
|
||||
@@ -436,6 +441,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r);
|
||||
if (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur));
|
||||
else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now());
|
||||
await emitSubscriptionChange(trx, cur, r, Object.keys(body));
|
||||
return r;
|
||||
});
|
||||
invalidateCoverage(r.id);
|
||||
@@ -446,6 +452,21 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
{ tag: TAG },
|
||||
);
|
||||
|
||||
/** 生命周期广播(ADR-006):改成 cancelled 发「已取消」,其余改动发「已改」 */
|
||||
const emitSubscriptionChange = async (
|
||||
trx: Exec,
|
||||
before: SubscriptionRow,
|
||||
after: SubscriptionRow,
|
||||
changes: string[],
|
||||
reason?: string,
|
||||
) => {
|
||||
if (after.status === "cancelled" && before.status !== "cancelled")
|
||||
await emitSubscriptionEvent(d, trx, "subscription.cancelled", after, {
|
||||
reason: reason ?? after.status_reason ?? "",
|
||||
});
|
||||
else if (changes.length) await emitSubscriptionEvent(d, trx, "subscription.updated", after, { changes });
|
||||
};
|
||||
|
||||
// ── 状态动作 ──
|
||||
const setStatus = async (
|
||||
sub: SubscriptionRow,
|
||||
@@ -456,16 +477,22 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
const now = d.ports.now();
|
||||
const searchOn = (subParams(sub).searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS) > 0;
|
||||
const r = await db()
|
||||
.updateTable("subscription")
|
||||
.set({
|
||||
status: to,
|
||||
status_reason: reason,
|
||||
...(to === "paused" || to === "cancelled" ? { next_search_at: null } : {}),
|
||||
...(extra.nextSearchNow && searchOn ? { next_search_at: now } : {}),
|
||||
})
|
||||
.where("id", "=", sub.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
.transaction()
|
||||
.execute(async (trx) => {
|
||||
const r = await trx
|
||||
.updateTable("subscription")
|
||||
.set({
|
||||
status: to,
|
||||
status_reason: reason,
|
||||
...(to === "paused" || to === "cancelled" ? { next_search_at: null } : {}),
|
||||
...(extra.nextSearchNow && searchOn ? { next_search_at: now } : {}),
|
||||
})
|
||||
.where("id", "=", sub.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await emitSubscriptionChange(trx, sub, r, ["status"], reason);
|
||||
return r;
|
||||
});
|
||||
// 私有规则组随订阅删除(取消),重新订阅时按 params 重建
|
||||
if (to === "cancelled" || sub.status === "cancelled") await syncPrivateRuleGroup(db(), r, now);
|
||||
invalidateCoverage(sub.id);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AcquisitionPolicyRules, AcquisitionPriority, CandidateEvaluation, SlotTemplate } from "@umt/contracts";
|
||||
import {
|
||||
type AcquisitionPolicyRules,
|
||||
type AcquisitionPriority,
|
||||
type CandidateEvaluation,
|
||||
lifecycleKey,
|
||||
type SlotTemplate,
|
||||
} from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow, MediaEntityRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
@@ -23,6 +29,7 @@ import {
|
||||
unitsOverlap,
|
||||
} from "@umt/domain";
|
||||
import { toContractSlotEvaluation } from "@umt/mapping";
|
||||
import { hasHook, rewriteHook } from "../../../hooks/index.ts";
|
||||
import { hashJson, jsonb, startOfDay } from "../util.ts";
|
||||
import {
|
||||
type AoDeps,
|
||||
@@ -38,6 +45,7 @@ import {
|
||||
recordCandidate,
|
||||
} from "./common.ts";
|
||||
import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts";
|
||||
import { emitSubscriptionEvent } from "./lifecycle-events.ts";
|
||||
import { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog } from "./rules.ts";
|
||||
import {
|
||||
@@ -430,7 +438,12 @@ async function reevaluate(c: Ctx, input: EvaluateInput): Promise<EvaluateOutcome
|
||||
focus,
|
||||
rels.filter((x) => !seen.has(x.id)),
|
||||
);
|
||||
const ordered = await rankReleases(c, entity, sub, focus[0], [...deferred, ...fresh]);
|
||||
const ranked = await rankReleases(c, entity, sub, focus[0], [...deferred, ...fresh]);
|
||||
const ordered = await selectResources(c, ranked, {
|
||||
mediaEntityId: entity.id,
|
||||
subscriptionId: sub?.id ?? null,
|
||||
slotId: input.acquisitionSlotId ?? null,
|
||||
});
|
||||
const scope = input.acquisitionSlotId ? { slotId: input.acquisitionSlotId } : sub ? { subscriptionId: sub.id } : {};
|
||||
for (const r of ordered) await evaluateRelease(c, r, [], scope);
|
||||
return c.out;
|
||||
@@ -500,6 +513,33 @@ async function rankReleases(
|
||||
})).map((x) => byParsed.get(x.candidate)!);
|
||||
}
|
||||
|
||||
/**
|
||||
* 链式钩子 resource.selection(ADR-006):在择优排序之后调整顺序或过滤。钩子只能重排 / 删减,
|
||||
* 返回里不认识的 ID 忽略、重复的只取第一次;没有钩子时原样返回。
|
||||
*/
|
||||
export async function selectResources(
|
||||
c: Ctx,
|
||||
rels: SourceReleaseRow[],
|
||||
context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null },
|
||||
): Promise<SourceReleaseRow[]> {
|
||||
if (!hasHook(c.d.ctx, "resource.selection") || rels.length === 0) return rels;
|
||||
const byId = new Map(rels.map((r) => [r.id, r]));
|
||||
const picked = await rewriteHook(
|
||||
c.d.ctx,
|
||||
"resource.selection",
|
||||
rels.map((r) => ({ sourceReleaseId: r.id, title: r.title, parsed: parsedOf(r) })),
|
||||
context,
|
||||
);
|
||||
const out: SourceReleaseRow[] = [];
|
||||
for (const p of picked) {
|
||||
const r = byId.get(p.sourceReleaseId);
|
||||
if (!r) continue;
|
||||
byId.delete(p.sourceReleaseId);
|
||||
out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 评估范围:只评某个收录版本,或只评某个订阅的收录版本;缺省评作品的全部收录版本 */
|
||||
interface EvalScope {
|
||||
slotId?: string | undefined;
|
||||
@@ -600,8 +640,9 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
policy_revision_id: pol.row.id,
|
||||
created_by: `policy:${pol.row.policy_key}`,
|
||||
})
|
||||
.returning("id")
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await emitSubscriptionEvent(c.d, trx, "subscription.created", sub);
|
||||
await createSlotsFromTemplates(trx, {
|
||||
entityId: entity.id,
|
||||
subscriptionId: sub.id,
|
||||
@@ -634,6 +675,10 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
.where("id", "=", s.id)
|
||||
.execute();
|
||||
await clearSubscriptionDeferrals(db, s.id);
|
||||
await emitSubscriptionEvent(c.d, db, "subscription.completed", s, {
|
||||
reason: "stop_after_reached",
|
||||
key: lifecycleKey("subscription.completed", s.id, `stop_after:${stopAfter}`),
|
||||
});
|
||||
s.status = "completed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping";
|
||||
import { withAttemptToken } from "@umt/tasks";
|
||||
import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts";
|
||||
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
|
||||
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import { parsedOf } from "./acquisition.ts";
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
timeSlot,
|
||||
upsertDecision,
|
||||
} from "./common.ts";
|
||||
import { emitDownloadLifecycle } from "./lifecycle-events.ts";
|
||||
import { matchesQbSource, qbSourceTags } from "./organize-config.ts";
|
||||
import { hnrSatisfied, retentionElapsed } from "./retention.ts";
|
||||
import {
|
||||
@@ -212,6 +214,8 @@ async function emitStatus(
|
||||
? { idempotencyKey: generation > 0 ? `download.completed:${id}:g${generation}` : `download.completed:${id}` }
|
||||
: {},
|
||||
);
|
||||
// 生命周期广播(ADR-006):下载已完成 / 失败 / 已移除
|
||||
await emitDownloadLifecycle(d, exec, id, from, to);
|
||||
}
|
||||
|
||||
// ── 提交 ──
|
||||
@@ -269,7 +273,10 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
||||
.executeTakeFirstOrThrow();
|
||||
// 开始下载 = 作品进入关注范围:从没刷新过元数据的(自动建档时不在关注范围)补排一次 full 刷新(集数两层,见 watched.ts)
|
||||
const watchedEntity = ev.mediaEntityId ?? rel.media_entity_id;
|
||||
if (watchedEntity) await enqueueWatchedRefresh(d.ctx, watchedEntity, "download");
|
||||
if (watchedEntity) {
|
||||
await enqueueWatchedRefresh(d.ctx, watchedEntity, "download");
|
||||
await emitMediaWatched(d.ctx, db, watchedEntity, "download", plan.id);
|
||||
}
|
||||
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
|
||||
const client = await d.ports.downloader(topo.inst);
|
||||
const tag = topo.inst.managed_tag;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { type DownloadStatus, lifecycleKey, type OrganizationStatus } from "@umt/contracts";
|
||||
import type { ScrapeRecordRow, SubscriptionRow } from "@umt/db";
|
||||
import type { AoDeps, Exec } from "./common.ts";
|
||||
|
||||
// 生命周期广播事件(ADR-006)在本模块的发出点:都在调用方的业务事务里写 outbox,订阅方按 ID 回查业务表。
|
||||
|
||||
type SubEvent = "subscription.created" | "subscription.updated" | "subscription.completed" | "subscription.cancelled";
|
||||
|
||||
type SubRef = Pick<SubscriptionRow, "id" | "media_entity_id" | "media_kind" | "status" | "origin">;
|
||||
|
||||
/** 订阅已建 / 已改 / 已完成 / 已取消 */
|
||||
export async function emitSubscriptionEvent(
|
||||
d: AoDeps,
|
||||
exec: Exec,
|
||||
type: SubEvent,
|
||||
sub: SubRef,
|
||||
extra: { reason?: string; changes?: string[]; key?: string } = {},
|
||||
): Promise<void> {
|
||||
const ref = { subscriptionId: sub.id, mediaEntityId: sub.media_entity_id, mediaKind: sub.media_kind };
|
||||
const opts = extra.key ? { idempotencyKey: extra.key } : {};
|
||||
switch (type) {
|
||||
case "subscription.created":
|
||||
return d.ctx.outbox.emit(
|
||||
exec,
|
||||
type,
|
||||
{ ...ref, origin: sub.origin, status: sub.status },
|
||||
{ idempotencyKey: lifecycleKey(type, sub.id) },
|
||||
);
|
||||
case "subscription.updated":
|
||||
return d.ctx.outbox.emit(exec, type, { ...ref, status: sub.status, changes: extra.changes ?? [] }, opts);
|
||||
case "subscription.completed":
|
||||
case "subscription.cancelled":
|
||||
return d.ctx.outbox.emit(exec, type, { ...ref, reason: extra.reason ?? "" }, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const DOWNLOAD_END: Partial<Record<DownloadStatus, "download.completed" | "download.failed" | "download.removed">> = {
|
||||
completed: "download.completed",
|
||||
failed: "download.failed",
|
||||
removed: "download.removed",
|
||||
};
|
||||
|
||||
/** 下载状态进入 completed / failed / removed 时发对应的生命周期事件(每代每种只发一次) */
|
||||
export async function emitDownloadLifecycle(
|
||||
d: AoDeps,
|
||||
exec: Exec,
|
||||
id: string,
|
||||
from: DownloadStatus | null,
|
||||
to: DownloadStatus,
|
||||
): Promise<void> {
|
||||
const type = DOWNLOAD_END[to];
|
||||
if (!type || from === to) return;
|
||||
const row = await exec
|
||||
.selectFrom("download_task as t")
|
||||
.leftJoin("acquisition_plan as p", "p.id", "t.acquisition_plan_id")
|
||||
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||
.select([
|
||||
"t.name",
|
||||
"t.generation",
|
||||
"t.acquisition_plan_id",
|
||||
"t.media_entity_id",
|
||||
"s.media_entity_id as slot_entity",
|
||||
])
|
||||
.where("t.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
if (!row) return;
|
||||
const ref = {
|
||||
downloadTaskId: id,
|
||||
planId: row.acquisition_plan_id,
|
||||
mediaEntityId: row.media_entity_id ?? row.slot_entity ?? null,
|
||||
name: row.name,
|
||||
generation: row.generation,
|
||||
};
|
||||
const key = { idempotencyKey: lifecycleKey(type, id, `g${row.generation}`) };
|
||||
if (type === "download.completed") await d.ctx.outbox.emit(exec, type, ref, key);
|
||||
else await d.ctx.outbox.emit(exec, type, { ...ref, from }, key);
|
||||
}
|
||||
|
||||
const FINISHED: readonly OrganizationStatus[] = ["succeeded", "partial", "failed"];
|
||||
|
||||
/** 整理记录进入终态时发 organization.finished(按 change_seq 去重:同一次状态变化只发一次) */
|
||||
export async function emitOrganizationFinished(
|
||||
d: AoDeps,
|
||||
exec: Exec,
|
||||
r: {
|
||||
id: string;
|
||||
status: OrganizationStatus;
|
||||
action: string;
|
||||
trigger: string;
|
||||
media_entity_id: string | null;
|
||||
download_task_id: string | null;
|
||||
reason: string | null;
|
||||
change_seq: number | string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!FINISHED.includes(r.status)) return;
|
||||
await d.ctx.outbox.emit(
|
||||
exec,
|
||||
"organization.finished",
|
||||
{
|
||||
recordId: r.id,
|
||||
outcome: r.status as "succeeded" | "partial" | "failed",
|
||||
action: r.action as never,
|
||||
trigger: r.trigger as never,
|
||||
mediaEntityId: r.media_entity_id,
|
||||
downloadTaskId: r.download_task_id,
|
||||
reason: r.reason,
|
||||
changeSeq: String(r.change_seq),
|
||||
},
|
||||
{ idempotencyKey: lifecycleKey("organization.finished", r.id, String(r.change_seq)) },
|
||||
);
|
||||
}
|
||||
|
||||
/** 刮削记录结束:failed → scrape.failed;succeeded / partial / skipped → scrape.completed */
|
||||
export async function emitScrapeFinished(d: AoDeps, row: ScrapeRecordRow): Promise<void> {
|
||||
if (row.status === "queued" || row.status === "running") return;
|
||||
const ref = {
|
||||
scrapeRecordId: row.id,
|
||||
mediaEntityId: row.media_entity_id,
|
||||
organizationRecordId: row.organization_record_id,
|
||||
trigger: row.trigger,
|
||||
status: row.status,
|
||||
written: row.written,
|
||||
failed: row.failed,
|
||||
};
|
||||
const at = row.finished_at?.getTime() ?? 0;
|
||||
if (row.status === "failed")
|
||||
await d.ctx.outbox.emit(
|
||||
d.ctx.db,
|
||||
"scrape.failed",
|
||||
{ ...ref, reason: row.reason },
|
||||
{
|
||||
idempotencyKey: lifecycleKey("scrape.failed", row.id, at),
|
||||
},
|
||||
);
|
||||
else
|
||||
await d.ctx.outbox.emit(d.ctx.db, "scrape.completed", ref, {
|
||||
idempotencyKey: lifecycleKey("scrape.completed", row.id, at),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import type { OrganizePlan } from "@umt/domain";
|
||||
import { hasHook, interceptHook, rewriteHook } from "../../../hooks/index.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
|
||||
// 整理计划上的链式钩子(ADR-006):organize.rename 改写主视频目标文件名、organize.intercept 拒绝整理。
|
||||
// 都在计划生成之后、写整理记录之前调用;没有注册钩子时什么都不做。
|
||||
|
||||
const BAD_NAME = /[\\/\0]/;
|
||||
const usableName = (n: unknown): boolean =>
|
||||
typeof n === "string" && n.trim() !== "" && !BAD_NAME.test(n) && n !== "." && n !== "..";
|
||||
|
||||
/**
|
||||
* organize.rename:逐个主视频把模板生成的目标文件名交给钩子改写,同主干的附属文件(字幕、NFO 等)跟着换主干。
|
||||
* 只改文件名不改目录;返回带路径分隔符或空名的钩子结果跳过。改名后撞名的项转 hold + duplicate_target(与保留原名同一处理)。
|
||||
* 改名后的目标是否已存在由执行阶段按冲突处理(计划期的存在性探测在改名之前)。
|
||||
*/
|
||||
export async function applyRenameHook(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
plan: OrganizePlan,
|
||||
base: { mediaEntityId: string; mediaType: "movie" | "tv" },
|
||||
): Promise<number> {
|
||||
if (!hasHook(ctx, "organize.rename")) return 0;
|
||||
const renamed = new Map<string, string>();
|
||||
for (const main of plan.files.filter((f) => f.role === "main")) {
|
||||
const name = basename(main.dst);
|
||||
// 返回带路径分隔符或空名的钩子结果跳过(记日志),其余钩子照常
|
||||
const next = await rewriteHook(
|
||||
ctx,
|
||||
"organize.rename",
|
||||
name,
|
||||
{
|
||||
targetPath: main.dst,
|
||||
sourcePath: main.src,
|
||||
mediaEntityId: base.mediaEntityId,
|
||||
mediaType: base.mediaType,
|
||||
season: main.season ?? null,
|
||||
episodes: main.episodes ?? null,
|
||||
},
|
||||
usableName,
|
||||
);
|
||||
if (next === name) continue;
|
||||
const oldStem = name.slice(0, name.length - extname(name).length);
|
||||
const newStem = next.slice(0, next.length - extname(next).length);
|
||||
const to = join(dirname(main.dst), next);
|
||||
renamed.set(main.dst, to);
|
||||
main.dst = to;
|
||||
for (const f of plan.files) {
|
||||
if (f.mainSrc !== main.src) continue;
|
||||
const side = basename(f.dst);
|
||||
if (!side.startsWith(oldStem)) continue;
|
||||
const nd = join(dirname(f.dst), newStem + side.slice(oldStem.length));
|
||||
renamed.set(f.dst, nd);
|
||||
f.dst = nd;
|
||||
}
|
||||
}
|
||||
if (renamed.size === 0) return 0;
|
||||
for (const c of plan.conflicts) if (c.dst && renamed.has(c.dst)) c.dst = renamed.get(c.dst)!;
|
||||
for (const pc of plan.preconditions)
|
||||
if (pc.kind === "target_absent" || pc.kind === "verify_after_copy")
|
||||
pc.paths = pc.paths.map((p) => renamed.get(p) ?? p);
|
||||
const byDst = new Map<string, OrganizePlan["files"]>();
|
||||
for (const f of plan.files) byDst.set(f.dst, [...(byDst.get(f.dst) ?? []), f]);
|
||||
for (const [dst, fs] of byDst) {
|
||||
if (fs.length < 2 || plan.conflicts.some((c) => c.kind === "duplicate_target" && c.dst === dst)) continue;
|
||||
for (const f of fs) f.action = "hold";
|
||||
plan.conflicts.push({ kind: "duplicate_target", blocking: true, dst, detail: fs.map((f) => f.src).join(" | ") });
|
||||
}
|
||||
plan.requiresApproval = plan.conflicts.some((c) => c.blocking);
|
||||
return renamed.size;
|
||||
}
|
||||
|
||||
/** organize.intercept:返回拒绝原因(含拒绝的钩子名),放行返回 null */
|
||||
export async function organizeIntercept(
|
||||
ctx: Pick<ServerContext, "hooks">,
|
||||
plan: OrganizePlan,
|
||||
base: {
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
action: string;
|
||||
trigger: string;
|
||||
downloadTaskId: string | null;
|
||||
},
|
||||
) {
|
||||
return interceptHook(ctx, "organize.intercept", () => ({
|
||||
...base,
|
||||
files: plan.files.map((f) => ({ src: f.src, dst: f.dst, kind: f.kind, role: f.role })),
|
||||
}));
|
||||
}
|
||||
@@ -42,10 +42,12 @@ import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
|
||||
import { classifyEntity } from "./classification.ts";
|
||||
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
|
||||
import { applySlotEvent } from "./downloads.ts";
|
||||
import { emitOrganizationFinished } from "./lifecycle-events.ts";
|
||||
import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts";
|
||||
import { resolveOrganizeSource } from "./organize-config.ts";
|
||||
import { PathGuardError } from "./organize-guard.ts";
|
||||
import { successfulOrganizeCounts } from "./organize-history.ts";
|
||||
import { applyRenameHook, organizeIntercept } from "./organize-hooks.ts";
|
||||
import { planTransfer } from "./organize-transfer.ts";
|
||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||
@@ -925,8 +927,19 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
plan.requiresApproval = true;
|
||||
}
|
||||
}
|
||||
// 链式钩子 organize.rename(ADR-006):模板命名时可改写主视频目标文件名
|
||||
if (!inPlace && !keepNames) await applyRenameHook(d.ctx, plan, { mediaEntityId: entity.id, mediaType });
|
||||
if (keepNames && !inPlace) keepOriginalNames(plan);
|
||||
if (req.keepBoth && !inPlace) await keepBothTargets(plan);
|
||||
// 链式钩子 organize.intercept:返回原因即拒绝本次整理(记为失败)
|
||||
const veto = await organizeIntercept(d.ctx, plan, {
|
||||
mediaEntityId: entity.id,
|
||||
mediaType,
|
||||
action,
|
||||
trigger: req.trigger,
|
||||
downloadTaskId: dt?.id ?? null,
|
||||
});
|
||||
if (veto) return fail(`intercepted:${veto.reason}`, { hook: veto.by, reason: veto.reason }, entity.id);
|
||||
const frozen: FrozenPlan = {
|
||||
version: 1,
|
||||
...(req.scrapeAfterImport !== undefined ? { scrapeAfterImport: req.scrapeAfterImport } : {}),
|
||||
@@ -1288,7 +1301,7 @@ export async function writeFailedRecord(
|
||||
export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promise<void> {
|
||||
const r = await exec
|
||||
.selectFrom("organization_record")
|
||||
.select(["status", "change_seq", "media_entity_id"])
|
||||
.select(["id", "status", "change_seq", "media_entity_id", "action", "trigger", "download_task_id", "reason"])
|
||||
.where("id", "=", recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
await d.ctx.outbox.emit(exec, "organization.changed", {
|
||||
@@ -1297,6 +1310,8 @@ export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promi
|
||||
changeSeq: String(r.change_seq),
|
||||
mediaEntityId: r.media_entity_id,
|
||||
});
|
||||
// 终态另发生命周期广播(ADR-006):整理成功 / 部分成功 / 失败
|
||||
await emitOrganizationFinished(d, exec, r);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../../sources-identity/service/episode-resolution.ts";
|
||||
import { errMessage, jsonb } from "../util.ts";
|
||||
import type { AoDeps } from "./common.ts";
|
||||
import { emitScrapeFinished } from "./lifecycle-events.ts";
|
||||
import {
|
||||
type EntityForNfo,
|
||||
episodeMetadataMissing,
|
||||
@@ -930,6 +931,7 @@ export async function runScrapeRecord(
|
||||
.execute()
|
||||
.catch(() => {});
|
||||
if (dirs.size) void d.ctx.events.publish("files.changed", { rootId: rec.root_id, dirs: [...dirs] });
|
||||
await emitScrapeFinished(d, row);
|
||||
return scrapeRecordView(row);
|
||||
};
|
||||
const fail = (reason: string) => finish({ status: "failed", reason, files: [] });
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AcquisitionSlot, SubscriptionParams, SubscriptionProgress, UnitProgressState } from "@umt/contracts";
|
||||
import {
|
||||
type AcquisitionSlot,
|
||||
lifecycleKey,
|
||||
type SubscriptionParams,
|
||||
type SubscriptionProgress,
|
||||
type UnitProgressState,
|
||||
} from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
@@ -20,8 +26,10 @@ import {
|
||||
type UnitRange,
|
||||
} from "@umt/domain";
|
||||
import { slotSpecFromRow } from "@umt/mapping";
|
||||
import { interceptHook } from "../../../hooks/index.ts";
|
||||
import { errMessage, jsonb, toApi } from "../util.ts";
|
||||
import { type AoDeps, type Exec, upsertDecision } from "./common.ts";
|
||||
import { emitSubscriptionEvent } from "./lifecycle-events.ts";
|
||||
import { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||
|
||||
@@ -868,6 +876,18 @@ export async function completeSubscription(
|
||||
reason: string,
|
||||
progress: Record<string, unknown> = {},
|
||||
): Promise<boolean> {
|
||||
// 链式钩子 subscription.completion.check(ADR-006):追加完成条件,拒绝时订阅继续追踪
|
||||
const veto = await interceptHook(d.ctx, "subscription.completion.check", () => ({
|
||||
subscriptionId: sub.id,
|
||||
mediaEntityId: sub.media_entity_id,
|
||||
mediaKind: sub.media_kind,
|
||||
reason,
|
||||
progress,
|
||||
}));
|
||||
if (veto) {
|
||||
d.ctx.logger.info({ subscriptionId: sub.id, reason, veto }, "订阅完成被钩子拦下,继续追踪");
|
||||
return false;
|
||||
}
|
||||
const done = await trx
|
||||
.updateTable("subscription")
|
||||
.set({ status: "completed", status_reason: reason, next_search_at: null })
|
||||
@@ -877,7 +897,7 @@ export async function completeSubscription(
|
||||
.executeTakeFirst();
|
||||
if (!done) return false;
|
||||
await clearSubscriptionDeferrals(trx, done.id);
|
||||
await trx
|
||||
const history = await trx
|
||||
.insertInto("subscription_history")
|
||||
.values({
|
||||
subscription_id: done.id,
|
||||
@@ -890,7 +910,12 @@ export async function completeSubscription(
|
||||
progress: jsonb(progress),
|
||||
completed_at: d.ports.now(),
|
||||
})
|
||||
.execute();
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await emitSubscriptionEvent(d, trx, "subscription.completed", done, {
|
||||
reason,
|
||||
key: lifecycleKey("subscription.completed", done.id, history.id),
|
||||
});
|
||||
d.ctx.logger.info({ subscriptionId: sub.id, reason }, "订阅完成,已写历史");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { capabilitiesOf, LOGIN_KINDS } from "../site-kinds.ts";
|
||||
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
||||
import { cookieFromBrowser, writeBrowserCookie } from "./credentials.ts";
|
||||
import { emitSiteReachability } from "./lifecycle-events.ts";
|
||||
import { browserOnlyCookiesOf, resolveUserAgent, type UserAgentDefaults } from "./user-agent.ts";
|
||||
|
||||
// W9-A 站点中心(docs/design/site-center.md):凭证写入、刷新登录(账密 takelogin / Cookie / API Key 校验)、
|
||||
@@ -865,12 +866,20 @@ export async function checkSiteHealth(
|
||||
? (HEALTH_OF[status] ?? "degraded")
|
||||
: site.health_status;
|
||||
const last = { ok, status, latencyMs, message, detail };
|
||||
// 生命周期广播(ADR-006):登录失效 / 访问不了 ⇄ 正常,只在跨越时发
|
||||
const bad = (h: string, st: unknown) => h === "failing" || st === "unreachable";
|
||||
const wasBad = bad(site.health_status, (site.last_health as { status?: unknown } | null)?.status);
|
||||
const isBad = site.enabled && bad(healthStatus, status);
|
||||
await rt.ctx.db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.updateTable("site")
|
||||
.set({ last_health: jsonb(last), last_health_at: now, health_status: healthStatus })
|
||||
.where("id", "=", site.id)
|
||||
.execute();
|
||||
if (isBad && !wasBad)
|
||||
await emitSiteReachability(rt.ctx, trx, site.id, { reachable: false, reason: status, message });
|
||||
else if (wasBad && healthStatus === "healthy")
|
||||
await emitSiteReachability(rt.ctx, trx, site.id, { reachable: true, reason: "health_check" });
|
||||
// 首页显示已登录:顺手记上次登录
|
||||
if (ok) {
|
||||
await trx
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
TmdbError,
|
||||
type TmdbRichDetail,
|
||||
} from "@umt/adapters";
|
||||
import type {
|
||||
EpisodeGroupList,
|
||||
EpisodeGroupSetResult,
|
||||
EpisodeStructureCurrent,
|
||||
EpisodeStructureSeason,
|
||||
EpisodeStructureSeasons,
|
||||
import {
|
||||
type EpisodeGroupList,
|
||||
type EpisodeGroupSetResult,
|
||||
type EpisodeStructureCurrent,
|
||||
type EpisodeStructureSeason,
|
||||
type EpisodeStructureSeasons,
|
||||
lifecycleKey,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
@@ -357,6 +358,13 @@ export async function setEpisodeGroup(
|
||||
},
|
||||
);
|
||||
const reresolve = await reresolveEntityEpisodes(rt.ctx, trx, tv.id);
|
||||
// 生命周期广播(ADR-006):季集结构已切换
|
||||
await rt.ctx.outbox.emit(
|
||||
trx,
|
||||
"media.episode_structure_changed",
|
||||
{ mediaEntityId: tv.id, fromGroupId: fromId, toGroupId: groupId },
|
||||
{ idempotencyKey: lifecycleKey("media.episode_structure_changed", tv.id, now.toISOString()) },
|
||||
);
|
||||
return { ...r, reresolve };
|
||||
});
|
||||
let refresh = "refreshed";
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { lifecycleKey } from "@umt/contracts";
|
||||
import type { DB, Kysely, Transaction } from "@umt/db";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
|
||||
// 生命周期广播事件(ADR-006)在来源与识别侧的发出点。元数据刷新、进入关注的判定本身在 metadata.ts / watched.ts,
|
||||
// 这里只在调用方发事件,不改那两处。
|
||||
|
||||
type Exec = Kysely<DB> | Transaction<DB>;
|
||||
|
||||
/**
|
||||
* 作品进入关注:建订阅、开始下载、用户搜索 / 手动认定时由调用方发。
|
||||
* 同一作品同一触发对象(订阅、下载计划、资源)只发一次;订阅方自己判断作品是否已经处理过。
|
||||
*/
|
||||
export async function emitMediaWatched(
|
||||
ctx: Pick<ServerContext, "outbox">,
|
||||
exec: Exec,
|
||||
mediaEntityId: string,
|
||||
reason: string,
|
||||
subjectId: string,
|
||||
): Promise<void> {
|
||||
await ctx.outbox.emit(
|
||||
exec,
|
||||
"media.watched",
|
||||
{ mediaEntityId, reason },
|
||||
{ idempotencyKey: lifecycleKey("media.watched", mediaEntityId, reason, subjectId) },
|
||||
);
|
||||
}
|
||||
|
||||
/** 作品元数据刷新完成(刷新任务的调用方发;按任务 run 去重) */
|
||||
export async function emitMetadataRefreshed(
|
||||
ctx: Pick<ServerContext, "outbox">,
|
||||
exec: Exec,
|
||||
input: { mediaEntityId: string; full: boolean; changedFields: string[]; runId: string },
|
||||
): Promise<void> {
|
||||
await ctx.outbox.emit(
|
||||
exec,
|
||||
"media.metadata_refreshed",
|
||||
{ mediaEntityId: input.mediaEntityId, full: input.full, changedFields: input.changedFields },
|
||||
{ idempotencyKey: lifecycleKey("media.metadata_refreshed", input.mediaEntityId, input.runId) },
|
||||
);
|
||||
}
|
||||
|
||||
/** 站点不可达 / 恢复。只在状态跨越时由调用方发(连续失败到阈值、登录失效、检测结果变化) */
|
||||
export async function emitSiteReachability(
|
||||
ctx: Pick<ServerContext, "outbox">,
|
||||
exec: Exec,
|
||||
siteId: string,
|
||||
change: { reachable: false; reason: string; message: string | null } | { reachable: true; reason: string },
|
||||
key?: string,
|
||||
): Promise<void> {
|
||||
const opts = key ? { idempotencyKey: key } : {};
|
||||
if (change.reachable) await ctx.outbox.emit(exec, "site.recovered", { siteId, reason: change.reason }, opts);
|
||||
else
|
||||
await ctx.outbox.emit(exec, "site.unreachable", { siteId, reason: change.reason, message: change.message }, opts);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
toParsedColumns,
|
||||
toReleaseResolutionStatus,
|
||||
} from "@umt/mapping";
|
||||
import { rewriteHook } from "../../../hooks/index.ts";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts";
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
openGroupDecision,
|
||||
refreshGroupDecision,
|
||||
} from "./identity-decisions.ts";
|
||||
import { emitMediaWatched } from "./lifecycle-events.ts";
|
||||
import { loadSubject, searchBangumi } from "./meta-subjects.ts";
|
||||
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
|
||||
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||
@@ -480,7 +482,18 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
||||
mediaEntityId: release.media_entity_id,
|
||||
};
|
||||
}
|
||||
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
|
||||
// 链式钩子 name.recognize(ADR-006):可改写解析结果;没有钩子时原样
|
||||
const parsed = await rewriteHook(
|
||||
ctx,
|
||||
"name.recognize",
|
||||
parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}),
|
||||
() => ({
|
||||
title: release.title,
|
||||
subtitle: release.subtitle ?? null,
|
||||
sourceReleaseId: release.id,
|
||||
trigger: input.trigger ?? (input.force ? "review" : "observed"),
|
||||
}),
|
||||
);
|
||||
const cols = toParsedColumns(parsed);
|
||||
// 站点给出的外部 ID(列表 / 详情页的 IMDb、豆瓣、TMDB 链接)作为显式 ID 证据参与打分
|
||||
const siteIds = siteExplicitIds(release.raw);
|
||||
@@ -909,6 +922,9 @@ async function bindAuto(
|
||||
} else if (userAsked && result.mediaEntityId) {
|
||||
await enqueueWatchedRefresh(ctx, result.mediaEntityId, `lookup:${lookup}`);
|
||||
}
|
||||
// 用户搜索 / 手动认定 = 作品进入关注(ADR-006 生命周期广播)
|
||||
if (userAsked && result.mediaEntityId)
|
||||
await emitMediaWatched(ctx, ctx.db, result.mediaEntityId, `lookup:${lookup}`, release.id);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof IdentityConflictError && attempt < 2) continue;
|
||||
@@ -1160,13 +1176,20 @@ export interface NameIdentifyResult {
|
||||
*/
|
||||
export async function identifyParsed(
|
||||
rt: SourcesRuntime,
|
||||
parsed: ParsedRelease,
|
||||
input: ParsedRelease,
|
||||
opts: { trigger?: ResolveTrigger; evidence?: Record<string, unknown> } = {},
|
||||
): Promise<NameIdentifyResult> {
|
||||
const { ctx } = rt;
|
||||
const none = { mediaEntityId: null, contentUnitIds: [] as string[], candidates: [], created: false };
|
||||
if (!parsed.title || parsed.auxiliaryOnly) return { outcome: "unparseable", reason: "unparseable_title", ...none };
|
||||
const trigger = opts.trigger ?? "takeover";
|
||||
// 链式钩子 name.recognize(ADR-006):整理 / 接管按名称识别时同样可改写解析结果
|
||||
const parsed = await rewriteHook(ctx, "name.recognize", input, () => ({
|
||||
title: input.raw,
|
||||
subtitle: null,
|
||||
sourceReleaseId: null,
|
||||
trigger,
|
||||
}));
|
||||
if (!parsed.title || parsed.auxiliaryOnly) return { outcome: "unparseable", reason: "unparseable_title", ...none };
|
||||
const local = await localCandidates(ctx.db, parsed, null);
|
||||
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null);
|
||||
let remote: MetadataCandidate[] = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
|
||||
import type { SiteSnapshotKind } from "@umt/contracts";
|
||||
import { lifecycleKey, type SiteSnapshotKind } from "@umt/contracts";
|
||||
import { type SiteRow, sql } from "@umt/db";
|
||||
import { parseRelease } from "@umt/domain";
|
||||
import { siteReleaseToSourceRelease } from "@umt/mapping";
|
||||
@@ -10,6 +10,7 @@ import { siteCan } from "../site-kinds.ts";
|
||||
import { enqueueRelogin } from "./account.ts";
|
||||
import { closeDecisions, type Exec, openDecision } from "./common.ts";
|
||||
import { hasDetailPage, saveDetailPage } from "./detail-page.ts";
|
||||
import { emitSiteReachability } from "./lifecycle-events.ts";
|
||||
|
||||
// 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。
|
||||
// 所有请求经站点共享限流器;结果按 site+站点资源 ID upsert 到 source_release,并记录 site_snapshot 覆盖范围。
|
||||
@@ -114,6 +115,12 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
|
||||
.where("status", "in", ["unknown", "active"])
|
||||
.execute();
|
||||
await trx.updateTable("site").set({ health_status: "failing" }).where("id", "=", site.id).execute();
|
||||
if (site.health_status !== "failing")
|
||||
await emitSiteReachability(rt.ctx, trx, site.id, {
|
||||
reachable: false,
|
||||
reason: e.kind,
|
||||
message: e.message.slice(0, 300),
|
||||
});
|
||||
await openDecision(rt.ctx, trx, {
|
||||
kind: "source_unreachable",
|
||||
severity: "warning",
|
||||
@@ -247,6 +254,23 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr
|
||||
const prevStreak = Number((site.snapshot_cursor as { failStreak?: unknown } | null)?.failStreak) || 0;
|
||||
const failStreak = status === "failed" ? prevStreak + 1 : 0;
|
||||
await syncSnapshotFailureDecision(rt, trx, site, failStreak, errJson);
|
||||
// 生命周期广播(ADR-006):连续失败到开待处理的阈值 = 不可达;从阈值以上恢复 = 恢复
|
||||
if (failStreak === SNAPSHOT_FAIL_DECISION_AT)
|
||||
await emitSiteReachability(
|
||||
rt.ctx,
|
||||
trx,
|
||||
site.id,
|
||||
{ reachable: false, reason: "snapshot_failed", message: errJson?.message.slice(0, 300) ?? null },
|
||||
lifecycleKey("site.unreachable", site.id, snap.id),
|
||||
);
|
||||
else if (failStreak === 0 && prevStreak >= SNAPSHOT_FAIL_DECISION_AT)
|
||||
await emitSiteReachability(
|
||||
rt.ctx,
|
||||
trx,
|
||||
site.id,
|
||||
{ reachable: true, reason: "snapshot_ok" },
|
||||
lifecycleKey("site.recovered", site.id, snap.id),
|
||||
);
|
||||
await trx
|
||||
.updateTable("site")
|
||||
.set({
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { emitMetadataRefreshed } from "./service/lifecycle-events.ts";
|
||||
import { refreshMetadata } from "./service/metadata.ts";
|
||||
import { resolveRelease } from "./service/resolve.ts";
|
||||
import { snapshotSite } from "./service/snapshot.ts";
|
||||
@@ -75,7 +76,18 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
|
||||
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 })),
|
||||
t.step.run("refresh", async () => {
|
||||
const r = await refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full });
|
||||
// 生命周期广播(ADR-006):元数据已刷新(在调用方发,刷新本身在 metadata.ts)
|
||||
if (r.status === "refreshed")
|
||||
await emitMetadataRefreshed(rt().ctx, rt().ctx.db, {
|
||||
mediaEntityId: t.input.mediaEntityId,
|
||||
full: !!t.input.full,
|
||||
changedFields: r.changedFields,
|
||||
runId: t.runId,
|
||||
});
|
||||
return r;
|
||||
}),
|
||||
}),
|
||||
defineTask({
|
||||
name: "searchSubscription",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SourceReleaseRow } from "@umt/db";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HookRegistry } from "../../src/hooks/index.ts";
|
||||
import { selectResources } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
|
||||
// 链式钩子 resource.selection(ADR-006,对照 MP ChainEventType.ResourceSelection):订阅重评时调整候选顺序 / 过滤,
|
||||
// 只能重排与删减,不能加入新候选。
|
||||
|
||||
const rel = (id: string, title: string) => ({ id, title, parsed: null }) as unknown as SourceReleaseRow;
|
||||
const rels = [
|
||||
rel("r1", "Show.S01E01.1080p.WEB-DL"),
|
||||
rel("r2", "Show.S01E01.2160p.WEB-DL"),
|
||||
rel("r3", "Show.S01E01.720p"),
|
||||
];
|
||||
const context = { mediaEntityId: "e", subscriptionId: "s", slotId: null };
|
||||
const ctxOf = (hooks?: HookRegistry) => ({ d: { ctx: { hooks } } }) as never;
|
||||
|
||||
describe("resource.selection 钩子", () => {
|
||||
it("没有钩子时原样返回同一个数组", async () => {
|
||||
expect(await selectResources(ctxOf(), rels, context)).toBe(rels);
|
||||
expect(await selectResources(ctxOf(new HookRegistry()), rels, context)).toBe(rels);
|
||||
});
|
||||
|
||||
it("重排与过滤生效;不认识的 ID 忽略、重复的只取一次", async () => {
|
||||
const hooks = new HookRegistry();
|
||||
const seen: unknown[] = [];
|
||||
hooks.forModule("test").rewrite("resource.selection", (cands, c) => {
|
||||
seen.push({ ids: cands.map((x) => x.sourceReleaseId), c });
|
||||
const by = new Map(cands.map((x) => [x.sourceReleaseId, x]));
|
||||
// 2160p 优先、去掉 720p,夹带一个不存在的候选和一个重复
|
||||
return [by.get("r2")!, { ...by.get("r1")!, sourceReleaseId: "intruder" }, by.get("r1")!, by.get("r2")!];
|
||||
});
|
||||
const out = await selectResources(ctxOf(hooks), rels, context);
|
||||
expect(out.map((r) => r.id)).toEqual(["r2", "r1"]);
|
||||
expect(seen).toEqual([{ ids: ["r1", "r2", "r3"], c: context }]);
|
||||
});
|
||||
|
||||
it("钩子出错时沿用原顺序", async () => {
|
||||
const hooks = new HookRegistry();
|
||||
hooks.forModule("test").rewrite("resource.selection", () => {
|
||||
throw new Error("坏了");
|
||||
});
|
||||
expect((await selectResources(ctxOf(hooks), rels, context)).map((r) => r.id)).toEqual(["r1", "r2", "r3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { type EventType, isEventType, LIFECYCLE_EVENTS, parseEventPayload } from "@umt/contracts";
|
||||
import { OutboxRelay } from "@umt/tasks";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { collectOutboxRoutes } from "../../src/context.ts";
|
||||
import { HookRegistry } from "../../src/hooks/index.ts";
|
||||
import {
|
||||
emitDownloadLifecycle,
|
||||
emitScrapeFinished,
|
||||
} from "../../src/modules/acquisition-organize/services/lifecycle-events.ts";
|
||||
import { emitRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { completeSubscription } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||
import { SERVER_MODULES } from "../../src/modules/index.ts";
|
||||
import {
|
||||
emitMetadataRefreshed,
|
||||
emitSiteReachability,
|
||||
} from "../../src/modules/sources-identity/service/lifecycle-events.ts";
|
||||
import type { ServerContext, ServerModule } from "../../src/modules/types.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
|
||||
|
||||
// 生命周期广播事件(ADR-006):各业务节点在事务内写 outbox,payload 过 contracts schema,幂等键防重复。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
|
||||
/** 某类事件的全部行;顺带确认 payload 过 schema */
|
||||
const events = async (type: EventType) => {
|
||||
const rows = await h.outbox(type);
|
||||
for (const r of rows) expect(parseEventPayload(type, r.payload), `${type} payload`).not.toBeNull();
|
||||
return rows;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("lifecycle");
|
||||
base = await seedBase(h.db, {
|
||||
libraryPath: "/tmp/umt-lc-lib",
|
||||
cachePath: "/tmp/umt-lc-dl",
|
||||
qbUrl: "http://127.0.0.1:9",
|
||||
});
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("清单里的事件都是已登记类型", () => {
|
||||
for (const e of LIFECYCLE_EVENTS) expect(isEventType(e.type)).toBe(true);
|
||||
});
|
||||
|
||||
it("订阅:已建(并发作品进入关注)/ 已改 / 暂停也算已改 / 删除发已取消", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "订阅事件片", year: 2024 });
|
||||
const created = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" });
|
||||
expect(created.status).toBe(201);
|
||||
const id = created.body.data.id as string;
|
||||
expect((await events("subscription.created")).filter((r) => r.payload.subscriptionId === id)).toEqual([
|
||||
expect.objectContaining({
|
||||
payload: { subscriptionId: id, mediaEntityId: e, mediaKind: "movie", origin: "user", status: "active" },
|
||||
idempotency_key: `subscription.created/${id}`,
|
||||
}),
|
||||
]);
|
||||
expect((await events("media.watched")).find((r) => r.payload.mediaEntityId === e)).toMatchObject({
|
||||
payload: { mediaEntityId: e, reason: "subscription" },
|
||||
idempotency_key: `media.watched/${e}/subscription/${id}`,
|
||||
});
|
||||
|
||||
expect((await h.api("PATCH", `/subscriptions/${id}`, { priority: "new_work" })).status).toBe(200);
|
||||
expect((await h.api("POST", `/subscriptions/${id}/pause`)).status).toBe(200);
|
||||
const updated = (await events("subscription.updated")).filter((r) => r.payload.subscriptionId === id);
|
||||
expect(updated.map((r) => [r.payload.changes, r.payload.status])).toEqual([
|
||||
[["priority"], "active"],
|
||||
[["status"], "paused"],
|
||||
]);
|
||||
|
||||
expect((await h.api("POST", "/subscriptions/batch", { ids: [id], action: "delete" })).status).toBe(200);
|
||||
expect((await events("subscription.cancelled")).filter((r) => r.payload.subscriptionId === id)).toEqual([
|
||||
expect.objectContaining({ payload: expect.objectContaining({ reason: "user_deleted" }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("订阅完成:发已完成(按历史行去重);钩子 subscription.completion.check 拒绝时不完成、不发事件", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "完成判定片", year: 2023 });
|
||||
const sub = await h.db
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: e, media_kind: "movie", status: "active" })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const hooks = new HookRegistry();
|
||||
const seen: unknown[] = [];
|
||||
hooks.forModule("test").intercept("subscription.completion.check", (c) => {
|
||||
seen.push(c);
|
||||
return "还要等导演剪辑版";
|
||||
});
|
||||
h.ctx.hooks = hooks;
|
||||
try {
|
||||
const done = await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"));
|
||||
expect(done).toBe(false);
|
||||
} finally {
|
||||
h.ctx.hooks = undefined;
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
{ subscriptionId: sub.id, mediaEntityId: e, mediaKind: "movie", reason: "slot_satisfied", progress: {} },
|
||||
]);
|
||||
const still = await h.db.selectFrom("subscription").select("status").where("id", "=", sub.id).executeTakeFirst();
|
||||
expect(still?.status).toBe("active");
|
||||
expect((await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id)).toEqual([]);
|
||||
|
||||
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(true);
|
||||
// 已完成的再判一次不会重复发
|
||||
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(
|
||||
false,
|
||||
);
|
||||
const completed = (await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id);
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0]!.payload.reason).toBe("slot_satisfied");
|
||||
expect(completed[0]!.idempotency_key).toMatch(new RegExp(`^subscription\\.completed/${sub.id}/`));
|
||||
});
|
||||
|
||||
it("整理结束:终态才发 organization.finished,同一次状态变化只发一次;进行中不发", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "整理事件片", year: 2022 });
|
||||
const rec = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({ trigger: "auto", actor_type: "system", action: "copy", status: "running", media_entity_id: e })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await emitRecord(h.d, h.db, rec.id);
|
||||
expect((await events("organization.finished")).filter((r) => r.payload.recordId === rec.id)).toEqual([]);
|
||||
|
||||
await h.db.updateTable("organization_record").set({ status: "succeeded" }).where("id", "=", rec.id).execute();
|
||||
await emitRecord(h.d, h.db, rec.id);
|
||||
await emitRecord(h.d, h.db, rec.id); // 重放:同一 change_seq
|
||||
const fin = (await events("organization.finished")).filter((r) => r.payload.recordId === rec.id);
|
||||
expect(fin).toHaveLength(1);
|
||||
expect(fin[0]!.payload).toMatchObject({
|
||||
outcome: "succeeded",
|
||||
action: "copy",
|
||||
trigger: "auto",
|
||||
mediaEntityId: e,
|
||||
downloadTaskId: null,
|
||||
});
|
||||
expect(fin[0]!.idempotency_key).toBe(`organization.finished/${rec.id}/${fin[0]!.payload.changeSeq}`);
|
||||
// organization.changed 照旧每次都发
|
||||
expect((await events("organization.changed")).filter((r) => r.payload.recordId === rec.id)).toHaveLength(3);
|
||||
|
||||
// 重新整理后失败:新的状态变化再发一条
|
||||
await h.db
|
||||
.updateTable("organization_record")
|
||||
.set({ status: "failed", reason: "target_exists" })
|
||||
.where("id", "=", rec.id)
|
||||
.execute();
|
||||
await emitRecord(h.d, h.db, rec.id);
|
||||
const again = (await events("organization.finished")).filter((r) => r.payload.recordId === rec.id);
|
||||
expect(again.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([
|
||||
["succeeded", null],
|
||||
["failed", "target_exists"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("下载:完成 / 失败 / 移除各发一次(按任务行代数去重),其它状态不发", async () => {
|
||||
const dt = await h.db
|
||||
.insertInto("download_task")
|
||||
.values({ instance_id: base.instanceId, torrent_hash: "a".repeat(40), name: "Some.Movie.2024.mkv" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await emitDownloadLifecycle(h.d, h.db, dt.id, "queued", "downloading");
|
||||
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
|
||||
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
|
||||
await emitDownloadLifecycle(h.d, h.db, dt.id, "completed", "removed");
|
||||
const done = (await events("download.completed")).filter((r) => r.payload.downloadTaskId === dt.id);
|
||||
expect(done).toEqual([
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
downloadTaskId: dt.id,
|
||||
planId: null,
|
||||
mediaEntityId: null,
|
||||
name: "Some.Movie.2024.mkv",
|
||||
generation: 0,
|
||||
},
|
||||
idempotency_key: `download.completed/${dt.id}/g0`,
|
||||
}),
|
||||
]);
|
||||
expect((await events("download.removed")).filter((r) => r.payload.downloadTaskId === dt.id)).toEqual([
|
||||
expect.objectContaining({ payload: expect.objectContaining({ from: "completed" }) }),
|
||||
]);
|
||||
// 复活后(代数 +1)再完成会再发
|
||||
await h.db.updateTable("download_task").set({ generation: 1 }).where("id", "=", dt.id).execute();
|
||||
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
|
||||
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "failed");
|
||||
expect((await events("download.completed")).filter((r) => r.payload.downloadTaskId === dt.id)).toHaveLength(2);
|
||||
expect((await events("download.failed")).filter((r) => r.payload.downloadTaskId === dt.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("刮削:失败发 scrape.failed,成功发 scrape.completed", async () => {
|
||||
const lib = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
|
||||
const mk = async (status: "failed" | "succeeded") =>
|
||||
h.db
|
||||
.insertInto("scrape_record")
|
||||
.values({
|
||||
root_id: lib.id,
|
||||
root_path: "/tmp/umt-lc-lib",
|
||||
status,
|
||||
reason: status === "failed" ? "scrape_no_metadata" : null,
|
||||
written: status === "failed" ? 0 : 3,
|
||||
finished_at: new Date(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
const bad = await mk("failed");
|
||||
const ok = await mk("succeeded");
|
||||
await emitScrapeFinished(h.d, bad);
|
||||
await emitScrapeFinished(h.d, bad);
|
||||
await emitScrapeFinished(h.d, ok);
|
||||
expect((await events("scrape.failed")).map((r) => r.payload)).toEqual([
|
||||
expect.objectContaining({ scrapeRecordId: bad.id, reason: "scrape_no_metadata", status: "failed" }),
|
||||
]);
|
||||
expect((await events("scrape.completed")).map((r) => r.payload)).toEqual([
|
||||
expect.objectContaining({ scrapeRecordId: ok.id, written: 3, status: "succeeded" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("站点不可达 / 恢复、元数据已刷新:payload 合法,带键的按键去重", async () => {
|
||||
await emitSiteReachability(h.ctx, h.db, base.siteId, {
|
||||
reachable: false,
|
||||
reason: "auth_expired",
|
||||
message: "登录失效",
|
||||
});
|
||||
await emitSiteReachability(
|
||||
h.ctx,
|
||||
h.db,
|
||||
base.siteId,
|
||||
{ reachable: true, reason: "health_check" },
|
||||
"site.recovered/x",
|
||||
);
|
||||
await emitSiteReachability(
|
||||
h.ctx,
|
||||
h.db,
|
||||
base.siteId,
|
||||
{ reachable: true, reason: "health_check" },
|
||||
"site.recovered/x",
|
||||
);
|
||||
expect((await events("site.unreachable")).map((r) => r.payload)).toEqual([
|
||||
{ siteId: base.siteId, reason: "auth_expired", message: "登录失效" },
|
||||
]);
|
||||
expect(await events("site.recovered")).toHaveLength(1);
|
||||
|
||||
const e = await seedEntity(h.db, { kind: "series", title: "刷新剧", year: 2021 });
|
||||
for (let i = 0; i < 2; i++)
|
||||
await emitMetadataRefreshed(h.ctx, h.db, {
|
||||
mediaEntityId: e,
|
||||
full: true,
|
||||
changedFields: ["overview"],
|
||||
runId: "run-1",
|
||||
});
|
||||
expect((await events("media.metadata_refreshed")).map((r) => r.payload)).toEqual([
|
||||
{ mediaEntityId: e, full: true, changedFields: ["overview"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("补字幕订阅整理结束:中继投递时补字幕与其它订阅方都收到,入库类整理被认作补字幕候选", async () => {
|
||||
await h.db.deleteFrom("outbox_event").execute();
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "字幕订阅片", year: 2020 });
|
||||
const recs = [];
|
||||
for (const action of ["copy", "trash"] as const) {
|
||||
const r = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({ trigger: "auto", actor_type: "system", action, status: "succeeded", media_entity_id: e })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await emitRecord(h.d, h.db, r.id);
|
||||
recs.push(r.id);
|
||||
}
|
||||
const debug = vi.fn();
|
||||
const ctx = { ...h.ctx, logger: { debug, warn: vi.fn() } } as unknown as ServerContext;
|
||||
const other: ServerModule = {
|
||||
name: "test-other",
|
||||
registerRoutes() {},
|
||||
registerTasks() {},
|
||||
outboxRoutes: () => ({
|
||||
"organization.finished": (ev) => ({
|
||||
task: "otherTask",
|
||||
input: { id: (ev.payload as { recordId: string }).recordId },
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const enqueued: { task: string; key: string }[] = [];
|
||||
const relay = new OutboxRelay({
|
||||
db: h.db,
|
||||
engine: {
|
||||
enqueue: async (task, _input, o) => {
|
||||
enqueued.push({ task, key: o.idempotencyKey });
|
||||
return { runId: o.idempotencyKey, deduplicated: false };
|
||||
},
|
||||
},
|
||||
routes: collectOutboxRoutes([...SERVER_MODULES, other], ctx),
|
||||
});
|
||||
await relay.pollOnce();
|
||||
// 补字幕只认复制 / 移动 / 原地入库(进回收站不算),自动补字幕还没做,不建任务
|
||||
expect(debug.mock.calls.map((c) => c[0])).toEqual([{ recordId: recs[0], mediaEntityId: e }]);
|
||||
expect(enqueued.map((x) => x.task)).toEqual(["otherTask", "otherTask"]);
|
||||
const pending = await h.db
|
||||
.selectFrom("outbox_event")
|
||||
.select(["type", "status"])
|
||||
.where("type", "=", "organization.finished")
|
||||
.execute();
|
||||
expect(pending.map((p) => p.status)).toEqual(["dispatched", "dispatched"]);
|
||||
});
|
||||
|
||||
it("payload 不合法直接抛错,不写 outbox", async () => {
|
||||
await expect(h.ctx.outbox.emit(h.db, "organization.finished", { recordId: "x" } as never)).rejects.toThrow(
|
||||
/不符合 contracts/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { parseRelease } from "@umt/domain";
|
||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { HookRegistry } from "../../src/hooks/index.ts";
|
||||
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedSite } from "../sources-identity/harness.ts";
|
||||
|
||||
// 链式钩子 name.recognize(ADR-006,对照 MP ChainEventType.NameRecognize):资源标题解析之后、召回之前改写解析结果。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const GARBLED = "Zhuo.Wu.Ming.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT";
|
||||
|
||||
describe.skipIf(!up)("name.recognize 钩子改写解析结果", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let siteId: string;
|
||||
let n = 0;
|
||||
const release = async (title: string) =>
|
||||
h.db
|
||||
.insertInto("source_release")
|
||||
.values({ site_id: siteId, site_release_id: String(910000 + ++n), title })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb({ fixtures: TMDB_FIXTURES });
|
||||
h = await createHarness("name_hook", { tmdbUrl: tmdb.url });
|
||||
siteId = (await seedSite(h, { baseUrl: "https://hook.example/" })).site.id;
|
||||
await h.db
|
||||
.insertInto("settings")
|
||||
.values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
|
||||
.execute();
|
||||
});
|
||||
afterEach(() => {
|
||||
h.ctx.hooks = undefined;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
it("没有钩子:认不出的标题照常不自动识别", async () => {
|
||||
const r = await release(GARBLED);
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res.outcome).not.toBe("auto");
|
||||
});
|
||||
|
||||
it("钩子按站点别名改写解析结果后自动识别;上下文带原标题与来源资源", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const hooks = new HookRegistry();
|
||||
hooks.forModule("test").rewrite("name.recognize", (p, c) => {
|
||||
seen.push({ title: c.title, sourceReleaseId: c.sourceReleaseId, trigger: c.trigger });
|
||||
return c.title.startsWith("Zhuo.Wu.Ming") ? parseRelease(c.title.replace("Zhuo.Wu.Ming", "Oppenheimer")) : p;
|
||||
});
|
||||
h.ctx.hooks = hooks;
|
||||
const r = await release(GARBLED);
|
||||
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||
expect(res.outcome).toBe("auto");
|
||||
const e = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select("external_id")
|
||||
.where("media_entity_id", "=", res.mediaEntityId!)
|
||||
.where("provider", "=", "tmdb")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(e.external_id).toBe("872585");
|
||||
expect(seen).toEqual([{ title: GARBLED, sourceReleaseId: r.id, trigger: "observed" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { HookRegistry } from "../../src/hooks/index.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
|
||||
|
||||
// 整理上的链式钩子(ADR-006):organize.rename 改写目标文件名(字幕跟着改)、organize.intercept 拒绝整理;
|
||||
// 钩子出错不影响整理。整理结束发 organization.finished。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
const DIR = "Inception.2010.1080p.BluRay.x264-GRP";
|
||||
const VIDEO = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.mkv`;
|
||||
const SUB = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.chs.srt`;
|
||||
|
||||
describe.skipIf(!hasPg)("整理钩子:改名与拦截(真实 PG + 临时目录)", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
let entity = "";
|
||||
const req = () => ({
|
||||
sourcePaths: [src.path(DIR)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy" as const,
|
||||
trigger: "manual" as const,
|
||||
actorType: "user" as const,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-hklib-" });
|
||||
src = await createTempTree({ [VIDEO]: SIZE, [SUB]: 2_000 }, { prefix: "umt-hksrc-" });
|
||||
h = await createHarness("organize_hooks");
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
await h.setSettings("organize", { scrapeAfterImport: false });
|
||||
entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 });
|
||||
await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
|
||||
}, 60_000);
|
||||
afterEach(() => {
|
||||
h.ctx.hooks = undefined;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
it("拦截:钩子返回原因 → 记录失败(intercepted:<原因>),不动文件;整理结束事件 outcome=failed", async () => {
|
||||
const hooks = new HookRegistry();
|
||||
const seen: string[][] = [];
|
||||
hooks.forModule("test").intercept("organize.intercept", (c) => {
|
||||
seen.push(c.files.map((f) => `${f.role}:${f.kind}`));
|
||||
return c.action === "copy" ? "演示:不许复制" : null;
|
||||
});
|
||||
h.ctx.hooks = hooks;
|
||||
const plan = await planImport(h.d, req());
|
||||
expect(plan).toMatchObject({ status: "failed", reason: "intercepted:演示:不许复制" });
|
||||
expect(seen).toEqual([["main:video", "sidecar:subtitle"]]);
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["status", "error"])
|
||||
.where("id", "=", plan.recordId!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rec).toMatchObject({ status: "failed", error: { hook: "test#1", reason: "演示:不许复制" } });
|
||||
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
|
||||
expect(fin.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([["failed", "intercepted:演示:不许复制"]]);
|
||||
});
|
||||
|
||||
it("改名:主视频按钩子返回的文件名入库,同主干的字幕跟着改;成功后发 organization.finished", async () => {
|
||||
const hooks = new HookRegistry();
|
||||
const ctxs: unknown[] = [];
|
||||
hooks.forModule("test").rewrite("organize.rename", (name, c) => {
|
||||
ctxs.push({ name, mediaType: c.mediaType, mediaEntityId: c.mediaEntityId });
|
||||
return name.replace(/\.mkv$/, " [自定义].mkv");
|
||||
});
|
||||
// 返回带路径分隔符的结果会被忽略(排在后面,拿到的是上一个钩子的结果)
|
||||
hooks.forModule("bad").rewrite("organize.rename", (name) => `../${name}`);
|
||||
h.ctx.hooks = hooks;
|
||||
const plan = await planImport(h.d, req());
|
||||
expect(plan.status).toBe("running");
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("succeeded");
|
||||
expect(ctxs).toEqual([{ name: expect.stringMatching(/\.mkv$/), mediaType: "movie", mediaEntityId: entity }]);
|
||||
const files = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["target_path", "status"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.execute();
|
||||
const targets = files.map((f) => f.target_path ?? "").sort();
|
||||
expect(targets).toHaveLength(2);
|
||||
const video = targets.find((t) => t.endsWith(".mkv"))!;
|
||||
const sub = targets.find((t) => t.endsWith(".srt"))!;
|
||||
expect(video).toMatch(/ \[自定义\]\.mkv$/);
|
||||
const stem = video.slice(0, -".mkv".length);
|
||||
expect(sub.startsWith(stem)).toBe(true);
|
||||
expect(existsSync(`${lib.root}/${video}`) || existsSync(video)).toBe(true);
|
||||
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
|
||||
expect(fin.map((r) => r.payload.outcome)).toEqual(["succeeded"]);
|
||||
});
|
||||
|
||||
it("钩子出错 / 超时不影响整理:按模板名照常生成计划", async () => {
|
||||
const hooks = new HookRegistry();
|
||||
hooks.forModule("broken").rewrite("organize.rename", () => {
|
||||
throw new Error("坏了");
|
||||
});
|
||||
hooks.forModule("broken").intercept("organize.intercept", () => new Promise<string>(() => {}), { timeoutMs: 20 });
|
||||
h.ctx.hooks = hooks;
|
||||
const plan = await planImport(h.d, { ...req(), dryRun: true, action: "move" });
|
||||
expect(plan.status).toBe("queued");
|
||||
const main = plan.plan!.organize.files.find((f) => f.role === "main")!;
|
||||
expect(main.dst).not.toContain("自定义");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user