Files
UHD_Media_Tool/packages/domain/src/slot/state.ts
T
rootandClaude Fable 5.1 7ba712ea55 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]>
2026-09-27 18:11:29 +08:00

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;
}
}