Files
UHD_Media_Tool/packages/contracts/src/settings.ts
T
rootandClaude Opus 5.5 260f3ab27f feat(web): 首页只回答三件事、计划页巡检置顶与分组折叠、任务列表去噪(DEAI-2)
首页:顶部一行状态(自动化开关 + 只在异常时出现的服务 / 下载盘 / 站点账号 / 下载器问题),
下面只有「在忙什么」「要我处理」「最近入库」三块;去掉运行状态、计划、缓存、来源健康、
满足率与 0 值铺陈;0 订阅时写「还没有订阅。去搜索添加」。只读对账移出首页,设置里放进
自动化 →「高级」(与存储预算、流水保留、缓存有效期一起默认收起)。

计划页:「智能体巡检」移到最上面——巡检数量少、由用户自己建、每次会花钱,是这页最需要
看的;自动任务任务多、多数不用管,放在下面。自动任务按分类分组、每组可折叠:有失败或
运行中的组默认展开,其余收起为「N 个任务 · 下次 xx」一行,折叠状态记在 localStorage;
维护类整组叫「数据清理」,未接入调度的备份不列。巡检卡只留名称、开关、间隔、上次结果与
费用,目标 / 能力 / 预算 / 模型 / 最近会话收进「详情」。去掉顶部 6 个计数卡。定时任务说明
去掉表名、字段名与实现备注。

任务页:默认隐藏系统任务(无业务对象的引擎步骤、定时任务的成功运行),可一键显示;
标题不再用 ID;耗时移入技术信息;< 1 秒写「< 1 秒」;费用两位小数,不足一分写「< $0.01」。
下载盘相关文案改用「暂停线 / 恢复线」。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:48:35 +08:00

