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:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:29 +08:00
1 parent a6d049db81
commit 7ba712ea55
40 files changed
+5885 -2

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
/** download_task 状态机(REQ §8.4 / §8.4.2) */
export type DownloadTaskStatus =
| "queued"
| "downloading"
| "paused_by_threshold"
| "paused_by_user"
| "completed"
| "error"
| "missing_files"
| "removed";
/** qB /api/v2/torrents/info 的一条记录(只取判定所需字段) */
export interface QbTorrentObservation {
state: string;
/** 0..1 */
progress: number;
amountLeft?: number;
}
export interface MapContext {
/** 业务账本记录:本任务是否因磁盘阈值被系统暂停 */
pausedByThreshold: boolean;
previous?: DownloadTaskStatus;
}
export interface MappedStatus {
status: DownloadTaskStatus;
/** qB 返回未知状态时沿用上次状态并标记不确定 */
uncertain?: boolean;
reason: string;
}
const DOWNLOADING = new Set([
"downloading",
"forceddl",
"stalleddl",
"metadl",
"forcedmetadl",
"allocating",
"checkingdl",
"moving",
]);
const SEEDING = new Set(["uploading", "stalledup", "queuedup", "forcedup", "checkingup", "pausedup", "stoppedup"]);
const NOT_SETTLED = new Set([
"checkingdl",
"checkingup",
"checkingresumedata",
"moving",
"metadl",
"forcedmetadl",
"allocating",
]);
/**
* qB 状态 → download_task 状态。
* observation 为 null 表示"qB 可达且列表里没有该任务"= removed;
* qB 不可达时调用方不得调用本函数(失败 ≠ 不存在)。
*/
export function mapQbState(obs: QbTorrentObservation | null, ctx: MapContext): MappedStatus {
if (obs === null) return { status: "removed", reason: "absent_from_downloader" };
const s = obs.state.toLowerCase();
if (s === "error") return { status: "error", reason: s };
if (s === "missingfiles") return { status: "missing_files", reason: s };
if (SEEDING.has(s)) {
if (obs.progress >= 1 && !NOT_SETTLED.has(s)) return { status: "completed", reason: s };
return { status: "downloading", reason: `${s}:progress<1_or_checking` };
}
if (s === "pauseddl" || s === "stoppeddl")
return ctx.pausedByThreshold
? { status: "paused_by_threshold", reason: s }
: { status: "paused_by_user", reason: s };
if (s === "queueddl" || s === "checkingresumedata") return { status: "queued", reason: s };
if (DOWNLOADING.has(s)) return { status: "downloading", reason: s };
return { status: ctx.previous ?? "queued", uncertain: true, reason: `unknown_state:${obs.state}` };
}
/** 完成判定:进度 100% 且不在校验/移动/出错/缺文件状态 */
export function isDownloadComplete(obs: QbTorrentObservation | null): boolean {
if (!obs) return false;
const s = obs.state.toLowerCase();
if (s === "error" || s === "missingfiles" || NOT_SETTLED.has(s)) return false;
if (obs.amountLeft !== undefined && obs.amountLeft > 0) return false;
return obs.progress >= 1;
}
const ALL: DownloadTaskStatus[] = [
"queued",
"downloading",
"paused_by_threshold",
"paused_by_user",
"completed",
"error",
"missing_files",
"removed",
];
/** removed 为终态:同一 torrent 重新出现应建新任务/走接管 */
const ALLOWED: Record<DownloadTaskStatus, readonly DownloadTaskStatus[]> = {
queued: ALL,
downloading: ALL,
paused_by_threshold: ALL,
paused_by_user: ALL,
completed: ALL,
error: ALL,
missing_files: ALL,
removed: ["removed"],
};
export class DownloadTransitionError extends Error {
override name = "DownloadTransitionError";
}
export interface AppliedObservation {
status: DownloadTaskStatus;
changed: boolean;
/** 需要记录但不自动处理的异常,例如完成后又回到下载中 */
anomaly?: "completed_regressed" | "unknown_state";
}
export function applyObservation(
current: DownloadTaskStatus,
obs: QbTorrentObservation | null,
ctx: Omit<MapContext, "previous">,
): AppliedObservation {
const mapped = mapQbState(obs, { ...ctx, previous: current });
if (!ALLOWED[current].includes(mapped.status))
throw new DownloadTransitionError(`非法的下载任务状态迁移: ${current} → ${mapped.status}`);
const out: AppliedObservation = { status: mapped.status, changed: mapped.status !== current };
if (current === "completed" && (mapped.status === "downloading" || mapped.status === "queued"))
out.anomaly = "completed_regressed";
else if (mapped.uncertain) out.anomaly = "unknown_state";
return out;
}
export interface ProgressSample {
/** 毫秒时间戳 */
at: number;
downloadedBytes: number;
status: DownloadTaskStatus;
}
export interface NoProgressEvidence {
stalled: boolean;
/** 只产出证据;换源必须由用户或显式授权决定 */
action: "none" | "record_evidence";
stalledMs: number;
lastProgressAt?: number;
lastBytes?: number;
/** 被排除的非下载时间(暂停/排队) */
excludedMs: number;
sampleCount: number;
}
/**
* 无进展判定:只累计"处于 downloading 且字节未增长"的时长;
* 人工暂停、阈值暂停、正常排队的时间不计入,不误判故障。
*/
export function detectNoProgress(
samples: readonly ProgressSample[],
opts: { thresholdMs: number },
): NoProgressEvidence {
const xs = [...samples].sort((a, b) => a.at - b.at);
let stalledMs = 0;
let excludedMs = 0;
let best = xs[0]?.downloadedBytes ?? 0;
let lastProgressAt = xs[0]?.at;
for (let i = 1; i < xs.length; i++) {
const a = xs[i - 1]!;
const b = xs[i]!;
const dt = b.at - a.at;
if (b.downloadedBytes > best) {
best = b.downloadedBytes;
lastProgressAt = b.at;
stalledMs = 0;
continue;
}
if (a.status === "downloading" && b.status === "downloading") stalledMs += dt;
else excludedMs += dt;
}
const last = xs[xs.length - 1];
const stalled = xs.length >= 2 && last?.status === "downloading" && stalledMs >= opts.thresholdMs;
return {
stalled,
action: stalled ? "record_evidence" : "none",
stalledMs,
...(lastProgressAt !== undefined ? { lastProgressAt } : {}),
...(last ? { lastBytes: best } : {}),
excludedMs,
sampleCount: xs.length,
};
}