feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)

- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue,
  cut, release group suffix fallback, dir+file merge, safe episode offset)
- identity: title normalization (NFKC, trad->simp map, season suffix) and
  evidence-level candidate ranking (year tolerance ±1, no probabilities)
- slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine,
  movie/unit completion (season pack must be covered by actual file list)
- acquisition: new_title/catch_up/upgrade modes, priority order, budget,
  cache volume hard threshold with resume watermark (no double counting)
- path: immutable path bindings, restricted naming templates, sanitize,
  conflict detection, downloader<->host path mapping
- organize: planOrganize with sidecar pairing, sample exclusion, existing
  target policy, crossDevice marking, H&R -> conflict for move
- download-state: qB state mapping, completion, no-progress evidence

230 tests, ~97% statement coverage.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:29 +08:00
1 parent a6d049db81
commit 7ba712ea55
40 files changed
+5885 -2

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import {
checkBudget,
DEFAULT_PRIORITY_ORDER,
determineMode,
evaluateCacheVolume,
type InflightTask,
type ModeInput,
priorityOf,
sortByPriority,
validatePriorityOrder,
} from "../src";
const GiB = 1024 ** 3;
const base: ModeInput = {
entity: null,
releaseUnits: [],
satisfiedUnits: [],
slotSatisfied: false,
now: "2026-09-27T00:00:00Z",
newTitleWindowDays: 90,
};
describe("三种模式判定", () => {
it("新作品按发行日期,不按站点上传日期", () => {
expect(determineMode({ ...base, firstReleaseDate: "2026-08-01" })).toEqual({
mode: "new_title",
reason: "released_within_window",
});
expect(determineMode({ ...base, firstReleaseDate: "1999-03-31" }).reason).toBe("not_new_release");
expect(determineMode(base).reason).toBe("release_date_unknown");
expect(determineMode({ ...base, firstReleaseDate: "bad" }).reason).toBe("release_date_unknown");
expect(determineMode({ ...base, firstReleaseDate: "2027-01-01" }).reason).toBe("not_yet_released");
});
it("追更补缺:覆盖未满足的单元", () => {
const tv = { ...base, entity: { tracked: true, kind: "tv" as const } };
expect(determineMode({ ...tv, releaseUnits: ["e1", "e2"], satisfiedUnits: ["e1"] }).mode).toBe("catch_up");
expect(determineMode({ ...tv, releaseUnits: ["e1"], satisfiedUnits: ["e1"] }).reason).toBe("already_satisfied");
expect(determineMode({ ...tv, releaseUnits: ["e1"], satisfiedUnits: ["e1"], upgradeCandidate: true }).mode).toBe(
"upgrade",
);
expect(determineMode({ ...tv }).reason).toBe("no_units_resolved");
});
it("已有电影:Slot 缺口/更优版本 → 升级;只登记的作品不自动下载", () => {
const mv = { ...base, entity: { tracked: true, kind: "movie" as const } };
expect(determineMode(mv)).toEqual({ mode: "upgrade", reason: "slot_gap" });
expect(determineMode({ ...mv, slotSatisfied: true }).mode).toBeNull();
expect(determineMode({ ...mv, slotSatisfied: true, upgradeCandidate: true }).reason).toBe("better_version");
expect(determineMode({ ...mv, entity: { tracked: false, kind: "movie" } }).reason).toBe("not_tracked");
});
});
describe("优先级", () => {
const items = [
{ id: "u", priorityClass: priorityOf("upgrade"), enqueuedAt: "2026-01-01T00:00:00Z" },
{ id: "n", priorityClass: priorityOf("new_title"), enqueuedAt: "2026-01-01T00:00:00Z" },
{ id: "c2", priorityClass: priorityOf("catch_up"), enqueuedAt: "2026-01-02T00:00:00Z" },
{ id: "c1", priorityClass: priorityOf("catch_up"), enqueuedAt: "2026-01-01T00:00:00Z" },
{ id: "i", priorityClass: priorityOf("upgrade", true), enqueuedAt: "2026-01-03T00:00:00Z" },
];
it("默认 即时 > 追更 > 新作品 > 升级,同级先进先出", () => {
expect(sortByPriority(items).map((x) => x.id)).toEqual(["i", "c1", "c2", "n", "u"]);
expect(DEFAULT_PRIORITY_ORDER[0]).toBe("immediate");
});
it("可配置", () => {
expect(sortByPriority(items, ["immediate", "new_title", "catch_up", "upgrade"]).map((x) => x.id)).toEqual([
"i",
"n",
"c1",
"c2",
"u",
]);
});
it("配置必须完整", () => {
expect(() => validatePriorityOrder(["immediate", "catch_up"])).toThrow();
expect(() => validatePriorityOrder(["immediate", "catch_up", "catch_up", "upgrade"])).toThrow();
});
});
describe("预算", () => {
const budget = { maxNewTitlesPerDay: 3, maxBytesPerDay: 100 * GiB, maxConcurrent: 2 };
it("放行", () => {
const r = checkBudget(
budget,
{ newTitlesToday: 2, bytesToday: 10 * GiB, activeDownloads: 1 },
{ countsAsNewTitle: true, bytes: GiB },
);
expect(r).toEqual({ decision: "allow", violations: [], unknown: [] });
});
it("超限列出全部违规,预留字节计入", () => {
const r = checkBudget(
budget,
{ newTitlesToday: 3, bytesToday: 60 * GiB, reservedBytesToday: 30 * GiB, activeDownloads: 2 },
{ countsAsNewTitle: true, bytes: 20 * GiB },
);
expect(r.decision).toBe("deny");
expect(r.violations.map((v) => v.kind)).toEqual(["new_titles", "bytes", "concurrency"]);
expect(r.violations[1]!.current).toBe(90 * GiB);
});
it("体积未知 → unknown,不放行;追更不占新作品额度", () => {
const r = checkBudget(
budget,
{ newTitlesToday: 3, bytesToday: 0, activeDownloads: 0 },
{ countsAsNewTitle: false },
);
expect(r).toEqual({ decision: "unknown", violations: [], unknown: ["bytes"] });
expect(
checkBudget({}, { newTitlesToday: 99, bytesToday: 0, activeDownloads: 99 }, { countsAsNewTitle: true }).decision,
).toBe("allow");
});
});
describe("缓存卷硬阈值", () => {
const cfg = { hardPct: 90, resumePct: 85 };
const vol = (usedPct: number) => ({ volumeId: "v1", totalBytes: 1000 * GiB, usedBytes: usedPct * 10 * GiB });
const task = (over: Partial<InflightTask>): InflightTask => ({
taskId: "t",
volumeId: "v1",
sizeBytes: 50 * GiB,
onDiskBytes: 0,
managed: true,
status: "downloading",
...over,
});
it("读不到磁盘状态 → 保守阻止", () => {
const r = evaluateCacheVolume({
volume: null,
config: cfg,
inflight: [],
reservations: [],
pausedByThreshold: false,
});
expect(r.decision).toBe("block");
expect(r.reasons).toEqual(["volume_state_unknown"]);
});
it("配置校验", () => {
expect(() =>
evaluateCacheVolume({
volume: vol(1),
config: { hardPct: 80, resumePct: 85 },
inflight: [],
reservations: [],
pausedByThreshold: false,
}),
).toThrow();
});
it("达到硬阈值 → 暂停纳管的下载中任务", () => {
const r = evaluateCacheVolume({
volume: vol(91),
config: cfg,
inflight: [
task({ taskId: "a" }),
task({ taskId: "b", managed: false }),
task({ taskId: "c", status: "paused" }),
task({ taskId: "d", volumeId: "v2" }),
],
reservations: [],
pausedByThreshold: false,
});
expect(r.decision).toBe("pause_managed");
expect(r.pauseTaskIds).toEqual(["a"]);
expect(r.unmanagedGrowthRisk).toBe(true);
});
it("在途剩余需求 + 预留:已预分配不重复扣,已提交计划不重复算预留", () => {
const r = evaluateCacheVolume({
volume: vol(70),
config: cfg,
inflight: [
task({ taskId: "a", planId: "p1", sizeBytes: 100 * GiB, onDiskBytes: 100 * GiB }),
task({ taskId: "b", planId: "p2", sizeBytes: 100 * GiB, onDiskBytes: 40 * GiB }),
task({ taskId: "c", status: "completed", sizeBytes: 500 * GiB }),
],
reservations: [
{ planId: "p2", volumeId: "v1", bytes: 100 * GiB },
{ planId: "p3", volumeId: "v1", bytes: 50 * GiB },
{ planId: "p4", volumeId: "v2", bytes: 999 * GiB },
],
request: { bytes: 60 * GiB },
pausedByThreshold: false,
});
expect(r.pendingBytes).toBe(60 * GiB);
expect(r.reservedBytes).toBe(50 * GiB);
expect(r.projectedPct).toBeCloseTo(87);
expect(r.decision).toBe("allow");
const over = evaluateCacheVolume({
volume: vol(70),
config: cfg,
inflight: [task({ sizeBytes: 150 * GiB })],
reservations: [],
request: { bytes: 60 * GiB },
pausedByThreshold: false,
});
expect(over.decision).toBe("block");
expect(over.reasons).toContain("projected_over_threshold");
});
it("体积未知 → 阻止新提交", () => {
const r = evaluateCacheVolume({
volume: vol(10),
config: cfg,
inflight: [task({ sizeBytes: undefined, taskId: "m" })],
reservations: [],
request: {},
pausedByThreshold: false,
});
expect(r.decision).toBe("block");
expect(r.reasons).toEqual(["request_size_unknown", "inflight_size_unknown"]);
expect(r.unknownSizeTaskIds).toEqual(["m"]);
});
it("恢复水位滞回:85%~90% 之间保持暂停,≤85% 恢复阈值暂停的任务", () => {
const mid = evaluateCacheVolume({
volume: vol(87),
config: cfg,
inflight: [],
reservations: [],
pausedByThreshold: true,
});
expect(mid.decision).toBe("block");
expect(mid.resumeThresholdPaused).toBe(false);
const low = evaluateCacheVolume({
volume: vol(80),
config: cfg,
inflight: [],
reservations: [],
pausedByThreshold: true,
});
expect(low.decision).toBe("allow");
expect(low.resumeThresholdPaused).toBe(true);
expect(low.reasons).toEqual(["below_resume_watermark"]);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
applyObservation,
DownloadTransitionError,
detectNoProgress,
isDownloadComplete,
mapQbState,
type ProgressSample,
} from "../src";
const ctx = { pausedByThreshold: false };
describe("qB 状态映射", () => {
it.each([
["downloading", 0.5, "downloading"],
["stalledDL", 0.5, "downloading"],
["metaDL", 0, "downloading"],
["forcedDL", 0.1, "downloading"],
["queuedDL", 0, "queued"],
["checkingResumeData", 0.4, "queued"],
["uploading", 1, "completed"],
["stalledUP", 1, "completed"],
["pausedUP", 1, "completed"],
["stoppedUP", 1, "completed"],
["queuedUP", 1, "completed"],
["checkingUP", 1, "downloading"],
["uploading", 0.9, "downloading"],
["error", 1, "error"],
["missingFiles", 1, "missing_files"],
["pausedDL", 0.3, "paused_by_user"],
["stoppedDL", 0.3, "paused_by_user"],
] as const)("%s @%s → %s", (state, progress, want) => {
expect(mapQbState({ state, progress }, ctx).status).toBe(want);
});
it("阈值暂停与用户暂停由业务账本区分", () => {
expect(mapQbState({ state: "pausedDL", progress: 0.3 }, { pausedByThreshold: true }).status).toBe(
"paused_by_threshold",
);
});
it("任务消失 → removed;未知状态沿用上次并标记不确定", () => {
expect(mapQbState(null, ctx).status).toBe("removed");
expect(mapQbState({ state: "weird", progress: 0 }, { ...ctx, previous: "downloading" })).toEqual({
status: "downloading",
uncertain: true,
reason: "unknown_state:weird",
});
expect(mapQbState({ state: "weird", progress: 0 }, ctx).status).toBe("queued");
});
});
describe("完成判定", () => {
it("error/missingFiles/校验中不算完成", () => {
expect(isDownloadComplete({ state: "uploading", progress: 1 })).toBe(true);
expect(isDownloadComplete({ state: "error", progress: 1 })).toBe(false);
expect(isDownloadComplete({ state: "missingFiles", progress: 1 })).toBe(false);
expect(isDownloadComplete({ state: "checkingUP", progress: 1 })).toBe(false);
expect(isDownloadComplete({ state: "pausedUP", progress: 1, amountLeft: 10 })).toBe(false);
expect(isDownloadComplete({ state: "downloading", progress: 0.99 })).toBe(false);
expect(isDownloadComplete(null)).toBe(false);
});
});
describe("状态机应用", () => {
it("正常迁移与异常标记", () => {
expect(applyObservation("queued", { state: "downloading", progress: 0.1 }, ctx)).toEqual({
status: "downloading",
changed: true,
});
expect(applyObservation("downloading", { state: "downloading", progress: 0.2 }, ctx).changed).toBe(false);
expect(applyObservation("completed", { state: "downloading", progress: 0.9 }, ctx).anomaly).toBe(
"completed_regressed",
);
expect(applyObservation("downloading", { state: "??", progress: 0.9 }, ctx)).toEqual({
status: "downloading",
changed: false,
anomaly: "unknown_state",
});
});
it("removed 是终态", () => {
expect(() => applyObservation("removed", { state: "downloading", progress: 0 }, ctx)).toThrow(
DownloadTransitionError,
);
expect(applyObservation("removed", null, ctx).status).toBe("removed");
});
});
describe("无进展判定", () => {
const min = 60_000;
const s = (t: number, bytes: number, status: ProgressSample["status"] = "downloading"): ProgressSample => ({
at: t * min,
downloadedBytes: bytes,
status,
});
it("持续下载中无增长 → 只记录证据", () => {
const r = detectNoProgress([s(0, 100), s(30, 100), s(61, 100)], { thresholdMs: 60 * min });
expect(r).toMatchObject({
stalled: true,
action: "record_evidence",
stalledMs: 61 * min,
lastProgressAt: 0,
lastBytes: 100,
});
});
it("有增长则清零;暂停/排队时间不计入", () => {
expect(detectNoProgress([s(0, 100), s(50, 200), s(100, 200)], { thresholdMs: 60 * min }).stalled).toBe(false);
const paused = detectNoProgress(
[s(0, 100), s(10, 100, "paused_by_threshold"), s(200, 100, "paused_by_threshold"), s(210, 100)],
{ thresholdMs: 60 * min },
);
expect(paused.stalled).toBe(false);
expect(paused.excludedMs).toBe(210 * min);
const ending = detectNoProgress([s(0, 1), s(100, 1), s(200, 1, "paused_by_user")], { thresholdMs: 60 * min });
expect(ending.stalled).toBe(false);
});
it("样本不足", () => {
expect(detectNoProgress([], { thresholdMs: 1 })).toMatchObject({ stalled: false, sampleCount: 0, action: "none" });
expect(detectNoProgress([s(0, 1)], { thresholdMs: 0 }).stalled).toBe(false);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
type IdentityCandidate,
normalizeTitle,
parseRelease,
queryTitles,
rankCandidates,
resolveIdentity,
scoreCandidate,
stripSeasonSuffix,
TRAD_TO_SIMP,
toSimplified,
} from "../src";
const movie = (over: Partial<IdentityCandidate> = {}): IdentityCandidate => ({
provider: "tmdb",
entityType: "movie",
externalId: "872585",
titles: ["Oppenheimer", "奥本海默"],
year: 2023,
aliases: [],
...over,
});
describe("标题归一化", () => {
it("繁简 / 全半角 / 标点 / 大小写", () => {
expect(toSimplified("葬送的芙莉蓮")).toBe("葬送的芙莉莲");
expect(normalizeTitle("葬送的芙莉蓮")).toBe(normalizeTitle("葬送的芙莉莲"));
expect(normalizeTitle("THE LAST OF US")).toBe("thelastofus");
expect(normalizeTitle("Spider-Man: No Way Home")).toBe("spidermannowayhome");
expect(normalizeTitle("Tom & Jerry")).toBe("tomandjerry");
expect(normalizeTitle("黑暗榮耀")).toBe("黑暗荣耀");
});
it("去季号/年份后缀", () => {
expect(stripSeasonSuffix("庆余年 第二季")).toBe("庆余年");
expect(stripSeasonSuffix("The Boys Season 4")).toBe("The Boys");
expect(stripSeasonSuffix("Show S02")).toBe("Show");
expect(stripSeasonSuffix("Title (2019)")).toBe("Title");
expect(stripSeasonSuffix("第二季")).toBe("第二季");
expect(normalizeTitle("庆余年 第二季")).toBe(normalizeTitle("慶餘年"));
});
it("映射表是一一对应的字符对", () => {
expect(TRAD_TO_SIMP.size).toBeGreaterThan(500);
for (const [k, v] of TRAD_TO_SIMP) {
expect([...k].length).toBe(1);
expect([...v].length).toBe(1);
}
});
it("queryTitles 去重", () => {
const p = parseRelease("奥本海默 Oppenheimer 2023 1080p");
expect(queryTitles(p)).toEqual(["奥本海默", "oppenheimer"]);
});
});
describe("候选打分(证据等级,无概率)", () => {
const p = parseRelease("Oppenheimer.2023.2160p.UHD.BluRay.REMUX-FraMeSToR");
it("标题精确 + 年份一致 → rule_confirmed", () => {
const r = scoreCandidate(p, movie());
expect(r.level).toBe("rule_confirmed");
expect(r.titleMatch).toBe("exact");
expect(r.yearMatch).toBe("exact");
expect(Object.values(r).some((v) => typeof v === "number")).toBe(false);
});
it("年份容差 ±1 → multi_evidence;±2 → rejected", () => {
expect(scoreCandidate(p, movie({ year: 2024 })).level).toBe("multi_evidence");
expect(scoreCandidate(p, movie({ year: 2022 })).yearMatch).toBe("within_tolerance");
expect(scoreCandidate(p, movie({ year: 2025 })).level).toBe("rejected");
expect(scoreCandidate(p, movie({ year: 2025 }), { yearTolerance: 2 }).level).toBe("multi_evidence");
});
it("别名命中 → multi_evidence;部分包含 → needs_confirm;无关 → rejected", () => {
expect(scoreCandidate(p, movie({ titles: ["X"], aliases: ["Oppenheimer"] })).level).toBe("multi_evidence");
expect(scoreCandidate(p, movie({ titles: ["Oppenheimer Returns"] })).level).toBe("needs_confirm");
expect(scoreCandidate(p, movie({ titles: ["Barbie"] })).level).toBe("rejected");
});
it("年份未知:电影需确认,剧集有季号且类型一致算多证据", () => {
const noYear = parseRelease("Oppenheimer 1080p WEB-DL");
expect(scoreCandidate(noYear, movie()).level).toBe("needs_confirm");
const tv = parseRelease("奔跑吧 第十二季 20240426 第1期 1080p WEB-DL");
const show: IdentityCandidate = {
provider: "tmdb",
entityType: "tv",
externalId: "1",
titles: ["奔跑吧"],
year: 2017,
aliases: [],
};
const r = scoreCandidate(tv, show);
expect(r.yearMatch).toBe("unknown");
expect(r.typeMatch).toBe("match");
expect(r.level).toBe("multi_evidence");
expect(scoreCandidate(tv, { ...show, entityType: "movie" }).level).toBe("needs_confirm");
});
it("剧集季年份:seasonYears 命中算 season_year;后续季无季年份信息不据此拒绝", () => {
const s2 = parseRelease("The.Last.of.Us.S02.2025.1080p.WEB-DL");
const show: IdentityCandidate = {
provider: "tmdb",
entityType: "tv",
externalId: "100088",
titles: ["The Last of Us"],
year: 2023,
aliases: [],
};
expect(scoreCandidate(s2, show).yearMatch).toBe("unknown");
const withYears = scoreCandidate(s2, { ...show, seasonYears: [2023, 2025] });
expect(withYears.yearMatch).toBe("season_year");
expect(withYears.level).toBe("rule_confirmed");
expect(scoreCandidate(s2, { ...show, seasonYears: [2023] }).level).toBe("rejected");
});
it("显式 ID 提升一级;标题不符但显式 ID 命中 → needs_confirm", () => {
const e = parseRelease("[tmdbid=872585] Oppenheimer 2024 1080p");
const r = scoreCandidate(e, movie());
expect(r.evidence).toContain("explicit_id");
expect(r.level).toBe("rule_confirmed");
const e2 = parseRelease("[tmdbid=872585] Something Else 2023 1080p");
expect(scoreCandidate(e2, movie()).level).toBe("needs_confirm");
const local = movie({
provider: "local",
externalId: "uuid-1",
linkedExternalIds: [{ provider: "tmdb", id: "872585" }],
});
expect(scoreCandidate(e2, local).evidence).toContain("explicit_id");
});
});
describe("排序与判定", () => {
const p = parseRelease("Oppenheimer.2023.1080p.BluRay.x264-CHD");
it("本地实体同等级优先,等级 > 标题 > 年份", () => {
const tmdb = movie();
const local = movie({ provider: "local", externalId: "uuid-1", localEntityId: "uuid-1" });
const weak = movie({ externalId: "9", titles: ["Oppenheimer X"] });
const ranked = rankCandidates(p, [weak, tmdb, local]);
expect(ranked.map((r) => r.candidate.externalId)).toEqual(["uuid-1", "872585", "9"]);
});
it("唯一强候选 → auto", () => {
const res = resolveIdentity(p, [movie(), movie({ externalId: "2", titles: ["Barbie"] })]);
expect(res.status).toBe("auto");
expect(res.best?.candidate.externalId).toBe("872585");
});
it("同等级两个不同身份 → ambiguous;同一身份(本地映射)不算冲突", () => {
const dup = resolveIdentity(p, [movie(), movie({ externalId: "999" })]);
expect(dup.status).toBe("ambiguous");
expect(dup.reason).toBe("multiple_candidates_same_level");
const local = movie({
provider: "local",
externalId: "uuid-1",
localEntityId: "uuid-1",
linkedExternalIds: [{ provider: "tmdb", id: "872585" }],
});
expect(resolveIdentity(p, [movie(), local]).status).toBe("auto");
expect(resolveIdentity(p, [local, movie()]).status).toBe("auto");
expect(resolveIdentity(p, [local, { ...local, externalId: "x" }]).status).toBe("auto");
});
it("弱证据 → ambiguous;无候选 → no_match", () => {
expect(resolveIdentity(p, [movie({ titles: ["Oppenheimer Returns"] })]).status).toBe("ambiguous");
expect(resolveIdentity(p, []).status).toBe("no_match");
expect(resolveIdentity(p, [movie({ titles: ["Barbie"] })]).status).toBe("no_match");
});
});
+238
View File
@@ -0,0 +1,238 @@
import { describe, expect, it } from "vitest";
import { type OrganizeInput, planOrganize, sidecarLanguageSuffix, sidecarStem } from "../src";
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.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> = {}): 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("");
});
});
+303
View File
@@ -0,0 +1,303 @@
import { describe, expect, it } from "vitest";
import {
buildNamingContext,
compileTemplate,
DEFAULT_TEMPLATES,
detectPathConflicts,
type PathBinding,
parseRelease,
renderMediaFileName,
renderTemplate,
resolveTargetDirectory,
sanitizeFileName,
sanitizeSegment,
TemplateError,
toDownloaderPath,
toHostPath,
utf8Length,
validatePathMappings,
validateTemplateSet,
} from "../src";
const root = { id: "lib-tv", hostPath: "/media/tv" };
const binding = (over: Partial<PathBinding>): PathBinding => ({
id: "b1",
entityId: "e1",
scope: "entity",
libraryRootId: "lib-tv",
relativePath: "最后生还者 (2023)",
templateVersion: 1,
...over,
});
describe("路径绑定", () => {
it("已有作品/季绑定优先且不随译名变化", () => {
const r = resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "最后的生还者", year: 2023 },
season: 1,
bindings: [binding({}), binding({ id: "b2", scope: "season", season: 1, relativePath: "最后生还者 (2023)/S1" })],
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
});
expect(r).toEqual({
kind: "existing",
libraryRootId: "lib-tv",
relativePath: "最后生还者 (2023)/S1",
bindingIds: ["b2"],
});
});
it("作品已绑定、新季:只按模板渲染季目录,作品目录复用", () => {
const r = resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "新译名", year: 2023 },
season: 2,
bindings: [binding({})],
libraryRoot: { id: "other", hostPath: "/x" },
templates: DEFAULT_TEMPLATES,
});
expect(r).toMatchObject({
kind: "new",
libraryRootId: "lib-tv",
relativePath: "最后生还者 (2023)/Season 2",
reusedBindingIds: ["b1"],
newBindings: [{ scope: "season", season: 2, templateVersion: 1 }],
});
});
it("首次:作品 + 季两级绑定,非法字符转全角", () => {
const r = resolveTargetDirectory({
entity: { id: "e9", kind: "tv" },
naming: { title: "Show: Part/2" },
season: 1,
bindings: [binding({})],
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
});
expect(r).toMatchObject({ kind: "new", relativePath: "Show: Part/2/Season 1" });
if (r.kind === "new") expect(r.newBindings).toHaveLength(2);
});
it("电影:已有 / 首次 / 模板缺变量", () => {
const mv = { entity: { id: "m1", kind: "movie" as const }, libraryRoot: root, templates: DEFAULT_TEMPLATES };
expect(
resolveTargetDirectory({
...mv,
naming: { title: "X" },
bindings: [binding({ entityId: "m1", relativePath: "沙丘2 (2024)" })],
}),
).toMatchObject({ kind: "existing", relativePath: "沙丘2 (2024)" });
expect(
resolveTargetDirectory({ ...mv, naming: { title: "沙丘:第二部", year: 2024 }, bindings: [] }),
).toMatchObject({
kind: "new",
relativePath: "沙丘:第二部 (2024)",
});
expect(resolveTargetDirectory({ ...mv, naming: {}, bindings: [] })).toEqual({
kind: "error",
reason: "template_missing_vars",
missing: ["title"],
});
});
it("两个历史目录 → conflict,不自动合并;停用绑定忽略", () => {
const two = [binding({}), binding({ id: "b3", relativePath: "The Last of Us (2023)" })];
expect(
resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "x" },
season: 1,
bindings: two,
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
}),
).toMatchObject({ kind: "conflict", reason: "multiple_entity_bindings" });
const inactive = [binding({}), binding({ id: "b3", relativePath: "Old", active: false })];
expect(
resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "x" },
season: 1,
bindings: inactive,
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
}).kind,
).toBe("new");
const seasons = [
binding({ id: "s1", scope: "season", season: 1, relativePath: "A/S1" }),
binding({ id: "s2", scope: "season", season: 1, relativePath: "A/Season 1" }),
];
expect(
resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "x" },
season: 1,
bindings: seasons,
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
}),
).toMatchObject({ kind: "conflict", reason: "multiple_season_bindings" });
});
it("剧集缺季号 / 模板缺变量", () => {
const tv = { entity: { id: "e5", kind: "tv" as const }, bindings: [], libraryRoot: root };
expect(resolveTargetDirectory({ ...tv, naming: { title: "x" }, templates: DEFAULT_TEMPLATES })).toEqual({
kind: "error",
reason: "season_required",
});
expect(resolveTargetDirectory({ ...tv, naming: {}, season: 1, templates: DEFAULT_TEMPLATES })).toMatchObject({
kind: "error",
missing: ["title"],
});
expect(
resolveTargetDirectory({
...tv,
naming: { title: "x" },
season: 1,
templates: { ...DEFAULT_TEMPLATES, seasonDir: "S{episode}" },
}),
).toMatchObject({ kind: "error", missing: ["episode"] });
});
});
describe("命名模板", () => {
it("电影 / 剧集 / 多集区间 / 综艺日期", () => {
const movie = buildNamingContext(
{ title: "沙丘2", year: 2024 },
parseRelease("Dune.Part.Two.2024.2160p.UHD.BluRay.x265-CHD"),
);
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, movie, "MKV")).toEqual({
ok: true,
fileName: "沙丘2 (2024) - 2160p.mkv",
});
const cut = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay"));
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, cut, "mkv")).toMatchObject({
fileName: "指环王 (2001) - Extended - 1080p.mkv",
});
const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p"));
expect(ep.seasonEpisode).toBe("S02E01-E03");
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({
fileName: "庆余年 - S02E01-E03.mkv",
});
const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p"));
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, daily, "mp4")).toMatchObject({
fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4",
});
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, { title: "x" }, "mp4")).toEqual({
ok: false,
missing: ["episode_or_air_date"],
});
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toEqual({ ok: false, missing: ["title"] });
});
it("上下文完整字段", () => {
const ctx = buildNamingContext(
{ title: "T", originalTitle: "O", year: 2020 },
parseRelease("T.2020.S01E05.CD2.2160p.WEB-DL.DV.HEVC-GRP"),
);
expect(ctx).toMatchObject({
originalTitle: "O",
episode: 5,
part: "CD2",
source: "WEB-DL",
videoCodec: "H.265",
hdr: "DV",
releaseGroup: "GRP",
});
expect(buildNamingContext({ title: "T" })).toEqual({ title: "T" });
});
it("格式化与可选片段", () => {
expect(renderTemplate("S{season:02}E{episode:03}", { season: 1, episode: 5 })).toEqual({
ok: true,
value: "S01E005",
});
expect(renderTemplate("{airDate:dot}", { airDate: "20240101" })).toEqual({ ok: true, value: "2024.01.01" });
expect(renderTemplate("{title:zz}", { title: "a" })).toEqual({ ok: true, value: "a" });
expect(renderTemplate("A[[ - {part}]] B", { part: "" })).toEqual({ ok: true, value: "A B" });
expect(renderTemplate("{title}/{year}", { title: "a/b" })).toEqual({ ok: false, missing: ["year"] });
expect(renderTemplate(compileTemplate("{title}"), { title: "a/b" })).toEqual({ ok: true, value: "a/b" });
expect(compileTemplate("{title}[[ ({year})]]").vars).toEqual(["title", "year"]);
});
it("模板校验:白名单、括号", () => {
for (const bad of ["{evil}", "{title", "a]]", "[[a", "[[ [[x]] ]]", "x".repeat(600)])
expect(() => compileTemplate(bad), bad).toThrow(TemplateError);
expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow();
expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, dailyFile: "{constructor}" })).toThrow(TemplateError);
});
});
describe("非法字符处理", () => {
it("转全角、去控制字符、首尾点空白、保留名、空串", () => {
expect(sanitizeSegment('A:B*C?"D<E>F|G\\H/I')).toBe("A:B*C?"D<E>F|G\H/I");
expect(sanitizeSegment(" ..hidden. ")).toBe("hidden");
expect(sanitizeSegment("a\u0000b\u001f\u007fc")).toBe("abc");
expect(sanitizeSegment("CON")).toBe("CON_");
expect(sanitizeSegment("...")).toBe("_");
expect(sanitizeSegment("a b")).toBe("a b");
});
it("UTF-8 字节截断,扩展名保留", () => {
const long = "长".repeat(200);
const s = sanitizeSegment(long);
expect(utf8Length(s)).toBeLessThanOrEqual(255);
const f = sanitizeFileName(long, ".MKV");
expect(f.endsWith(".mkv")).toBe(true);
expect(utf8Length(f)).toBeLessThanOrEqual(255);
expect(sanitizeFileName("a", "")).toBe("a");
});
});
describe("冲突检测", () => {
it("计划内重复 / 已存在 / 大小写碰撞", () => {
const out = detectPathConflicts(
[
{ src: "1", dst: "/m/A.mkv" },
{ src: "2", dst: "/m/A.mkv" },
{ src: "3", dst: "/m/b.mkv" },
{ src: "4", dst: "/m/C.mkv" },
{ src: "5", dst: "/m/c.mkv" },
],
[{ path: "/m/A.mkv", size: 1 }, { path: "/m/B.mkv" }],
{ caseInsensitive: true },
);
expect(out.map((c) => `${c.kind}:${c.dst}`)).toEqual([
"duplicate_in_plan:/m/A.mkv",
"exists:/m/A.mkv",
"case_collision:/m/b.mkv",
"case_collision:/m/c.mkv",
]);
expect(detectPathConflicts([{ src: "3", dst: "/m/b.mkv" }], [{ path: "/m/B.mkv" }])).toEqual([]);
});
});
describe("下载器 ↔ 宿主路径映射", () => {
const maps = [
{ instanceId: "qb1", downloaderPath: "/downloads", hostPath: "/mnt/cache/qb1" },
{ instanceId: "qb1", downloaderPath: "/downloads/tv", hostPath: "/mnt/array/tv-dl" },
{ instanceId: "qb2", downloaderPath: "D:\\Downloads\\", hostPath: "/mnt/win" },
{ instanceId: "qb3", downloaderPath: "/", hostPath: "/srv/qb3" },
];
it("最长前缀、按段边界、多实例", () => {
expect(toHostPath("/downloads/movie/a.mkv", "qb1", maps)).toMatchObject({
ok: true,
path: "/mnt/cache/qb1/movie/a.mkv",
});
expect(toHostPath("/downloads/tv/s/a.mkv", "qb1", maps)).toMatchObject({
ok: true,
path: "/mnt/array/tv-dl/s/a.mkv",
});
expect(toHostPath("/downloads2/a.mkv", "qb1", maps)).toEqual({ ok: false, reason: "no_mapping" });
expect(toHostPath("/downloads", "qb1", maps)).toMatchObject({ ok: true, path: "/mnt/cache/qb1" });
expect(toHostPath("D:\\Downloads\\x\\a.mkv", "qb2", maps)).toMatchObject({ ok: true, path: "/mnt/win/x/a.mkv" });
expect(toHostPath("/any/a.mkv", "qb3", maps)).toMatchObject({ ok: true, path: "/srv/qb3/any/a.mkv" });
expect(toHostPath("/downloads/a", "nope", maps)).toEqual({ ok: false, reason: "no_mapping" });
expect(toDownloaderPath("/mnt/cache/qb1/movie/a.mkv", "qb1", maps)).toMatchObject({
ok: true,
path: "/downloads/movie/a.mkv",
});
expect(toDownloaderPath("/srv/qb3/a", "qb3", maps)).toMatchObject({ ok: true, path: "/a" });
});
it("同前缀多目标 → ambiguous / 配置校验", () => {
const bad = [
{ instanceId: "q", downloaderPath: "/d", hostPath: "/a" },
{ instanceId: "q", downloaderPath: "/d/", hostPath: "/b" },
];
expect(toHostPath("/d/x", "q", bad)).toEqual({ ok: false, reason: "ambiguous" });
expect(validatePathMappings(bad)).toHaveLength(1);
expect(validatePathMappings(maps)).toEqual([]);
});
});
+650
View File
@@ -0,0 +1,650 @@
import { describe, expect, it } from "vitest";
import {
cnToInt,
evalEpisodeOffset,
expandEpisodes,
fileKindOf,
OffsetExpressionError,
type ParsedRelease,
parseRelease,
parseReleasePath,
splitReleasePath,
} from "../src/release-parser";
type Expect = Partial<ParsedRelease> & { group?: string; groupSource?: string };
interface Sample {
id: string;
input: string;
subtitle?: string;
expect: Expect;
/** 与 MoviePilot 行为不同的地方,写明原因 */
deviation?: string;
}
function check(r: ParsedRelease, e: Expect) {
const { group, groupSource, ...rest } = e;
for (const [k, v] of Object.entries(rest)) {
expect(r[k as keyof ParsedRelease], `${r.raw} → ${k}`).toEqual(v);
}
if (group !== undefined) expect(r.releaseGroup?.name, `${r.raw} → group`).toBe(group);
if (groupSource !== undefined) expect(r.releaseGroup?.source).toBe(groupSource);
}
// M-xx 编号对应 docs/porting/moviepilot-metadata-recognition.md §9.1
const TITLES: Sample[] = [
{
id: "M-01",
input: "Oppenheimer.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR",
expect: {
titleEn: "Oppenheimer",
year: 2023,
resolution: "2160p",
source: "REMUX",
isEncode: false,
videoCodec: "H.265",
hdr: ["DV", "HDR10"],
audio: ["TrueHD 7.1", "Atmos"],
group: "FraMeSToR",
confidence: "rule_confirmed",
},
deviation: "MP 发布组表无 FraMeSToR 时 grp=None;本项目种子表收录",
},
{
id: "M-02",
input: "奥本海默 Oppenheimer 2023 2160p UHD Blu-ray HEVC DV TrueHD 7.1-MTeam",
expect: {
title: "奥本海默",
titleCn: "奥本海默",
titleEn: "Oppenheimer",
source: "BluRay",
isEncode: true,
uhd: true,
hdr: ["DV"],
group: "MTeam",
groupSource: "known",
},
},
{
id: "M-03",
input: "The.Last.of.Us.S01.2023.1080p.WEB-DL.H264.DDP5.1.Atmos-ADWeb",
expect: {
titleEn: "The Last of Us",
season: 1,
episodes: undefined,
year: 2023,
audio: ["DDP 5.1", "Atmos"],
group: "ADWeb",
isSeasonPack: true,
packHint: "season_pack",
},
},
{
id: "M-04",
input: "The.Last.of.Us.S01E03.Long.Long.Time.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
expect: {
titleEn: "The Last of Us",
season: 1,
episodes: { start: 3, end: 3 },
webSource: "Max",
videoCodec: "H.265",
hdr: ["DV", "HDR10"],
packHint: "single",
},
},
{
id: "M-05",
input: "Breaking.Bad.S01-S05.Complete.1080p.BluRay.x264-ROVERS",
expect: { season: 1, seasonEnd: 5, packHint: "multi_season_pack", isSeasonPack: true, encoder: "x264" },
},
{
id: "M-06",
input: "庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 60fps DDP5.1-HHWEB",
expect: { titleCn: "庆余年", titleEn: "Joy of Life", season: 2, fps: 60, group: "HHWEB" },
},
{
id: "M-07",
input: "庆余年.Joy.of.Life.S02E01-E06.2024.2160p.WEB-DL.H265.AAC-ADWeb",
expect: { season: 2, episodes: { start: 1, end: 6 }, packHint: "multi_episode", isSeasonPack: false },
},
{
id: "M-08",
input:
"The.Lord.of.the.Rings.The.Fellowship.of.the.Ring.2001.Extended.Edition.2160p.UHD.BluRay.x265.10bit.HDR.DTS-HD.MA.6.1-CHD",
expect: {
titleEn: "The Lord of the Rings The Fellowship of the Ring",
cut: "Extended",
bitDepth: 10,
audio: ["DTS-HD MA 6.1"],
group: "CHD",
},
deviation: "MP 把 Extended.Edition 当干扰词删除;本项目保留为 cut",
},
{
id: "M-09",
input: "Kill.Bill.Vol.1.2003.Part.1.1080p.BluRay.x264-CHD",
expect: { titleEn: "Kill Bill Vol 1", year: 2003, part: "Part1" },
},
{
id: "M-10",
input: "Avatar.The.Way.of.Water.2022.2160p.WEB-DL.DDP5.1.Atmos.HDR10Plus.HEVC-CMCT",
expect: { hdr: ["HDR10+"], group: "CMCT", source: "WEB-DL" },
},
{
id: "M-11",
input: "奔跑吧 第十二季 20240426 第1期 1080p WEB-DL H264 AAC-HHWEB",
expect: {
titleCn: "奔跑吧",
season: 12,
issue: 1,
airDate: "20240426",
episodes: undefined,
packHint: "single",
mediaTypeHint: "tv",
},
deviation: "MP 把第1期当 episode、忽略 20240426;本项目 issue/airDate 分开保存",
},
{
id: "M-12",
input: "奔跑吧.Keep.Running.S12.2024.04.26.1080p.WEB-DL.H264.AAC-HHWEB",
expect: { season: 12, airDate: "20240426", year: undefined, isSeasonPack: false, packHint: "single" },
deviation: "MP 删除日期后判成整季包;本项目有日期即单期",
},
{
id: "M-13",
input: "向往的生活 第八季 全12期 2024 1080p WEB-DL",
expect: { titleCn: "向往的生活", season: 8, totalEpisodes: 12, year: 2024, isSeasonPack: true },
deviation: "MP 负向前瞻导致季号丢失",
},
{
id: "M-14",
input: "[喵萌奶茶屋&LoliHouse] 葬送的芙莉莲 / Sousou no Frieren - 12 [WebRip 1080p HEVC-10bit AAC][简繁日内封字幕]",
expect: {
titleCn: "葬送的芙莉莲",
titleEn: "Sousou no Frieren",
episodes: { start: 12, end: 12 },
season: 1,
seasonInferred: true,
group: "喵萌奶茶屋@LoliHouse",
bitDepth: 10,
source: "WEBRip",
subtitles: ["zh-Hans", "zh-Hant", "ja"],
confidence: "multi_evidence",
},
deviation: "MP 季号为空;本项目推定 1 并标 seasonInferred",
},
{
id: "M-15",
input: "Frieren.S01E12.1080p.NF.WEB-DL.DDP2.0.H.264-VARYG",
expect: { webSource: "Netflix", audio: ["DDP 2.0"], group: "VARYG", videoCodec: "H.264" },
},
{
id: "M-16",
input: "黑暗荣耀 The Glory 2022 S01 Complete 1080p NF WEB-DL x264 DDP5.1-HHWEB",
subtitle: "黑暗荣耀 第一季 全8集 宋慧乔",
expect: { season: 1, totalEpisodes: 8, isSeasonPack: true, confidence: "multi_evidence" },
},
{
id: "M-17",
input: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-MTeam",
expect: { titleEn: "Dune Part Two", part: undefined, audio: ["Atmos"], source: "REMUX" },
},
{
id: "M-18",
input: "Some.Show.S02E05E06.1080p.WEB-DL-GRP",
expect: { season: 2, episodes: { start: 5, end: 6 }, group: "GRP", groupSource: "suffix" },
},
{
id: "M-19",
input: "狂飙 The Knockout 2023 全39集 1080p WEB-DL H264 AAC-OurTV",
expect: { season: 1, seasonInferred: true, totalEpisodes: 39, packHint: "season_pack" },
},
{
id: "M-20",
input: "Some.Show.2019.1080p.WEB-DL-GRP",
subtitle: "第1-10集 全10集",
expect: { episodes: { start: 1, end: 10 }, totalEpisodes: 10 },
},
{
id: "M-21",
input: "[Nekomoe kissaten][Oshi no Ko][01-11 Fin][1080p]",
expect: { titleEn: "Oshi no Ko", episodes: { start: 1, end: 11 }, season: 1, group: "Nekomoe kissaten" },
},
{ id: "M-22", input: "Show.Name.2019-2020.1080p.WEB-DL", expect: { titleEn: "Show Name", year: 2019 } },
// ---- 国际/观众/馒头风格电影
{
id: "X-01",
input: "Blade.Runner.2049.2017.1080p.BluRay.x264-SPARKS",
expect: { titleEn: "Blade Runner 2049", year: 2017, isEncode: true },
},
{
id: "X-02",
input: "Dune.Part.Two.2024.1080p.BluRay.x264.DTS-HD.MA.7.1-ADE",
expect: { group: "ADE", audio: ["DTS-HD MA 7.1"], isEncode: true },
},
{
id: "X-03",
input: "流浪地球2 The Wandering Earth II 2023 2160p UHD BluRay REMUX HEVC DV TrueHD 7.1 Atmos-MTeam",
expect: { titleCn: "流浪地球2", titleEn: "The Wandering Earth II", source: "REMUX" },
},
{
id: "X-04",
input: "Interstellar.2014.IMAX.1080p.BluRay.x264.DTS-HD.MA.5.1-HDS",
expect: { cut: "IMAX", group: "HDS", titleEn: "Interstellar" },
},
{
id: "X-05",
input: "Blade.Runner.1982.The.Final.Cut.2160p.UHD.BluRay.x265.10bit.HDR.DTS-HD.MA.5.1-CiNEPHiLES",
expect: { cut: "Final Cut", titleEn: "Blade Runner", year: 1982 },
},
{
id: "X-06",
input: "Apocalypse.Now.1979.Theatrical.Cut.1080p.BluRay.x264-AMIABLE",
expect: { cut: "Theatrical", group: "AMIABLE", groupSource: "suffix" },
deviation: "未知组 MP 为 None;本项目末段兜底",
},
{
id: "X-07",
input: "Oppenheimer.2023.1080p.BluRay.DD+5.1.x264-EbP",
expect: { audio: ["DDP 5.1"], group: "EbP" },
},
{
id: "X-08",
input: "Oppenheimer.2023.COMPLETE.UHD.BLURAY-SURCODE",
expect: { source: "BluRay-Disc", isEncode: false, group: "SURCODE" },
},
{
id: "X-09",
input: "Some.Movie.2021.BD50.1080p.AVC.DTS-HD.MA.5.1",
expect: { source: "BluRay-Disc", isEncode: false, videoCodec: "H.264" },
},
{ id: "X-10", input: "Some.Movie.2021.1080p.HDTV.x264-GRP", expect: { source: "HDTV" } },
{
id: "X-11",
input: "Some.Movie.2021.720p.WEBRip.x264.AAC-YTS",
expect: { source: "WEBRip", resolution: "720p", audio: ["AAC"] },
},
{ id: "X-12", input: "Some.Movie.2021.DVDRip.XviD-GRP", expect: { source: "DVDRip", videoCodec: "XviD" } },
{ id: "X-13", input: "Movie.2020.3840x2160.WEB-DL.HEVC", expect: { resolution: "2160p", titleEn: "Movie" } },
{ id: "X-14", input: "Movie.2020.4K.WEB-DL.HEVC-GRP", expect: { resolution: "2160p" } },
{
id: "X-15",
input: "Mission.Impossible.Dead.Reckoning.Part.One.2023.2160p.UHD.BluRay.x265.10bit.HDR.TrueHD.7.1.Atmos-MTeam",
expect: { titleEn: "Mission Impossible Dead Reckoning Part One", part: undefined, audio: ["TrueHD 7.1", "Atmos"] },
},
{
id: "X-16",
input: "Harry.Potter.and.the.Deathly.Hallows.Part.2.2011.1080p.BluRay.x264-SPARKS",
expect: { titleEn: "Harry Potter and the Deathly Hallows Part 2", year: 2011 },
},
{
id: "X-17",
input: "Movie.Title.2021.1080p.BluRay.Remux.AVC.DTS-HD.MA.5.1-FGT",
expect: { source: "REMUX", isEncode: false },
},
{ id: "X-18", input: "The.Matrix.1999.Remastered.1080p.BluRay.x264-GRP", expect: { cut: "Remastered" } },
{ id: "X-19", input: "Movie.2020.Unrated.1080p.BluRay.x264-GRP", expect: { cut: "Unrated" } },
{
id: "X-20",
input: "Movie.Name.2019.Directors.Cut.1080p.BluRay.x264-FOO",
expect: { cut: "Director's Cut", titleEn: "Movie Name" },
},
{
id: "X-21",
input: "银翼杀手 导演剪辑版 1982 1080p BluRay x264",
expect: { titleCn: "银翼杀手", cut: "Director's Cut", year: 1982 },
},
{
id: "X-22",
input: "让子弹飞.Let.the.Bullets.Fly.2010.1080p.BluRay.x264.DTS-CMCT",
expect: { titleCn: "让子弹飞", titleEn: "Let the Bullets Fly", audio: ["DTS"], group: "CMCT" },
},
{
id: "X-23",
input: "长安三万里.Chang.An.2023.2160p.WEB-DL.H265.HDR.DDP5.1-OurTV",
expect: { titleCn: "长安三万里", hdr: ["HDR10"], group: "OurTV" },
},
{
id: "X-24",
input: "Movie.2020.1080p.BluRay.x264-GRP 12.5GB",
expect: { sizeBytes: Math.round(12.5 * 1024 ** 3), group: "GRP" },
},
{ id: "X-25", input: "Movie 2020 1080p WEB-DL 中英字幕", expect: { subtitles: ["zh", "en"] } },
{ id: "X-26", input: "Movie 2020 1080p 国语中字 WEB-DL", expect: { subtitles: ["zh"], titleEn: "Movie" } },
{
id: "X-27",
input: "[简繁英字幕] Movie 2020 1080p WEB-DL",
expect: { subtitles: ["zh-Hans", "zh-Hant", "en"], releaseGroup: undefined },
},
{ id: "X-28", input: "The Movie 2021", expect: { titleEn: "The Movie", year: 2021, confidence: "rule_confirmed" } },
{ id: "X-29", input: "The Movie 1080p WEB-DL", expect: { year: undefined, confidence: "needs_confirm" } },
// ---- 剧集
{
id: "T-01",
input: "三体 Three-Body S01E05 2023 1080p WEB-DL H264 AAC-OurTV",
expect: { titleCn: "三体", titleEn: "Three Body", season: 1, episodes: { start: 5, end: 5 } },
},
{
id: "T-02",
input: "Loki.S02E01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
expect: { webSource: "Disney+", hdr: ["DV", "HDR10"] },
},
{
id: "T-03",
input: "The.Bear.S03.1080p.DSNP.WEB-DL.DDP5.1.H.264-MWeb",
expect: { season: 3, isSeasonPack: true, group: "MWeb" },
},
{
id: "T-04",
input: "Beyond.the.Clouds.2024.S01.1080p.WEB-DL.H264.AAC-ADWeb",
expect: { titleEn: "Beyond the Clouds", year: 2024, season: 1 },
},
{
id: "T-05",
input: "The.Office.US.S05E13-E14.720p.HDTV.x264",
expect: { titleEn: "The Office US", episodes: { start: 13, end: 14 }, source: "HDTV" },
},
{ id: "T-06", input: "Friends.1x05.720p", expect: { season: 1, episodes: { start: 5, end: 5 } } },
{ id: "T-07", input: "Show.Season.2.1080p.BluRay.x264-GRP", expect: { season: 2, isSeasonPack: true } },
{
id: "T-08",
input: "Show.S01E01.Pilot.1080p.WEB-DL.mkv",
expect: { isFile: true, fileKind: "video", extension: "mkv", titleEn: "Show", packHint: "single" },
},
{ id: "T-09", input: "Show.S01E01.chs.srt", expect: { fileKind: "subtitle", subtitles: ["zh-Hans"] } },
{ id: "T-10", input: "Show.S01E01E02E03.1080p.WEB-DL", expect: { episodes: { start: 1, end: 3 } } },
{ id: "T-11", input: "Show.Name.S01.E01.1080p", expect: { season: 1, episodes: { start: 1, end: 1 } } },
{ id: "T-12", input: "Show.S01D02.1080p.BluRay", expect: { season: 1, part: "Disc2" } },
{
id: "T-13",
input: "Planet.Earth.III.S01.2160p.iP.WEB-DL.AAC2.0.HLG.H.265-GRP",
expect: { titleEn: "Planet Earth III", hdr: ["HLG"], audio: ["AAC 2.0"] },
},
{ id: "T-14", input: "天下长河 第1集 1080p WEB-DL", expect: { titleCn: "天下长河", segment: undefined } },
{
id: "T-15",
input: "庆余年 第二季 第1集 范闲入京 1080p WEB-DL",
expect: { titleCn: "庆余年", season: 2, episodes: { start: 1, end: 1 } },
},
{
id: "T-16",
input: "[tmdbid=94997] House.of.the.Dragon.S01E03.1080p.mkv",
expect: { explicitIds: [{ provider: "tmdb", id: "94997" }], season: 1, titleEn: "House of the Dragon" },
},
{ id: "T-17", input: "[tmdbid=0]Foo.2020", expect: { explicitIds: [], titleEn: "Foo" } },
// ---- 动漫
{
id: "A-01",
input: "[ANi] 葬送的芙莉蓮 - 13 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4",
expect: { group: "ANi", episodes: { start: 13, end: 13 }, subtitles: ["zh-Hant"], titleCn: "葬送的芙莉蓮" },
},
{
id: "A-02",
input: "[Lilith-Raws] Sousou no Frieren - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]",
expect: { group: "Lilith-Raws", titleEn: "Sousou no Frieren", episodes: { start: 1, end: 1 } },
},
{
id: "A-03",
input: "[UnknownSub][Some Anime][05][1080p]",
expect: { group: "UnknownSub", groupSource: "bracket", episodes: { start: 5, end: 5 }, titleEn: "Some Anime" },
},
{
id: "A-04",
input: "[Sub] Show - 26 Fin [1080p]",
expect: { episodes: { start: 26, end: 26 } },
},
{ id: "A-05", input: "Show 01-26 Fin 1080p", expect: { episodes: { start: 1, end: 26 }, isSeasonPack: true } },
// ---- 综艺
{
id: "V-01",
input: "快乐大本营 20210903 1080p WEB-DL H264 AAC-HHWEB",
expect: { airDate: "20210903", season: undefined, confidence: "needs_confirm", packHint: "single" },
},
{
id: "V-02",
input: "乘风2024 第5期 下 20240524 1080p WEB-DL",
expect: { titleCn: "乘风2024", issue: 5, segment: "lower", airDate: "20240524" },
},
{
id: "V-03",
input: "披荆斩棘 第三季 第12期 加更版 1080p WEB-DL-HHWEB",
expect: { season: 3, issue: 12, contentType: "extra", titleCn: "披荆斩棘" },
},
{
id: "V-04",
input: "歌手2024 第1期 会员版 1080p",
expect: { variantTags: ["会员版"], confidence: "needs_confirm", issue: 1 },
},
{
id: "V-05",
input: "声生不息 纯享版 20220501 1080p WEB-DL",
expect: { contentType: "performance_only", airDate: "20220501" },
},
{
id: "V-06",
input: "The.Voice.2024.05.03.1080p.WEB.h264-GRP",
expect: { titleEn: "The Voice", airDate: "20240503", source: "WEB-DL", year: undefined },
},
{
id: "V-07",
input: "Late.Night.2024.05.03.Guest.Name.1080p.WEB.h264-GRP",
expect: { titleEn: "Late Night", airDate: "20240503" },
},
{
id: "V-08",
input: "极限挑战 第十季 20240505 第2期 上 1080p WEB-DL",
expect: { season: 10, issue: 2, segment: "upper" },
},
{ id: "V-09", input: "声生不息 第2期(上) 1080p", expect: { issue: 2, segment: "upper" } },
{ id: "V-10", input: "奔跑吧 第十二季 第3期 下 1080p", expect: { issue: 3, segment: "lower", season: 12 } },
{ id: "V-11", input: "某综艺 2024年5月3日 完整版 1080p", expect: { airDate: "20240503", segment: "full" } },
{ id: "V-12", input: "某综艺 第1期 花絮", expect: { contentType: "behind_the_scenes" } },
{ id: "V-13", input: "某综艺 先导片", expect: { contentType: "preview" } },
{ id: "V-14", input: "某综艺 第3期 陪看", expect: { contentType: "reaction" } },
{ id: "V-15", input: "某综艺 第3期 正片", expect: { contentType: "main" } },
{ id: "V-16", input: "某剧 全3季 1080p", expect: { season: 1, seasonEnd: 3, packHint: "multi_season_pack" } },
{ id: "V-17", input: "某剧 第1-3季 1080p", expect: { season: 1, seasonEnd: 3 } },
{ id: "V-18", input: "某剧 第1-10集 1080p", expect: { episodes: { start: 1, end: 10 } } },
];
describe("parseRelease 样本表", () => {
it("样本数量 ≥ 60", () => {
expect(TITLES.length).toBeGreaterThanOrEqual(60);
});
for (const s of TITLES) {
it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => {
const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
check(r, s.expect);
expect(r.parserVersion).toBe(1);
expect(r.raw).toBe(s.input);
});
}
});
// P-xx 对应迁移文档 §9.2
const PATHS: Sample[] = [
{
id: "P-01",
input: "/downloads/The.Last.of.Us.S01.2023.1080p.WEB-DL-ADWeb/The.Last.of.Us.S01E03.1080p.WEB-DL-ADWeb.mkv",
expect: { season: 1, episodes: { start: 3, end: 3 }, year: 2023, confidence: "multi_evidence" },
},
{
id: "P-02",
input: "/downloads/庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 DDP5.1-HHWEB/01.mp4",
expect: { title: "庆余年", season: 2, year: 2024, episodes: { start: 1, end: 1 }, seasonInferred: undefined },
},
{
id: "P-03",
input: "/downloads/庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 DDP5.1-HHWEB/第02集.mkv",
expect: { episodes: { start: 2, end: 2 }, season: 2, group: "HHWEB", audio: ["DDP 5.1"] },
},
{
id: "P-04",
input: "/downloads/Breaking.Bad.S01-S05.1080p.BluRay.x264-ROVERS/S03/07.mkv",
expect: { title: "Breaking Bad", season: 3, episodes: { start: 7, end: 7 }, seasonEnd: undefined },
},
{
id: "P-05",
input: "/d/Oppenheimer.2023.2160p.UHD.BluRay.REMUX-FraMeSToR/Featurettes/Making.Of.mkv",
expect: { title: "Oppenheimer", contentType: "extra", confidence: "needs_confirm" },
deviation: "MP 把附加内容识别成名为 Making Of 的电影",
},
{
id: "P-07",
input: "/downloads/The Glory 2022 S01 1080p NF WEB-DL-HHWEB/简体.srt",
expect: { auxiliaryOnly: true, title: "The Glory", confidence: "needs_confirm", fileKind: "subtitle" },
deviation: "MP 取到根目录名 Downloads",
},
{
id: "P-08",
input: "/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.20240426.第1期.mp4",
expect: { season: 12, issue: 1, airDate: "20240426", episodes: undefined },
},
{
id: "P-09",
input: "/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.2024.05.03.mp4",
expect: { mediaTypeHint: "tv", season: 12, airDate: "20240503", year: undefined, confidence: "multi_evidence" },
deviation: "MP 文件无类型、不与 TV 目录合并",
},
{
id: "P-10",
input: "/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.20240503.加更版.mp4",
expect: { contentType: "extra", airDate: "20240503", season: 12 },
},
{
id: "P-11",
input: "/downloads/Kill.Bill.2003.1080p.BluRay.x264-CHD/CD1.mkv",
expect: { part: "CD1", title: "Kill Bill", year: 2003 },
deviation: "MP 因 Part 门槛丢失 CD1",
},
{
id: "P-12",
input: "/media/tv/庆余年 (2019)/Season 2/庆余年 - S02E03 - 第 3 集.mkv",
expect: { season: 2, episodes: { start: 3, end: 3 }, year: 2019 },
},
{
id: "P-13",
input:
"/dl/[LoliHouse] Sousou no Frieren [01-28][WebRip 1080p]/[LoliHouse] Sousou no Frieren - 13 [WebRip 1080p].mkv",
expect: { episodes: { start: 13, end: 13 }, titleEn: "Sousou no Frieren" },
},
{
id: "P-14",
input: "/dl/Some.Show.S01.1080p/Making.Of.Special.mkv",
expect: { mediaTypeHint: "tv", season: 1, confidence: "needs_confirm" },
deviation: "文件无剧集信号、目录是剧集:合并但降级为 needs_confirm",
},
{
id: "P-15",
input: "/dl/Collection.1080p/The.Matrix.Reloaded.2003.1080p.BluRay.x264.mkv",
expect: { year: 2003, confidence: "rule_confirmed" },
},
{
id: "P-16",
input: "/dl/The.Matrix.1999.1080p/The.Matrix.Reloaded.2003.1080p.mkv",
expect: { year: 2003, confidence: "needs_confirm" },
},
{
id: "P-17",
input: "/dl/Show.S01.1080p.WEB-DL-GRP/Sample/show.s01e01.sample.mkv",
expect: { season: 1 },
},
];
describe("parseReleasePath 目录 + 文件名合并", () => {
for (const s of PATHS) {
it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => {
check(parseReleasePath(s.input), s.expect);
});
}
it("接受已拆分的路径段", () => {
const r = parseReleasePath({ file: "E05.mkv", parent: "Show.S02.1080p.WEB-DL-GRP" });
expect(r.season).toBe(2);
expect(r.episodes).toEqual({ start: 5, end: 5 });
expect(r.title).toBe("Show");
});
it("sample 目录打证据", () => {
const r = parseReleasePath("/dl/Show.S01/Sample/x.mkv");
expect(r.evidence).toContain("dir:sample");
});
it("splitReleasePath 跳过附加内容目录", () => {
expect(splitReleasePath("/a/b/Extras/c.mkv")).toEqual({
file: "c.mkv",
parent: "b",
grandParent: "a",
extrasDir: "Extras",
});
expect(splitReleasePath("c.mkv")).toEqual({ file: "c.mkv" });
});
});
describe("集号偏移(安全求值)", () => {
it("W-03: 按表达式偏移集号", () => {
const r = parseRelease("[Sub] Kimetsu no Yaiba - 45 [1080p].mkv", { episodeOffset: "EP-44" });
expect(r.episodes).toEqual({ start: 1, end: 1 });
expect(r.evidence).toContain("episode:offset");
expect(r.confidence).toBe("multi_evidence");
});
it("负结果被拒绝,保留原集号", () => {
const r = parseRelease("Show.S01E03.1080p", { episodeOffset: "-10" });
expect(r.episodes).toEqual({ start: 3, end: 3 });
expect(r.evidence).toContain("episode:offset_rejected");
});
it("各运算符", () => {
expect(evalEpisodeOffset("EP-12", 13)).toBe(1);
expect(evalEpisodeOffset("+1", 1)).toBe(2);
expect(evalEpisodeOffset("*2", 3)).toBe(6);
expect(evalEpisodeOffset("5", 3)).toBe(8);
expect(evalEpisodeOffset("(EP+1)//2", 5)).toBe(3);
expect(evalEpisodeOffset("EP%4", 10)).toBe(2);
expect(evalEpisodeOffset("EP/2", 5)).toBe(2);
expect(evalEpisodeOffset("2**3+ep", 1)).toBe(9);
expect(evalEpisodeOffset("-EP+20", 5)).toBe(15);
expect(evalEpisodeOffset("+(EP)", 5)).toBe(5);
});
it("拒绝非白名单输入", () => {
for (const bad of ["", "EP; process.exit()", "EP+", "(EP+1", "EP/0", "EP 1", "a".repeat(80), "EP)", "2**2000"]) {
expect(() => evalEpisodeOffset(bad, 1), bad).toThrow(OffsetExpressionError);
}
});
});
describe("辅助函数", () => {
it("cnToInt", () => {
expect(cnToInt("十二")).toBe(12);
expect(cnToInt("二十")).toBe(20);
expect(cnToInt("二十三")).toBe(23);
expect(cnToInt("一百零五")).toBe(105);
expect(cnToInt("两")).toBe(2);
expect(cnToInt("08")).toBe(8);
expect(cnToInt("abc")).toBeUndefined();
expect(cnToInt("")).toBeUndefined();
});
it("fileKindOf", () => {
expect(fileKindOf("a.MKV")).toBe("video");
expect(fileKindOf("a.ass")).toBe("subtitle");
expect(fileKindOf("a.mka")).toBe("audio");
expect(fileKindOf("a.nfo")).toBe("nfo");
expect(fileKindOf("a.jpg")).toBe("image");
expect(fileKindOf("a.txt")).toBe("other");
expect(fileKindOf("a")).toBeUndefined();
});
it("expandEpisodes", () => {
expect(expandEpisodes({ start: 2, end: 4 })).toEqual([2, 3, 4]);
expect(expandEpisodes(undefined)).toEqual([]);
});
it("自定义发布组扩展", () => {
const r = parseRelease("Movie.2020.1080p.WEB-DL-MyGroup", { releaseGroups: ["MyGroup"] });
expect(r.releaseGroup).toEqual({ name: "MyGroup", source: "known" });
});
it("纯数字/Exx 文件名", () => {
expect(parseRelease("05.mkv").episodes).toEqual({ start: 5, end: 5 });
expect(parseRelease("E07.mkv").episodes).toEqual({ start: 7, end: 7 });
expect(parseRelease("EP07.mp4").episodes).toEqual({ start: 7, end: 7 });
expect(parseRelease("S03").season).toBe(3);
expect(parseRelease("Season 2").season).toBe(2);
});
it("isFile 选项", () => {
expect(parseRelease("12", { isFile: true }).episodes).toEqual({ start: 12, end: 12 });
expect(parseRelease("12").episodes).toBeUndefined();
});
});
+292
View File
@@ -0,0 +1,292 @@
import { describe, expect, it } from "vitest";
import {
canTransition,
checkDistinctAssets,
compareRank,
evaluateCandidate,
movieCompletion,
type ParsedRelease,
parseRelease,
qualityTier,
rankForSlot,
type SlotSpec,
type SlotState,
SlotTransitionError,
transition,
unitCompletion,
} from "../src";
const GiB = 1024 ** 3;
const rel = (t: string, over: Partial<ParsedRelease> = {}): ParsedRelease => ({ ...parseRelease(t), ...over });
const archival4k: SlotSpec = {
id: "s-4k",
name: "4K 蓝光压制",
hard: { resolutions: ["2160p"], sources: ["BluRay", "WEB-DL"], maxSizeBytes: 40 * GiB },
soft: { hdr: ["DV", "HDR10"], groups: ["CHD", "FraMeSToR"], preferredSizeBytes: { max: 30 * GiB } },
upgrade: { enabled: true },
};
describe("evaluateCandidate 硬条件", () => {
it("满足 → accept 并给软偏好排序键", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265.DV-CHD 20GB"));
expect(r.decision).toBe("accept");
if (r.decision !== "accept") return;
expect(r.softRank).toEqual([2, 2, 0, 0, 1]);
expect(r.tier).toEqual({ source: 3, resolution: 6 });
});
it("已知不满足 → reject,列出全部违规", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.1080p.HDTV.x264-GRP 50GB"));
expect(r.decision).toBe("reject");
if (r.decision !== "reject") return;
expect(r.violations.map((v) => `${v.field}:${v.code}`)).toEqual([
"resolution:not_allowed",
"source:not_allowed",
"size:too_large",
]);
});
it("缺字段 → unknown_field(与不满足区分)", () => {
const r = evaluateCandidate(archival4k, rel("Dune 2021 BluRay"));
expect(r).toEqual({ decision: "unknown_field", missing: ["resolution", "size"] });
});
it("unknownPolicy 可按字段配置 reject / accept", () => {
const spec: SlotSpec = { ...archival4k, unknownPolicy: { size: "accept", resolution: "reject" } };
const r = evaluateCandidate(spec, rel("Dune 2021 BluRay"));
expect(r.decision).toBe("reject");
const ok = evaluateCandidate({ ...archival4k, unknownPolicy: "accept" }, rel("Dune 2021 BluRay"));
expect(ok.decision).toBe("accept");
if (ok.decision === "accept") expect(ok.reasons).toContain("unknown_size_accepted_by_policy");
});
it("必需字幕:未知 hold,缺语言 reject,zh 可被 zh-Hans 满足", () => {
const spec: SlotSpec = { name: "zh", hard: { requiredSubtitles: ["zh"] } };
expect(evaluateCandidate(spec, rel("Movie 2020 1080p")).decision).toBe("unknown_field");
expect(evaluateCandidate(spec, rel("Movie 2020 1080p 简体字幕")).decision).toBe("accept");
const r = evaluateCandidate({ name: "en", hard: { requiredSubtitles: ["en"] } }, rel("Movie 2020 简体字幕"));
expect(r.decision).toBe("reject");
});
it("内容版本 / 仅二压 / 排除组 / 内容类型 / 最小体积", () => {
const cutSpec: SlotSpec = { name: "c", hard: { cuts: ["Extended"] } };
expect(evaluateCandidate(cutSpec, rel("LOTR 2001 Extended 1080p")).decision).toBe("accept");
expect(evaluateCandidate(cutSpec, rel("LOTR 2001 1080p")).decision).toBe("reject");
const enc: SlotSpec = { name: "e", hard: { requireEncode: true } };
expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay REMUX")).decision).toBe("reject");
expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay x264")).decision).toBe("accept");
expect(evaluateCandidate(enc, rel("Movie 2020 1080p")).decision).toBe("unknown_field");
const ex: SlotSpec = { name: "x", hard: { excludeGroups: ["badgrp"] } };
expect(evaluateCandidate(ex, rel("Movie.2020.1080p.WEB-DL-BADGRP")).decision).toBe("reject");
const ct: SlotSpec = { name: "m", hard: { contentTypes: ["main"] } };
expect(evaluateCandidate(ct, rel("某综艺 第1期 加更")).decision).toBe("reject");
expect(evaluateCandidate(ct, rel("某综艺 第1期")).decision).toBe("unknown_field");
expect(evaluateCandidate(ct, rel("某综艺 第1期 正片")).decision).toBe("accept");
const min: SlotSpec = { name: "min", hard: { minSizeBytes: 2 * GiB } };
expect(evaluateCandidate(min, rel("Movie 2020 1080p 1GB")).decision).toBe("reject");
});
});
describe("升级规则", () => {
const cur4kWeb = { resolution: "2160p" as const, source: "WEB-DL" as const };
it("WEB-DL → 蓝光二压:upgrade", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb);
expect(r.decision).toBe("upgrade");
if (r.decision === "upgrade") expect(r.fromTier.source).toBe(2);
});
it("同等级不反复更换", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"), cur4kWeb);
expect(r).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] });
const remuxToEncode = evaluateCandidate(
{ ...archival4k, hard: { resolutions: ["2160p"] } },
rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"),
{ resolution: "2160p", source: "REMUX" },
);
expect(remuxToEncode).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] });
});
it("更低等级 / 未开启升级 / 当前资产字段未知 / 达到上限", () => {
const hi = { resolution: "2160p" as const, source: "BluRay" as const };
expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL 20GB"), hi)).toMatchObject({
violations: [{ code: "lower_tier" }],
});
expect(
evaluateCandidate({ ...archival4k, upgrade: { enabled: false } }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb),
).toMatchObject({ violations: [{ code: "upgrade_disabled" }] });
expect(
evaluateCandidate({ ...archival4k, upgrade: undefined }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb),
).toMatchObject({ decision: "reject" });
expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay 2GB"), {})).toEqual({
decision: "unknown_field",
missing: ["current_asset"],
});
expect(
evaluateCandidate(
{ ...archival4k, upgrade: { enabled: true, ceilingTier: 2 } },
rel("Dune.2021.2160p.BluRay 2GB"),
cur4kWeb,
),
).toMatchObject({ violations: [{ code: "at_ceiling" }] });
});
it("最小改进阈值 2 级:WEBRip → WEB-DL 不够", () => {
const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, minTierSteps: 2 } };
const r = evaluateCandidate(spec, rel("Movie 2020 1080p WEB-DL"), { resolution: "1080p", source: "WEBRip" });
expect(r).toMatchObject({ violations: [{ code: "below_min_improvement" }] });
});
it("分辨率升级需显式允许", () => {
const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, allowResolutionUpgrade: true } };
const cur = { resolution: "1080p" as const, source: "WEB-DL" as const };
expect(evaluateCandidate(spec, rel("M 2020 2160p WEB-DL"), cur).decision).toBe("upgrade");
expect(evaluateCandidate({ ...spec, upgrade: { enabled: true } }, rel("M 2020 2160p WEB-DL"), cur).decision).toBe(
"reject",
);
});
it("合格压制组名单:名单外压制不自动升级", () => {
const spec: SlotSpec = { ...archival4k, upgrade: { enabled: true, encodeGroupAllowlist: ["CHD"] } };
expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb).decision).toBe("upgrade");
expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-LOWQ 20GB"), cur4kWeb)).toMatchObject({
violations: [{ code: "encode_not_qualified" }],
});
});
it("自定义来源等级表", () => {
expect(
qualityTier(
{ source: "REMUX", resolution: "1080p" },
{ name: "x", hard: {}, upgrade: { enabled: true, sourceTiers: { REMUX: 5 } } },
),
).toEqual({
source: 5,
resolution: 4,
});
expect(qualityTier({})).toEqual({ source: -1, resolution: -1 });
});
});
describe("rankForSlot 排序", () => {
it("可用在前;按等级再按软偏好", () => {
const cands = [
rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"),
rel("Dune.2021.1080p.BluRay-CHD 10GB"),
rel("Dune.2021.2160p.BluRay.x265.HDR-SPARKS 35GB"),
rel("Dune.2021.2160p.BluRay.x265.DV-CHD 25GB"),
];
const ranked = rankForSlot(archival4k, cands);
expect(ranked.map((r) => r.candidate.releaseGroup?.name)).toEqual(["CHD", "SPARKS", "FLUX", "CHD"]);
expect(ranked[3]!.evaluation.decision).toBe("reject");
});
it("compareRank 字典序", () => {
expect(compareRank([1, 0], [0, 5])).toBeGreaterThan(0);
expect(compareRank([1], [1, 0])).toBe(0);
});
});
describe("Slot 状态机", () => {
const s = (status: SlotState["status"], hasAsset = false): SlotState => ({ status, hasAsset });
it("主路径 empty→reserved→downloading→verifying→satisfied", () => {
let st = s("empty");
st = transition(st, { type: "reserve" });
st = transition(st, { type: "download_started" });
st = transition(st, { type: "download_completed" });
st = transition(st, { type: "verify_passed", complete: true });
expect(st).toEqual({ status: "satisfied", hasAsset: true });
});
it("升级路径保留旧资产,取消回到 satisfied", () => {
let st = transition(s("satisfied", true), { type: "upgrade_found" });
expect(st.status).toBe("upgrade_pending");
expect(transition(st, { type: "upgrade_cancelled" }).status).toBe("satisfied");
st = transition(st, { type: "reserve" });
expect(transition(st, { type: "release" })).toEqual({ status: "satisfied", hasAsset: true });
expect(transition(s("reserved"), { type: "release" }).status).toBe("empty");
});
it("失败/校验失败进 attention,解决后按是否有资产回退", () => {
expect(transition(s("downloading"), { type: "download_failed" }).status).toBe("attention");
expect(transition(s("reserved"), { type: "download_failed" }).status).toBe("attention");
expect(transition(s("verifying"), { type: "verify_failed" }).status).toBe("attention");
expect(transition(s("attention"), { type: "attention_resolved", hasAsset: true }).status).toBe("satisfied");
expect(transition(s("attention"), { type: "attention_resolved", hasAsset: false }).status).toBe("empty");
expect(transition(s("attention"), { type: "reserve" }).status).toBe("reserved");
expect(transition(s("satisfied", true), { type: "asset_missing_confirmed" })).toEqual({
status: "attention",
hasAsset: false,
});
});
it("部分覆盖回到 empty 但记住已有资产", () => {
expect(transition(s("verifying"), { type: "verify_passed", complete: false })).toEqual({
status: "empty",
hasAsset: true,
});
});
it("非法迁移抛错", () => {
const illegal: [SlotState["status"], Parameters<typeof transition>[1]][] = [
["empty", { type: "download_started" }],
["satisfied", { type: "reserve" }],
["empty", { type: "release" }],
["reserved", { type: "download_completed" }],
["empty", { type: "verify_passed", complete: true }],
["downloading", { type: "verify_failed" }],
["empty", { type: "upgrade_found" }],
["satisfied", { type: "upgrade_cancelled" }],
["empty", { type: "attention_resolved", hasAsset: false }],
["empty", { type: "asset_missing_confirmed" }],
["satisfied", { type: "download_failed" }],
];
for (const [from, ev] of illegal) {
expect(() => transition(s(from), ev), `${from} ${ev.type}`).toThrow(SlotTransitionError);
expect(canTransition(s(from), ev)).toBe(false);
}
expect(canTransition(s("empty"), { type: "reserve" })).toBe(true);
});
});
describe("完成条件", () => {
it("电影按内容版本", () => {
expect(movieCompletion(undefined, [{ assetId: "a", verified: true }]).complete).toBe(true);
expect(movieCompletion(undefined, [{ assetId: "a", verified: false }]).complete).toBe(false);
const r = movieCompletion(["default", "Extended"], [{ assetId: "a", verified: true }]);
expect(r).toEqual({ complete: false, missingCuts: ["Extended"], satisfiedBy: { default: "a" } });
});
it("剧集按 content_unit;season pack 需实际文件清单覆盖", () => {
const req = ["e1", "e2", "e3"];
const pack = { assetId: "p", verified: true, fileUnits: ["e1", "e2"], claimedUnits: ["e1", "e2", "e3"] };
const r = unitCompletion(req, [pack], { totalKnown: true });
expect(r).toEqual({
complete: false,
covered: ["e1", "e2"],
missing: ["e3"],
unverifiedClaims: ["e3"],
keepTracking: true,
});
const full = unitCompletion(req, [pack, { assetId: "x", verified: true, fileUnits: ["e3"] }], { totalKnown: true });
expect(full.complete).toBe(true);
expect(full.keepTracking).toBe(false);
expect(
unitCompletion(req, [{ assetId: "x", verified: false, fileUnits: req }], { totalKnown: true }).complete,
).toBe(false);
expect(
unitCompletion(["e1"], [{ assetId: "x", verified: true, fileUnits: ["e1"] }], { totalKnown: false }),
).toMatchObject({
complete: true,
keepTracking: true,
});
expect(unitCompletion([], [], { totalKnown: false }).complete).toBe(false);
});
it("require_distinct_asset 校验", () => {
const g = { slots: [], requireDistinctAsset: true };
expect(
checkDistinctAssets(g, [
{ slotId: "a", assetId: "1" },
{ slotId: "b", assetId: "1" },
]),
).toEqual({
ok: false,
sharedAssets: ["1"],
});
expect(
checkDistinctAssets(g, [
{ slotId: "a", assetId: "1" },
{ slotId: "b", assetId: "2" },
]).ok,
).toBe(true);
expect(
checkDistinctAssets({ slots: [] }, [
{ slotId: "a", assetId: "1" },
{ slotId: "b", assetId: "1" },
]).ok,
).toBe(true);
});
});