1006 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import { Id, Timestamp } from "./common.ts";
import { SiteRateLimit } from "./sources.ts";
// 全局设置:存于 settings 表(key → jsonb)。每个顶层分组对应一个 settings.key。
// schema 本身不带 .default()(zod 4 在 partial 里仍会套默认值,会让 PATCH 覆盖现值);
// 缺省值统一放在 DEFAULT_SETTINGS,服务端读取时 `{ ...DEFAULT_SETTINGS[key], ...stored }` 后再 parse。
const Percent = z.number().min(1).max(100);
export const AutomationSettings = z.object({
/** 自动化总开关:关闭后所有自动收录/下载/整理停止,只读查询与手动操作可用 */
enabled: z.boolean(),
/** 恢复后的只读对账模式(REQ §8.5):开启时禁止自动写入 */
readOnlyReconcile: z.boolean(),
});
export type AutomationSettings = z.infer<typeof AutomationSettings>;
const CacheShape = z.object({
/** 缓存卷使用率硬阈值:达到即停止新增下载并暂停纳管下载中任务 */
hardThresholdPercent: Percent,
/** 恢复水位:降到此值以下恢复因阈值暂停的任务 */
resumePercent: Percent,
checkIntervalSec: z.number().int().min(10).max(3600),
/** 缓存清理的目标使用率(低水位) */
cleanupTargetPercent: Percent,
/**
* @deprecated CACHE-2 起由 storage.cacheTtl(tmdbSearchDays / tmdbDetailDays)取代,服务端不再读取;
* 保留在 schema 里只为兼容已存的 JSON 与旧设置页。
*/
tmdbTtlHours: z.number().int().min(1).max(720),
/** TMDB 缓存每类条数上限,超出删最旧(MP CONF.tmdb 为 256 / 大内存 1024;本项目存 PG 不占进程内存,缺省 5000) */
tmdbMaxSize: z.number().int().min(16).max(100_000),
/** @deprecated CACHE-2 起由 storage.cacheTtl.tmdbMissDays 取代,服务端不再读取 */
emptyResultTtlSec: z.number().int().min(0).max(86_400),
/** 图片代理落盘缓存(对照 MP GLOBAL_IMAGE_CACHE,缺省关闭;关闭时仍代理,只是不落盘) */
globalImageCache: z.boolean(),
/** 图片缓存保留天数,超过即清理(对照 MP GLOBAL_IMAGE_CACHE_DAYS,缺省 7) */
globalImageCacheDays: z.number().int().min(1).max(3650),
});
/** 下载缓存卷硬阈值与恢复水位(REQ §8.4.1) */
export const CacheSettings = CacheShape.refine((v) => v.resumePercent < v.hardThresholdPercent, {
message: "恢复水位必须低于硬阈值",
}).refine((v) => v.cleanupTargetPercent <= v.resumePercent, { message: "清理目标不得高于恢复水位" });
export type CacheSettings = z.infer<typeof CacheSettings>;
export const OrganizeMode = z.enum(["copy", "move"]);
export type OrganizeMode = z.infer<typeof OrganizeMode>;
export const OrganizeSettings = z.object({
defaultMode: OrganizeMode,
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
allowCrossVolumeMove: z.boolean(),
/** 整理 / 文件管理识别时视频文件的最小体积(字节),低于它的视为样片跳过;请求(向导)里给的值优先 */
minSizeBytes: z.number().int().nonnegative(),
});
export type OrganizeSettings = z.infer<typeof OrganizeSettings>;
export const RetentionSettings = z.object({
/** Copy 入库后下载源在缓存中至少保留的天数(与 H&R 要求分开判断) */
seedRetentionDays: z.number().int().min(0).max(365),
/** 观察/日志类数据保留天数 */
logRetentionDays: z.number().int().min(1).max(3650),
});
export type RetentionSettings = z.infer<typeof RetentionSettings>;
export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export type ThinkingLevel = z.infer<typeof ThinkingLevel>;
/**
* Pi Agent 设置(REQ §10.5)。W7-A 起上游改为 pi_upstream 表(多上游共存,见 contracts/pi.ts 与 /pi/upstreams),
* 这里只留默认上游与运行预算(上下文预算/压缩/子代理)。
*
* provider / baseUrl / model / thinkingLevel / apiKeySecretRef 是 v0.1 的旧字段(DEPRECATED_SETTINGS_FIELDS):
* 0027 迁移已把保存的旧值转成一条上游并设为默认。W8-A 起服务端不再读取、PATCH 不再接受(传了会被丢弃),
* 设置页不展示;保留在 schema 里只为兼容已存的 JSON。runAgent 只走上游,没有上游直接失败。
*/
export const PiSettings = z.object({
/** 缺省上游(runAgent 未指定 upstreamId 时使用);null = 未配置,Agent 运行直接失败 */
defaultUpstreamId: Id.nullable(),
/** @deprecated 旧字段,不再读取,见上 */
provider: z.string().min(1),
/** @deprecated */
baseUrl: z.url().nullable(),
/** @deprecated */
model: z.string().min(1).nullable(),
/** @deprecated 上游级 thinkingLevel 见 pi_upstream */
thinkingLevel: ThinkingLevel,
/** 上下文预算(token) */
contextBudget: z.number().int().min(4096).max(2_000_000),
compaction: z.object({
enabled: z.boolean(),
/** 达到上下文预算的该比例时触发压缩 */
thresholdRatio: z.number().min(0.3).max(0.95),
}),
/** @deprecated 同 subagents.enabled(合并设置时两者保持一致);新代码读 subagents.enabled */
subagentsEnabled: z.boolean(),
/**
* 子代理(agent.spawn,W7-B):关闭时不向模型提供派生能力、服务端拒绝派生;
* maxConcurrent = 一个会话同时在跑的子会话上限,maxDepth = 派生层数上限(1 = 子会话不能再派生)。
*/
subagents: z.object({
enabled: z.boolean(),
maxConcurrent: z.number().int().min(1).max(8),
maxDepth: z.number().int().min(1).max(3),
}),
/** @deprecated */
apiKeySecretRef: Id.nullable(),
});
export type PiSettings = z.infer<typeof PiSettings>;
/**
* Agent 网页搜索(web.search / web.fetch,REQ §10.2、§10.5):默认关闭(provider = none,不向模型提供这两个能力)。
* Key 只存 secret 引用(apiKeySecretRef);搜索结果与网页正文按不可信数据交给模型。
*/
export const WebSearchProvider = z.enum(["brave", "none"]);
export type WebSearchProvider = z.infer<typeof WebSearchProvider>;
export const WebSearchSafesearch = z.enum(["off", "moderate", "strict"]);
export type WebSearchSafesearch = z.infer<typeof WebSearchSafesearch>;
export const WebSearchSettings = z.object({
provider: WebSearchProvider,
apiKeySecretRef: Id.nullable(),
/** 搜索地区:两位国家码(如 CN、US)或 ALL */
country: z.string().regex(/^([A-Z]{2}|ALL)$/, "两位大写国家码或 ALL"),
/** 搜索语言(Brave search_lang,如 zh-hans、en);null = 不指定 */
searchLang: z
.string()
.regex(/^[a-z]{2}(-[a-z]{2,4})?$/, "语言码,如 zh-hans、en")
.nullable(),
safesearch: WebSearchSafesearch,
/** 每次搜索返回的条数(模型可以要更少,不能更多) */
maxResults: z.number().int().min(1).max(10),
/** 每分钟最多调用几次搜索接口(三个进程共用;缓存命中不计) */
requestsPerMinute: z.number().int().min(1).max(600),
});
export type WebSearchSettings = z.infer<typeof WebSearchSettings>;
export const DEFAULT_WEB_SEARCH: WebSearchSettings = {
provider: "none",
apiKeySecretRef: null,
country: "ALL",
searchLang: null,
safesearch: "moderate",
maxResults: 5,
requestsPerMinute: 30,
};
/**
* Agent 运行参数(REQ §10.5、§10.7):单次运行预算、后台自动调查、fake 演示脚本。
* 上游/模型/思考档位在 pi_upstream(见 contracts/pi.ts);provider = "fake" 的上游使用脚本化假模型(不联网,演示与 e2e 用)。
*/
export const AgentRuntimeSettings = z.object({
/** 识别歧义 / 下载失败诊断等待处理事项创建时,自动发起有界调查 */
autoInvestigate: z.boolean(),
/**
* 自动调查的模式:investigate = 只读调查,结论交还人工;
* recover = 在已发布策略 / 持续授权范围内可执行恢复类能力(task.retry、download.recheck、identity.resolve),
* 换源、删除、清理始终排除;没有覆盖的授权时自动降级为 investigate。
*/
autoInvestigateMode: z.enum(["investigate", "recover"]),
/** 同一根因(decision 去重键)最多自动恢复几次;用尽后只交还人工,不再唤醒模型 */
investigateMaxAttempts: z.number().int().min(1).max(10),
/** 恢复动作后等待业务事实(下载状态 / 任务状态)核实的最长时间(秒) */
recoverVerifySec: z
.number()
.int()
.min(5)
.max(24 * 3600),
/** 单次运行:工具调用次数上限 */
maxToolCalls: z.number().int().min(1).max(500),
/** 单次运行:时长上限(秒) */
maxDurationSec: z
.number()
.int()
.min(10)
.max(24 * 3600),
/** 单次运行:token 上限(null = 不限) */
maxTokensPerRun: z.number().int().positive().nullable(),
/** 单次运行:费用上限(美元,null = 不限) */
maxCostUsdPerRun: z.number().positive().nullable(),
/** 后台自动调查的工具调用上限(比用户委托更小) */
investigateMaxToolCalls: z.number().int().min(1).max(100),
/** 等待目标关联的下载/入库结束时的轮询间隔(秒) */
goalPollSec: z.number().int().min(1).max(3600),
/** 等待目标完成的最长时间(小时),超时后以"仍在等待"汇报结束 */
goalWaitHours: z
.number()
.min(0)
.max(24 * 30),
/** agent.propose_options 等用户选择的最长时间(秒);超时按 optionsTimeoutAction 处理 */
optionsTimeoutSec: z
.number()
.int()
.min(5)
.max(7 * 86400),
/** 方案选择超时:recommended = 自动选推荐项(没有推荐项时选第一个);stop = 不选,模型收尾 */
optionsTimeoutAction: z.enum(["recommended", "stop"]),
/** 会话内审批卡的有效期(秒);过期未批准的调用按拒绝处理 */
approvalTimeoutSec: z
.number()
.int()
.min(60)
.max(30 * 86400),
/** fake 模式脚本(JSON,FakeScript 的可序列化子集);null 用内置演示脚本 */
fakeScript: z.string().max(100_000).nullable(),
/**
* Agent JSONL 会话目录(绝对路径,api 与 worker 进程都要能读写);
* null = 部署默认(环境变量 AGENT_SESSION_DIR,缺省 DATA_DIR/agent-sessions)
*/
sessionDir: z.string().max(1024).regex(/^\//, "必须是绝对路径").nullable(),
/** 网页搜索(web.search / web.fetch) */
webSearch: WebSearchSettings,
});
export type AgentRuntimeSettings = z.infer<typeof AgentRuntimeSettings>;
/** 元数据源(TMDB):密钥只存 secret 引用;未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN */
export const MetadataSettings = z.object({
tmdbApiKeySecretRef: Id.nullable(),
/** 自建反代或测试 mock;null 用官方地址 */
tmdbBaseUrl: z.url().nullable(),
/**
* 元数据语言回退链(MD-1):作品 / 季 / 集的标题与简介按顺序取第一个非空值;中文名(titleZh)取链中第一个中文值。
* BCP47 语言-地区,如 zh-CN;缺省 zh-CN → zh-SG → zh-TW → zh-HK → en-US
*/
languages: z
.array(z.string().regex(/^[a-z]{2,3}(-[A-Z]{2})?$/))
.min(1)
.max(8)
.default(["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]),
});
export type MetadataSettings = z.infer<typeof MetadataSettings>;
/** 固定桌面 Chrome UA(账号未设 UA 时使用) */
export const DEFAULT_SITE_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
/** 站点默认(site-center §8):站点行的限流 / 快照周期为空时继承;站点和凭证在站点页管理 */
export const SitesSettings = z.object({
/** 站点 rate_limit 为空时使用 */
defaultRateLimit: SiteRateLimit,
/** 站点未设快照周期时使用(秒) */
defaultSnapshotIntervalSec: z
.number()
.int()
.min(300)
.max(7 * 24 * 3600),
/** 账号未设 UA 时使用 */
userAgent: z.string().min(1).max(500),
/** 站点开"使用代理"时生效;null = 不用代理 */
proxyUrl: z.url().nullable(),
/** 用户数据做种分页上限 */
userDataMaxSeedingPages: z.number().int().min(1).max(50),
/** 快照(定时 / 手动)最多抓几页列表;站点定义给了更小的值时取更小的 */
defaultSnapshotMaxPages: z.number().int().min(1).max(20),
/** 同一站点两次定时健康检测的最短间隔(秒);站点定义给了更长的取更长的 */
defaultHealthCheckIntervalSec: z
.number()
.int()
.min(900)
.max(7 * 24 * 3600),
/** 详情页结果缓存(秒):期内不重复抓同一条详情;0 = 不缓存 */
defaultDetailCacheSec: z
.number()
.int()
.min(0)
.max(7 * 24 * 3600),
});
export type SitesSettings = z.infer<typeof SitesSettings>;
// ── 整理命名模板(docs/design/naming-template.md;引擎在 @umt/domain naming/**)──
/** 一条全路径模板(Jinja 安全子集,`/` 切目录,最后一段是文件名且以 {{fileExt}} 结尾) */
export const NamingTemplate = z.string().min(1).max(1024);
export const NamingKind = z.enum(["movie", "tv", "variety"]);
export type NamingKind = z.infer<typeof NamingKind>;
/** 库根 / 作品级覆盖:按类型覆盖,未给的类型回落上一级 */
export const NamingOverride = z.object({
movie: NamingTemplate.optional(),
tv: NamingTemplate.optional(),
variety: NamingTemplate.optional(),
});
export type NamingOverride = z.infer<typeof NamingOverride>;
export const NamingSettings = z.object({
movie: NamingTemplate,
tv: NamingTemplate,
variety: NamingTemplate,
/** 已绑定作品也固定文件名模板版本(按 path_binding.template_version 取当时的模板);false = 每次入库按当前模板 */
lockFileNameForBound: z.boolean(),
});
export type NamingSettings = z.infer<typeof NamingSettings>;
/** MP 默认(与 @umt/domain MP_DEFAULT_NAMING 一致,server 测试断言) */
export const DEFAULT_NAMING_TEMPLATES = {
movie:
"{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}",
tv: "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}",
variety:
"{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {% if season_episode and episode %}{{season_episode}}{% else %}{{airDate | date('YYYY-MM-DD')}}{% endif %}{% if issue %} - 第 {{issue}} 期{% endif %}{{fileExt}}",
} as const;
export const NamingIssue = z.object({
code: z.string(),
message: z.string(),
line: z.number().int(),
col: z.number().int(),
});
export type NamingIssue = z.infer<typeof NamingIssue>;
export const NamingSegmentRole = z.enum(["category", "root", "season", "sub", "file"]);
export const NamingSource = z.enum(["entity", "root", "global"]);
export const NamingVariable = z.object({
name: z.string(),
group: z.enum(["title", "episode", "tech", "id", "ours"]),
label: z.string(),
example: z.string(),
/** 我们特有(MP 没有) */
ours: z.boolean(),
});
export const NamingVariablesResponse = z.object({
items: z.array(NamingVariable),
filters: z.array(z.object({ name: z.string(), doc: z.string() })),
/** 预览内置样本(sample.builtin 取 id) */
samples: z.array(z.object({ id: z.number().int(), label: z.string(), kind: NamingKind, fileName: z.string() })),
});
export type NamingVariablesResponse = z.infer<typeof NamingVariablesResponse>;
export const NamingSample = z.union([
z.object({ fileName: z.string().min(1).max(1024) }),
z.object({ sourceReleaseId: Id }),
z.object({ assetId: Id }),
z.object({ builtin: z.number().int().min(1) }),
]);
export const NamingPreviewInput = z.object({
/** 不传 = 按 作品 > 库根 > 全局 取当前生效模板 */
template: NamingTemplate.optional(),
kind: NamingKind,
/** 不传 = 该类型的第一条内置样本 */
sample: NamingSample.optional(),
libraryRootId: Id.optional(),
/** 作品:取其覆盖模板与路径绑定(已绑定时目录用绑定路径) */
entityId: Id.optional(),
});
export type NamingPreviewInput = z.infer<typeof NamingPreviewInput>;
export const NamingPreviewResult = z.object({
ok: z.boolean(),
relativePath: z.string(),
segments: z.array(z.object({ text: z.string(), role: NamingSegmentRole })),
errors: z.array(NamingIssue),
warnings: z.array(z.string()),
/** 输出为空的变量 */
missing: z.array(z.string()),
/** 目录来自已有路径绑定(模板只决定文件名) */
usesBinding: z.boolean(),
templateSource: z.union([NamingSource, z.literal("input")]),
template: z.string(),
sample: z.object({ fileName: z.string(), entityTitle: z.string() }),
/** 渲染所用变量值(调试/变量清单"当前示例值") */
vars: z.record(z.string(), z.union([z.string(), z.number()])),
});
export type NamingPreviewResult = z.infer<typeof NamingPreviewResult>;
export const NamingTemplatesQuery = z.object({ libraryRootId: Id.optional(), entityId: Id.optional() });
export const NamingTemplatesView = z.object({
global: NamingSettings,
defaults: z.object({ movie: z.string(), tv: z.string(), variety: z.string() }),
root: NamingOverride.nullable(),
entity: NamingOverride.nullable(),
effective: z.object({ movie: z.string(), tv: z.string(), variety: z.string() }),
sources: z.object({ movie: NamingSource, tv: NamingSource, variety: NamingSource }),
});
export type NamingTemplatesView = z.infer<typeof NamingTemplatesView>;
export const NamingSettingsPut = NamingSettings.partial();
export const LibraryRootNamingPut = z.object({ naming: NamingOverride.nullable() });
export const EntityNamingOverridePut = z.object({ namingOverride: NamingOverride.nullable() });
/** 每日预算与并发(REQ §6.4、§10.5) */
export const BudgetSettings = z.object({
maxNewTitlesPerDay: z.number().int().min(0),
maxDownloadBytesPerDay: z.number().int().min(0),
maxConcurrentDownloads: z.number().int().min(1).max(100),
agentMaxRunsPerDay: z.number().int().min(0),
agentMaxCostUsdPerDay: z.number().min(0),
});
export type BudgetSettings = z.infer<typeof BudgetSettings>;
/**
* 资源搜索(SEARCH-2):GET /search/releases 不带 sites 时参与搜索的站点。
* default = 启用且「默认参与搜索」的站点;all = 全部启用站点;custom = siteIds 里仍启用的站点。
*/
export const SearchSiteMode = z.enum(["default", "all", "custom"]);
export type SearchSiteMode = z.infer<typeof SearchSiteMode>;
export const SearchSettings = z.object({
defaultSiteMode: SearchSiteMode,
/** custom 时的站点 ID */
siteIds: z.array(z.uuid()).max(200),
});
export type SearchSettings = z.infer<typeof SearchSettings>;
/**
* 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)。
* eagerTmdbLookup = true 恢复旧行为:每条新观察到的资源都查 TMDB。每条资源约 1–2 次搜索 + 命中时 1 次详情
* (识别缓存命中则 0 次);一个每小时 100 条的 RSS 约为每天 2400–7200 次 TMDB 请求,多数是站点独有的短剧 / 综艺,查不到。
* 关闭(缺省)时只在命中订阅 / 本地作品别名、已发布的新作品收录规则前置条件通过、用户手动识别或搜索时查询。
*/
/** 元数据补全缺省白名单:视频平台、短剧平台、百科 / 影视资料站(不含任何 PT 站) */
export const DEFAULT_ENRICH_DOMAINS: string[] = [
"baike.baidu.com",
"douban.com",
"hongguoduanju.com",
"wikipedia.org",
"iqiyi.com",
"v.qq.com",
"youku.com",
"mgtv.com",
"bilibili.com",
"douyin.com",
"kuaishou.com",
"ixigua.com",
"baike.sogou.com",
"mydramalist.com",
];
export const IdentitySettings = z.object({
eagerTmdbLookup: z.boolean(),
/** @deprecated CACHE-2 起由 storage.retentionDays.identityPoolArchive 取代,服务端不再读取 */
poolArchiveDays: z.number().int().min(1).max(3650),
/**
* META-1:订阅别名 / 新作品收录规则命中、TMDB 却查不到时自动尝试一次元数据补全(只出建议,不建作品)。
* 每次约 1–2 次网页搜索 + 最多 3 次读取网页 + 1 次模型调用。
*/
autoEnrich: z.boolean(),
/** 元数据补全每日上限(手动 + 自动;命中 7 天标题缓存不计);0 = 停用补全 */
enrichDailyBudget: z.number().int().min(0).max(500),
/** 元数据补全只读取这些域名(含子域名)的网页:平台站点、百科类;已登记站点的域名一律不读 */
enrichDomains: z
.array(
z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9.-]+\.[a-z]{2,}$/),
)
.max(100),
});
export type IdentitySettings = z.infer<typeof IdentitySettings>;
// ── 存储预算与保留(CACHE-2,docs/runbook/storage.md)──
const Gb = z.number().min(0.01).max(10_000);
const Days = z.number().int().min(1).max(3650);
export const StorageBudgets = z
.object({
/** PG 数据目录预算(GB):超过 pgDataWarnGb 告警,超过本值 critical */
pgDataGb: Gb,
pgDataWarnGb: Gb,
/** 图片代理缓存容量(GB):超出按最久未访问删除(LRU) */
imagesGb: Gb,
/** DATA_DIR/backups 占用预算(GB) */
backupsGb: Gb,
/** Agent 会话 + 视觉证据(DATA_DIR/agent-sessions、tmp/evidence)预算(GB) */
sessionsEvidenceGb: Gb,
})
.refine((v) => v.pgDataWarnGb <= v.pgDataGb, { message: "PG 告警线不得高于预算" });
export type StorageBudgets = z.infer<typeof StorageBudgets>;
/** 各类流水数据保留期(到期由 storage.retention.* 定时任务分批删除) */
export const StorageRetentionDays = z.object({
/** observation(Feed 观察) */
observation: Days,
/** sse_event(SSE 补读) */
sseEvent: Days,
/** process_heartbeat_sample(小时) */
heartbeatHours: z
.number()
.int()
.min(1)
.max(24 * 90),
/** 已结束的业务任务 task 与引擎 run */
taskRun: Days,
/** 已结束 Agent 会话的 agent_run_event / agent_run_message */
agentEvent: Days,
/** audit_event(能力调用审计除外) */
audit: Days,
/** 能力调用统计:audit_event(target_type=capability)与已结束的 capability_invocation */
capabilityStats: Days,
/** 待识别池 unmatched 条目无新证据多少天后归档 */
identityPoolArchive: Days,
});
export type StorageRetentionDays = z.infer<typeof StorageRetentionDays>;
/** 缓存有效期(天):只改 TTL 的取值来源,缓存语义仍对照 MP(docs/porting/moviepilot-tmdb-cache.md) */
export const StorageCacheTtl = z.object({
/** TMDB 搜索 / find 响应与识别结果 */
tmdbSearchDays: Days,
/** TMDB 详情(/movie/{id}、/tv/{id}、季);追踪中的作品命中时续期 */
tmdbDetailDays: Days,
/** TMDB 空结果与未识别负缓存(MP EMPTY_RESULT_CACHE_TTL) */
tmdbMissDays: Days,
/** 站点详情页扩展内容(kv_cache site.detail) */
siteDetailDays: Days,
/** 网页搜索结果 */
webSearchDays: Days,
});
export type StorageCacheTtl = z.infer<typeof StorageCacheTtl>;
export const StorageSettings = z.object({
budgets: StorageBudgets,
retentionDays: StorageRetentionDays,
cacheTtl: StorageCacheTtl,
});
export type StorageSettings = z.infer<typeof StorageSettings>;
export const DEFAULT_STORAGE_SETTINGS: StorageSettings = {
budgets: { pgDataGb: 25, pgDataWarnGb: 20, imagesGb: 20, backupsGb: 20, sessionsEvidenceGb: 10 },
retentionDays: {
observation: 90,
sseEvent: 7,
heartbeatHours: 24,
taskRun: 60,
agentEvent: 90,
audit: 180,
capabilityStats: 90,
identityPoolArchive: 30,
},
cacheTtl: { tmdbSearchDays: 7, tmdbDetailDays: 30, tmdbMissDays: 1, siteDetailDays: 7, webSearchDays: 1 },
};
/** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */
export function withStorageDefaults(v: unknown): StorageSettings {
const o = (v && typeof v === "object" ? v : {}) as Partial<Record<keyof StorageSettings, object>>;
return {
budgets: { ...DEFAULT_STORAGE_SETTINGS.budgets, ...(o.budgets ?? {}) },
retentionDays: { ...DEFAULT_STORAGE_SETTINGS.retentionDays, ...(o.retentionDays ?? {}) },
cacheTtl: { ...DEFAULT_STORAGE_SETTINGS.cacheTtl, ...(o.cacheTtl ?? {}) },
} as StorageSettings;
}
export const GlobalSettings = z.object({
automation: AutomationSettings,
cache: CacheSettings,
organize: OrganizeSettings,
retention: RetentionSettings,
pi: PiSettings,
budget: BudgetSettings,
agent: AgentRuntimeSettings,
metadata: MetadataSettings,
sites: SitesSettings,
naming: NamingSettings,
identity: IdentitySettings,
search: SearchSettings,
storage: StorageSettings,
});
export type GlobalSettings = z.infer<typeof GlobalSettings>;
/** settings 表的合法 key */
export const SettingsKey = GlobalSettings.keyof();
export type SettingsKey = z.infer<typeof SettingsKey>;
/** 缺省设置(settings 表缺 key 或缺字段时使用) */
export const DEFAULT_SETTINGS: GlobalSettings = {
automation: { enabled: true, readOnlyReconcile: false },
cache: {
hardThresholdPercent: 90,
resumePercent: 85,
checkIntervalSec: 60,
cleanupTargetPercent: 80,
tmdbTtlHours: 24,
tmdbMaxSize: 5000,
emptyResultTtlSec: 1800,
globalImageCache: false,
globalImageCacheDays: 7,
},
organize: { defaultMode: "copy", allowCrossVolumeMove: true, minSizeBytes: 50 * 1024 * 1024 },
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
pi: {
defaultUpstreamId: null,
provider: "anthropic",
baseUrl: null,
model: null,
thinkingLevel: "medium",
contextBudget: 128_000,
compaction: { enabled: true, thresholdRatio: 0.8 },
subagentsEnabled: false,
subagents: { enabled: false, maxConcurrent: 2, maxDepth: 1 },
apiKeySecretRef: null,
},
budget: {
maxNewTitlesPerDay: 20,
maxDownloadBytesPerDay: 500 * 1024 ** 3,
maxConcurrentDownloads: 5,
agentMaxRunsPerDay: 50,
agentMaxCostUsdPerDay: 5,
},
agent: {
autoInvestigate: false,
autoInvestigateMode: "investigate",
investigateMaxAttempts: 2,
recoverVerifySec: 600,
maxToolCalls: 40,
maxDurationSec: 900,
maxTokensPerRun: null,
maxCostUsdPerRun: null,
investigateMaxToolCalls: 8,
goalPollSec: 60,
goalWaitHours: 72,
optionsTimeoutSec: 1800,
optionsTimeoutAction: "recommended",
approvalTimeoutSec: 86400,
fakeScript: null,
sessionDir: null,
webSearch: DEFAULT_WEB_SEARCH,
},
metadata: { tmdbApiKeySecretRef: null, tmdbBaseUrl: null, languages: ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"] },
sites: {
defaultRateLimit: { requestsPerMinute: 20, concurrency: 2 },
defaultSnapshotIntervalSec: 3600,
userAgent: DEFAULT_SITE_USER_AGENT,
proxyUrl: null,
userDataMaxSeedingPages: 10,
defaultSnapshotMaxPages: 1,
defaultHealthCheckIntervalSec: 21600,
defaultDetailCacheSec: 86400,
},
naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false },
identity: {
eagerTmdbLookup: false,
poolArchiveDays: 30,
autoEnrich: false,
enrichDailyBudget: 20,
enrichDomains: DEFAULT_ENRICH_DOMAINS,
},
search: { defaultSiteMode: "default", siteIds: [] },
storage: DEFAULT_STORAGE_SETTINGS,
};
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
export const GlobalSettingsUpdate = z.object({
automation: AutomationSettings.partial().optional(),
cache: CacheShape.partial().optional(),
organize: OrganizeSettings.partial().optional(),
retention: RetentionSettings.partial().optional(),
/**
* defaultUpstreamId 只能经 /pi/upstreams(makeDefault)修改,保证指向存在的上游;
* 旧字段(provider / baseUrl / model / thinkingLevel / apiKey)不再接受,传了会被丢弃。
*/
pi: PiSettings.omit({
apiKeySecretRef: true,
defaultUpstreamId: true,
provider: true,
baseUrl: true,
model: true,
thinkingLevel: true,
})
.partial()
.extend({
compaction: PiSettings.shape.compaction.partial().optional(),
subagents: PiSettings.shape.subagents.partial().optional(),
})
.optional(),
budget: BudgetSettings.partial().optional(),
agent: AgentRuntimeSettings.omit({ webSearch: true })
.partial()
.extend({
webSearch: WebSearchSettings.omit({ apiKeySecretRef: true })
.partial()
.extend({
/** 只写:搜索服务 API Key,服务端存为 secret 并回填 apiKeySecretRef;null 清除 */
apiKey: z.string().min(1).max(4096).nullable().optional(),
})
.optional(),
})
.optional(),
metadata: MetadataSettings.omit({ tmdbApiKeySecretRef: true })
.partial()
.extend({
/** 只写:TMDB API Key(v3 key 或 v4 read token),服务端存为 secret 并回填 tmdbApiKeySecretRef;null 清除 */
tmdbApiKey: z.string().min(1).max(4096).nullable().optional(),
})
.optional(),
sites: SitesSettings.partial().optional(),
naming: NamingSettings.partial().optional(),
identity: IdentitySettings.partial().optional(),
search: SearchSettings.partial().optional(),
storage: z
.object({
budgets: z.object(StorageBudgets.shape).partial().optional(),
retentionDays: StorageRetentionDays.partial().optional(),
cacheTtl: StorageCacheTtl.partial().optional(),
})
.optional(),
});
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
const { compaction, subagents, ...pi } = patch.pi ?? {};
const { tmdbApiKey: _tmdbApiKey, ...metadata } = patch.metadata ?? {};
const { webSearch: wsPatch, ...agent } = patch.agent ?? {};
const { apiKey: _wsApiKey, ...webSearch } = wsPatch ?? {};
return {
automation: { ...current.automation, ...patch.automation },
cache: { ...current.cache, ...patch.cache },
organize: { ...current.organize, ...patch.organize },
retention: { ...current.retention, ...patch.retention },
pi: mergePi(current.pi, pi, compaction, subagents),
budget: { ...current.budget, ...patch.budget },
agent: {
...current.agent,
...agent,
webSearch: { ...DEFAULT_WEB_SEARCH, ...current.agent.webSearch, ...webSearch },
},
metadata: { ...current.metadata, ...metadata },
sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites },
naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming },
identity: { ...(current.identity ?? DEFAULT_SETTINGS.identity), ...patch.identity },
search: { ...(current.search ?? DEFAULT_SETTINGS.search), ...patch.search },
storage: mergeStorage(current.storage, patch.storage),
};
}
function mergeStorage(current: StorageSettings | undefined, patch: GlobalSettingsUpdate["storage"]): StorageSettings {
const base = withStorageDefaults(current);
return {
budgets: { ...base.budgets, ...patch?.budgets },
retentionDays: { ...base.retentionDays, ...patch?.retentionDays },
cacheTtl: { ...base.cacheTtl, ...patch?.cacheTtl },
};
}
/** 子代理开关:subagents.enabled 优先,旧字段 subagentsEnabled 作别名,合并后两者一致 */
function mergePi(
current: PiSettings,
pi: Partial<Omit<PiSettings, "compaction" | "subagents">>,
compaction: Partial<PiSettings["compaction"]> | undefined,
subagents: Partial<PiSettings["subagents"]> | undefined,
): PiSettings {
const base = current.subagents ?? DEFAULT_SETTINGS.pi.subagents;
const enabled = subagents?.enabled ?? pi.subagentsEnabled ?? base.enabled;
return {
...current,
...pi,
compaction: { ...current.compaction, ...compaction },
subagents: { ...base, ...subagents, enabled },
subagentsEnabled: enabled,
};
}
/** settings 表行 */
export const SettingEntry = z.object({
key: SettingsKey,
value: z.unknown(),
updatedAt: Timestamp,
});
export type SettingEntry = z.infer<typeof SettingEntry>;
/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释) */
export const DEPRECATED_SETTINGS_FIELDS = [
"pi.provider",
"pi.baseUrl",
"pi.model",
"pi.thinkingLevel",
"pi.apiKeySecretRef",
// CACHE-2:由 storage.cacheTtl / storage.retentionDays 取代
"cache.tmdbTtlHours",
"cache.emptyResultTtlSec",
"identity.poolArchiveDays",
] as const;
// ── 设置页分区(docs/design/console-ia.md §3.4) ──
// 只是"展示分区 → 字段"的映射,不改存储 key;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
// 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。
export const SettingsSectionId = z.enum([
"automation",
"sites",
"downloaders",
"library",
"media-servers",
"metadata",
"agent",
"api-keys",
"interface",
"account",
]);
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
export type SettingsSectionDef = {
id: SettingsSectionId;
label: string;
description: string;
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
legacyTabs: { tab: string; anchor?: string }[];
/** 分区内锚点(节),顺序即页面顺序 */
anchors: { id: string; label: string }[];
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
fields: string[];
/** 本分区管理的非 settings 对象 */
resources: string[];
};
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "automation",
label: "自动化",
description: "总开关、下载盘、每日预算、保留天数",
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
anchors: [
{ id: "switches", label: "开关" },
{ id: "cache", label: "下载盘" },
{ id: "budget", label: "每日预算" },
{ id: "retention", label: "保留" },
{ id: "advanced", label: "高级" },
],
fields: [
"automation.enabled",
"automation.readOnlyReconcile",
"cache.hardThresholdPercent",
"cache.resumePercent",
"cache.checkIntervalSec",
"cache.cleanupTargetPercent",
"budget.maxNewTitlesPerDay",
"budget.maxDownloadBytesPerDay",
"budget.maxConcurrentDownloads",
"retention.seedRetentionDays",
"retention.logRetentionDays",
"storage.budgets",
"storage.retentionDays",
"storage.cacheTtl",
],
resources: [],
},
{
id: "sites",
label: "站点默认",
description: "新站点的限流、快照周期、UA 和代理,以及原站视图用的浏览器服务;站点和凭证在站点页管理",
legacyTabs: [{ tab: "site-credentials" }],
anchors: [
{ id: "defaults", label: "站点默认" },
{ id: "browser", label: "浏览器服务" },
],
fields: [
"sites.defaultRateLimit",
"sites.defaultSnapshotIntervalSec",
"sites.userAgent",
"sites.proxyUrl",
"sites.userDataMaxSeedingPages",
"sites.defaultSnapshotMaxPages",
"sites.defaultHealthCheckIntervalSec",
"sites.defaultDetailCacheSec",
"search.defaultSiteMode",
"search.siteIds",
],
resources: [],
},
{
id: "downloaders",
label: "下载器",
description: "qBittorrent 实例、连通测试、路径映射",
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }],
anchors: [{ id: "instances", label: "实例" }],
fields: [],
resources: ["downloader-instances", "path-mappings"],
},
{
id: "library",
label: "媒体库与目录",
description: "媒体库目录、默认整理方式、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "roots", label: "媒体库目录" },
{ id: "organize", label: "整理方式" },
{ id: "naming", label: "命名模板" },
],
fields: [
"organize.defaultMode",
"organize.allowCrossVolumeMove",
"organize.minSizeBytes",
"naming.movie",
"naming.tv",
"naming.variety",
"naming.lockFileNameForBound",
],
resources: ["library-roots"],
},
{
id: "media-servers",
label: "媒体服务器",
description: "Emby / Jellyfin 实例、连通测试、路径映射",
legacyTabs: [{ tab: "media-servers" }],
anchors: [{ id: "instances", label: "实例" }],
fields: [],
resources: ["media-server-instances", "path-mappings"],
},
{
id: "metadata",
label: "元数据",
description: "TMDB API Key 与地址、TMDB 缓存、资源识别、图片代理",
legacyTabs: [],
anchors: [
{ id: "tmdb", label: "TMDB" },
{ id: "tmdb-cache", label: "TMDB 缓存" },
{ id: "identity", label: "资源识别" },
{ id: "images", label: "图片代理与缓存" },
],
fields: [
"metadata.tmdbApiKeySecretRef",
"metadata.tmdbBaseUrl",
"metadata.languages",
"cache.tmdbMaxSize",
"identity.eagerTmdbLookup",
"identity.autoEnrich",
"identity.enrichDailyBudget",
"identity.enrichDomains",
"cache.globalImageCache",
"cache.globalImageCacheDays",
],
resources: [],
},
{
id: "agent",
label: "智能体",
description: "模型服务、运行预算、自动调查、网页搜索",
legacyTabs: [{ tab: "pi" }],
anchors: [
{ id: "upstream", label: "模型服务" },
{ id: "runtime", label: "上下文与子会话" },
{ id: "budget", label: "预算" },
{ id: "investigate", label: "自动调查" },
{ id: "websearch", label: "网页搜索" },
{ id: "advanced", label: "高级" },
{ id: "mcp", label: "MCP 服务端" },
{ id: "capabilities", label: "能力清单" },
],
fields: [
"pi.defaultUpstreamId",
"pi.contextBudget",
"pi.compaction",
"pi.subagentsEnabled",
"pi.subagents",
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
"agent.autoInvestigate",
"agent.autoInvestigateMode",
"agent.investigateMaxAttempts",
"agent.recoverVerifySec",
"agent.maxToolCalls",
"agent.maxDurationSec",
"agent.maxTokensPerRun",
"agent.maxCostUsdPerRun",
"agent.investigateMaxToolCalls",
"agent.goalPollSec",
"agent.goalWaitHours",
"agent.optionsTimeoutSec",
"agent.optionsTimeoutAction",
"agent.approvalTimeoutSec",
"agent.fakeScript",
"agent.sessionDir",
"agent.webSearch",
],
resources: ["pi-upstreams"],
},
{
id: "api-keys",
label: "API Key",
description: "签发给外部程序的 Key",
legacyTabs: [{ tab: "api-keys" }],
anchors: [{ id: "keys", label: "API Key" }],
fields: [],
resources: ["api-keys"],
},
{
id: "interface",
label: "界面",
description: "只影响当前浏览器",
legacyTabs: [],
anchors: [{ id: "display", label: "显示" }],
fields: [],
resources: ["ui-preferences"],
},
{
id: "account",
label: "账户与安全",
description: "密码、二次验证、会话、快捷键",
legacyTabs: [],
anchors: [
{ id: "password", label: "密码" },
{ id: "totp", label: "二次验证" },
{ id: "passkeys", label: "Passkey" },
{ id: "session", label: "会话" },
{ id: "shortcuts", label: "快捷键" },
],
fields: [],
resources: ["auth"],
},
];
/** 旧 `?tab=` → 分区与锚点;不认识的返回 null */
export function settingsSectionForLegacyTab(tab: string): { section: SettingsSectionId; anchor?: string } | null {
for (const s of SETTINGS_SECTIONS) {
const hit = s.legacyTabs.find((t) => t.tab === tab);
if (hit) return { section: s.id, anchor: hit.anchor };
}
return null;
}