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:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user