feat(downloads): 下载器单独的水位与保种天数

- contracts DownloaderPolicy(存 downloader_instance.settings.policy):硬阈值 / 恢复水位 / 清理目标 / 保种天数,
  不填用全局 cache / retention
- volumeThresholds:卷的生效水位 = 各实例「自己的值 ?? 全局」取最小;decideVolume、阈值清理、手动恢复、
  首页卷信息、定时清理检查都改用它;cleanupCandidates 保种天数按实例
- 保存时校验合并全局后 恢复 < 硬阈值、清理目标 ≤ 恢复
- 下载器设置表单新增四项(留空用全局)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 20:51:04 +08:00
1 parent ff79554a09
commit ee128e0b3f
9 files changed
+158 -14

No files matched your search

@@ -18,7 +18,7 @@ import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot }
import { SWEEP_SLOT_MS, sweepReservedPlans, syncDownloads } from "./services/downloads.ts";
import { sweepDueSlots, sweepMetadataRefresh, sweepSubscriptionSearch } from "./services/subscriptions.ts";
import { organizeDecisionHook } from "./services/takeover.ts";
import { loadVolumes, usedPercent } from "./services/volumes.ts";
import { loadVolumes, usedPercent, volumeThresholds } from "./services/volumes.ts";
import { errMessage } from "./util.ts";
/**
@@ -137,8 +137,10 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
schedule: every(planMs),
},
run: guarded(d, async (d) => {
const hard = (await d.ctx.settings.get("cache")).hardThresholdPercent;
const over = (await loadVolumes(d)).some((v) => (usedPercent(v) ?? 0) >= hard);
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)) ? "已入队清理" : "清理进行中";
}),
@@ -3,7 +3,7 @@ import { sql } from "@umt/db";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps } from "../services/common.ts";
import { cleanupCandidates } from "../services/downloads.ts";
import { decideVolume, loadVolumes, usedPercent } from "../services/volumes.ts";
import { decideVolume, loadVolumes, usedPercent, volumeThresholds } from "../services/volumes.ts";
import { startOfDay } from "../util.ts";
import { mount } from "./helpers.ts";
@@ -153,8 +153,8 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
usedBytes: v.snapshot?.usedBytes ?? null,
pendingBytes: dv.pendingBytes,
reservedBytes: dv.reservedBytes,
hardThresholdPercent: cache.hardThresholdPercent,
resumePercent: cache.resumePercent,
hardThresholdPercent: volumeThresholds(v, cache).hardThresholdPercent,
resumePercent: volumeThresholds(v, cache).resumePercent,
pausedByThreshold: thr
.filter((t) => v.instances.some((i) => i.inst.id === t.instance_id))
.reduce((s, t) => s + t.n, 0),
@@ -57,7 +57,14 @@ import {
slotCoveredUnits,
slotInflightPlans,
} from "./subscriptions.ts";
import { decideVolume, loadVolumes, usedPercent, type VolumeState } from "./volumes.ts";
import {
decideVolume,
instancePolicy,
loadVolumes,
usedPercent,
type VolumeState,
volumeThresholds,
} from "./volumes.ts";
// 下载:提交(幂等 + fencing)、qB 同步与标签接管、无进展证据、缓存卷硬阈值、缓存清理。
@@ -1006,7 +1013,7 @@ export async function userResume(d: AoDeps, id: string): Promise<DownloadTaskRow
const cache = await d.ctx.settings.get("cache");
const v = (await loadVolumes(d, d.ctx.db, [topo!]))[0];
const pct = v ? usedPercent(v) : null;
if (pct === null || pct > cache.resumePercent) return "threshold_active";
if (pct === null || !v || pct > volumeThresholds(v, cache).resumePercent) return "threshold_active";
}
const client = await d.ports.downloader(topo!.inst);
await client.resume([row.torrent_hash]);
@@ -1060,7 +1067,8 @@ export async function cleanupCandidates(d: AoDeps, v: VolumeState): Promise<Clea
const site = rel
? await db.selectFrom("site").select("config").where("id", "=", rel.site_id).executeTakeFirst()
: null;
const lock = await lockReason(d, row, rel ?? null, site ?? null, retention.seedRetentionDays, now);
const days = instancePolicy(topo.inst).seedRetentionDays ?? retention.seedRetentionDays;
const lock = await lockReason(d, row, rel ?? null, site ?? null, days, now);
out.push({ row, topo, bytes: row.size_bytes ?? 0, cleanable: lock === null, lockReason: lock });
}
}
@@ -1145,14 +1153,15 @@ export async function cleanupDownloadCache(
entry.action = "unknown";
continue;
}
if (before < cache.hardThresholdPercent) continue;
const th = volumeThresholds(v, cache);
if (before < th.hardThresholdPercent) continue;
let pct = before;
const cands = (await cleanupCandidates(d, v)).filter((c) => {
if (!c.cleanable) entry.skipped.push({ downloadTaskId: c.row.id, reason: c.lockReason ?? "locked" });
return c.cleanable;
});
for (const cand of cands) {
if (pct <= cache.cleanupTargetPercent) break;
if (pct <= th.cleanupTargetPercent) break;
const ok = await removeWithData(
d,
cand,
@@ -1168,7 +1177,7 @@ export async function cleanupDownloadCache(
}
entry.usedPercentAfter = pct;
entry.action = entry.removed.length > 0 ? "cleaned" : "none";
if (pct > cache.cleanupTargetPercent && pct >= cache.hardThresholdPercent) {
if (pct > th.cleanupTargetPercent && pct >= th.hardThresholdPercent) {
entry.action = entry.removed.length > 0 ? "cleaned" : "insufficient";
// 没有可清理对象:待处理 + 阈值继续阻止新下载(不突破保留要求)
await upsertDecision(d, db, {
@@ -1,5 +1,5 @@
import type { DiskUsage } from "@umt/adapters";
import type { CacheSettings } from "@umt/contracts";
import { type CacheSettings, DownloaderPolicy } from "@umt/contracts";
import {
type CacheVolumeDecision,
evaluateCacheVolume,
@@ -106,10 +106,34 @@ export async function loadVolumes(
return out;
}
/** 下载器自己的水位 / 保种(settings.policy);格式不对时当没设 */
export function instancePolicy(inst: { settings: unknown }): DownloaderPolicy {
const p = DownloaderPolicy.safeParse((inst.settings as { policy?: unknown } | null)?.policy ?? {});
return p.success ? p.data : {};
}
export interface VolumeThresholds {
hardThresholdPercent: number;
resumePercent: number;
cleanupTargetPercent: number;
}
/** 卷的生效水位:各实例「自己的值 ?? 全局」里取最严格(最小) */
export function volumeThresholds(v: Pick<VolumeState, "instances">, cache: CacheSettings): VolumeThresholds {
const pick = (k: keyof VolumeThresholds) =>
v.instances.length ? Math.min(...v.instances.map((t) => instancePolicy(t.inst)[k] ?? cache[k])) : cache[k];
return {
hardThresholdPercent: pick("hardThresholdPercent"),
resumePercent: pick("resumePercent"),
cleanupTargetPercent: pick("cleanupTargetPercent"),
};
}
export function decideVolume(v: VolumeState, cache: CacheSettings, request?: { bytes?: number }): CacheVolumeDecision {
const th = volumeThresholds(v, cache);
return evaluateCacheVolume({
volume: v.snapshot,
config: { hardPct: cache.hardThresholdPercent, resumePct: cache.resumePercent },
config: { hardPct: th.hardThresholdPercent, resumePct: th.resumePercent },
inflight: v.inflight,
reservations: v.reservations,
...(request ? { request } : {}),
@@ -7,6 +7,7 @@ import {
DownloaderInstance,
DownloaderInstanceCreate,
DownloaderInstanceUpdate,
DownloaderPolicy,
IdParam,
InstanceTestResult,
LibraryRoot,
@@ -262,6 +263,18 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
throw new ApiException("validation_failed", "API Key 认证需要填写 API Key");
return { ...curSettings, ...(next.settings ?? {}), authKind };
};
/** 下载器自己的水位 / 保种:格式合法,且与全局值合并后仍满足 恢复 < 硬阈值、清理目标 ≤ 恢复 */
const checkPolicy = async (settings: Record<string, unknown> | undefined) => {
if (settings?.policy === undefined) return;
const r = DownloaderPolicy.safeParse(settings.policy);
if (!r.success) throw new ApiException("validation_failed", "下载器水位 / 保种设置格式不对");
const g = await ctx.settings.get("cache");
const hard = r.data.hardThresholdPercent ?? g.hardThresholdPercent;
const resume = r.data.resumePercent ?? g.resumePercent;
const target = r.data.cleanupTargetPercent ?? g.cleanupTargetPercent;
if (resume >= hard) throw new ApiException("validation_failed", "恢复水位必须低于硬阈值");
if (target > resume) throw new ApiException("validation_failed", "清理目标不得高于恢复水位");
};
/** 默认下载器至多一个:设新的之前先取消旧的 */
const clearDefault = (exceptId?: string) => {
let q = ctx.db.updateTable("downloader_instance").set({ is_default: false }).where("is_default", "=", true);
@@ -273,6 +286,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
if (rest.isDefault) await clearDefault();
const credential = secretInput(rawCredential) ?? undefined;
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) });
await checkPolicy(settings ?? (rest.settings as Record<string, unknown> | undefined));
const keep = authKind !== "none" && credential;
const ref = keep ? await ctx.secrets.create({ purpose: "downloader_password", plaintext: credential }) : null;
try {
@@ -303,6 +317,7 @@ export function registerInstanceRoutes(app: ModuleApp, ctx: ServerContext) {
if (credential === null && (authKind ?? curAuth) === "api_key")
throw new ApiException("validation_failed", "API Key 认证不能清除 API Key;不用认证时把认证方式改为无认证");
const settings = dlAuth(authKind, { ...rest, ...(credential ? { credential } : {}) }, cur);
await checkPolicy(settings);
const values = toDb({ ...rest, ...(settings ? { settings } : {}) }, JSON_COLS);
let dropRef: string | null = null;
if (authKind === "none" || credential === null) {
@@ -1,5 +1,7 @@
import type { CacheSettings } from "@umt/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadInstances, pickInstance } from "../../src/modules/acquisition-organize/services/common.ts";
import { instancePolicy, volumeThresholds } from "../../src/modules/acquisition-organize/services/volumes.ts";
import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts";
const hasPg = await pgAvailable();
@@ -61,3 +63,35 @@ describe.skipIf(!hasPg)("下载器选择", () => {
).rejects.toThrow();
});
});
describe("下载器自己的水位 / 保种", () => {
const cache = { hardThresholdPercent: 85, resumePercent: 75, cleanupTargetPercent: 70 } as CacheSettings;
const inst = (policy?: unknown) => ({ inst: { settings: policy === undefined ? {} : { policy } } }) as never;
it("没设用全局;单实例用自己的值(可以比全局宽松)", () => {
expect(volumeThresholds({ instances: [inst()] }, cache)).toEqual({
hardThresholdPercent: 85,
resumePercent: 75,
cleanupTargetPercent: 70,
});
expect(volumeThresholds({ instances: [inst({ hardThresholdPercent: 95, resumePercent: 90 })] }, cache)).toEqual({
hardThresholdPercent: 95,
resumePercent: 90,
cleanupTargetPercent: 70,
});
});
it("多实例共用一块盘:各自生效值里取最严格", () => {
const th = volumeThresholds(
{ instances: [inst({ hardThresholdPercent: 95 }), inst({ resumePercent: 60 })] },
cache,
);
expect(th).toEqual({ hardThresholdPercent: 85, resumePercent: 60, cleanupTargetPercent: 70 });
});
it("保种天数按实例取;格式不对当没设", () => {
expect(instancePolicy({ settings: { policy: { seedRetentionDays: 0 } } })).toEqual({ seedRetentionDays: 0 });
expect(instancePolicy({ settings: { policy: { seedRetentionDays: -1 } } })).toEqual({});
expect(instancePolicy({ settings: null })).toEqual({});
});
});
@@ -90,6 +90,26 @@ export function DownloadersSection() {
);
}
/** 下载器自己的水位 / 保种(settings.policy):表单里是字符串,空 = 用全局 */
const POLICY_FIELDS = [
["hardPct", "hardThresholdPercent"],
["resumePct", "resumePercent"],
["targetPct", "cleanupTargetPercent"],
["seedDays", "seedRetentionDays"],
] as const;
function policyForm(r: DownloaderInstance): Record<string, string> {
const p = ((r.settings as { policy?: Record<string, unknown> }).policy ?? {}) as Record<string, unknown>;
return Object.fromEntries(POLICY_FIELDS.map(([f, k]) => [f, typeof p[k] === "number" ? String(p[k]) : ""]));
}
function policyBody(v: FormValues): Record<string, number> {
const out: Record<string, number> = {};
for (const [f, k] of POLICY_FIELDS) {
const raw = String(v[f] ?? "").trim();
if (raw !== "" && Number.isFinite(Number(raw))) out[k] = Number(raw);
}
return out;
}
function DownloadersTable() {
const list = downloaders.useList();
const create = downloaders.useCreate();
@@ -166,6 +186,22 @@ function DownloadersTable() {
},
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{
name: "hardPct",
label: "硬阈值(%)",
kind: "number",
placeholder: "留空用全局",
hint: "这个下载器的缓存盘用到多少就停止新增下载;多个下载器共用一块盘时取最严格的",
},
{ name: "resumePct", label: "恢复水位(%)", kind: "number", placeholder: "留空用全局" },
{ name: "targetPct", label: "清理目标(%)", kind: "number", placeholder: "留空用全局" },
{
name: "seedDays",
label: "保种天数",
kind: "number",
placeholder: "留空用全局",
hint: "入库后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
},
{
name: "isDefault",
label: "设为默认下载器",
@@ -183,6 +219,10 @@ function DownloadersTable() {
apiKey: "",
managedTag: "umt",
cacheVolumeKey: "",
hardPct: "",
resumePct: "",
targetPct: "",
seedDays: "",
isDefault: false,
enabled: true,
},
@@ -197,6 +237,7 @@ function DownloadersTable() {
"secret:authKind": authKindOf(r),
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
...policyForm(r),
isDefault: r.isDefault,
enabled: r.enabled,
}),
@@ -214,6 +255,7 @@ function DownloadersTable() {
: undefined,
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
settings: { policy: policyBody(v) },
isDefault: v.isDefault === true,
enabled: v.enabled === true,
}),