Files
UHD_Media_Tool/apps/server/test/acquisition-organize/library-versions.test.ts
T
rootandClaude Opus 5.5 6549ab92c3 fix(media): 详情页标语只显示简体中文,简中没有时不显示
- adapters 新增 simplifiedChineseTagline:取值语言为繁中 / 非中文、或文字是繁体特有字时不显示;原语言为中文(zh / cn)的作品按简中处理
- TMDB 富详情带回 taglineLanguage;发现页详情的 tagline 按上述口径给出
- 媒体库作品详情新增 displayTagline(存储的标语语言为 null,只能按文字判断);metadata.tagline 存储不变
- 补 adapters 单测与作品详情接口测试

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:32:00 +08:00

368 lines
14 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);
});
it("作品详情头部标语只给简体中文(displayTagline);存储的 metadata.tagline 不变", async () => {
const field = (value: unknown, language: string | null = null) => ({
value,
source: "tmdb",
language,
at: "2026-10-01T00:00:00.000Z",
locked: false,
});
const cases: [string, string, string | null][] = [
["我们注定要离开地球", "en", "我们注定要离开地球"],
["我們註定要離開地球", "en", null],
["Mankind was born on Earth.", "en", null],
["我們註定要離開地球", "zh", "我們註定要離開地球"],
];
for (const [tagline, orig, want] of cases) {
const entityId = await seedEntity(h.db, { kind: "movie", title: `标语 ${tagline}`, year: 2026 });
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ tagline: field(tagline), originalLanguage: field(orig) }) })
.where("id", "=", entityId)
.execute();
const r = await h.api("GET", `/library/${entityId}`);
expect(r.status).toBe(200);
expect(r.body.data.displayTagline).toBe(want);
expect(r.body.data.entity.metadata.tagline.value).toBe(tagline);
}
});
});