- 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]>
98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
/** 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;
|
|
}
|
|
}
|