Files
UHD_Media_Tool/packages/contracts/src/settings.ts
T
rootandClaude Opus 5.5 cf364435eb feat(web): 存储设置与文件管理的 Alist 存储切换
- 设置 → 媒体库与目录 → 存储:登记 Alist / OpenList(令牌 / 账号密码 / 访客、路径密码、限速与监控选项),
  测试连接显示版本、用户与容量;媒体库目录可选存储
- 文件管理左栏多一组「Alist 存储」,路径显示「存储名:/路径」;移动 / 复制目标可选 Alist 存储;
  上传按钮;Alist 根点刷新才让 Alist 刷新目录缓存
- 运行状态的挂载根列出存储实例(令牌失效单独提示)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 23:57:33 +08:00

1310 lines
53 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 { DEFAULT_CLASSIFICATION_YAML } from "./classification.ts";
import { Id, Timestamp } from "./common.ts";
import { OrganizeSourceConfig } from "./organize-sources.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);
/** 并发槽上限(与 io worker 各道开的槽位数一致;改设置只在这个范围内热生效) */
export const WORKER_LANE_MAX = 16;
/**
* io worker 三条道的并发(WK-1,docs/runbook/dev.md「IO Worker 分道」):改后约 5 秒内热生效,不用重启;
* 设置了环境变量 WORKER_FILE_CONCURRENCY / WORKER_NETWORK_CONCURRENCY(旧 WORKER_CONCURRENCY)/
* WORKER_MAINTENANCE_CONCURRENCY 时以环境变量为准。
*/
export const WorkerLaneSettings = z.object({
/** 文件道:整理、硬链接/移动、ffprobe、校验 */
file: z.number().int().min(1).max(WORKER_LANE_MAX),
/** 网络道:站点、RSS、TMDB、下载器、搜索(单站限流另计) */
network: z.number().int().min(1).max(WORKER_LANE_MAX),
/** 维护道:清理、回填、对账;文件道 / 网络道有活时让路 */
maintenance: z.number().int().min(1).max(WORKER_LANE_MAX),
});
export type WorkerLaneSettings = z.infer<typeof WorkerLaneSettings>;
export const DEFAULT_WORKER_LANES: WorkerLaneSettings = { file: 2, network: 4, maintenance: 1 };
export const AutomationSettings = z.object({
/** 自动化总开关:关闭后所有自动收录/下载/整理停止,只读查询与手动操作可用 */
enabled: z.boolean(),
/** 恢复后的只读对账模式(REQ §8.5):开启时禁止自动写入 */
readOnlyReconcile: z.boolean(),
/** io worker 分道并发;旧库缺省时补 DEFAULT_WORKER_LANES */
workerLanes: WorkerLaneSettings.default(DEFAULT_WORKER_LANES),
});
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({
classificationYaml: z.string().min(1).max(100_000),
sources: z.array(OrganizeSourceConfig),
defaultMode: OrganizeMode,
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
allowCrossVolumeMove: z.boolean(),
/** 整理 / 文件管理识别时视频文件的最小体积(字节),低于它的视为样片跳过;请求(向导)里给的值优先 */
minSizeBytes: z.number().int().nonnegative(),
/** SCR-1:整理入库成功后自动刮削(往媒体库目录写 NFO 与图片);缺省关(同 MP 目录「刮削」缺省) */
scrapeAfterImport: z.boolean(),
/**
* 目标文件已存在时:update = 更新(缺省;只覆盖本工具写过且没被改过的文件)/ missing = 只补缺失(MP「仅缺失」)/
* overwrite = 全部覆盖
*/
scrapeMode: z.enum(["missing", "overwrite", "update"]),
/** SCR-1:下载海报、背景图、季海报 */
scrapeImages: z.boolean(),
/** SCR-1:下载每集剧照(`<视频名>-thumb.jpg`) */
scrapeEpisodeThumbs: z.boolean(),
});
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(4000).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),
/** 已弃用(2026-10-06 起不限 token,旧值兼容读取、不生效) */
maxTokensPerRun: z.number().int().positive().nullable().optional(),
/** 已弃用(2026-10-06 起不限单次费用,旧值兼容读取、不生效) */
maxCostUsdPerRun: z.number().positive().nullable().optional(),
/** 整理时没识别出作品:自动派智能体识别一次,认出来就按该作品重新整理(不需要持续授权) */
autoIdentifyOrganize: z.boolean(),
/** 后台自动调查的工具调用上限(比用户委托更小) */
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。
* 多语言保留规则(adapters tmdb/language-policy.ts):只有简中(zh-CN / zh-SG)、繁中(zh-TW / zh-HK / zh-MO)、
* 英文(en-*)和作品原语言生效;其它语言(如 ja-JP)只在作品原语言是它时生效,否则按作品跳过
*/
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"]),
/**
* MD-2:简介 / 标语是否允许回退到非中文。缺省 false = 只按链里的中文变体(及排在第一个中文之前的语言)取,
* 取不到留空;标题不受影响。链里没有中文时不起作用
*/
overviewNonChineseFallback: z.boolean().default(false),
/**
* DH-1 季 0(特别篇):true = full 刷新与元数据回刷在 TMDB 有季 0 时一并拉取每集信息并建集单元(对照 MP);
* 季 0 不计入正片季总集数与订阅完成判定
*/
includeSpecials: z.boolean().default(true),
});
export type MetadataSettings = z.infer<typeof MetadataSettings>;
/**
* 读不到浏览器身份(没有浏览器服务 / 旧镜像 / 暂时连不上)时站点请求用的 UA:与浏览器身份同型的 Windows Firefox
* (camofox 1.17.0 = Camoufox 152;升级浏览器镜像时同步改这里,BR-4)。
*/
export const FALLBACK_SITE_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0";
/** BR-4 之前的缺省 UA(Windows Chrome 140):迁移时据此把存量账号标"待切换",设置里的旧缺省值清空改为跟随浏览器 */
export const LEGACY_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 覆盖:空串 = 跟随浏览器(缺省,BR-4);填了则账号没有手动 UA 时都用它(浏览器仍是自己的 UA,两端会不一致)
*/
userAgent: z.string().trim().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),
/** 已弃用(2026-10-06 起智能体不设每日次数上限,旧值兼容读取、不生效) */
agentMaxRunsPerDay: z.number().int().min(0).optional(),
/** 已弃用(2026-10-06 起智能体不设每日费用上限,旧值兼容读取、不生效) */
agentMaxCostUsdPerDay: z.number().min(0).optional(),
});
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",
// META-2:快懂百科、河马剧场(短剧平台公开作品页);红果短剧的 /series/ 页 robots 不允许,只读 robots 放行的页面
"baike.com",
"kuaikaw.cn",
];
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),
/**
* META-2 摘要模式:白名单外网站(多为短剧聚合站)的搜索结果摘要也用来抽字段(只读搜索引擎给的摘要,不打开这些网页)。
* 缺省开;关掉后只用白名单网站的摘要与网页。旧设置没有这个键,按开处理
*/
enrichSnippetAllSites: z.boolean().optional(),
/**
* ID-3 识别升级:标题识别不确定时,每个站点每小时最多为识别抓多少个详情页(取 IMDb / TMDB / 豆瓣 ID);0 = 不抓。
* 只在搜索结果、订阅相关的候选、池条目补全和手动重新识别时抓,RSS 批量入库不抓。旧设置没有这个键,按缺省 10
*/
detailIdsPerSiteHour: z.number().int().min(0).max(200).optional(),
});
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,
// ── 2026-10-06 数据库清理(docs/runbook/storage.md「保留规则」);旧库缺这些字段时按缺省补 ──
/** 已结束的引擎 run(步骤级联)、运行元数据 / 事实 / 信号;仍被保留中的业务任务引用的跟随任务 */
engineRun: Days,
/** 已投递的 outbox 事件(只删无幂等键或一次性 / 带时间槽的幂等键) */
outboxEvent: Days,
/** 识别尝试记录;每条资源至少保留最近 3 次 */
resolutionAttempt: Days,
/** 已识别(资源已绑定作品)的待识别池条目;忽略、归档的不删 */
identityResolved: Days,
/** 站点列表快照;每个站点最近一次成功的保留 */
siteSnapshot: 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,
/** 豆瓣 / bgm 搜索请求(kv_cache douban.request / bangumi.request;条目详情走 douban.subject / bangumi.subject,见 docs/porting/moviepilot-discover.md) */
metaSearchDays: Days,
/** 豆瓣 / bgm 榜单、热门、每日放送等列表请求(小时) */
metaListHours: z.number().int().min(1).max(720),
/** 豆瓣 / bgm 列表空结果(小时,对照 MP empty_ttl);搜索空结果改按 metaMissDays */
metaEmptyHours: z.number().int().min(1).max(720),
// ── 统一元数据缓存(2026-10-06,docs/runbook/cache.md 策略表);旧库缺这些字段时按缺省补 ──
/** 豆瓣 / bgm 条目详情(douban.subject / bangumi.subject)与发现页条目 → TMDB 匹配(discover.match) */
metaDetailDays: Days,
/** 豆瓣 / bgm 查无结果:搜索空结果、条目不存在(meta.subject_miss) */
metaMissDays: Days,
/** TMDB 榜单 / 热门 / 趋势 / 按条件筛选(小时) */
tmdbListHours: z.number().int().min(1).max(720),
/** TMDB 列表空结果(小时) */
tmdbListEmptyHours: z.number().int().min(1).max(720),
/** TMDB 人物 / 图片列表(/person/*、/images) */
imageDays: 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,
engineRun: 14,
outboxEvent: 30,
resolutionAttempt: 30,
identityResolved: 60,
siteSnapshot: 30,
},
cacheTtl: {
tmdbSearchDays: 7,
tmdbDetailDays: 30,
tmdbMissDays: 1,
siteDetailDays: 7,
webSearchDays: 1,
metaSearchDays: 7,
metaListHours: 6,
metaEmptyHours: 1,
metaDetailDays: 30,
metaMissDays: 1,
tmdbListHours: 6,
tmdbListEmptyHours: 1,
imageDays: 90,
},
};
/** 存储设置与缺省逐层合并(旧库里缺子字段时补齐) */
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, workerLanes: DEFAULT_WORKER_LANES },
cache: {
hardThresholdPercent: 90,
resumePercent: 85,
checkIntervalSec: 60,
cleanupTargetPercent: 80,
tmdbTtlHours: 24,
tmdbMaxSize: 5000,
emptyResultTtlSec: 1800,
globalImageCache: false,
globalImageCacheDays: 7,
},
organize: {
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
sources: [],
defaultMode: "copy",
allowCrossVolumeMove: true,
minSizeBytes: 50 * 1024 * 1024,
// 缺省开:只对有 TMDB id 或本地元数据的作品排刮削(否则在整理记录上记「未刮削」),「更新」方式不动别人写的文件
scrapeAfterImport: true,
scrapeMode: "update",
scrapeImages: true,
scrapeEpisodeThumbs: true,
},
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,
},
agent: {
autoInvestigate: false,
autoInvestigateMode: "investigate",
investigateMaxAttempts: 2,
recoverVerifySec: 600,
maxToolCalls: 120,
maxDurationSec: 1800,
autoIdentifyOrganize: true,
investigateMaxToolCalls: 20,
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"],
overviewNonChineseFallback: false,
includeSpecials: true,
},
sites: {
defaultRateLimit: { requestsPerMinute: 20, concurrency: 2 },
defaultSnapshotIntervalSec: 3600,
userAgent: "",
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,
enrichSnippetAllSites: true,
detailIdsPerSiteHour: 10,
},
search: { defaultSiteMode: "default", siteIds: [] },
storage: DEFAULT_STORAGE_SETTINGS,
};
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
export const GlobalSettingsUpdate = z.object({
automation: AutomationSettings.omit({ workerLanes: true })
.partial()
.extend({ workerLanes: WorkerLaneSettings.partial().optional() })
.optional(),
cache: CacheShape.partial().optional(),
organize: OrganizeSettings.omit({ sources: true }).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().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().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,
workerLanes: { ...DEFAULT_WORKER_LANES, ...current.automation.workerLanes, ...patch.automation?.workerLanes },
},
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",
// CACHE-2 起流水数据按 storage.retentionDays 分类清理,这个总的日志保留天数没有任何地方读取
"retention.logRetentionDays",
// 2026-10-06:智能体不设硬性预算上限,只保留单次工具调用次数与时长两个防跑飞限制
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
"agent.maxTokensPerRun",
"agent.maxCostUsdPerRun",
] as const;
// ── 设置页分区(docs/design/console-ia.md §3.4,2026-10-06 重排) ──
// 只是"展示分区 → 字段"的映射,不改存储 key 与缺省值;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
// 非 settings 表的对象(实例、站点、API Key、模型服务、账户)用 resources 标注,不进 fields。
// 分区按三组排:接入对象(外部服务与数据源)→ 业务环节(获取 → 落盘 → 整理 → 智能体)→ 系统与账户。
export const SettingsSectionId = z.enum([
"downloaders",
"media-servers",
"sites",
"metadata",
"acquire",
"library",
"organize",
"agent",
"system",
"cache",
"account",
"interface",
]);
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
export const SettingsGroupId = z.enum(["connect", "flow", "system"]);
export type SettingsGroupId = z.infer<typeof SettingsGroupId>;
export const SETTINGS_GROUPS: readonly { id: SettingsGroupId; label: string }[] = [
{ id: "connect", label: "接入" },
{ id: "flow", label: "流程" },
{ id: "system", label: "系统" },
];
export type SettingsAnchorDef = {
id: string;
label: string;
/** 搜索用的别名:不在表单字段里的设置(实例抽屉的字段、登录方式等) */
keywords?: string[];
};
export type SettingsSectionDef = {
id: SettingsSectionId;
group: SettingsGroupId;
label: string;
/** 一句说明:这里管什么 */
description: string;
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
legacyTabs: { tab: string; anchor?: string }[];
/** 分区内锚点(节),顺序即页面顺序 */
anchors: SettingsAnchorDef[];
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
fields: string[];
/** 本分区管理的非 settings 对象 */
resources: string[];
};
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "downloaders",
group: "connect",
label: "下载器",
description: "qBittorrent 实例与路径映射;下载盘水位和保种天数的缺省值,实例里可以单独改",
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }, { tab: "storage", anchor: "cache" }],
anchors: [
{
id: "instances",
label: "实例",
keywords: [
"qBittorrent",
"WebUI",
"管理标签",
"缓存卷标识",
"默认下载器",
"路径映射",
"H&R 未知也可清理",
"认证方式",
],
},
{ id: "cache", label: "下载盘", keywords: ["水位", "缓存盘"] },
{ id: "seeding", label: "保种", keywords: ["做种"] },
],
fields: [
"cache.hardThresholdPercent",
"cache.resumePercent",
"cache.checkIntervalSec",
"cache.cleanupTargetPercent",
"retention.seedRetentionDays",
],
resources: ["downloader-instances", "path-mappings"],
},
{
id: "media-servers",
group: "connect",
label: "媒体服务器",
description: "Emby / Jellyfin 实例与路径映射,只用来查作品有没有",
legacyTabs: [{ tab: "media-servers" }],
anchors: [{ id: "instances", label: "实例", keywords: ["Emby", "Jellyfin", "用户 ID"] }],
fields: [],
resources: ["media-server-instances", "path-mappings"],
},
{
id: "sites",
group: "connect",
label: "站点与网络",
description: "站点缺省值、User-Agent 与代理、浏览器服务;站点、凭证和 Cloudflare 在站点页",
legacyTabs: [{ tab: "site-credentials" }],
anchors: [
{ id: "defaults", label: "站点缺省值", keywords: ["限流", "抓取周期", "健康检测"] },
{ id: "network", label: "UA 与代理", keywords: ["User-Agent", "代理", "出口"] },
{ id: "browser", label: "浏览器服务", keywords: ["原站视图", "Cloudflare"] },
],
fields: [
"sites.defaultRateLimit",
"sites.defaultSnapshotIntervalSec",
"sites.userAgent",
"sites.proxyUrl",
"sites.userDataMaxSeedingPages",
"sites.defaultSnapshotMaxPages",
"sites.defaultHealthCheckIntervalSec",
"sites.defaultDetailCacheSec",
],
resources: ["browser-providers"],
},
{
id: "metadata",
group: "connect",
label: "元数据",
description: "TMDB 接入、语言回退与特别篇;缓存在「缓存」里管理",
legacyTabs: [],
anchors: [
{ id: "tmdb", label: "TMDB", keywords: ["API Key", "Base URL"] },
{ id: "language", label: "语言与季集", keywords: ["季 0", "特别篇", "简介"] },
],
fields: [
"metadata.tmdbApiKeySecretRef",
"metadata.tmdbBaseUrl",
"metadata.languages",
"metadata.overviewNonChineseFallback",
"metadata.includeSpecials",
],
resources: [],
},
{
id: "acquire",
group: "flow",
label: "获取",
description: "每日下载预算、搜索站点、资源识别;订阅和规则在各自页面",
legacyTabs: [],
anchors: [
{ id: "budget", label: "每日预算", keywords: ["并发下载"] },
{ id: "search", label: "搜索站点", keywords: ["默认参与搜索"] },
{ id: "identity", label: "资源识别", keywords: ["元数据补全", "TMDB"] },
],
fields: [
"budget.maxNewTitlesPerDay",
"budget.maxDownloadBytesPerDay",
"budget.maxConcurrentDownloads",
"search.defaultSiteMode",
"search.siteIds",
"identity.eagerTmdbLookup",
"identity.autoEnrich",
"identity.enrichDailyBudget",
"identity.enrichDomains",
"identity.enrichSnippetAllSites",
"identity.detailIdsPerSiteHour",
],
resources: [],
},
{
id: "library",
group: "flow",
label: "媒体库与目录",
description: "存储、媒体库目录、整理来源、分类规则、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "storages", label: "存储", keywords: ["Alist", "OpenList", "网盘", "令牌"] },
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
{ id: "sources", label: "整理来源" },
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
{ id: "naming", label: "命名模板", keywords: ["重命名"] },
{ id: "naming-roots", label: "目录命名覆盖" },
],
fields: [
"organize.classificationYaml",
"organize.sources",
"naming.movie",
"naming.tv",
"naming.variety",
"naming.lockFileNameForBound",
],
resources: ["storage-instances", "library-roots"],
},
{
id: "organize",
group: "flow",
label: "整理与刮削",
description: "整理方式、最小视频体积、整理后自动刮削和已有文件的覆盖方式",
legacyTabs: [],
anchors: [
{ id: "mode", label: "整理方式", keywords: ["复制", "移动", "跨卷"] },
{ id: "scrape", label: "刮削", keywords: ["NFO", "海报", "剧照", "更新", "只补缺失", "全部覆盖"] },
],
fields: [
"organize.defaultMode",
"organize.allowCrossVolumeMove",
"organize.minSizeBytes",
"organize.scrapeAfterImport",
"organize.scrapeMode",
"organize.scrapeImages",
"organize.scrapeEpisodeThumbs",
],
resources: [],
},
{
id: "agent",
group: "flow",
label: "智能体",
description: "模型服务、用量与防跑飞限制、自动处理、网页搜索、MCP",
legacyTabs: [{ tab: "pi" }],
anchors: [
{ id: "upstream", label: "模型服务", keywords: ["默认模型服务", "订阅登录"] },
{ id: "budget", label: "用量与限制", keywords: ["花费", "预算"] },
{ id: "investigate", label: "自动处理", keywords: ["自动调查", "识别失败"] },
{ id: "runtime", label: "上下文与子会话", keywords: ["压缩"] },
{ id: "websearch", label: "网页搜索", keywords: ["Brave"] },
{ id: "mcp", label: "MCP 服务端" },
{ id: "capabilities", label: "能力清单" },
{ id: "advanced", label: "高级", keywords: ["方案", "审批"] },
],
fields: [
"pi.defaultUpstreamId",
"pi.contextBudget",
"pi.compaction",
"pi.subagentsEnabled",
"pi.subagents",
"agent.autoInvestigate",
"agent.autoIdentifyOrganize",
"agent.autoInvestigateMode",
"agent.investigateMaxAttempts",
"agent.recoverVerifySec",
"agent.maxToolCalls",
"agent.maxDurationSec",
"agent.investigateMaxToolCalls",
"agent.goalPollSec",
"agent.goalWaitHours",
"agent.optionsTimeoutSec",
"agent.optionsTimeoutAction",
"agent.approvalTimeoutSec",
"agent.fakeScript",
"agent.sessionDir",
"agent.webSearch",
],
resources: ["pi-upstreams"],
},
{
id: "system",
group: "system",
label: "系统",
description: "自动化总开关、只读对账、后台任务并发、存储预算、数据保留",
legacyTabs: [{ tab: "automation" }],
anchors: [
{ id: "switches", label: "自动化", keywords: ["总开关", "暂停"] },
{ id: "readonly", label: "只读对账" },
{ id: "worker-lanes", label: "后台任务并发" },
{ id: "storage", label: "存储预算" },
{ id: "storage-retention", label: "数据保留", keywords: ["日志保留"] },
],
fields: [
"automation.enabled",
"automation.readOnlyReconcile",
"automation.workerLanes",
"storage.budgets",
"storage.retentionDays",
],
resources: [],
},
{
id: "cache",
group: "system",
label: "缓存",
description: "各类缓存的条数、占用和有效期,可以按区清空",
legacyTabs: [],
anchors: [
{
id: "regions",
label: "缓存区",
keywords: ["清空缓存", "豆瓣", "Bangumi", "站点详情", "发现", "网页搜索"],
},
{ id: "tmdb-cache", label: "TMDB 缓存", keywords: ["条数上限"] },
{ id: "images", label: "图片缓存", keywords: ["海报"] },
{ id: "ttl", label: "缓存有效期" },
],
fields: ["cache.tmdbMaxSize", "cache.globalImageCache", "cache.globalImageCacheDays", "storage.cacheTtl"],
resources: ["kv-cache", "image-cache"],
},
{
id: "account",
group: "system",
label: "账户与安全",
description: "密码、二次验证、通行密钥、会话与本机 Token、API Key",
legacyTabs: [{ tab: "api-keys", anchor: "api-keys" }],
anchors: [
{ id: "password", label: "密码" },
{ id: "totp", label: "二次验证", keywords: ["TOTP", "恢复码"] },
{ id: "passkeys", label: "通行密钥", keywords: ["Passkey"] },
{ id: "session", label: "会话", keywords: ["本机 Token", "退出登录"] },
{ id: "api-keys", label: "API Key", keywords: ["外部程序"] },
],
fields: [],
resources: ["auth", "api-keys"],
},
{
id: "interface",
group: "system",
label: "界面",
description: "只影响当前浏览器",
legacyTabs: [],
anchors: [
{ id: "display", label: "显示", keywords: ["技术信息"] },
{ id: "shortcuts", label: "快捷键" },
],
fields: [],
resources: ["ui-preferences"],
},
];
/** 旧 `?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;
}
/**
* 旧 hash(2026-10-06 重排前的 `#分区[/节]`)→ 新位置。先查整条,再查只有分区的那一条;
* 新旧同名的(`#downloaders`、`#sites/browser`、`#agent/budget` 等)不用登记。
*/
export const SETTINGS_HASH_ALIASES: Readonly<Record<string, string>> = {
automation: "system",
"automation/switches": "system/switches",
"automation/cache": "downloaders/cache",
"automation/budget": "acquire/budget",
"automation/retention": "downloaders/seeding",
"automation/advanced": "system/readonly",
"automation/readonly": "system/readonly",
"automation/worker-lanes": "system/worker-lanes",
"automation/storage": "system/storage",
"automation/storage-retention": "system/storage-retention",
"automation/storage-ttl": "cache/ttl",
"system/storage-ttl": "cache/ttl",
"metadata/tmdb-cache": "cache/tmdb-cache",
"metadata/images": "cache/images",
"library/organize": "organize/mode",
"library/scrape": "organize/scrape",
"metadata/identity": "acquire/identity",
"api-keys": "account/api-keys",
"api-keys/keys": "account/api-keys",
"account/shortcuts": "interface/shortcuts",
"sites/accounts": "sites",
};
/**
* 解析设置页 hash:`#分区[/节]`,旧 hash 按 SETTINGS_HASH_ALIASES 换成新位置(moved = true 时调用方应 replace 地址栏);
* 不认识的分区返回 null 分区(调用方落到第一个分区),不认识的节丢掉。
*/
export function resolveSettingsHash(hash: string): {
section: SettingsSectionId | null;
anchor: string | null;
moved: boolean;
} {
let raw = hash.replace(/^#/, "");
try {
raw = decodeURIComponent(raw);
} catch {
// 保持原样
}
const [head = "", tail] = raw.split("/");
const full = tail ? `${head}/${tail}` : head;
const alias = SETTINGS_HASH_ALIASES[full];
let target = alias ?? full;
let moved = alias !== undefined;
if (!alias && SETTINGS_HASH_ALIASES[head] && !SETTINGS_SECTIONS.some((s) => s.id === head)) {
// 旧分区里没登记的节:落到新分区顶部
target = SETTINGS_HASH_ALIASES[head] as string;
moved = true;
}
const [sid, anchor] = target.split("/");
const section = SETTINGS_SECTIONS.find((s) => s.id === sid);
if (!section) return { section: null, anchor: null, moved: false };
const known = anchor && section.anchors.some((a) => a.id === anchor) ? anchor : null;
return { section: section.id, anchor: known, moved };
}