Files
UHD_Media_Tool/packages/contracts/src/settings.ts
T
rootandClaude Opus 5.5 f38eca419e feat(server): scrapeMedia 刮削任务,入库后自动刮削(SCR-1)
- 文件道任务 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]>
2026-09-30 22:36:14 +08:00

1092 lines
43 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);
/** 并发槽上限(与 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;
}