- 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]>
191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
/** 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,
|
|
};
|
|
}
|