生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。
- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
(metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)
同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距
依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
368 lines
17 KiB
TypeScript
368 lines
17 KiB
TypeScript
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> = {}): 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 - 第 1 集.mkv"],
|
|
["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E02 - 第 2 集.mkv"],
|
|
["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.chi.zh-cn.srt"],
|
|
["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.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> = {}): 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("逐文件季/集覆写:优先于文件名解析,目录前缀对其下文件生效,电影忽略", () => {
|
|
const D = "/dl/Weird.Name";
|
|
const plan = planOrganize({
|
|
operation: "copy",
|
|
mediaType: "tv",
|
|
sourceRoot: D,
|
|
files: [
|
|
{ path: `${D}/weird-a.mkv`, size: GiB },
|
|
{ path: `${D}/weird-a.chs.srt`, size: 1000 },
|
|
{ path: `${D}/Show.S01E05.mkv`, size: GiB },
|
|
{ path: `${D}/sub/Show.S01E07.mkv`, size: GiB },
|
|
],
|
|
entity: { title: "剧", year: 2020 },
|
|
target: { seasonDirs: { 1: "/lib/剧/Season 1", 2: "/lib/剧/Season 2" } },
|
|
overrides: {
|
|
[`${D}/weird-a.mkv`]: { season: 2, episodes: { start: 3, end: 4 } },
|
|
[`${D}/Show.S01E05.mkv`]: { episodes: { start: 6, end: 6 } },
|
|
[`${D}/sub`]: { season: 2 },
|
|
},
|
|
});
|
|
expect(plan.files.map((f) => f.dst)).toEqual([
|
|
"/lib/剧/Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv",
|
|
"/lib/剧/Season 1/剧 - S01E06 - 第 6 集.mkv",
|
|
"/lib/剧/Season 2/剧 - S02E07 - 第 7 集.mkv",
|
|
"/lib/剧/Season 2/剧 - S02E03-E04 - 第 3-4 集.chi.zh-cn.srt",
|
|
]);
|
|
expect(plan.files[0]).toMatchObject({ season: 2, episodes: { start: 3, end: 4 } });
|
|
const movie = planOrganize({
|
|
operation: "copy",
|
|
mediaType: "movie",
|
|
files: [{ path: "/dl/M.2020.mkv", size: GiB }],
|
|
entity: { title: "M", year: 2020 },
|
|
target: { movieDir: "/lib/M (2020)" },
|
|
overrides: { "/dl/M.2020.mkv": { season: 3 } },
|
|
});
|
|
expect(movie.files[0]!.dst).toBe("/lib/M (2020)/M (2020).mkv");
|
|
});
|
|
|
|
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",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("原地入库计划", () => {
|
|
it("保留视频与配对字幕路径,不依赖命名模板和目标,不要求搬运空间或目标不存在", () => {
|
|
const input = tvInput({
|
|
operation: "in_place",
|
|
target: { device: 2 },
|
|
templates: { version: 2, movie: "{{invalid}}", tv: "{{invalid}}", variety: "{{invalid}}" },
|
|
});
|
|
const p = planOrganize(input);
|
|
expect(p.files).toHaveLength(4);
|
|
expect(p.files.every((f) => f.src === f.dst && f.action === "transfer")).toBe(true);
|
|
expect(p.requiresApproval).toBe(false);
|
|
expect(p.crossDevice).toBe(false);
|
|
expect(p.preconditions.map((p) => p.kind)).toEqual(["source_unchanged"]);
|
|
expect(p.files[0]?.season).toBe(1);
|
|
expect(p.files[0]?.episodes).toEqual({ start: 1, end: 1 });
|
|
});
|
|
it("不将当前文件视作目标冲突,仍排除样例与无法定位的剧集", () => {
|
|
const input = tvInput({
|
|
operation: "in_place",
|
|
files: [
|
|
{ path: `${ROOT}/Unknown.mkv`, size: GiB },
|
|
{ path: `${ROOT}/Known.S02E03.mkv`, size: GiB },
|
|
{ path: `${ROOT}/sample.mkv`, size: GiB },
|
|
],
|
|
});
|
|
const p = planOrganize({ ...input, existingTargets: input.files.map((f) => ({ path: f.path, size: f.size })) });
|
|
expect(p.files).toHaveLength(1);
|
|
expect(p.files[0]?.dst).toBe(`${ROOT}/Known.S02E03.mkv`);
|
|
expect(p.excluded.map((f) => f.reason)).toEqual(["no_episode", "sample"]);
|
|
expect(p.requiresApproval).toBe(false);
|
|
});
|
|
});
|