import { describe, expect, it } from "vitest"; import { findOtherVersions, type OrganizeInput, planOrganize, sidecarLanguageSuffix, sidecarStem, } from "../src/index.ts"; const MiB = 1024 ** 2; const GiB = 1024 ** 3; const ROOT = "/downloads/tv/The.Last.of.Us.S01.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX"; const tvInput = (over: Partial = {}): OrganizeInput => ({ operation: "copy", mediaType: "tv", sourceRoot: ROOT, files: [ { path: `${ROOT}/The.Last.of.Us.S01E01.2160p.mkv`, size: 5 * GiB, device: 1 }, { path: `${ROOT}/The.Last.of.Us.S01E02.2160p.mkv`, size: 5 * GiB, device: 1 }, { path: `${ROOT}/The.Last.of.Us.S01E01.2160p.chs.srt`, size: 50_000, device: 1 }, { path: `${ROOT}/The.Last.of.Us.S01E01.2160p.eng.default.srt`, size: 50_000, device: 1 }, { path: `${ROOT}/Sample/the.last.of.us.s01e01.sample.mkv`, size: 80 * MiB, device: 1 }, { path: `${ROOT}/The.Last.of.Us.S01.nfo`, size: 1000, device: 1 }, { path: `${ROOT}/poster.jpg`, size: 1000, device: 1 }, ], entity: { title: "最后生还者", year: 2023 }, target: { seasonDirs: { 1: "/media/tv/最后生还者 (2023)/Season 1" }, device: 2 }, ...over, }); describe("planOrganize 整季包", () => { it("主视频 + 随行字幕,样例/nfo/图片排除,跨卷标记", () => { const plan = planOrganize(tvInput()); expect(plan.files.map((f) => [f.role, f.dst])).toEqual([ ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.mkv"], ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E02.mkv"], ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.chi.zh-cn.srt"], ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.eng.default.srt"], ]); expect(plan.files[0]).toMatchObject({ kind: "video", crossDevice: true, season: 1, episodes: { start: 1, end: 1 }, }); expect(plan.files[2]).toMatchObject({ kind: "subtitle", mainSrc: `${ROOT}/The.Last.of.Us.S01E01.2160p.mkv` }); expect(plan.excluded.map((e) => [e.reason, e.kind])).toEqual([ ["sample", "video"], ["not_imported_kind", "nfo"], ["not_imported_kind", "other"], ]); expect(plan.crossDevice).toBe(true); expect(plan.requiresApproval).toBe(false); expect(plan.preconditions.map((p) => p.kind)).toEqual([ "free_space", "target_absent", "source_unchanged", "verify_after_copy", ]); expect(plan.preconditions[0]).toEqual({ kind: "free_space", device: 2, bytes: 10 * GiB + 100_000 }); }); it("move 跨卷:不降级,只标记 crossDevice 让执行层 copy→verify→delete", () => { const plan = planOrganize(tvInput({ operation: "move", retention: { hnrSatisfied: true } })); expect(plan.operation).toBe("move"); expect(plan.crossDevice).toBe(true); expect(plan.preconditions.map((p) => p.kind)).toContain("hnr_satisfied"); expect(plan.preconditions.map((p) => p.kind)).toContain("verify_after_copy"); expect(plan.requiresApproval).toBe(false); }); it("move 同卷:无需空间/校验前置", () => { const plan = planOrganize( tvInput({ operation: "move", retention: { hnrSatisfied: true }, target: { seasonDirs: { 1: "/m/S1" }, device: 1 }, }), ); expect(plan.crossDevice).toBe(false); expect(plan.preconditions.map((p) => p.kind)).toEqual(["hnr_satisfied", "target_absent", "source_unchanged"]); }); it("H&R 未满足/未知:move 变为冲突,不静默改 copy", () => { const no = planOrganize(tvInput({ operation: "move", retention: { hnrSatisfied: false } })); expect(no.operation).toBe("move"); expect(no.conflicts).toContainEqual(expect.objectContaining({ kind: "hnr_unsatisfied", blocking: true })); expect(no.requiresApproval).toBe(true); const unk = planOrganize(tvInput({ operation: "move" })); expect(unk.conflicts.map((c) => c.kind)).toContain("hnr_unknown"); }); it("缺集号:特典跳过,普通文件进待识别;季目录缺失", () => { const plan = planOrganize( tvInput({ files: [ { path: `${ROOT}/NCOP01.mkv`, size: GiB }, { path: `${ROOT}/Random.Clip.mkv`, size: GiB }, { path: `${ROOT}/The.Last.of.Us.S02E01.mkv`, size: GiB }, ], }), ); expect(plan.excluded.map((e) => e.reason)).toEqual(["special_extra", "no_episode", "no_target_dir"]); expect(plan.conflicts).toContainEqual(expect.objectContaining({ kind: "no_main_video", blocking: true })); }); it("综艺按日期命名;单季目录兜底", () => { const plan = planOrganize({ operation: "copy", mediaType: "tv", sourceRoot: "/dl/奔跑吧 第十二季 1080p WEB-DL-HHWEB", files: [ { path: "/dl/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.2024.05.03.mp4", size: GiB }, { path: "/dl/奔跑吧 第十二季 1080p WEB-DL-HHWEB/tiny.mp4", size: 10 }, ], entity: { title: "奔跑吧" }, target: { seasonDirs: { 12: "/media/variety/奔跑吧/Season 12" } }, }); expect(plan.files[0]!.dst).toBe("/media/variety/奔跑吧/Season 12/奔跑吧 - 2024-05-03.mp4"); expect(plan.files[0]!.airDate).toBe("20240503"); expect(plan.excluded[0]!.reason).toBe("too_small"); const noSeason = planOrganize({ operation: "copy", mediaType: "tv", files: [{ path: "/dl/某综艺.20240503.mp4", size: GiB }], entity: { title: "某综艺" }, target: { seasonDirs: { 1: "/m/S1" } }, }); expect(noSeason.files[0]!.dst).toBe("/m/S1/某综艺 - 2024-05-03.mp4"); }); it("附加内容目录不入库(M2)", () => { const plan = planOrganize({ operation: "copy", mediaType: "movie", files: [{ path: "/dl/Oppenheimer.2023.1080p/Featurettes/Making.Of.mkv", size: GiB }], entity: { title: "奥本海默", year: 2023 }, target: { movieDir: "/media/movies/奥本海默 (2023)" }, }); expect(plan.excluded[0]!.reason).toBe("extra_content"); }); }); describe("planOrganize 电影与冲突", () => { const MOVIE = "/downloads/movie/Oppenheimer.2023.1080p.BluRay.x264-SPARKS"; const base = (over: Partial = {}): OrganizeInput => ({ operation: "copy", mediaType: "movie", sourceRoot: MOVIE, files: [ { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.mkv`, size: 10 * GiB, sha256: "aa" }, { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.cht.srt`, size: 10 }, { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.繁体.srt`, size: 10 }, { path: `${MOVIE}/other.srt`, size: 10 }, ], entity: { title: "奥本海默", year: 2023 }, target: { movieDir: "/media/movies/奥本海默 (2023)/" }, ...over, }); const DST = "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.mkv"; it("电影命名、繁体字幕后缀、同名字幕去重、孤儿字幕不搬不删", () => { const plan = planOrganize(base()); expect(plan.files.map((f) => f.dst)).toEqual([ DST, "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.zh-tw.srt", "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.zh-tw.1.srt", ]); expect(plan.excluded).toContainEqual( expect.objectContaining({ reason: "orphan_sidecar", src: `${MOVIE}/other.srt` }), ); expect(plan.conflicts).toContainEqual(expect.objectContaining({ kind: "orphan_sidecar", blocking: false })); expect(plan.crossDevice).toBe(false); expect(plan.files[0]!.crossDevice).toBeUndefined(); }); it("目标存在:默认 conflict(hold + 需审批)", () => { const plan = planOrganize(base({ existingTargets: [{ path: DST, size: 9 * GiB }] })); expect(plan.files[0]!.action).toBe("hold"); expect(plan.files[1]!.action).toBe("hold"); expect(plan.requiresApproval).toBe(true); }); it("目标存在:skip 策略记录待处理但不阻塞;附属文件随主视频跳过", () => { const plan = planOrganize(base({ onExisting: "skip", existingTargets: [{ path: DST, size: 9 * GiB }] })); expect(plan.files[0]).toMatchObject({ action: "skip", skipReason: "target_exists" }); expect(plan.files[1]).toMatchObject({ action: "skip", skipReason: "main_not_transferred" }); expect(plan.requiresApproval).toBe(false); expect(plan.preconditions).toEqual([]); }); it("目标存在且内容相同 → 已在库,不是冲突", () => { const plan = planOrganize(base({ existingTargets: [{ path: DST, size: 10 * GiB, sha256: "aa" }] })); expect(plan.files[0]).toMatchObject({ action: "skip", skipReason: "already_in_library" }); expect(plan.conflicts.filter((c) => c.blocking)).toEqual([]); }); it("replace_with_verify:需授权,旧文件先隔离", () => { const opts = { onExisting: "replace_with_verify" as const, existingTargets: [{ path: DST, size: 9 * GiB }] }; const noGrant = planOrganize(base(opts)); expect(noGrant.files[0]!.action).toBe("replace"); expect(noGrant.conflicts).toContainEqual( expect.objectContaining({ kind: "replace_requires_grant", blocking: true }), ); const granted = planOrganize(base({ ...opts, grants: { replace: true } })); expect(granted.requiresApproval).toBe(false); expect(granted.preconditions).toContainEqual({ kind: "quarantine_replaced", paths: [DST] }); }); it("计划内两个源写同一目标 → duplicate_target", () => { const plan = planOrganize( base({ files: [ { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.mkv`, size: 10 * GiB }, { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.v2.mkv`, size: 10 * GiB }, ], }), ); expect(plan.conflicts[0]).toMatchObject({ kind: "duplicate_target", blocking: true, dst: DST }); expect(plan.files.every((f) => f.action === "hold")).toBe(true); }); it("rename:库内改名,无空间与校验前置;电影缺目标目录 / 命名失败", () => { const plan = planOrganize(base({ operation: "rename", files: base().files.slice(0, 1) })); expect(plan.preconditions.map((p) => p.kind)).toEqual(["target_absent", "source_unchanged"]); const noDir = planOrganize(base({ target: {} })); expect(noDir.excluded[0]!.reason).toBe("no_target_dir"); const noName = planOrganize(base({ entity: { title: "" } })); expect(noName.excluded[0]).toMatchObject({ reason: "naming_failed", detail: "title" }); }); it("两个同名主视频(不同目录)时字幕只配同目录的", () => { const plan = planOrganize( base({ sourceRoot: undefined, files: [ { path: "/a/Movie.2020.mkv", size: GiB }, { path: "/b/Movie.2020.mkv", size: GiB }, { path: "/a/Movie.2020.srt", size: 1 }, ], }), ); const sub = plan.files.find((f) => f.kind === "subtitle"); expect(sub?.mainSrc).toBe("/a/Movie.2020.mkv"); }); }); describe("附属文件主干名", () => { it("逐级剥离语言标签", () => { expect(sidecarStem("/x/Show.S01E03.chs.default.srt")).toEqual({ stem: "show.s01e03", tags: ["chs", "default"] }); expect(sidecarStem("Show.S01E03.mkv.srt").stem).toBe("show.s01e03.mkv"); expect(sidecarStem("简体.srt")).toEqual({ stem: "简体", tags: [] }); expect(sidecarLanguageSuffix(["jpn"])).toBe(".ja"); expect(sidecarLanguageSuffix(["kor", "forced"])).toBe(".ko.forced"); expect(sidecarLanguageSuffix(["简体", "繁体"])).toBe(".zh-tw"); expect(sidecarLanguageSuffix([])).toBe(""); }); }); describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", () => { it("defaultLang 命中的随行字幕加 .default;源自带 .default 保留;不命中不加", () => { expect(sidecarLanguageSuffix(["chs"], "zh-cn")).toBe(".chi.zh-cn.default"); expect(sidecarLanguageSuffix(["cht"], "zh-cn")).toBe(".zh-tw"); expect(sidecarLanguageSuffix(["eng"], "en")).toBe(".eng.default"); expect(sidecarLanguageSuffix(["eng", "default"])).toBe(".eng.default"); expect(sidecarLanguageSuffix([], "zh-cn")).toBe(""); }); it("planOrganize 透传 defaultSubtitleLang", () => { const plan = planOrganize({ operation: "copy", mediaType: "movie", files: [ { path: "/dl/Movie.2020.1080p.BluRay.x264-GRP.mkv", size: 2e9 }, { path: "/dl/Movie.2020.1080p.BluRay.x264-GRP.chs.srt", size: 1e4 }, { path: "/dl/Movie.2020.1080p.BluRay.x264-GRP.eng.srt", size: 1e4 }, ], entity: { title: "Movie", year: 2020 }, target: { movieDir: "/lib/Movie (2020)" }, defaultSubtitleLang: "zh-cn", }); const subs = plan.files.filter((f) => f.role === "sidecar").map((f) => f.dst.split("/").pop()); expect(subs.some((n) => n?.endsWith(".chi.zh-cn.default.srt"))).toBe(true); expect(subs.some((n) => n?.endsWith(".eng.srt"))).toBe(true); }); it("findOtherVersions:同季同集同 Part、只看视频扩展名", () => { const sib = [ "/lib/S01/Show - S01E01 - 1080p.mkv", "/lib/S01/Show - S01E01 - 2160p.mkv", "/lib/S01/Show - S01E01 - 2160p.chi.zh-cn.srt", "/lib/S01/Show - S01E01 - 720p.mp4", "/lib/S01/Show - S01E02 - 1080p.mkv", "/lib/S01/Show - S01E01 - 1080p.nfo", ]; expect(findOtherVersions("/lib/S01/Show - S01E01 - 2160p.mkv", sib).sort()).toEqual([ "/lib/S01/Show - S01E01 - 1080p.mkv", "/lib/S01/Show - S01E01 - 720p.mp4", ]); expect(findOtherVersions("/lib/S01/Show - S01E01 - 2160p.chi.zh-cn.srt", sib)).toEqual([]); const movie = [ "/lib/M (2020)/M (2020) - 1080p.mkv", "/lib/M (2020)/M (2020) - 2160p.mkv", "/lib/M (2020)/M (2020) - 1080p.srt", ]; expect(findOtherVersions("/lib/M (2020)/M (2020) - 2160p.mkv", movie)).toEqual([ "/lib/M (2020)/M (2020) - 1080p.mkv", ]); }); });