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:
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) {
|
||||
|
||||
Reference in new issue
Block a user