- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀, w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名 - vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行); pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s - CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争) - ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单 Co-Authored-By: Claude Opus 5.5 <[email protected]>
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import type { DB } from "@umt/db";
|
|
import type { Kysely } from "kysely";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { backfillVersionSpecs } from "../../src/modules/acquisition-organize/services/version-backfill.ts";
|
|
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
|
|
|
|
// LIBV-1:入库版本 API(每集多个版本、季汇总、单目标比对)与规格回填(dry-run / apply)。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const GB = 1_000_000_000;
|
|
|
|
const PROBE_DV = {
|
|
status: "ok",
|
|
durationSec: 3300,
|
|
video: [{ codec: "hevc", width: 3840, height: 2160, pixFmt: "yuv420p10le", hdr: "DV" }],
|
|
audio: [{ codec: "eac3", profile: "Dolby Digital Plus + Dolby Atmos", channels: 6, language: "eng" }],
|
|
subtitles: [{ language: "chi", title: "简体" }],
|
|
};
|
|
|
|
async function seedAsset(
|
|
db: Kysely<DB>,
|
|
a: {
|
|
entityId: string;
|
|
rootId: string;
|
|
path: string;
|
|
size: number;
|
|
units: string[];
|
|
mediaInfo?: object;
|
|
delivery?: object;
|
|
},
|
|
) {
|
|
const asset = await db
|
|
.insertInto("media_asset")
|
|
.values({
|
|
media_entity_id: a.entityId,
|
|
kind: "video",
|
|
size_bytes: a.size,
|
|
media_info: JSON.stringify(a.mediaInfo ?? {}),
|
|
delivery: JSON.stringify(a.delivery ?? {}),
|
|
status: "active",
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await db
|
|
.insertInto("asset_location")
|
|
.values({
|
|
media_asset_id: asset.id,
|
|
location_kind: "library",
|
|
library_root_id: a.rootId,
|
|
relative_path: a.path,
|
|
status: "available",
|
|
is_primary: true,
|
|
size_bytes: a.size,
|
|
})
|
|
.execute();
|
|
for (const u of a.units)
|
|
await db.insertInto("asset_unit_coverage").values({ media_asset_id: asset.id, content_unit_id: u }).execute();
|
|
return asset.id;
|
|
}
|
|
|
|
describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
|
|
let h: Harness;
|
|
let rootId: string;
|
|
let series: string;
|
|
let movie: string;
|
|
let bare: string;
|
|
const eps: string[] = [];
|
|
const ids: Record<string, string> = {};
|
|
|
|
beforeAll(async () => {
|
|
h = await createHarness("libv1");
|
|
rootId = (
|
|
await h.db
|
|
.insertInto("library_root")
|
|
.values({ name: "剧集", media_kind: "mixed", local_path: "/media/tv/", management_mode: "managed" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({ media_entity_id: series, kind: "season", season_number: 1, status: "confirmed", episode_total: 3 })
|
|
.execute();
|
|
for (const n of [1, 2, 3])
|
|
eps.push(
|
|
(
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: series,
|
|
kind: "episode",
|
|
season_number: 1,
|
|
episode_number: n,
|
|
status: "confirmed",
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id,
|
|
);
|
|
const dir = "人生切割术 (2022)/Season 1";
|
|
ids.e1dv = await seedAsset(h.db, {
|
|
entityId: series,
|
|
rootId,
|
|
path: `${dir}/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
|
size: 14.2 * GB,
|
|
units: [eps[0]!],
|
|
mediaInfo: { probe: PROBE_DV, _umt: { quality: { resolution: "2160p", source: "WEB-DL" } } },
|
|
delivery: { resolution: "2160p", sourceType: "WEB-DL" },
|
|
});
|
|
ids.e1bd = await seedAsset(h.db, {
|
|
entityId: series,
|
|
rootId,
|
|
path: `${dir}/Severance.S01E01.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD.mkv`,
|
|
size: 8 * GB,
|
|
units: [eps[0]!],
|
|
});
|
|
ids.e2 = await seedAsset(h.db, {
|
|
entityId: series,
|
|
rootId,
|
|
path: `${dir}/Severance.S01E02.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
|
size: 13 * GB,
|
|
units: [eps[1]!],
|
|
});
|
|
// 附属外挂字幕
|
|
const sub = await h.db
|
|
.insertInto("media_asset")
|
|
.values({ media_entity_id: series, kind: "subtitle", parent_asset_id: ids.e2, status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("asset_location")
|
|
.values({
|
|
media_asset_id: sub.id,
|
|
library_root_id: rootId,
|
|
relative_path: `${dir}/Severance.S01E02.chs.srt`,
|
|
status: "available",
|
|
})
|
|
.execute();
|
|
await seedSlot(h.db, { entityId: series, name: "4K", hard: { resolutions: ["2160p"] } });
|
|
|
|
movie = await seedEntity(h.db, { kind: "movie", title: "奥本海默", year: 2023 });
|
|
ids.movie = await seedAsset(h.db, {
|
|
entityId: movie,
|
|
rootId,
|
|
path: "奥本海默 (2023)/Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR10.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
|
|
size: 80 * GB,
|
|
units: [],
|
|
});
|
|
bare = await seedEntity(h.db, { kind: "movie", title: "没有版本目标", year: 2020 });
|
|
await seedAsset(h.db, {
|
|
entityId: bare,
|
|
rootId,
|
|
path: "x/Bare.2020.1080p.WEB-DL.H264-GRP.mkv",
|
|
size: GB,
|
|
units: [],
|
|
});
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
});
|
|
|
|
it("剧集:一集两个版本、季汇总、按版本目标逐集比对", async () => {
|
|
const r = await h.api("GET", `/library/${series}/versions`);
|
|
expect(r.status).toBe(200);
|
|
const v = r.body.data;
|
|
expect(v.kind).toBe("series");
|
|
expect(v.targets).toEqual([{ slotId: expect.any(String), name: "4K" }]);
|
|
expect(v.summary.versionCount).toBe(3);
|
|
const s1 = v.seasons[0];
|
|
expect(s1).toMatchObject({ seasonNumber: 1, episodeTotal: 3, episodesWithVersion: 2 });
|
|
expect(s1.summary.sizeBytes).toBe(35.2 * GB);
|
|
expect(s1.summary.specs[0]).toEqual({ resolution: "2160p", source: "WEB-DL", hdr: ["DV"], count: 2 });
|
|
expect(s1.matchCounts).toEqual({ satisfied: 2, missing: 1, upgradeable: 0 });
|
|
|
|
const [e1, e2, e3] = s1.episodes;
|
|
expect(e1.versions.map((x: { assetId: string }) => x.assetId).sort()).toEqual([ids.e1dv, ids.e1bd].sort());
|
|
expect(e1.matches).toEqual([
|
|
{ slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [], codes: [] },
|
|
]);
|
|
const dv = e1.versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
|
expect(dv).toMatchObject({
|
|
resolution: "2160p",
|
|
source: "WEB-DL",
|
|
videoCodec: "H.265",
|
|
hdr: ["DV", "HDR10"],
|
|
releaseGroup: "FLUX",
|
|
audio: [{ codec: "DDP Atmos", channels: "5.1", language: "en" }],
|
|
subtitleLanguages: ["zh-Hans"],
|
|
durationSec: 3300,
|
|
sizeBytes: 14.2 * GB,
|
|
specFrom: ["name", "probe"],
|
|
specStored: false,
|
|
locationStatus: "available",
|
|
unitIds: [eps[0]],
|
|
});
|
|
expect(dv.absolutePath).toBe(
|
|
"/media/tv/人生切割术 (2022)/Season 1/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv",
|
|
);
|
|
expect(dv.probe.status).toBe("ok");
|
|
expect(e2.versions[0].subtitleLanguages).toEqual(["zh-Hans"]);
|
|
expect(e3).toMatchObject({ episodeNumber: 3, versions: [] });
|
|
expect(e3.matches[0]).toMatchObject({ state: "missing", assetId: null });
|
|
expect(v.unplaced).toEqual([]);
|
|
});
|
|
|
|
it("指定一个作品没有的版本目标 = 不比对;电影给出版本与比对", async () => {
|
|
const r = await h.api("GET", `/library/${series}/versions?slotId=00000000-0000-4000-8000-000000000000`);
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.targets).toEqual([]);
|
|
expect(r.body.data.seasons[0].episodes[0].matches).toBeUndefined();
|
|
expect(r.body.data.seasons[0].matchCounts).toBeUndefined();
|
|
|
|
const m = await h.api("GET", `/library/${bare}/versions`);
|
|
expect(m.body.data.targets).toEqual([]);
|
|
expect(m.body.data.movie.versions).toHaveLength(1);
|
|
expect(m.body.data.movie.matches).toBeUndefined();
|
|
|
|
await seedSlot(h.db, { entityId: movie, name: "1080p", hard: { resolutions: ["1080p"] } });
|
|
const mv = await h.api("GET", `/library/${movie}/versions`);
|
|
expect(mv.body.data.movie.versions[0]).toMatchObject({ source: "REMUX", resolution: "2160p", edition: null });
|
|
expect(mv.body.data.movie.matches[0]).toMatchObject({ state: "upgradeable", assetId: ids.movie });
|
|
|
|
const nf = await h.api("GET", "/library/00000000-0000-4000-8000-000000000000/versions");
|
|
expect(nf.status).toBe(404);
|
|
});
|
|
|
|
it("回填:dry-run 只出清单不写库;apply 后再跑没有待更新", async () => {
|
|
const before = await h.db
|
|
.selectFrom("media_asset")
|
|
.select(["id", "delivery"])
|
|
.where("kind", "=", "video")
|
|
.execute();
|
|
const dry = await backfillVersionSpecs(h.db, { entityId: series });
|
|
expect(dry).toMatchObject({ apply: false, scanned: 3, upToDate: 0, changed: 3, probed: 0, noLocation: 0 });
|
|
const item = dry.items.find((i) => i.assetId === ids.e1dv)!;
|
|
expect(item).toMatchObject({ reason: "no_spec", entityTitle: "人生切割术", before: "2160p · WEB-DL" });
|
|
expect(item.after).toBe("2160p · WEB-DL · H.265 · DV/HDR10 · DDP Atmos · 字幕 zh-Hans · FLUX");
|
|
const after = await h.db.selectFrom("media_asset").select(["id", "delivery"]).where("kind", "=", "video").execute();
|
|
expect(after).toEqual(before);
|
|
|
|
// 可选探测:只对缺探测结果的本地文件调用端口
|
|
const probed: string[] = [];
|
|
const withProbe = await backfillVersionSpecs(h.db, {
|
|
entityId: series,
|
|
probe: async (p) => {
|
|
probed.push(p);
|
|
return { status: "unknown" };
|
|
},
|
|
});
|
|
expect(withProbe.probed).toBe(0);
|
|
expect(probed).toHaveLength(2);
|
|
expect(probed.every((p) => p.startsWith("/media/tv/人生切割术 (2022)/Season 1/"))).toBe(true);
|
|
|
|
const applied = await backfillVersionSpecs(h.db, { apply: true });
|
|
expect(applied.changed).toBe(5);
|
|
const row = await h.db
|
|
.selectFrom("media_asset")
|
|
.select("delivery")
|
|
.where("id", "=", ids.e1dv!)
|
|
.executeTakeFirstOrThrow();
|
|
expect(row.delivery).toMatchObject({
|
|
specVersion: 1,
|
|
sourceType: "WEB-DL",
|
|
releaseGroup: "FLUX",
|
|
durationSec: 3300,
|
|
});
|
|
const again = await backfillVersionSpecs(h.db, {});
|
|
expect(again).toMatchObject({ changed: 0, upToDate: 5 });
|
|
|
|
const r = await h.api("GET", `/library/${series}/versions`);
|
|
const dv = r.body.data.seasons[0].episodes[0].versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
|
expect(dv).toMatchObject({ specStored: true, hdr: ["DV", "HDR10"], releaseGroup: "FLUX" });
|
|
});
|
|
|
|
it("作品详情保留跨过旧条数上限的整理、下载和失败计划供分页查看", async () => {
|
|
const entityId = await seedEntity(h.db, { kind: "movie", title: "历史分页测试", year: 2026 });
|
|
const instance = await h.db
|
|
.insertInto("downloader_instance")
|
|
.values({ name: "历史下载器", base_url: "http://127.0.0.1:1" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("download_task")
|
|
.values(
|
|
Array.from({ length: 105 }, (_, i) => ({
|
|
instance_id: instance.id,
|
|
media_entity_id: entityId,
|
|
torrent_hash: String(i + 1).padStart(40, "0"),
|
|
name: `历史下载 ${i + 1}`,
|
|
})),
|
|
)
|
|
.execute();
|
|
const slotId = await seedSlot(h.db, { entityId, name: "历史版本" });
|
|
const site = await h.db
|
|
.insertInto("site")
|
|
.values({ key: "history-source", name: "历史来源", base_url: "http://127.0.0.1:1", adapter: "nexusphp" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
const releaseId = await seedRelease(h.db, {
|
|
siteId: site.id,
|
|
entityId,
|
|
title: "历史分页测试",
|
|
size: 1000,
|
|
hash: "a".repeat(40),
|
|
});
|
|
await h.db
|
|
.insertInto("acquisition_plan")
|
|
.values(
|
|
Array.from({ length: 51 }, (_, i) => ({
|
|
slot_id: slotId,
|
|
source_release_id: releaseId,
|
|
status: "failed" as const,
|
|
payload_hash: `history-${i}`,
|
|
idempotency_key: `history:${i}`,
|
|
})),
|
|
)
|
|
.execute();
|
|
await h.db
|
|
.insertInto("organization_record")
|
|
.values(
|
|
Array.from({ length: 105 }, (_, i) => ({
|
|
media_entity_id: entityId,
|
|
trigger: "manual" as const,
|
|
actor_type: "user" as const,
|
|
action: "copy" as const,
|
|
status: "succeeded" as const,
|
|
source_path: `/history/${i}.mkv`,
|
|
target_path: `/library/history/${i}.mkv`,
|
|
})),
|
|
)
|
|
.execute();
|
|
const response = await h.api("GET", `/library/${entityId}`);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data.organizationRecords).toHaveLength(105);
|
|
expect(response.body.data.downloads).toHaveLength(105);
|
|
expect(response.body.data.droppedPlans).toHaveLength(51);
|
|
});
|
|
});
|