diff --git a/apps/web/app/components/settings/automation.tsx b/apps/web/app/components/settings/automation.tsx
index 3b4a98d0..c05a9402 100644
--- a/apps/web/app/components/settings/automation.tsx
+++ b/apps/web/app/components/settings/automation.tsx
@@ -5,11 +5,11 @@ import { useUpdateSettings } from "~/api/settings.ts";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
-import { num } from "./resource-section.tsx";
+import { type FormValues, num } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
-// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数
+// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、存储预算与保留(CACHE-2)
const GiB = 1024 ** 3;
@@ -89,6 +89,88 @@ const RETENTION_CARD: CardSpec = {
}),
};
+// CACHE-2:存储预算、流水保留期、缓存有效期(settings.storage,docs/runbook/storage.md)
+const gbField = (k: string, label: string, hint?: string) => ({
+ name: `storage.budgets.${k}`,
+ label,
+ kind: "number" as const,
+ hint,
+});
+const dayField = (group: "retentionDays" | "cacheTtl", k: string, label: string, hint?: string) => ({
+ name: `storage.${group}.${k}`,
+ label,
+ kind: "number" as const,
+ hint,
+});
+const pick = (v: FormValues, prefix: string, keys: readonly string[]) =>
+ Object.fromEntries(keys.map((k) => [k, num(v[`${prefix}.${k}`])]));
+
+const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const;
+const STORAGE_BUDGET_CARD: CardSpec = {
+ anchor: "storage",
+ title: "存储预算",
+ description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
+ fields: [
+ gbField("pgDataGb", "数据库预算", "超过为严重"),
+ gbField("pgDataWarnGb", "数据库告警线", "不高于数据库预算"),
+ gbField("imagesGb", "图片缓存容量"),
+ gbField("backupsGb", "备份目录预算"),
+ gbField("sessionsEvidenceGb", "Agent 会话与截图证据预算"),
+ ],
+ fromSettings: (s) => Object.fromEntries(BUDGET_KEYS.map((k) => [`storage.budgets.${k}`, n(s.storage.budgets[k])])),
+ toPatch: (v) => ({ storage: { budgets: pick(v, "storage.budgets", BUDGET_KEYS) } }),
+};
+
+const RETENTION_KEYS = [
+ "observation",
+ "sseEvent",
+ "heartbeatHours",
+ "taskRun",
+ "agentEvent",
+ "audit",
+ "capabilityStats",
+ "identityPoolArchive",
+] as const;
+const STORAGE_RETENTION_CARD: CardSpec = {
+ anchor: "storage-retention",
+ title: "流水数据保留",
+ description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
+ fields: [
+ dayField("retentionDays", "observation", "Feed 观察"),
+ dayField("retentionDays", "sseEvent", "实时事件"),
+ dayField("retentionDays", "heartbeatHours", "进程心跳(小时)"),
+ dayField("retentionDays", "taskRun", "任务运行记录"),
+ dayField("retentionDays", "agentEvent", "Agent 会话事件", "只删已结束的会话"),
+ dayField("retentionDays", "audit", "审计日志", "能力调用审计除外"),
+ dayField("retentionDays", "capabilityStats", "能力调用统计"),
+ dayField(
+ "retentionDays",
+ "identityPoolArchive",
+ "待识别池归档",
+ "未命中的条目这么久没有新证据就归档,仍可手动指定",
+ ),
+ ],
+ fromSettings: (s) =>
+ Object.fromEntries(RETENTION_KEYS.map((k) => [`storage.retentionDays.${k}`, n(s.storage.retentionDays[k])])),
+ toPatch: (v) => ({ storage: { retentionDays: pick(v, "storage.retentionDays", RETENTION_KEYS) } }),
+};
+
+const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const;
+const STORAGE_TTL_CARD: CardSpec = {
+ anchor: "storage-ttl",
+ title: "缓存有效期",
+ description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。",
+ fields: [
+ dayField("cacheTtl", "tmdbSearchDays", "TMDB 搜索与识别结果"),
+ dayField("cacheTtl", "tmdbDetailDays", "TMDB 详情"),
+ dayField("cacheTtl", "tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
+ dayField("cacheTtl", "siteDetailDays", "站点详情页内容"),
+ dayField("cacheTtl", "webSearchDays", "网页搜索结果"),
+ ],
+ fromSettings: (s) => Object.fromEntries(TTL_KEYS.map((k) => [`storage.cacheTtl.${k}`, n(s.storage.cacheTtl[k])])),
+ toPatch: (v) => ({ storage: { cacheTtl: pick(v, "storage.cacheTtl", TTL_KEYS) } }),
+};
+
function ToggleRow({
id,
icon,
@@ -181,6 +263,9 @@ export function AutomationSection() {
+
+
+
>
)}
diff --git a/apps/web/app/components/settings/metadata.tsx b/apps/web/app/components/settings/metadata.tsx
index a5fc7381..f253d33d 100644
--- a/apps/web/app/components/settings/metadata.tsx
+++ b/apps/web/app/components/settings/metadata.tsx
@@ -102,27 +102,15 @@ function TmdbKeyStatus({ saved }: { saved: boolean }) {
const TMDB_CACHE_CARD: CardSpec = {
anchor: "tmdb-cache",
title: "TMDB 缓存",
- description: "请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目。",
- fields: [
- { name: "cache.tmdbTtlHours", label: "有效期(小时)", kind: "number" },
- { name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" },
- {
- name: "cache.emptyResultTtlSec",
- label: "空结果有效期(秒)",
- kind: "number",
- hint: "搜索无结果、未识别时的短缓存",
- },
- ],
+ description:
+ "请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目;有效期在「自动化 → 存储与保留」里设。",
+ fields: [{ name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" }],
fromSettings: (s) => ({
- "cache.tmdbTtlHours": n(s.cache.tmdbTtlHours),
"cache.tmdbMaxSize": n(s.cache.tmdbMaxSize),
- "cache.emptyResultTtlSec": n(s.cache.emptyResultTtlSec),
}),
toPatch: (v) => ({
cache: {
- tmdbTtlHours: Number(v["cache.tmdbTtlHours"]),
tmdbMaxSize: Number(v["cache.tmdbMaxSize"]),
- emptyResultTtlSec: Number(v["cache.emptyResultTtlSec"]),
},
}),
extra: () => ,
@@ -224,12 +212,6 @@ const IDENTITY_CARD: CardSpec = {
hint: "旧行为。每条资源约 1–2 次搜索 + 命中时 1 次详情(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到",
wide: true,
},
- {
- name: "identity.poolArchiveDays",
- label: "待识别池归档(天)",
- kind: "number",
- hint: "未命中订阅的条目超过这么多天没有新证据就归档,仍可筛出并手动指定",
- },
{
name: "identity.autoEnrich",
label: "TMDB 查不到时自动补全元数据",
@@ -253,7 +235,6 @@ const IDENTITY_CARD: CardSpec = {
],
fromSettings: (s) => ({
"identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
- "identity.poolArchiveDays": n(s.identity.poolArchiveDays),
"identity.autoEnrich": s.identity.autoEnrich,
"identity.enrichDailyBudget": n(s.identity.enrichDailyBudget),
"identity.enrichDomains": s.identity.enrichDomains.join("\n"),
@@ -261,7 +242,6 @@ const IDENTITY_CARD: CardSpec = {
toPatch: (v) => ({
identity: {
eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
- poolArchiveDays: Number(v["identity.poolArchiveDays"]),
autoEnrich: v["identity.autoEnrich"] === true,
enrichDailyBudget: Number(v["identity.enrichDailyBudget"]),
enrichDomains: String(v["identity.enrichDomains"] ?? "")
diff --git a/docs/porting/moviepilot-tmdb-cache.md b/docs/porting/moviepilot-tmdb-cache.md
index f649ecd3..56f4f1a1 100644
--- a/docs/porting/moviepilot-tmdb-cache.md
+++ b/docs/porting/moviepilot-tmdb-cache.md
@@ -23,7 +23,7 @@ PG 读写失败时 TMDB 路径按未命中处理(内存缓存不会失败)。
| 上游 | 我们 |
|---|---|
-| `maxsize = CONF.tmdb`(256;大内存 1024) | 设置 `cache.tmdbMaxSize`(缺省 256) |
+| `maxsize = CONF.tmdb`(256;大内存 1024) | 设置 `cache.tmdbMaxSize`(缺省 5000,存 PG 不受进程内存约束) |
| `ttl = CONF.meta = META_CACHE_EXPIRE 小时`(缺省 24) | CACHE-2 起按请求类别取设置:`/search/`、`/find/` 用 `storage.cacheTtl.tmdbSearchDays`(7 天),其余用 `tmdbDetailDays`(30 天) |
| `empty_ttl = EMPTY_RESULT_CACHE_TTL`(1800 秒),`empty_if = results 为空列表` | `storage.cacheTtl.tmdbMissDays`(1 天);`isEmptyResult` 同判定 |
| `skip_none`(连接失败不缓存)、`skip_if = success:false`(业务失败不缓存) | 这两类在 `fetchWithRetry` 抛错,不会写缓存 |
diff --git a/docs/runbook/cache.md b/docs/runbook/cache.md
index c6f2c610..176bd935 100644
--- a/docs/runbook/cache.md
+++ b/docs/runbook/cache.md
@@ -31,7 +31,7 @@
TTL 设置(CACHE-2 起):`settings.storage.cacheTtl`,见 [storage.md](storage.md)。MP 的 `META_CACHE_EXPIRE` 只有一个值,
我们按请求类别拆成搜索 / 详情两档,`EMPTY_RESULT_CACHE_TTL` 对应 `tmdbMissDays`;缓存语义不变。
-条数上限仍是 `cache.tmdbMaxSize`(缺省 256,MP `CONF.tmdb`)。旧字段 `cache.tmdbTtlHours`、`cache.emptyResultTtlSec` 不再读取。
+条数上限仍是 `cache.tmdbMaxSize`(缺省 5000;MP `CONF.tmdb` 为 256,本项目缓存在 PG 里不占进程内存)。旧字段 `cache.tmdbTtlHours`、`cache.emptyResultTtlSec` 不再读取。
API:`GET /api/v1/cache/tmdb?kind=recognize|request&limit=` 列出条目(键 / 过期 / 大小,识别条目带 tmdbId / 标题 / 海报);
`POST /api/v1/cache/tmdb/clear` 清空两类(仅 admin)。
@@ -63,5 +63,5 @@ API:`GET /api/v1/cache/tmdb?kind=recognize|request&limit=` 列出条目(键 /
- TMDB 缓存不生效:确认 `select region, count(*) from kv_cache group by 1` 有 `tmdb.request`;PG 读写失败时按未命中处理并记
`TMDB 缓存读写失败,按未命中处理`。
- 改了 TMDB 缓存设置:新写入的条目按新 TTL / 上限;已有条目保持原过期时间,必要时清空。
-- TTL 拉长后条目更早被条数上限挤掉:`cache.tmdbMaxSize` 缺省只有 256,追踪作品多时调大。
+- TTL 拉长后条目更早被条数上限挤掉:`cache.tmdbMaxSize` 缺省 5000,追踪作品很多时再调大。
- 数据库重启后缓存为空:UNLOGGED 表的正常行为。
diff --git a/docs/runbook/storage.md b/docs/runbook/storage.md
index fa79de6f..5b0ca3a7 100644
--- a/docs/runbook/storage.md
+++ b/docs/runbook/storage.md
@@ -34,7 +34,7 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}}
被取代的旧字段(保留在 schema 里兼容已存 JSON,服务端不再读取,已列入 `DEPRECATED_SETTINGS_FIELDS`):
`cache.tmdbTtlHours` → `cacheTtl.tmdbSearchDays / tmdbDetailDays`,`cache.emptyResultTtlSec` → `cacheTtl.tmdbMissDays`,
`identity.poolArchiveDays` → `retentionDays.identityPoolArchive`。设置页「元数据」里这三项接 UI 时要换成新字段。
-`cache.tmdbMaxSize`(条数上限,缺省 256)仍然有效。TTL 拉长后条数上限会先起作用,需要时调大。
+`cache.tmdbMaxSize`(条数上限,缺省 5000)仍然有效。TTL 拉长后条数上限会先起作用,需要时调大。
## 缓存有效期
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 1d71866a..173e034f 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -29,7 +29,7 @@ const CacheShape = z.object({
* 保留在 schema 里只为兼容已存的 JSON 与旧设置页。
*/
tmdbTtlHours: z.number().int().min(1).max(720),
- /** TMDB 缓存每类条数上限,超出删最旧(对照 MP CONF.tmdb,缺省 256;大内存模式 1024) */
+ /** 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),
@@ -566,7 +566,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
checkIntervalSec: 60,
cleanupTargetPercent: 80,
tmdbTtlHours: 24,
- tmdbMaxSize: 256,
+ tmdbMaxSize: 5000,
emptyResultTtlSec: 1800,
globalImageCache: false,
globalImageCacheDays: 7,
diff --git a/packages/db/migrations/0051_tmdb_cache_size.ts b/packages/db/migrations/0051_tmdb_cache_size.ts
new file mode 100644
index 00000000..5bb52f83
--- /dev/null
+++ b/packages/db/migrations/0051_tmdb_cache_size.ts
@@ -0,0 +1,23 @@
+import type { Kysely } from "kysely";
+import { execDdl } from "../src/migration-helpers.ts";
+
+// INT-3:cache.tmdbMaxSize 缺省 256 → 5000(CACHE-2 把 TTL 拉长到天级后,256 条先被挤掉)。
+// settings 表里整段存 cache 对象,已保存过设置的库仍是旧缺省 256:只改仍等于旧缺省的值,用户改过的不动。
+
+// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
+export async function up(db: Kysely): Promise {
+ await execDdl(
+ db,
+ `update settings set value = jsonb_set(value, '{tmdbMaxSize}', '5000'::jsonb), updated_at = now()
+ where key = 'cache' and value->'tmdbMaxSize' = '256'::jsonb;`,
+ );
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: 同上
+export async function down(db: Kysely): Promise {
+ await execDdl(
+ db,
+ `update settings set value = jsonb_set(value, '{tmdbMaxSize}', '256'::jsonb), updated_at = now()
+ where key = 'cache' and value->'tmdbMaxSize' = '5000'::jsonb;`,
+ );
+}