药屋少女的呢喃:一条「S01-S24 季度全24集」的资源按季范围建出 S04–S24 二十一个空季。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { ParsedRelease } from "@umt/domain";
|
|
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
|
import { sql } from "kysely";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { backfillLanguages } from "../../src/modules/sources-identity/service/backfill-languages.ts";
|
|
import { ensureUnits } from "../../src/modules/sources-identity/service/entities.ts";
|
|
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
|
import { seasonsToRefresh } from "../../src/modules/sources-identity/service/metadata-rich.ts";
|
|
import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts";
|
|
|
|
// DH-1 内容单元数据治理:季 0(特别篇)、临时转正式、重复单元合并(refreshMetadata 一步 + identity-cleanup --merge-duplicates)。
|
|
|
|
const up = await pgAvailable();
|
|
const ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
const SCRIPT = fileURLToPath(new URL("../../../../scripts/identity-cleanup.ts", import.meta.url));
|
|
|
|
describe("seasonsToRefresh 季 0", () => {
|
|
it("specials 开且 TMDB 有季 0:full 含季 0;库里有季 0 集单元时默认刷新也含;关时不含", () => {
|
|
expect(seasonsToRefresh([0, 1, 2], { full: true, tracked: [], inLibrary: [], specials: true })).toEqual([0, 1, 2]);
|
|
expect(seasonsToRefresh([0, 1, 2], { full: true, tracked: [], inLibrary: [], specials: false })).toEqual([1, 2]);
|
|
expect(seasonsToRefresh([1, 2], { full: true, tracked: [], inLibrary: [], specials: true })).toEqual([1, 2]);
|
|
expect(seasonsToRefresh([0, 1, 2], { full: false, tracked: [2], inLibrary: [0], specials: true })).toEqual([0, 2]);
|
|
expect(seasonsToRefresh([0, 1, 2], { full: false, tracked: [2], inLibrary: [0], specials: false })).toEqual([2]);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!up)("DH-1 内容单元数据治理", () => {
|
|
let h: Harness;
|
|
let tmdb: MockTmdb;
|
|
let id: string;
|
|
let siteId: string;
|
|
const ids: Record<string, string> = {};
|
|
|
|
const unit = async (
|
|
kind: "season" | "episode",
|
|
season: number,
|
|
episode: number | null,
|
|
extra: Record<string, unknown> = {},
|
|
) =>
|
|
(
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: id,
|
|
kind,
|
|
season_number: season,
|
|
episode_number: episode,
|
|
status: "provisional",
|
|
source: "release",
|
|
...extra,
|
|
} as never)
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
const release = async (key: string, unitId: string) =>
|
|
h.db
|
|
.insertInto("source_release")
|
|
.values({ site_id: siteId, site_release_id: key, title: key, media_entity_id: id, content_unit_id: unitId })
|
|
.execute();
|
|
const asset = async () =>
|
|
(
|
|
await h.db
|
|
.insertInto("media_asset")
|
|
.values({ media_entity_id: id, kind: "video" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
const cover = (assetId: string, unitId: string) =>
|
|
h.db.insertInto("asset_unit_coverage").values({ media_asset_id: assetId, content_unit_id: unitId }).execute();
|
|
const units = () =>
|
|
h.db
|
|
.selectFrom("content_unit")
|
|
.select(["id", "kind", "season_number", "episode_number", "status", "source", "title"])
|
|
.where("media_entity_id", "=", id)
|
|
.orderBy("season_number")
|
|
.orderBy("kind", "desc")
|
|
.orderBy("episode_number")
|
|
.execute();
|
|
const label = (u: { kind: string; season_number: number | null; episode_number: number | null }) =>
|
|
u.kind === "season" ? `S${u.season_number}` : `S${u.season_number}E${u.episode_number}`;
|
|
|
|
beforeAll(async () => {
|
|
tmdb = await createMockTmdb();
|
|
h = await createHarness("dh1", { tmdbUrl: tmdb.url, now: () => new Date("2026-01-01T00:00:00Z") });
|
|
siteId = (
|
|
await h.db
|
|
.insertInto("site")
|
|
.values({ key: "dh1", name: "DH1", adapter: "nexusphp", base_url: "https://dh1.example/" } as never)
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
// 《向往的生活》(mock TMDB 900001:季 0 两集特别篇、第 1 季 12 集、第 2 季 3 集)
|
|
id = (
|
|
await h.db
|
|
.insertInto("media_entity")
|
|
.values({ kind: "variety", title: "向往的生活", year: 2017, status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
await h.db
|
|
.insertInto("external_identity")
|
|
.values({ media_entity_id: id, provider: "tmdb", entity_type: "tv", external_id: "900001", status: "accepted" })
|
|
.execute();
|
|
// 资源解析懒建的临时单元:S01 / S01E01 在 TMDB 结构内;S01E13、S03、S03E01 对不上
|
|
ids.s1 = await unit("season", 1, null);
|
|
ids.e1 = await unit("episode", 1, 1, { parent_unit_id: ids.s1 });
|
|
ids.e13 = await unit("episode", 1, 13, { parent_unit_id: ids.s1 });
|
|
ids.s3 = await unit("season", 3, null);
|
|
ids.s3e1 = await unit("episode", 3, 1, { parent_unit_id: ids.s3 });
|
|
// S01E02 并发懒建的三条重复:e2a 最早、无引用、带一条字段来源;e2b 引用 2;e2c 引用 4(保留)
|
|
ids.e2a = await unit("episode", 1, 2, { parent_unit_id: ids.s1 });
|
|
ids.e2b = await unit("episode", 1, 2, { parent_unit_id: ids.s1 });
|
|
ids.e2c = await unit("episode", 1, 2, { parent_unit_id: ids.s1 });
|
|
await release("r1", ids.e2b);
|
|
await release("r2", ids.e2c);
|
|
await release("r3", ids.e2c);
|
|
const a = await asset();
|
|
const b = await asset();
|
|
await cover(a, ids.e2b);
|
|
await cover(a, ids.e2c);
|
|
await cover(b, ids.e2c);
|
|
for (const u of [ids.e2a, ids.e2c])
|
|
await h.db
|
|
.insertInto("metadata_field")
|
|
.values({ content_unit_id: u, field: "runtime", value: JSON.stringify(1), source: "user" } as never)
|
|
.execute();
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await tmdb?.close();
|
|
});
|
|
|
|
it("full 刷新:合并重复单元,对上 TMDB 的临时单元转正式,季 0 特别篇建单元;对不上的保持临时", async () => {
|
|
const r = await refreshMetadata(h.rt, id, { full: true });
|
|
expect(r.status).toBe("refreshed");
|
|
expect(r.mergedUnits).toBe(2);
|
|
expect(r.episodes?.map((x) => x.season)).toEqual([0, 1, 2]);
|
|
|
|
const list = await units();
|
|
const byLabel = new Map(list.map((u) => [label(u), u]));
|
|
// 重复只剩一条,保留被引用最多的 e2c(不是最早的 e2a)
|
|
expect(list.filter((u) => label(u) === "S1E2")).toHaveLength(1);
|
|
expect(byLabel.get("S1E2")!.id).toBe(ids.e2c);
|
|
for (const k of ["e2a", "e2b"])
|
|
expect(await h.db.selectFrom("content_unit").select("id").where("id", "=", ids[k]!).execute()).toEqual([]);
|
|
// 引用全部改指向保留的单元;同一资产的覆盖撞唯一约束的那行删掉
|
|
const rels = await h.db
|
|
.selectFrom("source_release")
|
|
.select(["site_release_id", "content_unit_id"])
|
|
.where("site_id", "=", siteId)
|
|
.orderBy("site_release_id")
|
|
.execute();
|
|
expect(rels.map((x) => x.content_unit_id)).toEqual([ids.e2c, ids.e2c, ids.e2c]);
|
|
const cov = await h.db
|
|
.selectFrom("asset_unit_coverage")
|
|
.select("content_unit_id")
|
|
.where("content_unit_id", "in", [ids.e2a!, ids.e2b!, ids.e2c!])
|
|
.execute();
|
|
expect(cov.map((x) => x.content_unit_id)).toEqual([ids.e2c, ids.e2c]);
|
|
const rt = await h.db
|
|
.selectFrom("metadata_field")
|
|
.select("content_unit_id")
|
|
.where("field", "=", "runtime")
|
|
.where("source", "=", "user")
|
|
.execute();
|
|
expect(rt).toEqual([{ content_unit_id: ids.e2c }]);
|
|
|
|
// 临时转正式:保留原 id,来源 tmdb
|
|
for (const [k, l] of [
|
|
["s1", "S1"],
|
|
["e1", "S1E1"],
|
|
["e2c", "S1E2"],
|
|
] as const)
|
|
expect(byLabel.get(l)).toMatchObject({ id: ids[k], status: "confirmed", source: "tmdb" });
|
|
expect(byLabel.get("S1E1")!.title).toBe("第 1 期");
|
|
// 对不上的保持临时
|
|
for (const l of ["S1E13", "S3", "S3E1"]) expect(byLabel.get(l)).toMatchObject({ status: "provisional" });
|
|
// 季 0:特别篇季与两集按 TMDB 建(confirmed、来源 tmdb)
|
|
expect(byLabel.get("S0")).toMatchObject({ status: "confirmed", source: "tmdb" });
|
|
expect(list.filter((u) => u.season_number === 0 && u.kind === "episode").map((u) => [u.title, u.status])).toEqual([
|
|
["先导片 1", "confirmed"],
|
|
["先导片 2", "confirmed"],
|
|
]);
|
|
// 季 0 不进正片季总集数账本
|
|
const s0 = await h.db
|
|
.selectFrom("content_unit")
|
|
.select(["episode_total"])
|
|
.where("media_entity_id", "=", id)
|
|
.where("kind", "=", "season")
|
|
.where("season_number", "=", 0)
|
|
.executeTakeFirstOrThrow();
|
|
expect(s0.episode_total).toBeNull();
|
|
});
|
|
|
|
it("设置 metadata.includeSpecials = false:full 刷新不拉季 0;回刷任务按设置覆盖季 0", async () => {
|
|
const dropS0 = () =>
|
|
sql`delete from content_unit where media_entity_id = ${id} and season_number = 0 and kind = 'episode'`
|
|
.execute(h.db)
|
|
.then(() => sql`delete from content_unit where media_entity_id = ${id} and season_number = 0`.execute(h.db));
|
|
await dropS0();
|
|
await h.db
|
|
.insertInto("settings")
|
|
.values({
|
|
key: "metadata",
|
|
value: JSON.stringify({ languages: ["zh-CN", "en-US"], includeSpecials: false }),
|
|
} as never)
|
|
.execute();
|
|
const r = await refreshMetadata(h.rt, id, { full: true });
|
|
expect(r.episodes?.map((x) => x.season)).toEqual([1, 2]);
|
|
expect((await units()).filter((u) => u.season_number === 0)).toEqual([]);
|
|
|
|
await h.db.deleteFrom("settings").where("key", "=", "metadata").execute();
|
|
const b = await backfillLanguages(h.rt, { minIntervalMs: 0, disableWhenDone: false });
|
|
expect(b.refreshed).toBe(1);
|
|
expect((await units()).filter((u) => u.season_number === 0).map(label)).toEqual(["S0", "S0E1", "S0E2"]);
|
|
});
|
|
|
|
it("并发懒建同一集只建一条(咨询锁)", async () => {
|
|
const parsed = { season: 1, episodes: { start: 20, end: 20 } } as unknown as ParsedRelease;
|
|
// 事务 1 建了单元但还没提交时,事务 2 懒建同一集:没有锁时看不到未提交的行,会再建一条
|
|
let created!: () => void;
|
|
const inserted = new Promise<void>((r) => {
|
|
created = r;
|
|
});
|
|
const t1 = h.db.transaction().execute(async (trx) => {
|
|
await ensureUnits(trx, id, "series", parsed);
|
|
created();
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
});
|
|
await inserted;
|
|
const t2 = h.db.transaction().execute((trx) => ensureUnits(trx, id, "series", parsed));
|
|
await Promise.all([t1, t2]);
|
|
expect((await units()).filter((u) => label(u) === "S1E20")).toHaveLength(1);
|
|
});
|
|
|
|
it("季范围资源(把全 24 集写成 S01-S24)只建第一季、挂已有的季;刷新时退役空的临时季", async () => {
|
|
const seasonsNow = async () =>
|
|
(
|
|
await h.db
|
|
.selectFrom("content_unit")
|
|
.select(["id", "season_number", "status"])
|
|
.where("media_entity_id", "=", id)
|
|
.where("kind", "=", "season")
|
|
.where("status", "<>", "retired")
|
|
.orderBy("season_number")
|
|
.execute()
|
|
).map((s) => s.season_number);
|
|
const before = await seasonsNow();
|
|
const parsed = { season: 1, seasonEnd: 24 } as ParsedRelease;
|
|
const r = await h.db.transaction().execute((trx) => ensureUnits(trx, id, "series", parsed));
|
|
expect(await seasonsNow()).toEqual(before);
|
|
expect(r.unitIds.length).toBe(before.filter((n) => n !== null && n >= 1).length);
|
|
|
|
// 空的临时季:S7 没人引用 → 退役;S8 被资源引用 → 留着;S3 下面有集 → 留着
|
|
const s7 = await unit("season", 7, null);
|
|
const s8 = await unit("season", 8, null);
|
|
await release("s8-pack", s8);
|
|
await refreshMetadata(h.rt, id);
|
|
const status = async (uid: string) =>
|
|
(await h.db.selectFrom("content_unit").select("status").where("id", "=", uid).executeTakeFirstOrThrow()).status;
|
|
expect(await status(s7)).toBe("retired");
|
|
expect(await status(s8)).toBe("provisional");
|
|
expect(await status(ids.s3!)).toBe("provisional");
|
|
});
|
|
|
|
it("identity-cleanup --merge-duplicates:默认 dry-run 列清单不改库,--apply 只合并重复、不删孤立单元", async () => {
|
|
const schema = (await sql<{ s: string }>`select current_schema() as s`.execute(h.db)).rows[0]!.s;
|
|
const u = new URL(PG_URL);
|
|
u.searchParams.set("options", `-c search_path=${schema},public`);
|
|
const run = (...args: string[]) =>
|
|
execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, "--merge-duplicates", ...args], {
|
|
cwd: ROOT,
|
|
env: { ...process.env, DATABASE_URL: u.toString(), NO_COLOR: "1" },
|
|
encoding: "utf8",
|
|
});
|
|
// 再造重复:S01E03 已有 TMDB 建的正式单元,再懒建两条临时的,第二条有资源引用
|
|
const d1 = await unit("episode", 1, 3);
|
|
const d2 = await unit("episode", 1, 3);
|
|
await release("r4", d2);
|
|
const before = await units();
|
|
|
|
const out = run();
|
|
expect(out).toContain("DRY-RUN");
|
|
expect(out).toContain("## 重复的内容单元:1 组,删除 2 条");
|
|
expect(out).toContain(`《向往的生活》S01E03:保留 ${d2}(provisional,引用 1)`);
|
|
expect(out).not.toContain("## 冲突别名");
|
|
expect(out).not.toContain("## 孤立的临时内容单元");
|
|
expect(await units()).toEqual(before);
|
|
|
|
const applied = run("--apply");
|
|
expect(applied).toContain("合并 2 个重复单元");
|
|
const after = await units();
|
|
expect(after.map((x) => x.id)).not.toContain(d1);
|
|
// 组内有正式单元:保留的那条转正式,标题取被合并单元的值
|
|
expect(after.filter((x) => label(x) === "S1E3")).toEqual([
|
|
expect.objectContaining({ id: d2, status: "confirmed", source: "tmdb", title: "第 3 期" }),
|
|
]);
|
|
// 孤立的临时单元(S1E13 / S3)不动
|
|
expect(after.filter((x) => ["S1E13", "S3", "S3E1"].includes(label(x)))).toHaveLength(3);
|
|
});
|
|
});
|