- 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]>
129 lines
4.7 KiB
TypeScript
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 };
|
|
}
|