Files
UHD_Media_Tool/apps/server/test/subscriptions/subscriptions-c1a-sources.test.ts
rootandClaude Opus 5.5 044e718ec6 feat(identity): content unit data hygiene — specials, provisional promotion, duplicate merge (DH-1)
- metadata.includeSpecials (default true): full refresh and the language
  backfill fetch TMDB season 0 (specials) when present, like MP; season 0
  stays out of the episode-total ledger
- refreshMetadata promotes provisional season/episode units that match the
  TMDB season/episode layout to confirmed (source=tmdb), keeping ids and all
  references; non-matching units stay provisional
- content_unit.source (migration 0053): release / file / tmdb / user
- duplicate units (same entity/kind/season/episode/content type/segment) are
  merged as a refresh step and via `scripts/identity-cleanup.ts
  --merge-duplicates` (dry-run by default); keeper = most referenced, then
  confirmed, then earliest; references auto-discovered via FKs and repointed
- ensureUnits takes a per-unit advisory xact lock to stop concurrent lazy
  creation from producing duplicates
- runbooks updated; user DB read-only dry-run: 征途 S01E09/E10 4 units each
  -> 1 each (6 removed), not applied

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:49:56 +08:00

156 lines
5.9 KiB
TypeScript

import { createMockNexusPhpSite, createMockTmdb, type MockNexusPhpSite, type MockTmdb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { trackedSeasonsForAirDates } from "../../src/modules/sources-identity/service/metadata-totals.ts";
import { searchSubscription } from "../../src/modules/sources-identity/tasks.ts";
import { createHarness, type Harness, json, pgAvailable } from "../sources-identity/harness.ts";
// C1-A:订阅搜索关键词追加到主动搜索;元数据刷新按 TMDB 季详情给追踪中订阅的季写集播出日期(订阅日历数据源)。
const up = await pgAvailable();
describe.skipIf(!up)("订阅搜索关键词与集播出日期", () => {
let h: Harness;
let site: MockNexusPhpSite;
let tmdb: MockTmdb;
let siteId: string;
beforeAll(async () => {
site = await createMockNexusPhpSite();
tmdb = await createMockTmdb();
h = await createHarness("subsc1a", { tmdbUrl: tmdb.url });
const ok = await json(
await h.req("/sites", {
body: {
key: "audiences",
name: "观众",
adapter: "nexusphp",
baseUrl: `${site.baseUrl}/`,
rateLimit: { minIntervalMs: 0, concurrency: 4 },
},
}),
);
expect(ok.status).toBe(200);
siteId = ok.body.data.id;
await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: site.cookie } });
});
afterAll(async () => {
await h?.close();
await site?.close();
await tmdb?.close();
});
it("params.keywords 追加在标题之后(去重,不占标题检索词上限),每个站点每个词搜一次", async () => {
const e = (
await h.db
.insertInto("media_entity")
.values({ kind: "movie", title: "Dune", original_title: "Dune", year: 2021, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
const sub = (
await h.db
.insertInto("subscription")
.values({
media_entity_id: e,
media_kind: "movie",
mode: "once",
params: JSON.stringify({ keywords: ["Dune Part One", "dune", " "] }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const r = await searchSubscription(h.rt, sub);
expect(r).toMatchObject({ status: "ok", keywords: ["Dune", "Dune Part One"], sites: [siteId], searches: 2 });
const snaps = await h.db
.selectFrom("site_snapshot")
.select("query")
.where("kind", "=", "live_search")
.where("query", "=", "Dune Part One")
.execute();
expect(snaps).toHaveLength(1);
});
it("元数据刷新:追踪中订阅的季按 TMDB 季详情建集单元并写播出日期;已有集单元只补日期;没有追踪订阅不拉季详情", async () => {
const e = (
await h.db
.insertInto("media_entity")
.values({ kind: "series", title: "The Bear", original_title: "The Bear", year: 2022, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await h.db
.insertInto("external_identity")
.values({
media_entity_id: e,
provider: "tmdb",
entity_type: "tv",
external_id: "136315",
status: "accepted",
})
.execute();
expect(await trackedSeasonsForAirDates(h.db, e, [1, 2, 3])).toEqual([]);
const seasonRequests = () => tmdb.callsTo(/\/season\/\d+/).length;
const before = seasonRequests();
await refreshMetadata(h.rt, e);
expect(seasonRequests()).toBe(before);
const s2 = (
await h.db
.insertInto("content_unit")
.values({ media_entity_id: e, kind: "season", season_number: 2, status: "confirmed" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 已有的 E01(发布懒建,没有日期)
const e1 = (
await h.db
.insertInto("content_unit")
.values({ media_entity_id: e, kind: "episode", season_number: 2, episode_number: 1, status: "provisional" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await h.db
.insertInto("subscription")
.values({
media_entity_id: e,
media_kind: "series",
mode: "continuous",
season_unit_id: s2,
season_scope: "following_seasons",
})
.execute();
expect(await trackedSeasonsForAirDates(h.db, e, [1, 2, 3])).toEqual([2, 3]);
const r = await refreshMetadata(h.rt, e);
expect(r.status).toBe("refreshed");
const eps = await h.db
.selectFrom("content_unit")
.select(["id", "season_number", "episode_number", "status", "title"])
.select((eb) => eb.fn<string>("to_char", [eb.ref("air_date"), eb.val("YYYY-MM-DD")]).as("air"))
.where("media_entity_id", "=", e)
.where("kind", "=", "episode")
.orderBy("season_number")
.orderBy("episode_number")
.execute();
expect(eps.filter((x) => x.season_number === 2)).toHaveLength(10);
expect(eps.filter((x) => x.season_number === 3)).toHaveLength(10);
expect(eps.filter((x) => x.season_number === 1)).toHaveLength(0);
const first = eps.find((x) => x.season_number === 2 && x.episode_number === 1)!;
// 已有的临时集单元:id 不变,补日期,对上 TMDB 季集结构后转正式(DH-1)
expect(first).toMatchObject({ id: e1, status: "confirmed", air: "2023-06-22" });
expect(eps.find((x) => x.season_number === 2 && x.episode_number === 2)).toMatchObject({
status: "confirmed",
air: "2023-06-29",
});
// 再刷新幂等:不重复建
await refreshMetadata(h.rt, e);
const n = await h.db
.selectFrom("content_unit")
.select((eb) => eb.fn.countAll<number>().as("n"))
.where("media_entity_id", "=", e)
.where("kind", "=", "episode")
.executeTakeFirstOrThrow();
expect(Number(n.n)).toBe(20);
});
});