fix(download): 下载器离线与下载目录读不到容量开待处理,季包暂停添加后选文件

- M5:单个下载器连续 10 分钟连不上开 downloader_offline(按实例去重),恢复连接后自动关闭
- M3:下载目录读不到容量按卷开 critical 的 cache_volume_unknown,读到后关闭;面板单独显示
  「下载目录读不到」不再归为等空间;cache.threshold 不再把未知当 0%
- M4:季包 / 多集包 .torrent 暂停添加、磁力用「拿到元数据后停止」,选完文件再恢复;文件清单
  拿不到时留给同步重试,超过 10 分钟整包下载并记 file_list_timeout
- L1:提交 commit 时锁计划复核仍是 reserved,被取消的不写回 submitted,qB 里刚加的任务暂停、
  去掉管理标签并打 <管理标签>-cancelled
- L3:已收单未确认的提交在同步首次看到任务时补调 afterDownloadAccepted
- L4:排队中(queuedDL)的纳管任务一并受阈值暂停
- L6:停用的下载器上还有在下的任务时仍同步这些任务(不接管新任务、不开离线事项)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:20:29 +08:00
1 parent 07d5f42143
commit 19f7bd0330
8 files changed
+924 -60

No files matched your search

@@ -15,7 +15,7 @@ import { registerNamingRoutes } from "./routes/naming.ts";
import { registerOrganizationRoutes } from "./routes/organization.ts";
import { registerRuleRoutes } from "./routes/rules.ts";
import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot } from "./services/common.ts";
import { SWEEP_SLOT_MS, sweepReservedPlans, syncDownloads } from "./services/downloads.ts";
import { reportVolumeStates, SWEEP_SLOT_MS, sweepReservedPlans, syncDownloads } from "./services/downloads.ts";
import { recoverStalledRecords } from "./services/organize-records.ts";
import { sweepDueSlots, sweepMetadataRefresh, sweepSubscriptionSearch } from "./services/subscriptions.ts";
import { organizeDecisionHook } from "./services/takeover.ts";
@@ -139,11 +139,17 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
},
run: guarded(d, async (d) => {
const cache = await d.ctx.settings.get("cache");
const over = (await loadVolumes(d)).some(
(v) => (usedPercent(v) ?? 0) >= volumeThresholds(v, cache).hardThresholdPercent,
);
if (!over) return "未超阈值";
return (await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中";
const vols = await loadVolumes(d);
// 读不到容量的卷不当 0%:开待处理(同步每轮也会判定),摘要里写明
await reportVolumeStates(d, vols);
const unknown = vols.filter((v) => usedPercent(v) === null).length;
const note = unknown ? `,${unknown} 个下载目录读不到容量` : "";
const over = vols.some((v) => {
const pct = usedPercent(v);
return pct !== null && pct >= volumeThresholds(v, cache).hardThresholdPercent;
});
if (!over) return `未超阈值${note}`;
return ((await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中") + note;
}),
});
registerScheduledJob(ctx, {
@@ -124,7 +124,7 @@ export function instanceLive(d: AoDeps, inst: DownloaderInstanceRow): Promise<In
/**
* 还没提交到下载器、在等条件的计划(DL-3):草案且有待批准的审批(等批准);已预留但还没有下载任务
* (等下载盘空间 / 自动化开启 / 提交任务执行)。没有审批的草案(智能体只生成没提交)不算在等。
* (等下载盘空间 / 下载目录读不到容量 / 自动化开启 / 提交任务执行)。没有审批的草案(智能体只生成没提交)不算在等。
*/
export function waitingPlansQuery(db: Exec, instanceId?: string) {
return db
@@ -161,7 +161,9 @@ export function waitingPlansQuery(db: Exec, instanceId?: string) {
export function planWaitOf(status: string, deferred: string | null): PlanWait {
if (status === "draft") return "approval";
if (!deferred) return "queued";
if (deferred.startsWith("cache:") || deferred === "volume_state_unknown") return "space";
// 下载目录读不到容量是配置问题(路径映射 / 挂载),不是空间不够
if (deferred === "volume_state_unknown" || deferred === "cache:volume_state_unknown") return "volume_unknown";
if (deferred.startsWith("cache:")) return "space";
if (deferred === "automation_disabled" || deferred === "read_only_reconcile") return "automation_off";
return "queued";
}
@@ -2,6 +2,7 @@ import {
assertSafeToDeleteData,
type DownloaderClient,
DownloaderError,
type QbFile,
type QbTorrent,
SiteError,
type TorrentSource,
@@ -268,6 +269,8 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
const client = await d.ports.downloader(topo.inst);
const tag = topo.inst.managed_tag;
// 季包 / 多集包要按缺集选文件:先不让它开始下载(对照 MP QK1),选完文件再恢复
const pack = needsPackSelection(plan, rel);
// 评估时定下的保存路径(订阅 downloadSavePath 经路径映射后的下载器侧路径),没有用实例默认
const savePath = ev.savePath || topo.savePath;
@@ -280,6 +283,10 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
});
let dtId: string | undefined;
let inUseBy: string | undefined;
/** commit 时计划已不是 reserved(提交期间被取消等):不落任务行,事后撤销 qB 里的任务 */
let lostPlan: string | undefined;
/** 已存在的任务原本没有管理标签、是本次补上的 */
let tagAdded = false;
const result = await withAttemptToken<SubmitResult>(
db,
`submit:${plan.id}`,
@@ -304,6 +311,8 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
savePath,
tags: [tag],
...(topo.category ? { category: topo.category } : {}),
// 磁力暂停添加时 qB 不取元数据:改用「拿到元数据后停止」;.torrent / URL 直接暂停添加
...(pack ? (source.kind === "magnet" ? { stopCondition: "MetadataReceived" as const } : { paused: true }) : {}),
});
switch (out.kind) {
case "added": {
@@ -314,7 +323,10 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
}
case "already_exists": {
// 原本已存在:接管,只补管理标签,不改保存路径/其它标签/文件选择
if (!out.existing.tags.includes(tag)) await client.addTags([out.hash], [tag]);
if (!out.existing.tags.includes(tag)) {
await client.addTags([out.hash], [tag]);
tagAdded = true;
}
return { kind: "already_exists", ...view(out.existing), tags: [...new Set([...out.existing.tags, tag])] };
}
case "accepted_unconfirmed":
@@ -336,6 +348,17 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
{
...(opts.runId ? { runId: opts.runId } : {}),
commit: async (trx, res) => {
// 复核计划仍是 reserved(取消接口同样锁计划行):qB 回查期间被取消的,不把它写回 submitted
const cur = await trx
.selectFrom("acquisition_plan")
.select("status")
.where("id", "=", plan.id)
.forUpdate()
.executeTakeFirst();
if (cur?.status !== "reserved") {
lostPlan = cur?.status ?? "missing";
return;
}
if (res.kind === "rejected" || res.kind === "source_failed") {
const r = res;
await failPlanTx(d, trx, plan.id, plan.slot_id, `${r.kind}: ${r.reason}`);
@@ -402,8 +425,9 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
tags: r.tags,
managed: true,
origin: r.kind === "already_exists" ? "adopted" : "submitted",
status: r.kind === "accepted_unconfirmed" ? "submitting" : "queued",
pause_reason: null,
status:
r.kind === "accepted_unconfirmed" ? "submitting" : pack && r.kind === "added" ? "paused" : "queued",
pause_reason: pack && r.kind === "added" ? "policy" : null,
removed_at: null,
completed_at: null,
no_progress_since: null,
@@ -420,12 +444,13 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
const ins = await sql<{ id: string; inserted: boolean }>`
insert into download_task (instance_id, torrent_hash, infohash_v1, infohash_v2, acquisition_plan_id, import_plan_id, source_release_id,
media_entity_id, name,
save_path, content_path, category, tags, managed, origin, status, size_bytes, next_check_at)
save_path, content_path, category, tags, managed, origin, status, pause_reason, size_bytes, next_check_at)
values (${topo.inst.id}, ${r.hash}, ${r.hash.length === 40 ? r.hash : null}, ${r.hash.length === 64 ? r.hash : null},
${plan.id}, ${ev.manual ? plan.id : null}, ${rel.id}, coalesce((select media_entity_id from acquisition_slot where id = ${plan.slot_id}), ${ev.mediaEntityId ?? rel.media_entity_id}::uuid),
${r.name}, ${r.savePath}, ${r.contentPath}, ${topo.category},
${r.tags}, true, ${r.kind === "already_exists" ? "adopted" : "submitted"},
${r.kind === "accepted_unconfirmed" ? "submitting" : "queued"}, ${rel.size_bytes}, now())
${r.kind === "accepted_unconfirmed" ? "submitting" : pack && r.kind === "added" ? "paused" : "queued"},
${pack && r.kind === "added" ? "policy" : null}, ${rel.size_bytes}, now())
on conflict (instance_id, torrent_hash) do update
set acquisition_plan_id = coalesce(download_task.acquisition_plan_id, excluded.acquisition_plan_id),
import_plan_id = coalesce(excluded.import_plan_id, download_task.import_plan_id),
@@ -435,9 +460,17 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
returning id, (xmax = 0) as inserted`.execute(trx);
dtId = ins.rows[0]!.id;
}
// 暂停添加的季包:选文件挂起,选完(或元数据超时整包)由提交后续步骤 / 同步恢复下载
const pendingSel = pack && r.kind !== "already_exists";
await trx
.updateTable("acquisition_plan")
.set({ status: "submitted", status_reason: r.kind })
.set({
status: "submitted",
status_reason: r.kind,
...(pendingSel
? { file_selection: jsonb({ mode: "pending", since: d.ports.now().toISOString(), attempts: 0 }) }
: {}),
})
.where("id", "=", plan.id)
.execute();
await applySlotEvent(
@@ -456,14 +489,18 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
},
},
);
if (lostPlan) {
await undoLostSubmit(d, client, result, tag, tagAdded, plan.id, lostPlan);
return { status: "skipped", reason: `plan_${lostPlan}` };
}
if (result.kind === "rejected" || result.kind === "source_failed") return { status: "failed", reason: result.reason };
if (inUseBy) return { status: "skipped", reason: "torrent_in_use_by_plan" };
const hash = (result as Extract<SubmitResult, { hash: string }>).hash;
// 季包/多集包:按缺集选文件(接管的已有任务不改文件选择);失败不影响提交结果
if (result.kind === "added" && plan.unit_keys.length > 0 && plan.file_selection === null) {
await selectPackFiles(d, client, hash, plan, rel).catch((err) =>
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
);
// 季包/多集包:按缺集选文件再恢复下载(接管的已有任务不改文件选择);文件清单还拿不到(磁力元数据未就绪)
// 时留给同步重试,失败不影响提交结果
if (pack && result.kind !== "already_exists") {
const fresh = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", plan.id).executeTakeFirst();
if (fresh && isPendingSelection(fresh)) await settlePackFiles(d, client, hash, fresh, rel);
}
if (result.kind !== "accepted_unconfirmed") await afterDownloadAccepted(d, plan.id);
if (!dtId) {
@@ -495,11 +532,150 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
}
const PACK_FILE_LIST = { retries: 3, intervalMs: 300 } as const;
/** 季包文件清单(磁力元数据)最多等多久;超时仍拿不到就整包下载并记录原因 */
export const PACK_METADATA_TIMEOUT_MS = 10 * 60_000;
/** 资源是季包(没有集号也没有日期)或多集包 */
function isPackRelease(rel: SourceReleaseRow): boolean {
const parsed = parsedOf(rel);
const isPack = !parsed.episodes && !parsed.airDate;
const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start;
return isPack || multi;
}
/** 订阅计划下季包 / 多集包、还没选过文件:提交时暂停添加,选完文件再恢复 */
function needsPackSelection(plan: AcquisitionPlanRow, rel: SourceReleaseRow): boolean {
return !!plan.slot_id && plan.unit_keys.length > 0 && plan.file_selection === null && isPackRelease(rel);
}
interface PendingSelection {
mode: "pending";
since: string;
attempts: number;
}
const isPendingSelection = (plan: Pick<AcquisitionPlanRow, "file_selection">) =>
(plan.file_selection as { mode?: string } | null)?.mode === "pending";
/**
* 季包按缺集选文件(对照 MP 从整季包按集选文件,仅 qB):提交后读文件清单,把不需要单元的文件优先级置 0;
* 暂停添加的季包:读文件清单 → 选文件 → 恢复下载 → 记录选择。
* 文件清单还拿不到(磁力元数据未就绪)且没到 PACK_METADATA_TIMEOUT_MS:只累加重试次数,留给下一轮同步;
* 超时仍拿不到:整包下载并记 file_list_timeout。恢复下载失败时选择保持 pending,下一轮同步重做(选文件幂等)。
* 返回 true 表示已收尾(不再 pending)。
*/
async function settlePackFiles(
d: AoDeps,
client: DownloaderClient,
hash: string,
plan: AcquisitionPlanRow,
rel: SourceReleaseRow,
): Promise<boolean> {
const pending = plan.file_selection as unknown as PendingSelection;
const now = d.ports.now();
const waitedMs = now.getTime() - new Date(pending.since).getTime();
const files = await client.files(hash, PACK_FILE_LIST).catch(() => [] as QbFile[]);
const record = (sel: Record<string, unknown>, unitKeys?: string[]) =>
d.ctx.db
.updateTable("acquisition_plan")
.set({ file_selection: jsonb(sel), ...(unitKeys ? { unit_keys: unitKeys } : {}) })
.where("id", "=", plan.id)
.execute();
if (files.length === 0 && waitedMs < PACK_METADATA_TIMEOUT_MS) {
await record({ ...pending, attempts: pending.attempts + 1 });
return false;
}
let sel: { selection: Record<string, unknown>; unitKeys?: string[] };
if (files.length === 0) {
sel = {
selection: { mode: "whole", reason: "file_list_timeout", waitedSec: Math.round(waitedMs / 1000) },
};
} else {
try {
sel = await selectPackFiles(d, client, hash, plan, rel, files);
} catch (err) {
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载");
sel = { selection: { mode: "whole", reason: "select_failed", error: errMessage(err).slice(0, 300) } };
}
}
try {
await client.resume([hash]);
} catch (err) {
d.ctx.logger.warn(
{ planId: plan.id, err: { message: errMessage(err) } },
"季包选完文件后恢复下载失败,下一轮同步重试",
);
return false;
}
await record(sel.selection, sel.unitKeys);
return true;
}
/** 同步时收尾挂起的季包选文件(本轮在 qB 里看得到的任务) */
async function settlePendingPacks(d: AoDeps, client: DownloaderClient, topo: InstanceTopology, seen: Set<string>) {
const rows = await d.ctx.db
.selectFrom("acquisition_plan as p")
.innerJoin("download_task as dt", "dt.acquisition_plan_id", "p.id")
.selectAll("p")
.select("dt.torrent_hash")
.where("dt.instance_id", "=", topo.inst.id)
.where("dt.removed_at", "is", null)
.where("dt.status", "!=", "removed")
.where("p.status", "=", "submitted")
.where(sql<string>`p.file_selection->>'mode'`, "=", "pending")
.execute();
for (const { torrent_hash, ...plan } of rows) {
if (!seen.has(torrent_hash)) continue;
try {
const rel = await d.ctx.db
.selectFrom("source_release")
.selectAll()
.where("id", "=", plan.source_release_id)
.executeTakeFirstOrThrow();
await settlePackFiles(d, client, torrent_hash, plan as AcquisitionPlanRow, rel);
} catch (err) {
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件重试失败");
}
}
}
/**
* 提交期间计划被取消(commit 复核不是 reserved):任务行没落,qB 里刚加的任务暂停、去掉管理标签、
* 打上 <管理标签>-cancelled 留给人删(不自动删数据);原本就在 qB 里的任务只撤掉本次补上的管理标签。
*/
async function undoLostSubmit(
d: AoDeps,
client: DownloaderClient,
result: SubmitResult,
tag: string,
tagAdded: boolean,
planId: string,
planStatus: string,
) {
if (!("hash" in result)) return;
try {
if (result.kind === "already_exists") {
if (tagAdded) await client.removeTags([result.hash], [tag]);
} else {
await client.pause([result.hash]);
await client.removeTags([result.hash], [tag]);
await client.addTags([result.hash], [`${tag}-cancelled`]);
}
d.ctx.logger.warn(
{ planId, planStatus, hash: result.hash, kind: result.kind },
"提交期间计划已结束,已撤下 qB 里的任务(暂停并去掉管理标签)",
);
} catch (err) {
d.ctx.logger.error(
{ planId, planStatus, hash: result.hash, err: { message: errMessage(err) } },
"提交期间计划已结束,撤下 qB 任务失败,需要到 qB 里手动处理",
);
}
}
/**
* 季包按缺集选文件(对照 MP 从整季包按集选文件,仅 qB):按文件清单把不需要单元的文件优先级置 0;
* 需要单元 = 计划预留的集(季通配 = 该季未覆盖、且不在其它在途计划里的集)。
* 文件清单拿不到 / 有视频文件认不出集号 → 整包下载并记录理由;计划的 unit_keys 收敛为实际选中的集。
* 有视频文件认不出集号 → 整包下载并记录理由;计划的 unit_keys 收敛为实际选中的集。返回要记录的选择(由调用方写)。
*/
async function selectPackFiles(
d: AoDeps,
@@ -507,20 +683,12 @@ async function selectPackFiles(
hash: string,
plan: AcquisitionPlanRow,
rel: SourceReleaseRow,
): Promise<void> {
if (!plan.slot_id) return;
files: QbFile[],
): Promise<{ selection: Record<string, unknown>; unitKeys?: string[] }> {
const parsed = parsedOf(rel);
const isPack = !parsed.episodes && !parsed.airDate;
const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start;
if (!isPack && !multi) return;
const record = (sel: Record<string, unknown>, unitKeys?: string[]) =>
d.ctx.db
.updateTable("acquisition_plan")
.set({ file_selection: jsonb(sel), ...(unitKeys ? { unit_keys: unitKeys } : {}) })
.where("id", "=", plan.id)
.execute();
const files = await client.files(hash, PACK_FILE_LIST).catch(() => []);
if (files.length === 0) return void (await record({ mode: "whole", reason: "file_list_unavailable" }));
const whole = (selection: Record<string, unknown>) => ({ selection: { mode: "whole", ...selection } });
const slotId = plan.slot_id;
if (!slotId) return whole({ reason: "no_slot" });
// 季通配:展开为"该季未覆盖且不在其它在途计划里"
const wildSeasons = new Set(
@@ -535,7 +703,7 @@ async function selectPackFiles(
const slot = await d.ctx.db
.selectFrom("acquisition_slot")
.select(["id", "current_asset_id", "media_entity_id"])
.where("id", "=", plan.slot_id)
.where("id", "=", slotId)
.executeTakeFirstOrThrow();
const slots = await d.ctx.db
.selectFrom("acquisition_slot")
@@ -543,7 +711,7 @@ async function selectPackFiles(
.where("media_entity_id", "=", slot.media_entity_id)
.execute();
const covered = await slotCoveredUnits(d.ctx.db, slot, { entityWide: slots.length <= 1 });
const others = await slotInflightPlans(d.ctx.db, plan.slot_id, plan.id);
const others = await slotInflightPlans(d.ctx.db, slotId, plan.id);
blocked = new Set([...covered.keys, ...others.flatMap((o) => o.unitKeys)]);
}
const wantedKey = (k: string) => {
@@ -588,13 +756,12 @@ async function selectPackFiles(
const videos = rows.filter((r) => r.kind === "video");
const unmapped = videos.filter((r) => r.keys.length === 0 && !r.special);
if (videos.length === 0 || unmapped.length > 0)
return void (await record({
mode: "whole",
return whole({
reason: videos.length === 0 ? "no_video_files" : "unmapped_video_file",
files: unmapped.slice(0, 20).map((r) => r.name),
}));
});
const selected = [...new Set(videos.filter((r) => r.wanted).flatMap((r) => r.keys.filter(wantedKey)))];
if (selected.length === 0) return void (await record({ mode: "whole", reason: "no_missing_units_in_pack" }));
if (selected.length === 0) return whole({ reason: "no_missing_units_in_pack" });
// 视频不需要 → 不下;附属文件(字幕等)有集号且不需要 → 不下;无集号的附属文件保留
const skip = rows.filter((r) => (r.kind === "video" ? !r.wanted : r.keys.length > 0 && !r.wanted));
if (skip.length > 0)
@@ -603,15 +770,15 @@ async function selectPackFiles(
skip.map((r) => r.index),
0,
);
await record(
{
return {
selection: {
mode: skip.length > 0 ? "selected" : "all_needed",
selectedUnits: selected,
skippedFiles: skip.slice(0, 200).map((r) => r.name),
totalFiles: files.length,
},
selected,
);
unitKeys: selected,
};
}
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string | null, reason: string) {
@@ -716,8 +883,16 @@ export interface SyncStats {
removed: number;
thresholdPaused: number;
thresholdResumed: number;
/** 本轮首次在 qB 里确认的「已收单未确认」提交的计划(同步后补调 afterDownloadAccepted) */
confirmedPlans: string[];
}
/** 下载器连续不可达多久开 downloader_offline 待处理 */
export const DOWNLOADER_OFFLINE_DECISION_MS = 10 * 60_000;
/** 在途(还没下完)的任务状态:停用的实例上有这些任务时仍同步 */
const INFLIGHT_STATUSES: DownloadStatus[] = ["queued", "submitting", "downloading", "stalled", "paused"];
/** 连续多少轮同步(列表 + 按 hash 查)都查不到才判 removed;与资产对账的 missingConfirmAfter 同口径 */
export const MISSING_CONFIRM_ROUNDS = 3;
@@ -731,23 +906,44 @@ export async function syncDownloads(d: AoDeps): Promise<SyncStats> {
removed: 0,
thresholdPaused: 0,
thresholdResumed: 0,
confirmedPlans: [],
};
const insts = await loadInstances(d.ctx.db);
for (const topo of insts) {
stats.instances++;
await syncInstance(d, topo, stats);
}
// 停用的实例上还有在途任务:只同步这些任务的状态(不接管新任务、不开离线事项、不计入失联统计)
const enabled = new Set(insts.map((t) => t.inst.id));
for (const topo of await loadInstances(d.ctx.db, { enabledOnly: false })) {
if (enabled.has(topo.inst.id)) continue;
const inflight = await d.ctx.db
.selectFrom("download_task")
.select("id")
.where("instance_id", "=", topo.inst.id)
.where("removed_at", "is", null)
.where("managed", "=", true)
.where("status", "in", INFLIGHT_STATUSES)
.executeTakeFirst();
if (inflight) await syncInstance(d, topo, stats, { observeOnly: true });
}
for (const planId of stats.confirmedPlans) {
await afterDownloadAccepted(d, planId).catch((err) =>
d.ctx.logger.warn({ planId, err: { message: errMessage(err) } }, "补记订阅进度失败"),
);
}
await enforceThresholds(d, insts, stats);
return stats;
}
async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats) {
async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats, opts: { observeOnly?: boolean } = {}) {
const db = d.ctx.db;
const now = d.ports.now();
const client = await d.ports.downloader(topo.inst);
const sourceTags = await qbSourceTags(d, topo);
let list: QbTorrent[];
let client: DownloaderClient;
try {
client = await d.ports.downloader(topo.inst);
const sourceTags = await qbSourceTags(d, topo);
const tags = [...new Set([topo.inst.managed_tag, ...sourceTags.map((s) => s.config.tag!)])];
const batches = await Promise.all(tags.map((tag) => client.list({ tag })));
list = [...new Map(batches.flat().map((t) => [t.hash, t])).values()].filter(
@@ -755,13 +951,15 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
);
} catch (err) {
// 下载器失联:不改任何任务状态(失败 ≠ 不存在)
stats.unreachable.push(topo.inst.id);
await db
.updateTable("downloader_instance")
.set({ health_status: "offline" })
.where("id", "=", topo.inst.id)
.execute();
d.ctx.logger.warn({ instanceId: topo.inst.id, err: { message: errMessage(err) } }, "qB 同步失败");
if (opts.observeOnly) return;
stats.unreachable.push(topo.inst.id);
await reportDownloaderOffline(d, topo, now, err);
return;
}
await db
@@ -769,6 +967,7 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
.set({ health_status: "healthy", last_seen_at: now })
.where("id", "=", topo.inst.id)
.execute();
await closeRecoveredDecision(d, db, `downloader_offline:${topo.inst.id}`, "下载器已恢复连接");
const rows = await db
.selectFrom("download_task")
.selectAll()
@@ -783,10 +982,11 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
const row = byHash.get(t.hash);
if (row) {
if (await observe(d, row, t, now, stats)) stats.updated++;
} else {
} else if (!opts.observeOnly) {
await adopt(d, topo, t, now, stats);
}
}
await settlePendingPacks(d, client, topo, seen);
for (const row of rows) {
if (seen.has(row.torrent_hash) || !row.managed) continue;
let still: QbTorrent | null;
@@ -920,9 +1120,21 @@ async function observeLocked(
);
// 卡住 / 异常后恢复正常(进度增长或状态恢复):关闭同一任务未关闭的无进展 / 异常事项
if (changed && RECOVERED.has(merged.status)) {
if (row.status === "stalled") await closeRecoveredDecision(d, trx, `download_stalled:${row.id}`, merged.status);
const note = `下载已恢复(${merged.status})`;
if (row.status === "stalled") await closeRecoveredDecision(d, trx, `download_stalled:${row.id}`, note);
if (row.status === "failed" || row.status === "missing")
await closeRecoveredDecision(d, trx, `download_failed:${row.id}`, merged.status);
await closeRecoveredDecision(d, trx, `download_failed:${row.id}`, note);
}
// 「已收单未确认」的提交第一次在 qB 里看到:计划改记已确认,同步后补调 afterDownloadAccepted(订阅进度 / 提前完成)
if (row.status === "submitting" && row.acquisition_plan_id) {
const confirmed = await trx
.updateTable("acquisition_plan")
.set({ status_reason: "accepted_confirmed" })
.where("id", "=", row.acquisition_plan_id)
.where("status_reason", "=", "accepted_unconfirmed")
.returning("id")
.executeTakeFirst();
if (confirmed) stats.confirmedPlans.push(confirmed.id);
}
if (becameComplete) {
stats.completed.push(row.id);
@@ -974,7 +1186,7 @@ const RECOVERED = new Set<DownloadStatus>(["queued", "downloading", "completed",
* 卡住 / 异常事项自动关闭:只关 open / awaiting_user;Agent 正在调查(investigating)的由它自己核实后关闭。
* resolution 不带处理选项,executeResolution 不会再执行任何动作。
*/
async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, status: DownloadStatus) {
async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, note: string) {
const by = "system:download-sync";
const rows = await trx
.updateTable("decision")
@@ -982,7 +1194,7 @@ async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, s
status: "resolved",
resolved_by: by,
resolved_at: d.ports.now(),
resolution: jsonb({ key: "download_recovered", optionKey: null, by, note: `下载已恢复(${status})` }),
resolution: jsonb({ key: "download_recovered", optionKey: null, by, note }),
})
.where("dedupe_key", "=", dedupeKey)
.where("status", "in", ["open", "awaiting_user"])
@@ -998,6 +1210,82 @@ async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, s
}
}
/** 同一 dedupeKey 已有未关闭的事项(巡检类事项每轮都会判定,已开着就不再刷新、不累加次数) */
async function hasOpenDecision(exec: Exec, dedupeKey: string): Promise<boolean> {
const r = await exec
.selectFrom("decision")
.select("id")
.where("dedupe_key", "=", dedupeKey)
.where("status", "in", ["open", "investigating", "awaiting_user"])
.executeTakeFirst();
return !!r;
}
/** 下载器连续不可达超过 DOWNLOADER_OFFLINE_DECISION_MS:按实例开一条待处理,恢复连接后由同步关闭 */
async function reportDownloaderOffline(d: AoDeps, topo: InstanceTopology, now: Date, err: unknown) {
const since = topo.inst.last_seen_at ?? topo.inst.created_at;
const offlineMs = now.getTime() - since.getTime();
if (offlineMs < DOWNLOADER_OFFLINE_DECISION_MS) return;
const dedupeKey = `downloader_offline:${topo.inst.id}`;
if (await hasOpenDecision(d.ctx.db, dedupeKey)) return;
await upsertDecision(d, d.ctx.db, {
kind: "downloader_offline",
severity: "critical",
title: `下载器连不上:${topo.inst.name}`,
summary:
`${topo.inst.last_seen_at ? `已 ${Math.round(offlineMs / 60_000)} 分钟` : "添加后一直"}连不上 ${topo.inst.base_url},` +
"新下载提交不了,在下的任务状态也不更新。检查下载器是否在运行、地址和登录信息是否正确",
subjectType: "downloader_instance",
subjectId: topo.inst.id,
dedupeKey,
evidence: {
instanceId: topo.inst.id,
baseUrl: topo.inst.base_url,
lastSeenAt: topo.inst.last_seen_at?.toISOString() ?? null,
error: errMessage(err).slice(0, 500),
},
});
}
/**
* 下载目录读不到容量(statfs 失败、保存路径没有路径映射):按卷开 critical 待处理,读得到后关闭。
* 读不到时新下载一律延后(不能把"未知"当"有空间"),所以这是配置问题(路径映射 / 容器挂载),不是空间问题。
*/
export async function reportVolumeStates(d: AoDeps, vols: VolumeState[]): Promise<void> {
for (const v of vols) {
const dedupeKey = `cache_volume_unknown:${v.volumeKey}`;
if (v.snapshot) {
await closeRecoveredDecision(d, d.ctx.db, dedupeKey, "下载目录已能读到容量");
continue;
}
if (await hasOpenDecision(d.ctx.db, dedupeKey)) continue;
const usage = v.usage && v.usage.status !== "ok" ? (v.usage as { code?: string; message?: string }) : null;
const savePaths = v.instances.map((t) => ({
instance: t.inst.name,
savePath: t.savePath,
localPath: t.localSavePath,
}));
const why = v.path
? `读不到本机路径 ${v.path} 的容量${usage?.code ? `(${usage.code})` : ""},检查容器是否挂载了下载目录`
: "下载器的保存路径没法换算成本机路径,检查下载器的保存路径和路径映射";
await upsertDecision(d, d.ctx.db, {
kind: "cache_volume_unknown",
severity: "critical",
title: `下载目录读不到容量:${v.instances.map((t) => t.inst.name).join("、")}`,
summary: `${why}。读到之前新下载一律延后`,
subjectType: "cache_volume",
dedupeKey,
evidence: {
volumeKey: v.volumeKey,
path: v.path,
reason: v.path ? "statfs_failed" : "save_path_unmapped",
usage: usage ? { code: usage.code ?? null, message: usage.message ?? null } : null,
instances: savePaths,
},
});
}
}
/** 用户给已有任务贴了管理标签:登记接管,不改保存路径与其它标签 */
async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, stats: SyncStats) {
const db = d.ctx.db;
@@ -1092,20 +1380,31 @@ async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date,
async function enforceThresholds(d: AoDeps, insts: InstanceTopology[], stats: SyncStats) {
if (insts.length === 0) return;
const vols = await loadVolumes(d, d.ctx.db, insts);
await reportVolumeStates(d, vols);
// 只读对账模式禁止自动写入(含对 qB 的暂停/恢复)
if ((await d.ctx.settings.get("automation")).readOnlyReconcile) return;
const cache = await d.ctx.settings.get("cache");
const vols = await loadVolumes(d, d.ctx.db, insts);
for (const v of vols) {
const dv = decideVolume(v, cache);
if (dv.decision === "pause_managed" && dv.pauseTaskIds.length > 0) {
const n = await pauseForThreshold(d, v, dv.pauseTaskIds);
// 排队中(queuedDL)的纳管任务一并暂停:否则其它任务被暂停后 qB 的队列会把它拉起来
const pauseIds =
dv.decision === "pause_managed"
? [
...new Set([
...dv.pauseTaskIds,
...v.inflight.filter((t) => t.managed && t.status === "queued").map((t) => t.taskId),
]),
]
: [];
if (pauseIds.length > 0) {
const n = await pauseForThreshold(d, v, pauseIds);
stats.thresholdPaused += n;
await d.ctx.outbox.emit(d.ctx.db, "download.threshold", {
volumeKey: v.volumeKey,
state: "tripped",
usedPercent: dv.usedPct === undefined ? null : Math.min(100, dv.usedPct),
pausedTaskIds: dv.pauseTaskIds,
pausedTaskIds: pauseIds,
});
} else if (dv.resumeThresholdPaused) {
const ids = await resumeThresholdPaused(d, v);
@@ -0,0 +1,549 @@
import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain";
import { toParsedColumns } from "@umt/mapping";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import {
cleanupCandidates,
DOWNLOADER_OFFLINE_DECISION_MS,
PACK_METADATA_TIMEOUT_MS,
submitDownload,
syncDownloads,
} from "../../src/modules/acquisition-organize/services/downloads.ts";
import { loadVolumes } from "../../src/modules/acquisition-organize/services/volumes.ts";
import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts";
import {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// 下载侧可见性与剩余隐患:下载器离线 / 下载目录读不到容量开待处理并自动关闭、H&R 未知按下载器设置放行、
// 季包暂停添加选完文件再恢复(磁力元数据延后 / 超时整包)、取消与提交并发、已收单未确认的提交补记订阅进度、
// 排队中的任务受阈值暂停、停用实例上的在途任务仍同步。真实 PG + mock qB。
const hasPg = await pgAvailable();
const SIZE = 300_000;
const TABLE = { seasons: [{ season: 1, total: 12 }], latestSeason: 1, ended: true };
const ep = (n: number) => `Pack.Show.S01E${String(n).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`;
describe.skipIf(!hasPg)("下载侧可见性(真实 PG + mock qB)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000_000);
/** releaseId → 种子来源(.torrent 字节或磁力) */
const sources = new Map<string, { kind: "file"; bytes: Uint8Array } | { kind: "magnet"; uri: string }>();
/** releaseId → 取种子时执行的钩子(模拟提交过程中发生的事) */
const onSource = new Map<string, () => Promise<void>>();
let base: Awaited<ReturnType<typeof seedBase>>;
let n = 0;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-vislib-" });
cache = await createTempTree({}, { prefix: "umt-viscache-" });
disk.state.watch = [cache.root];
disk.setPct(40);
h = await createHarness(
"dl_visibility",
{
diskUsage: disk.fn,
torrentSource: async ({ release }) => {
await onSource.get(release.id)?.();
return sources.get(release.id)!;
},
},
{ withSources: true },
);
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const decision = (key: string) =>
h.db
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", key)
.orderBy("created_at", "desc")
.executeTakeFirst();
const planRow = (id: string) =>
h.db.selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const dtByHash = (hash: string) =>
h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirst();
async function movieRelease(title: string, hr: boolean | "unknown" = false) {
const entityId = await seedEntity(h.db, { kind: "movie", title, year: 2020 + (n++ % 5) });
const slotId = await seedSlot(h.db, { entityId });
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: `${title}.2021.1080p.BluRay.x264-GRP`,
size: SIZE,
hash: t.hash,
hr,
});
sources.set(id, { kind: "file", bytes: t.bytes });
return { entityId, slotId, releaseId: id, hash: t.hash };
}
async function planFor(releaseId: string) {
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: releaseId });
expect(ev.plans).toHaveLength(1);
return ev.plans[0]!;
}
/** 季包:剧集 + 季表 + 订阅收录版本 + 只缺 S01E03 的计划 */
async function seasonPack(opts: { magnet?: boolean } = {}) {
const title = `Pack Show ${n++}`;
const entityId = await seedEntity(h.db, { kind: "series", title, year: 2023 });
await h.db
.insertInto("content_unit")
.values({
media_entity_id: entityId,
kind: "season",
season_number: 1,
episode_total: 12,
status: "confirmed",
source: "tmdb",
})
.execute();
const sub = await h.db
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: "series", mode: "continuous", status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
const slot = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: sub.id,
name: "1080p",
hard_conditions: JSON.stringify({}),
upgrade_threshold: JSON.stringify({}),
})
.returning("id")
.executeTakeFirstOrThrow();
const relTitle = `Pack.Show.${n}.S01.1080p.WEB-DL.x264-GRP`;
const raw = parseRelease(relTitle);
const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] });
const cols = toParsedColumns(canonicalParsed(raw, resolution));
const files = [1, 2, 3, 4].map((e) => ({ path: ep(e), length: SIZE }));
const t = makeTorrent({ name: relTitle, files, pieceSeed: Math.floor(Math.random() * 1e6) });
const rel = await h.db
.insertInto("source_release")
.values({
site_id: base.siteId,
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
title: relTitle,
size_bytes: SIZE * 4,
infohash_v1: t.hash,
media_entity_id: entityId,
resolution_status: "resolved",
parsed: JSON.stringify(cols.parsed),
parser_version: cols.parserVersion,
episode_resolution: JSON.stringify(resolution),
raw: JSON.stringify({ _site: { hr: false } }),
})
.returning("id")
.executeTakeFirstOrThrow();
sources.set(
rel.id,
opts.magnet
? { kind: "magnet", uri: `magnet:?xt=urn:btih:${t.hash}&dn=${relTitle}` }
: { kind: "file", bytes: t.bytes },
);
const plan = await h.db
.insertInto("acquisition_plan")
.values({
slot_id: slot.id,
source_release_id: rel.id,
status: "reserved",
evaluation: JSON.stringify({ instanceId: base.instanceId, savePath: cache.root, mediaEntityId: entityId }),
payload_hash: `ph-${rel.id}`,
idempotency_key: `ik-${rel.id}`,
unit_keys: ["s1e3"],
})
.returning("id")
.executeTakeFirstOrThrow();
return { planId: plan.id, hash: t.hash, relTitle };
}
/** 磁力的 mock 种子:元数据到达后换成真实文件清单 */
const giveMetadata = (hash: string) => {
const t = qb.get(hash)!;
t.files = [1, 2, 3, 4].map((e) => ({ name: ep(e), size: SIZE, progress: 0, priority: 1 }));
};
const priorities = (hash: string) =>
Object.fromEntries(qb.get(hash)!.files.map((f) => [f.name.split("/").pop()!, f.priority]));
const resumedHashes = () =>
[...qb.callsTo("torrents/resume"), ...qb.callsTo("torrents/start")].flatMap((c) =>
(c.form.hashes ?? "").split("|"),
);
it("下载器连不上超过 10 分钟开 downloader_offline 待处理(按实例去重),恢复连接后自动关闭", async () => {
const qb2 = await createMockQb();
const inst = await h.db
.insertInto("downloader_instance")
.values({
name: "离线的 qB",
base_url: "http://127.0.0.1:9",
username: "admin",
managed_tag: "umt-offline",
settings: JSON.stringify({ savePath: cache.root }),
last_seen_at: new Date(Date.now() - 2 * 60_000),
})
.returning("id")
.executeTakeFirstOrThrow();
const key = `downloader_offline:${inst.id}`;
try {
// 刚断 2 分钟:只标离线,不开事项
let st = await syncDownloads(h.d);
expect(st.unreachable).toEqual([inst.id]);
expect(await decision(key)).toBeUndefined();
await h.db
.updateTable("downloader_instance")
.set({ last_seen_at: new Date(Date.now() - DOWNLOADER_OFFLINE_DECISION_MS - 60_000) })
.where("id", "=", inst.id)
.execute();
await syncDownloads(h.d);
await syncDownloads(h.d);
const open = await h.db.selectFrom("decision").selectAll().where("dedupe_key", "=", key).execute();
expect(open).toHaveLength(1);
expect(open[0]).toMatchObject({
kind: "downloader_offline",
severity: "critical",
status: "open",
subject_type: "downloader_instance",
subject_id: inst.id,
});
expect(open[0]!.summary).toContain("分钟连不上");
// 恢复:地址改对
await h.db.updateTable("downloader_instance").set({ base_url: qb2.url }).where("id", "=", inst.id).execute();
st = await syncDownloads(h.d);
expect(st.unreachable).toEqual([]);
expect(await decision(key)).toMatchObject({ status: "resolved", resolved_by: "system:download-sync" });
const health = await h.db
.selectFrom("downloader_instance")
.select(["health_status", "last_seen_at"])
.where("id", "=", inst.id)
.executeTakeFirstOrThrow();
expect(health.health_status).toBe("healthy");
} finally {
await h.db.deleteFrom("downloader_instance").where("id", "=", inst.id).execute();
await qb2.close();
}
});
it("下载目录读不到容量:提交延后、面板显示「下载目录读不到」而不是等空间,按卷开 critical 事项,恢复后关闭", async () => {
const { releaseId } = await movieRelease("读不到容量片");
const planId = await planFor(releaseId);
disk.state.fail = true;
try {
const s = await submitDownload(h.d, planId);
expect(s).toMatchObject({ status: "deferred", reason: "cache:volume_state_unknown" });
// 评估时已建好提交任务行(假引擎不执行):按处理器的做法记下延后结果
await h.db
.updateTable("task")
.set({ status: "succeeded", output: JSON.stringify(s) })
.where("kind", "=", "submitDownload")
.where("idempotency_key", "=", `plan:${planId}`)
.execute();
await syncDownloads(h.d);
const vol = (await loadVolumes(h.d))[0]!;
const key = `cache_volume_unknown:${vol.volumeKey}`;
const dec = await decision(key);
expect(dec).toMatchObject({ kind: "cache_volume_unknown", severity: "critical", status: "open" });
expect(dec!.evidence).toMatchObject({ reason: "statfs_failed", path: cache.root, usage: { code: "EIO" } });
// 再同步一轮不重复开
await syncDownloads(h.d);
expect(await h.db.selectFrom("decision").select("id").where("dedupe_key", "=", key).execute()).toHaveLength(1);
const board = (await h.api("GET", "/download-tasks/board")).body.data;
const row = board.items.find((x: { id: string }) => x.id === planId);
expect(row).toMatchObject({ kind: "plan", wait: "volume_unknown", waitReason: "cache:volume_state_unknown" });
disk.state.fail = false;
await syncDownloads(h.d);
expect(await decision(key)).toMatchObject({ status: "resolved" });
} finally {
disk.state.fail = false;
await h.db
.updateTable("acquisition_plan")
.set({ status: "cancelled", status_reason: "test_cleanup" })
.where("id", "=", planId)
.execute();
}
});
it("H&R 未知:缺省锁定(hnr_unknown);下载器打开「H&R 未知也可清理」后保种到期即可清理", async () => {
const { releaseId, hash, entityId } = await movieRelease("H&R 未知片", "unknown");
const dt = await h.db
.insertInto("download_task")
.values({
instance_id: base.instanceId,
torrent_hash: hash,
source_release_id: releaseId,
media_entity_id: entityId,
name: "H&R 未知片",
save_path: cache.root,
content_path: `${cache.root}/H&R 未知片.mkv`,
tags: ["umt"],
managed: true,
status: "imported",
size_bytes: SIZE,
completed_at: new Date(Date.now() - 30 * 86_400_000),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_record")
.values({
download_task_id: dt.id,
trigger: "auto",
actor_type: "system",
action: "copy",
status: "succeeded",
} as never)
.execute();
const lockOf = async () => {
const vol = (await loadVolumes(h.d))[0]!;
return (await cleanupCandidates(h.d, vol)).find((c) => c.row.id === dt.id)!;
};
expect(await lockOf()).toMatchObject({ cleanable: false, lockReason: "hnr_unknown" });
await h.db
.updateTable("downloader_instance")
.set({ settings: JSON.stringify({ savePath: cache.root, policy: { hnrUnknownCleanable: true } }) })
.where("id", "=", base.instanceId)
.execute();
try {
expect(await lockOf()).toMatchObject({ cleanable: true, lockReason: null });
// H&R 明确未满足的不受这个开关影响
await h.db
.updateTable("source_release")
.set({ raw: JSON.stringify({ _site: { hr: true } }) })
.where("id", "=", releaseId)
.execute();
expect(await lockOf()).toMatchObject({ cleanable: false, lockReason: "hnr_unsatisfied" });
} finally {
await h.db
.updateTable("downloader_instance")
.set({ settings: JSON.stringify({ savePath: cache.root }) })
.where("id", "=", base.instanceId)
.execute();
await h.db.updateTable("download_task").set({ managed: false }).where("id", "=", dt.id).execute();
}
});
it("季包(.torrent)暂停添加:选完文件再恢复下载", async () => {
const p = await seasonPack();
const s = await submitDownload(h.d, p.planId);
expect(s, JSON.stringify(s)).toMatchObject({ status: "submitted", reason: "added" });
const add = qb.callsTo("torrents/add").at(-1)!;
expect(add.status).toBe(200);
const plan = await planRow(p.planId);
expect(plan.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3"] });
expect(priorities(p.hash)).toEqual({ [ep(1)]: 0, [ep(2)]: 0, [ep(3)]: 1, [ep(4)]: 0 });
// 先设文件优先级再恢复
const prioAt = qb.callsTo("torrents/filePrio").at(-1)!.at;
const resume = qb
.callsTo("torrents/resume")
.concat(qb.callsTo("torrents/start"))
.find((c) => (c.form.hashes ?? "").includes(p.hash));
expect(resume).toBeDefined();
expect(resume!.at).toBeGreaterThanOrEqual(prioAt);
expect(qb.get(p.hash)!.state).toBe("downloading");
// 提交时任务行记为策略暂停,恢复后同步回到下载中
await syncDownloads(h.d);
expect(await dtByHash(p.hash)).toMatchObject({ status: "downloading", pause_reason: null });
});
it("磁力季包:不暂停添加而是「拿到元数据后停止」;元数据未就绪时延后选文件,同步里拿到后选完再恢复", async () => {
const p = await seasonPack({ magnet: true });
// 磁力元数据还没到:文件清单为空
qb.fault("torrents/files", { status: 200, body: "[]", times: 3 });
const s = await submitDownload(h.d, p.planId);
expect(s).toMatchObject({ status: "submitted", reason: "added" });
expect(await planRow(p.planId)).toMatchObject({ file_selection: { mode: "pending", attempts: 1 } });
expect(resumedHashes()).not.toContain(p.hash);
qb.clearFaults();
giveMetadata(p.hash);
await syncDownloads(h.d);
const plan = await planRow(p.planId);
expect(plan.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3"] });
expect(plan.unit_keys).toEqual(["s1e3"]);
expect(priorities(p.hash)[ep(3)]).toBe(1);
expect(priorities(p.hash)[ep(1)]).toBe(0);
expect(resumedHashes()).toContain(p.hash);
});
it("磁力季包元数据一直拿不到:超时后整包下载并记录原因", async () => {
const p = await seasonPack({ magnet: true });
qb.fault("torrents/files", { status: 200, body: "[]", times: 1000 });
const realNow = h.d.ports.now;
try {
await submitDownload(h.d, p.planId);
await syncDownloads(h.d);
expect(await planRow(p.planId)).toMatchObject({ file_selection: { mode: "pending", attempts: 2 } });
h.d.ports.now = () => new Date(Date.now() + PACK_METADATA_TIMEOUT_MS + 60_000);
await syncDownloads(h.d);
} finally {
h.d.ports.now = realNow;
qb.clearFaults();
}
const plan = await planRow(p.planId);
expect(plan.file_selection).toMatchObject({ mode: "whole", reason: "file_list_timeout" });
expect(resumedHashes()).toContain(p.hash);
});
it("取消与提交并发:qB 已收单时计划被取消,commit 复核后不写回 submitted,qB 里的任务暂停并去掉管理标签", async () => {
const { releaseId, hash } = await movieRelease("并发取消片");
const planId = await planFor(releaseId);
onSource.set(releaseId, async () => {
const r = await h.api("POST", `/acquisition-plans/${planId}/cancel`);
expect(r.status).toBe(200);
});
const s = await submitDownload(h.d, planId);
expect(s).toMatchObject({ status: "skipped", reason: "plan_cancelled" });
expect(await planRow(planId)).toMatchObject({ status: "cancelled", status_reason: "cancelled_by_user" });
expect(await dtByHash(hash)).toBeUndefined();
const t = qb.get(hash)!;
expect(t.tags).not.toContain("umt");
expect(t.tags).toContain("umt-cancelled");
expect(t.state).toMatch(/^(paused|stopped)/);
// 同步不接管它,计划也不被复活
await syncDownloads(h.d);
expect(await dtByHash(hash)).toBeUndefined();
expect(await planRow(planId)).toMatchObject({ status: "cancelled" });
});
it("已收单未确认的提交:同步首次在 qB 里看到时补记订阅进度,计划改记已确认", async () => {
const { releaseId, hash, slotId } = await movieRelease("未确认片");
const plan = await h.db
.insertInto("acquisition_plan")
.values({
slot_id: slotId,
source_release_id: releaseId,
status: "submitted",
status_reason: "accepted_unconfirmed",
evaluation: JSON.stringify({ instanceId: base.instanceId }),
payload_hash: `ph-${releaseId}`,
idempotency_key: `ik-${releaseId}`,
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("download_task")
.values({
instance_id: base.instanceId,
torrent_hash: hash,
acquisition_plan_id: plan.id,
source_release_id: releaseId,
name: "未确认片",
tags: ["umt"],
managed: true,
status: "submitting",
})
.execute();
const sub = () =>
h.db
.selectFrom("subscription as s")
.innerJoin("acquisition_slot as sl", "sl.subscription_id", "s.id")
.select("s.last_progress_at")
.where("sl.id", "=", slotId)
.executeTakeFirstOrThrow();
expect((await sub()).last_progress_at).toBeNull();
// 还没出现在 qB 里:不补记
await syncDownloads(h.d);
expect(await planRow(plan.id)).toMatchObject({ status_reason: "accepted_unconfirmed" });
qb.seed({ hash, name: "未确认片", tags: ["umt"], save_path: cache.root });
const st = await syncDownloads(h.d);
expect(st.confirmedPlans).toEqual([plan.id]);
expect(await planRow(plan.id)).toMatchObject({ status: "submitted", status_reason: "accepted_confirmed" });
expect((await sub()).last_progress_at).not.toBeNull();
// 只补一次
expect((await syncDownloads(h.d)).confirmedPlans).toEqual([]);
await h.db.updateTable("download_task").set({ managed: false }).where("torrent_hash", "=", hash).execute();
});
it("排队中(queuedDL)的纳管任务也受阈值暂停,降到恢复线后恢复", async () => {
const { releaseId, hash } = await movieRelease("排队片");
const planId = await planFor(releaseId);
expect(await submitDownload(h.d, planId)).toMatchObject({ status: "submitted" });
qb.setState(hash, "queuedDL");
await syncDownloads(h.d);
expect(await dtByHash(hash)).toMatchObject({ status: "queued" });
disk.setPct(95);
try {
const st = await syncDownloads(h.d);
expect(st.thresholdPaused).toBeGreaterThan(0);
expect(await dtByHash(hash)).toMatchObject({ status: "paused", pause_reason: "threshold" });
expect(qb.get(hash)!.state).toMatch(/^(paused|stopped)/);
} finally {
disk.setPct(40);
}
await syncDownloads(h.d);
expect(await dtByHash(hash)).toMatchObject({ pause_reason: null });
expect((await dtByHash(hash))!.status).not.toBe("paused");
});
it("停用的实例上还有在途任务:仍同步其状态,但不接管新任务", async () => {
const inst = await h.db
.insertInto("downloader_instance")
.values({
name: "停用的 qB",
base_url: qb.url,
username: "admin",
managed_tag: "umt-old",
enabled: false,
settings: JSON.stringify({ savePath: cache.root }),
})
.returning("id")
.executeTakeFirstOrThrow();
const hash = (900 + n++).toString(16).padStart(40, "e");
const other = (901 + n++).toString(16).padStart(40, "e");
qb.seed({ hash, name: "停用实例上的任务", tags: ["umt-old"], save_path: cache.root });
qb.seed({ hash: other, name: "停用实例上的新任务", tags: ["umt-old"], save_path: cache.root });
qb.setProgress(hash, 0.5);
const dt = await h.db
.insertInto("download_task")
.values({
instance_id: inst.id,
torrent_hash: hash,
name: "停用实例上的任务",
tags: ["umt-old"],
managed: true,
status: "downloading",
progress: 0.1,
})
.returning("id")
.executeTakeFirstOrThrow();
try {
const st = await syncDownloads(h.d);
expect(st.unreachable).toEqual([]);
const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow();
expect(row.progress).toBeCloseTo(0.5);
expect(await dtByHash(other)).toBeUndefined();
} finally {
await h.db.deleteFrom("download_task").where("instance_id", "=", inst.id).execute();
await h.db.deleteFrom("downloader_instance").where("id", "=", inst.id).execute();
}
});
});
@@ -10,6 +10,7 @@ export const PURPOSE_LABEL: Record<PlanPurpose, string> = { fill: "补缺", upgr
export const PLAN_WAIT: Record<PlanWait, { label: string; tone: "warning" | "neutral" }> = {
approval: { label: "等你批准", tone: "warning" },
space: { label: "等磁盘空间", tone: "warning" },
volume_unknown: { label: "下载目录读不到", tone: "warning" },
automation_off: { label: "等开启自动化", tone: "warning" },
queued: { label: "准备提交", tone: "neutral" },
};