/** 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 = { 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, ): 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, }; }