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