评估跳过 episode_unresolved 时,给仍在追踪的收录版本各写一条 skipped 候选评估 (原因 episode_unresolved),作品「为什么选 / 不选」与订阅记录能看到"集数待确认"。 确认后重发评估按 (收录版本, 资源) 覆盖;换算到别的季不再进入原收录版本时, 先清掉旧的待确认记录,不留陈旧原因。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
203 lines
7.7 KiB
TypeScript
203 lines
7.7 KiB
TypeScript
import type { SourceReleaseRow } from "@umt/db";
|
|
import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain";
|
|
import { toParsedColumns } from "@umt/mapping";
|
|
import { createMockQb, type MockQb } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { evaluateAcquisition, parsedOf } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
|
import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts";
|
|
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
|
|
|
// 订阅评估与集数解析(docs/plan/episode-resolution.md 批次 2):parsed 列是规范季集,S2 追更订阅能评到连续编号的资源;
|
|
// 集数待确认的资源跳过(episode_unresolved);parsedOf 重解析时保留规范季集。
|
|
|
|
const hasPg = await pgAvailable();
|
|
|
|
// 已完结:S1 = 12、S2 = 13
|
|
const TABLE = {
|
|
seasons: [
|
|
{ season: 1, total: 12 },
|
|
{ season: 2, total: 13 },
|
|
],
|
|
latestSeason: 2,
|
|
ended: true,
|
|
};
|
|
const ANI_25 = "[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]";
|
|
const LILITH_40 = "[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]";
|
|
|
|
function resolved(title: string): { parsed: ReturnType<typeof parseRelease>; resolution: EpisodeResolution } {
|
|
const raw = parseRelease(title);
|
|
const resolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] });
|
|
return { parsed: canonicalParsed(raw, resolution), resolution };
|
|
}
|
|
|
|
describe("parsedOf 与集数解析", () => {
|
|
it("解析版本过期按原标题重解析时,叠加 episode_resolution 的规范季集", () => {
|
|
const { resolution } = resolved(ANI_25);
|
|
expect(resolution.method).toBe("absolute_cross_season");
|
|
const row = {
|
|
title: ANI_25,
|
|
subtitle: null,
|
|
parsed: { season: 1, episodes: { start: 25, end: 25 } },
|
|
parser_version: "0",
|
|
size_bytes: null,
|
|
episode_resolution: resolution,
|
|
} as unknown as SourceReleaseRow;
|
|
const p = parsedOf(row);
|
|
expect(p).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, seasonInferred: false });
|
|
expect(p.releaseGroup?.name).toBe("ANi");
|
|
// 没有映射结果的照旧
|
|
expect(parsedOf({ ...row, episode_resolution: null })).toMatchObject({
|
|
season: 1,
|
|
episodes: { start: 25, end: 25 },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!hasPg)("订阅评估跳过集数待确认的资源", () => {
|
|
let h: Harness;
|
|
let qb: MockQb;
|
|
let base: Awaited<ReturnType<typeof seedBase>>;
|
|
let entityId: string;
|
|
|
|
beforeAll(async () => {
|
|
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
|
const disk = fakeDisk(10_000_000_000);
|
|
disk.setPct(10);
|
|
h = await createHarness("episode_resolution_eval", { diskUsage: disk.fn });
|
|
base = await seedBase(h.db, { libraryPath: "/tmp/umt-none-lib", cachePath: "/tmp/umt-none-cache", qbUrl: qb.url });
|
|
entityId = await seedEntity(h.db, { kind: "series", title: "药屋少女的呢喃", year: 2023 });
|
|
const units: Record<number, string> = {};
|
|
for (const s of TABLE.seasons) {
|
|
units[s.season] = (
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: entityId,
|
|
kind: "season",
|
|
season_number: s.season,
|
|
episode_total: s.total,
|
|
status: "confirmed",
|
|
source: "tmdb",
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
}
|
|
// S2 追更订阅(仅本季)
|
|
const sub = await h.db
|
|
.insertInto("subscription")
|
|
.values({
|
|
media_entity_id: entityId,
|
|
media_kind: "series",
|
|
mode: "continuous",
|
|
status: "active",
|
|
season_unit_id: units[2]!,
|
|
season_scope: "current_season",
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("acquisition_slot")
|
|
.values({
|
|
media_entity_id: entityId,
|
|
subscription_id: sub.id,
|
|
name: "1080p",
|
|
hard_conditions: JSON.stringify({ resolutions: ["1080p"] }),
|
|
upgrade_threshold: JSON.stringify({}),
|
|
})
|
|
.execute();
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await qb?.close();
|
|
});
|
|
|
|
const seed = async (title: string) => {
|
|
const { parsed, resolution } = resolved(title);
|
|
const cols = toParsedColumns(parsed);
|
|
return (
|
|
await h.db
|
|
.insertInto("source_release")
|
|
.values({
|
|
site_id: base.siteId,
|
|
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
|
|
title,
|
|
size_bytes: 300_000_000,
|
|
infohash_v1: Math.random().toString(16).slice(2).padEnd(40, "0").slice(0, 40),
|
|
media_entity_id: entityId,
|
|
resolution_status: "resolved",
|
|
parsed: JSON.stringify(cols.parsed),
|
|
parser_version: cols.parserVersion,
|
|
episode_resolution: JSON.stringify(resolution),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
};
|
|
|
|
it("ANi `- 25` 规范为 S02E13:S2 追更订阅进入评估并出计划", async () => {
|
|
const id = await seed(ANI_25);
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id });
|
|
expect(ev.skipped.map((s) => s.reason)).not.toContain("episode_unresolved");
|
|
expect(ev.evaluated, JSON.stringify(ev)).toBe(1);
|
|
expect(ev.plans).toHaveLength(1);
|
|
const plan = await h.db
|
|
.selectFrom("acquisition_plan")
|
|
.select(["unit_keys"])
|
|
.where("id", "=", ev.plans[0]!)
|
|
.executeTakeFirstOrThrow();
|
|
expect(plan.unit_keys).toEqual(["s2e13"]);
|
|
});
|
|
|
|
it("集数待确认的资源跳过,原因 episode_unresolved", async () => {
|
|
const id = await seed(LILITH_40);
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id });
|
|
expect(ev.evaluated).toBe(0);
|
|
expect(ev.plans).toHaveLength(0);
|
|
expect(ev.skipped).toEqual([{ sourceReleaseId: id, reason: "episode_unresolved" }]);
|
|
});
|
|
|
|
it("集数待确认在订阅侧可见:写候选记录 episode_unresolved;确认后正常评估并覆盖", async () => {
|
|
const slot = await h.db
|
|
.selectFrom("acquisition_slot")
|
|
.select("id")
|
|
.where("media_entity_id", "=", entityId)
|
|
.executeTakeFirstOrThrow();
|
|
const candidate = async (releaseId: string) =>
|
|
(
|
|
await h.db
|
|
.selectFrom("artifact")
|
|
.select("content")
|
|
.where("title", "=", "candidate_evaluation")
|
|
.where("subject_id", "=", slot.id)
|
|
.execute()
|
|
)
|
|
.map(
|
|
(r) => r.content as { sourceReleaseId: string; decision: string; reasons: string[]; planId: string | null },
|
|
)
|
|
.find((c) => c.sourceReleaseId === releaseId);
|
|
const id = await seed(LILITH_40);
|
|
await evaluateAcquisition(h.d, { sourceReleaseId: id });
|
|
expect(await candidate(id)).toMatchObject({ decision: "skipped", reasons: ["episode_unresolved"] });
|
|
|
|
// 人工确认为 S02E12(与 `- 24` 的换算结果相同),重发评估:正常建计划,记录被覆盖
|
|
const fixed = resolved("[Lilith-Raws] Kusuriya no Hitorigoto - 24 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]");
|
|
const cols = toParsedColumns(fixed.parsed);
|
|
await h.db
|
|
.updateTable("source_release")
|
|
.set({
|
|
parsed: JSON.stringify(cols.parsed),
|
|
parser_version: cols.parserVersion,
|
|
episode_resolution: JSON.stringify(fixed.resolution),
|
|
})
|
|
.where("id", "=", id)
|
|
.execute();
|
|
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: id });
|
|
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
|
const c = await candidate(id);
|
|
expect(c?.decision).toBe("accept");
|
|
expect(c?.planId).toBe(ev.plans[0]);
|
|
expect(c?.reasons).not.toContain("episode_unresolved");
|
|
});
|
|
});
|