From ee128e0b3faf423ed5f7cb4af557e3b657d7ac8d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 2 Oct 2026 20:51:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(downloads):=20=E4=B8=8B=E8=BD=BD=E5=99=A8?= =?UTF-8?q?=E5=8D=95=E7=8B=AC=E7=9A=84=E6=B0=B4=E4=BD=8D=E4=B8=8E=E4=BF=9D?= =?UTF-8?q?=E7=A7=8D=E5=A4=A9=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contracts DownloaderPolicy(存 downloader_instance.settings.policy):硬阈值 / 恢复水位 / 清理目标 / 保种天数, 不填用全局 cache / retention - volumeThresholds:卷的生效水位 = 各实例「自己的值 ?? 全局」取最小;decideVolume、阈值清理、手动恢复、 首页卷信息、定时清理检查都改用它;cleanupCandidates 保种天数按实例 - 保存时校验合并全局后 恢复 < 硬阈值、清理目标 ≤ 恢复 - 下载器设置表单新增四项(留空用全局) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ .../src/modules/acquisition-organize/index.ts | 8 ++-- .../acquisition-organize/routes/dashboard.ts | 6 +-- .../services/downloads.ts | 21 +++++++--- .../acquisition-organize/services/volumes.ts | 28 ++++++++++++- .../modules/auth-settings/instances-routes.ts | 15 +++++++ .../downloader-routing.test.ts | 34 +++++++++++++++ .../web/app/components/settings/instances.tsx | 42 +++++++++++++++++++ packages/contracts/src/instances.ts | 15 +++++++ 9 files changed, 158 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d66209dd..799f656a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 新增 +- 下载器可以单独设水位(硬阈值 / 恢复水位 / 清理目标)和保种天数,不填用全局;多个下载器共用一块缓存盘时取最严格的。公网 BT 专用的 qB 可设保种 0 天(站点 H&R 仍遵守)。 + ## [0.3.16] - 2026-10-02 ### 新增 diff --git a/apps/server/src/modules/acquisition-organize/index.ts b/apps/server/src/modules/acquisition-organize/index.ts index b061a5c3..97f29087 100644 --- a/apps/server/src/modules/acquisition-organize/index.ts +++ b/apps/server/src/modules/acquisition-organize/index.ts @@ -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)) ? "已入队清理" : "清理进行中"; }), diff --git a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts index 6311b391..e5ea0055 100644 --- a/apps/server/src/modules/acquisition-organize/routes/dashboard.ts +++ b/apps/server/src/modules/acquisition-organize/routes/dashboard.ts @@ -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), diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index ff620999..dcc0f533 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -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 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 { 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, { diff --git a/apps/server/src/modules/acquisition-organize/services/volumes.ts b/apps/server/src/modules/acquisition-organize/services/volumes.ts index fc251dbe..bed04a4d 100644 --- a/apps/server/src/modules/acquisition-organize/services/volumes.ts +++ b/apps/server/src/modules/acquisition-organize/services/volumes.ts @@ -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, 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 } : {}), diff --git a/apps/server/src/modules/auth-settings/instances-routes.ts b/apps/server/src/modules/auth-settings/instances-routes.ts index 2f72ec2d..4d0e6249 100644 --- a/apps/server/src/modules/auth-settings/instances-routes.ts +++ b/apps/server/src/modules/auth-settings/instances-routes.ts @@ -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 | 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 | 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) { diff --git a/apps/server/test/acquisition-organize/downloader-routing.test.ts b/apps/server/test/acquisition-organize/downloader-routing.test.ts index 10764fa4..34e9b4c7 100644 --- a/apps/server/test/acquisition-organize/downloader-routing.test.ts +++ b/apps/server/test/acquisition-organize/downloader-routing.test.ts @@ -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({}); + }); +}); diff --git a/apps/web/app/components/settings/instances.tsx b/apps/web/app/components/settings/instances.tsx index 3111b7cb..f017896a 100644 --- a/apps/web/app/components/settings/instances.tsx +++ b/apps/web/app/components/settings/instances.tsx @@ -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 { + const p = ((r.settings as { policy?: Record }).policy ?? {}) as Record; + return Object.fromEntries(POLICY_FIELDS.map(([f, k]) => [f, typeof p[k] === "number" ? String(p[k]) : ""])); +} +function policyBody(v: FormValues): Record { + const out: Record = {}; + 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, }), diff --git a/packages/contracts/src/instances.ts b/packages/contracts/src/instances.ts index cba0967a..1be15e8d 100644 --- a/packages/contracts/src/instances.ts +++ b/packages/contracts/src/instances.ts @@ -57,6 +57,21 @@ export type DownloaderKind = z.infer; export const DownloaderAuthKind = z.enum(["password", "api_key", "none"]); export type DownloaderAuthKind = z.infer; +/** + * 下载器自己的水位与保种(存 settings.policy,不填的项用全局「缓存」「保留」设置)。 + * 多个实例共用一个缓存卷时取各实例生效值里最严格的(硬阈值 / 恢复水位 / 清理目标都取最小)。 + * 公网 BT 专用的 qB 可以单独设,例如保种 0 天 = 入库后即可清理。 + */ +export const DownloaderPolicy = z + .object({ + hardThresholdPercent: z.number().min(1).max(100).optional(), + resumePercent: z.number().min(1).max(100).optional(), + cleanupTargetPercent: z.number().min(1).max(100).optional(), + seedRetentionDays: z.number().int().min(0).max(365).optional(), + }) + .strict(); +export type DownloaderPolicy = z.infer; + export const DownloaderInstance = EntityBase.extend({ name: z.string().min(1), kind: DownloaderKind,