- 文件道任务 scrapeMedia:NFO 原子写入、图片经图片缓存下载(并发 4,失败不阻塞 NFO)、只补缺失 / 全部覆盖
- 设置 organize.scrapeAfterImport / scrapeMode / scrapeImages / scrapeEpisodeThumbs
- POST /library/{id}/scrape、详情 scrape 状态;files/scrape writeNfo 与 scrape-batch write 走同一任务
- 迁移 0057_media_scrape、原因码、审计 organize.scrape
- 与 MP 输出的 NFO 差分测试(apps/server/test/scrape)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1092 lines
43 KiB
TypeScript
1092 lines
43 KiB
TypeScript
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);
|
||
|
||
/** 并发槽上限(与 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({
|
||
defaultMode: OrganizeMode,
|
||
/** 跨卷 Move 采用 Copy→核验→删源;EXDEV 不静默降级 */
|
||
allowCrossVolumeMove: z.boolean(),
|
||
/** 整理 / 文件管理识别时视频文件的最小体积(字节),低于它的视为样片跳过;请求(向导)里给的值优先 */
|
||
minSizeBytes: z.number().int().nonnegative(),
|
||
/** SCR-1:整理入库成功后自动刮削(往媒体库目录写 NFO 与图片);缺省关(同 MP 目录「刮削」缺省) */
|
||
scrapeAfterImport: z.boolean(),
|
||
/** SCR-1:目标文件已存在时 missing = 只补缺失(MP「仅缺失」)/ overwrite = 全部覆盖 */
|
||
scrapeMode: z.enum(["missing", "overwrite"]),
|
||
/** 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),
|
||
/** 单次运行: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"]),
|
||
/**
|
||
* 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),
|
||
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",
|
||
// 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(),
|
||
});
|
||
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, workerLanes: DEFAULT_WORKER_LANES },
|
||
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,
|
||
scrapeAfterImport: false,
|
||
scrapeMode: "missing",
|
||
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,
|
||
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"],
|
||
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,
|
||
},
|
||
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.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",
|
||
] 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",
|
||
"automation.workerLanes",
|
||
"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: "scrape", label: "刮削" },
|
||
{ id: "naming", label: "命名模板" },
|
||
],
|
||
fields: [
|
||
"organize.defaultMode",
|
||
"organize.allowCrossVolumeMove",
|
||
"organize.minSizeBytes",
|
||
"organize.scrapeAfterImport",
|
||
"organize.scrapeMode",
|
||
"organize.scrapeImages",
|
||
"organize.scrapeEpisodeThumbs",
|
||
"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",
|
||
"metadata.overviewNonChineseFallback",
|
||
"metadata.includeSpecials",
|
||
"cache.tmdbMaxSize",
|
||
"identity.eagerTmdbLookup",
|
||
"identity.autoEnrich",
|
||
"identity.enrichDailyBudget",
|
||
"identity.enrichDomains",
|
||
"identity.enrichSnippetAllSites",
|
||
"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: "通行密钥" },
|
||
{ 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;
|
||
}
|