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, 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 = {}; 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); } }); });