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) {