diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index e65ad868..7f739a3f 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -118,10 +118,22 @@ export interface ResolveResult { const candKey = (c: DomainCandidate) => c.localEntityId ? `local:${c.localEntityId}` : `${c.provider}:${c.entityType}:${c.externalId}`; +/** 作品候选与季集映射输入一起判幂等;全局词改编号/发布组时必须重算。 */ export function evidenceVersion(parsed: ParsedRelease | null, ranked: RankedCandidate[]): string { const h = createHash("sha1") .update( - JSON.stringify([parsed?.title ?? null, parsed?.year ?? null, ranked.map((r) => [candKey(r.candidate), r.level])]), + JSON.stringify([ + parsed?.title ?? null, + parsed?.year ?? null, + parsed?.season ?? null, + parsed?.seasonEnd ?? null, + parsed?.seasonInferred ?? false, + parsed?.episodes ?? null, + parsed?.airDate ?? null, + parsed?.releaseGroup?.name ?? null, + parsed?.words?.applied ?? [], + ranked.map((r) => [candKey(r.candidate), r.level]), + ]), ) .digest("hex") .slice(0, 16); diff --git a/apps/server/test/sources-identity/evidence-version.test.ts b/apps/server/test/sources-identity/evidence-version.test.ts new file mode 100644 index 00000000..d21377b0 --- /dev/null +++ b/apps/server/test/sources-identity/evidence-version.test.ts @@ -0,0 +1,19 @@ +import { type ParsedRelease, parseRelease } from "@umt/domain"; +import { expect, it } from "vitest"; +import { evidenceVersion } from "../../src/modules/sources-identity/service/resolve.ts"; + +it("身份候选不变时,季集和规则匹配输入变化也必须重算;相同事实仍幂等", () => { + const original = parseRelease("[Lilith-Raws] Example S01E40 [1080p]"); + const before = evidenceVersion(original, []); + const changes: Partial[] = [ + { season: 2 }, + { seasonEnd: 2 }, + { seasonInferred: true }, + { episodes: { start: 12, end: 12 } }, + { airDate: "20250314" }, + { releaseGroup: { name: "ANi", source: "bracket" } }, + { words: { applied: ["Example >> EP-28"], original: original.raw } }, + ]; + for (const change of changes) expect(evidenceVersion({ ...original, ...change }, [])).not.toBe(before); + expect(evidenceVersion(structuredClone(original), [])).toBe(before); +}); diff --git a/apps/server/test/sources-identity/identify-words-api.test.ts b/apps/server/test/sources-identity/identify-words-api.test.ts index 07ef19bf..0d107a49 100644 --- a/apps/server/test/sources-identity/identify-words-api.test.ts +++ b/apps/server/test/sources-identity/identify-words-api.test.ts @@ -307,7 +307,7 @@ describe.skipIf(!pg)("识别词接口", () => { const g = (await call("/identify-words/global")).body.data; const saved = await call("/identify-words/global", { method: "PUT", - body: { text: `${g.text}Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28\n`, expectedVersion: g.version }, + body: { text: `${g.text}Kusuriya no Hitorigoto - (?=4\\d) <> \\[Baha >> EP-28\n`, expectedVersion: g.version }, }); const version = saved.body.data.block.version as number; const before = h.engine.enqueued.length; @@ -321,15 +321,19 @@ describe.skipIf(!pg)("识别词接口", () => { expect(p.scanned).toBeGreaterThanOrEqual(4); // 重新识别后集数按全局识别词偏移(40 → 12,对上第 1 季),命中的行记在解析结果上 - await resolveRelease(h.rt, { sourceReleaseId: lilith40, force: true, trigger: "review" }); + const resolved = await resolveRelease(h.rt, { sourceReleaseId: lilith40, force: true, trigger: "review" }); + expect(resolved.outcome).toBe("auto"); const row = await h.db .selectFrom("source_release") .select(["parsed"]) .where("id", "=", lilith40) .executeTakeFirstOrThrow(); expect((row.parsed as { words?: { applied: string[] } }).words?.applied).toEqual([ - "Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28", + "Kusuriya no Hitorigoto - (?=4\\d) <> \\[Baha >> EP-28", ]); + expect(await er(lilith40)).toMatchObject({ source: { season: 1, episodes: { start: 12, end: 12 } } }); + const unchanged = await resolveRelease(h.rt, { sourceReleaseId: lilith40, force: true, trigger: "review" }); + expect(unchanged).toMatchObject({ outcome: "skipped", reason: "unchanged" }); }); it("全局示例:没给标题时从近期资源里找命中的", async () => {