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,97 @@
|
||||
/** Slot 状态(REQ §6.1) */
|
||||
export type SlotStatus =
|
||||
| "empty"
|
||||
| "reserved"
|
||||
| "downloading"
|
||||
| "verifying"
|
||||
| "satisfied"
|
||||
| "attention"
|
||||
| "upgrade_pending";
|
||||
|
||||
export interface SlotState {
|
||||
status: SlotStatus;
|
||||
/** 是否已有可用的当前资产(升级流程中旧资产仍在,部分覆盖的剧集也可能有资产) */
|
||||
hasAsset: boolean;
|
||||
}
|
||||
|
||||
export type SlotEvent =
|
||||
| { type: "reserve" }
|
||||
| { type: "release" }
|
||||
| { type: "download_started" }
|
||||
| { type: "download_completed" }
|
||||
| { type: "download_failed"; reason?: string }
|
||||
| { type: "verify_passed"; complete: boolean }
|
||||
| { type: "verify_failed"; reason?: string }
|
||||
| { type: "upgrade_found" }
|
||||
| { type: "upgrade_cancelled" }
|
||||
| { type: "attention_resolved"; hasAsset: boolean }
|
||||
| { type: "asset_missing_confirmed" };
|
||||
|
||||
export class SlotTransitionError extends Error {
|
||||
override name = "SlotTransitionError";
|
||||
constructor(
|
||||
readonly from: SlotStatus,
|
||||
readonly event: SlotEvent["type"],
|
||||
) {
|
||||
super(`非法的 Slot 状态迁移: ${from} --${event}-->`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slot 状态机(纯函数)。非法迁移抛 SlotTransitionError。
|
||||
* 升级流程:satisfied → upgrade_pending → reserved → downloading → verifying → satisfied;
|
||||
* 新文件验证通过前旧资产一直保留(hasAsset=true)。
|
||||
* 剧集部分覆盖:verify_passed(complete=false) 回到 empty 但 hasAsset=true,继续追更。
|
||||
*/
|
||||
export function transition(state: SlotState, event: SlotEvent): SlotState {
|
||||
const { status, hasAsset } = state;
|
||||
const bad = (): never => {
|
||||
throw new SlotTransitionError(status, event.type);
|
||||
};
|
||||
switch (event.type) {
|
||||
case "reserve":
|
||||
if (status === "empty" || status === "attention" || status === "upgrade_pending")
|
||||
return { status: "reserved", hasAsset };
|
||||
return bad();
|
||||
case "release":
|
||||
if (status === "reserved") return { status: hasAsset ? "satisfied" : "empty", hasAsset };
|
||||
return bad();
|
||||
case "download_started":
|
||||
if (status === "reserved") return { status: "downloading", hasAsset };
|
||||
return bad();
|
||||
case "download_completed":
|
||||
if (status === "downloading") return { status: "verifying", hasAsset };
|
||||
return bad();
|
||||
case "download_failed":
|
||||
if (status === "downloading" || status === "reserved") return { status: "attention", hasAsset };
|
||||
return bad();
|
||||
case "verify_passed":
|
||||
if (status === "verifying") return { status: event.complete ? "satisfied" : "empty", hasAsset: true };
|
||||
return bad();
|
||||
case "verify_failed":
|
||||
if (status === "verifying") return { status: "attention", hasAsset };
|
||||
return bad();
|
||||
case "upgrade_found":
|
||||
if (status === "satisfied") return { status: "upgrade_pending", hasAsset };
|
||||
return bad();
|
||||
case "upgrade_cancelled":
|
||||
if (status === "upgrade_pending") return { status: "satisfied", hasAsset };
|
||||
return bad();
|
||||
case "attention_resolved":
|
||||
if (status === "attention") return { status: event.hasAsset ? "satisfied" : "empty", hasAsset: event.hasAsset };
|
||||
return bad();
|
||||
case "asset_missing_confirmed":
|
||||
// 只接受"确认缺失"(对账多次观察后);单次观察失败不得触发(失败 ≠ 不存在)
|
||||
if (status === "satisfied" || status === "upgrade_pending") return { status: "attention", hasAsset: false };
|
||||
return bad();
|
||||
}
|
||||
}
|
||||
|
||||
export function canTransition(state: SlotState, event: SlotEvent): boolean {
|
||||
try {
|
||||
transition(state, event);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user