Files
UHD_Media_Tool/packages/domain/src/acquisition/disk.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

129 lines
4.7 KiB
TypeScript

export interface VolumeSnapshot {
volumeId: string;
totalBytes: number;
usedBytes: number;
}
export interface InflightTask {
taskId: string;
planId?: string;
volumeId: string;
/** 种子选中文件总大小;未知(磁力未取到元数据)时为 undefined */
sizeBytes?: number;
/** 已占用磁盘的字节(已下载或已预分配,取二者较大者由调用方给出) */
onDiskBytes: number;
/** 是否在管理标签范围内 */
managed: boolean;
status: "queued" | "downloading" | "paused" | "completed" | "other";
}
export interface Reservation {
planId: string;
volumeId: string;
bytes: number;
}
export interface ThresholdConfig {
/** 硬阈值使用率(%),例如 90 */
hardPct: number;
/** 恢复水位(%),例如 85,必须低于 hardPct */
resumePct: number;
}
export interface CacheVolumeInput {
/** 读不到磁盘状态时传 null */
volume: VolumeSnapshot | null;
config: ThresholdConfig;
inflight: readonly InflightTask[];
reservations: readonly Reservation[];
/** 待提交的新下载;不传表示只做巡检 */
request?: { bytes?: number };
/** 当前是否处于"因本阈值暂停"状态 */
pausedByThreshold: boolean;
}
export interface CacheVolumeDecision {
/** allow = 可提交;block = 阻止新增;pause_managed = 阻止新增并暂停纳管的下载中任务 */
decision: "allow" | "block" | "pause_managed";
/** 降到恢复水位:恢复"因本阈值暂停"的任务(不含用户手动暂停) */
resumeThresholdPaused: boolean;
pauseTaskIds: string[];
reasons: string[];
usedPct?: number;
projectedPct?: number;
pendingBytes: number;
reservedBytes: number;
unknownSizeTaskIds: string[];
/** 未纳管任务仍在增长:不能声称暂停后磁盘不再增长 */
unmanagedGrowthRisk: boolean;
}
/**
* 缓存卷硬阈值判定(REQ §8.4.1)。
* 预测占用 = 当前已用 + 在途任务剩余需求(size - 已落盘,已预分配不重复扣) + 未提交计划预留 + 本次请求;
* 已提交的计划只按在途任务计算一次,不再算预留。多个 qB 共用卷时由调用方按 volumeId 汇总传入。
*/
export function evaluateCacheVolume(input: CacheVolumeInput): CacheVolumeDecision {
const { config } = input;
if (!(config.resumePct < config.hardPct)) throw new Error("resumePct 必须低于 hardPct");
const base = {
pauseTaskIds: [] as string[],
pendingBytes: 0,
reservedBytes: 0,
unknownSizeTaskIds: [] as string[],
unmanagedGrowthRisk: false,
};
const v = input.volume;
if (!v || !(v.totalBytes > 0)) {
return { ...base, decision: "block", resumeThresholdPaused: false, reasons: ["volume_state_unknown"] };
}
const tasks = input.inflight.filter((t) => t.volumeId === v.volumeId);
let pending = 0;
const unknownSize: string[] = [];
let unmanagedGrowth = false;
for (const t of tasks) {
if (t.status === "completed") continue;
if (t.sizeBytes === undefined) {
unknownSize.push(t.taskId);
continue;
}
const rest = Math.max(0, t.sizeBytes - t.onDiskBytes);
pending += rest;
if (!t.managed && rest > 0) unmanagedGrowth = true;
}
const submittedPlans = new Set(tasks.map((t) => t.planId).filter((p): p is string => p !== undefined));
const reserved = input.reservations
.filter((r) => r.volumeId === v.volumeId && !submittedPlans.has(r.planId))
.reduce((s, r) => s + r.bytes, 0);
const reqBytes = input.request?.bytes ?? 0;
const usedPct = (v.usedBytes / v.totalBytes) * 100;
const projectedPct = ((v.usedBytes + pending + reserved + reqBytes) / v.totalBytes) * 100;
const out = {
...base,
pendingBytes: pending,
reservedBytes: reserved,
unknownSizeTaskIds: unknownSize,
unmanagedGrowthRisk: unmanagedGrowth,
usedPct,
projectedPct,
};
const reasons: string[] = [];
if (usedPct >= config.hardPct) {
return {
...out,
decision: "pause_managed",
resumeThresholdPaused: false,
pauseTaskIds: tasks.filter((t) => t.managed && t.status === "downloading").map((t) => t.taskId),
reasons: ["hard_threshold_reached"],
};
}
const resume = input.pausedByThreshold && usedPct <= config.resumePct;
if (input.pausedByThreshold && !resume) reasons.push("awaiting_resume_watermark");
if (input.request && input.request.bytes === undefined) reasons.push("request_size_unknown");
if (input.request && unknownSize.length > 0) reasons.push("inflight_size_unknown");
if (projectedPct >= config.hardPct) reasons.push("projected_over_threshold");
if (resume) reasons.push("below_resume_watermark");
const blocked = reasons.some((r) => r !== "below_resume_watermark");
return { ...out, decision: blocked ? "block" : "allow", resumeThresholdPaused: resume, reasons };
}