feat(organize): identify takeover downloads by file name and close organize decisions

Takeover identify (item 2):
- Adopted tasks without source release / plan: on completion planImport
  parses main video path (file + parent + grandparent dirs, like MP
  MetaInfoPath) merged with the torrent name, then runs ID-2 matching via
  identifyParsed (local recall + TMDB; trigger "takeover" may query TMDB).
  auto -> bind/create entity, backfill download_task.media_entity_id and
  organize right away; no pool entry, no identity decision.
- Failure / ambiguity: organize_identity decision with evidence and
  options: candidates, select entity (local / TMDB), provisional entity,
  dismiss.

Decision closure (item 3):
- decision-hooks registry: the decisions resolve route runs the owning
  module's executor before closing; errors keep the decision open.
- organize_identity: backfill entity, freeze manual plan, enqueue run.
- organize_blocked with target_exists: replace (verify + trash old) /
  keep both (rename "<name> (n)" with sidecars) / skip.
- Successful organize closes both organize_identity and organize_blocked
  of the same download task (resolution manual_organize), also for manual
  path organizes matched to download tasks via path mappings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:54:19 +08:00
1 parent 05d1f76b9f
commit e7de7e2e69
13 files changed
+1127 -17

No files matched your search

@@ -1,9 +1,9 @@
import { sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { normalizeTitle, parseReleasePath } from "@umt/domain";
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { down, up } from "../../../../packages/db/migrations/0047_identity_pool.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { identifyParsed, resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
// DEC-1 惰性识别(对照 MP:RSS / 站点资源只在匹配订阅或用户搜索时识别,识别失败不告警)与待识别池治理。
@@ -37,6 +37,8 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
twin(112),
twin(211, "Nightfall", "夜幕", "1995-03-01"),
twin(212, "Nightfall", "夜幕", "1995-03-01"),
twin(311, "Harborlight", "港灯", "2003-05-01"),
twin(312, "Harborlight", "港灯", "2003-05-01"),
],
});
h = await createHarness("lazyid", { tmdbUrl: tmdb.url });
@@ -271,6 +273,44 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
.executeTakeFirstOrThrow();
expect(p).toMatchObject({ status: "archived", next_review_at: null });
});
it("QB-1 接管任务按文件名识别(trigger=takeover):不需要本地别名也查 TMDB,auto 建档;歧义只返回候选,不进池不开待处理", async () => {
const calls = tmdbCalls();
const parsed = parseReleasePath(
"Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP/Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP.mkv",
);
const r = await identifyParsed(h.rt, parsed, { trigger: "takeover" });
expect(tmdbCalls()).toBeGreaterThan(calls);
expect(r.outcome).toBe("auto");
expect(r.mediaEntityId).toBeTruthy();
const ent = await h.db
.selectFrom("media_entity")
.selectAll()
.where("id", "=", r.mediaEntityId!)
.executeTakeFirstOrThrow();
expect(ent.year).toBe(2024);
// 同名两部 → 歧义:只给候选
const pool = await h.db
.selectFrom("identity_candidate")
.select((eb) => eb.fn.countAll().as("n"))
.executeTakeFirstOrThrow();
const amb = await identifyParsed(h.rt, parseReleasePath("Harborlight.2003.1080p.BluRay.mkv"), {
trigger: "takeover",
});
expect(amb.outcome).toBe("ambiguous");
expect(amb.candidates.map((c) => c.externalId).sort()).toEqual(["311", "312"]);
expect(amb.mediaEntityId).toBeNull();
const pool2 = await h.db
.selectFrom("identity_candidate")
.select((eb) => eb.fn.countAll().as("n"))
.executeTakeFirstOrThrow();
expect(pool2.n).toBe(pool.n);
// 默认 observed 触发:不相关资源不查 TMDB(惰性识别不变)
const before = tmdbCalls();
const lazy = await identifyParsed(h.rt, parseReleasePath("Zqxv.Kxlmn.2011.720p.mkv"), { trigger: "observed" });
expect(lazy.outcome).toBe("no_match");
expect(tmdbCalls()).toBe(before);
});
});
describe.skipIf(!pg)("迁移 0047:存量 identity_unresolved 清理", () => {