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,128 @@
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user