- metadata.enrich (propose, low, replay safe): normalized-title cache (kv_cache
region enrich, 7 days) -> daily budget -> web.search (title + 短剧 / 电视剧 平台,
second query limited to whitelist via site:) -> web.fetch of up to 3 relevant
whitelisted pages (registered site domains never read, redirects out of the
whitelist dropped) -> upstream model extraction with rule fallback -> server-side
evidence check (rule_confirmed / multi_evidence / needs_review). Suggestion only.
- POST /identity-pool/{id}/enrich (suggest) and /enrich/apply (console session):
provisional entity or same-title merge, season/episode units, field-level
metadata with source web/agent + evidence, aliases, release assignment.
Poster proxy GET /img/enrich/{id} (og:image from whitelisted pages only).
- Auto-enrich once per pool entry when subscription/rule lookups find nothing on
TMDB (identity.autoEnrich, off by default); identity.enrichDailyBudget,
identity.enrichDomains settings; web.fetch now returns og:image.
- Pool sheet "元数据补全" section; entity detail shows evidence links.
- Migration 0049_enrichment (metadata_enrichment, metadata_field.evidence, source
web). Requires image rebuild / api + worker restart to take effect.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
259 lines
9.1 KiB
TypeScript
259 lines
9.1 KiB
TypeScript
import { toast } from "sonner";
|
||
import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
|
||
import { useUpdateSettings } from "~/api/settings.ts";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { str } from "./resource-section.tsx";
|
||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||
|
||
// 元数据:TMDB API Key(只写,存 secret)与 Base URL
|
||
|
||
const TMDB_CARD: CardSpec = {
|
||
anchor: "tmdb",
|
||
title: "TMDB",
|
||
description: "留空时用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
|
||
fields: [
|
||
{
|
||
name: "metadata.tmdbApiKey",
|
||
label: "API Key",
|
||
kind: "password",
|
||
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
|
||
wide: true,
|
||
},
|
||
{
|
||
name: "metadata.tmdbBaseUrl",
|
||
label: "Base URL",
|
||
kind: "url",
|
||
placeholder: "https://api.themoviedb.org",
|
||
hint: "自建反代时填写;留空用官方地址",
|
||
wide: true,
|
||
},
|
||
],
|
||
fromSettings: (s) => ({ "metadata.tmdbApiKey": "", "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "" }),
|
||
toPatch: (v) => ({
|
||
metadata: {
|
||
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
|
||
...(str(v["metadata.tmdbApiKey"]) ? { tmdbApiKey: str(v["metadata.tmdbApiKey"]) } : {}),
|
||
},
|
||
}),
|
||
extra: (s) => <TmdbKeyStatus saved={!!s.metadata.tmdbApiKeySecretRef} />,
|
||
};
|
||
|
||
function TmdbKeyStatus({ saved }: { saved: boolean }) {
|
||
const update = useUpdateSettings();
|
||
return (
|
||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||
<StatusBadge tone={saved ? "success" : "neutral"}>{saved ? "API Key 已保存" : "未保存 API Key"}</StatusBadge>
|
||
{saved ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="xs"
|
||
className="hover:text-destructive"
|
||
disabled={update.isPending}
|
||
onClick={async () => {
|
||
try {
|
||
await update.mutateAsync({ metadata: { tmdbApiKey: null } });
|
||
toast.success("TMDB API Key 已清除");
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "清除失败"));
|
||
}
|
||
}}
|
||
>
|
||
清除
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// TMDB 缓存(对照 MP:META_CACHE_EXPIRE / CONF.tmdb / EMPTY_RESULT_CACHE_TTL)
|
||
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: "搜索无结果、未识别时的短缓存",
|
||
},
|
||
],
|
||
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: () => <TmdbCacheStatus />,
|
||
};
|
||
|
||
function TmdbCacheStatus() {
|
||
const q = useTmdbCache();
|
||
const clear = useClearTmdbCache();
|
||
const c = q.data?.counts;
|
||
return (
|
||
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
|
||
<span className="tabular-nums">
|
||
{c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="xs"
|
||
className="hover:text-destructive"
|
||
disabled={clear.isPending}
|
||
onClick={async () => {
|
||
try {
|
||
const r = await clear.mutateAsync();
|
||
toast.success(`已清空 ${r.deleted} 条 TMDB 缓存`);
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "清空失败"));
|
||
}
|
||
}}
|
||
>
|
||
清空
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 图片代理与缓存(对照 MP GLOBAL_IMAGE_CACHE / GLOBAL_IMAGE_CACHE_DAYS)
|
||
const IMAGES_CARD: CardSpec = {
|
||
anchor: "images",
|
||
title: "图片代理与缓存",
|
||
description: "海报经服务端从 TMDB 取回。开启缓存后保存到数据目录,下次直接读取。",
|
||
fields: [
|
||
{ name: "cache.globalImageCache", label: "缓存图片", kind: "switch" },
|
||
{ name: "cache.globalImageCacheDays", label: "保留天数", kind: "number", hint: "超过天数的图片每天清理一次" },
|
||
],
|
||
fromSettings: (s) => ({
|
||
"cache.globalImageCache": s.cache.globalImageCache,
|
||
"cache.globalImageCacheDays": n(s.cache.globalImageCacheDays),
|
||
}),
|
||
toPatch: (v) => ({
|
||
cache: {
|
||
globalImageCache: v["cache.globalImageCache"] === true,
|
||
globalImageCacheDays: Number(v["cache.globalImageCacheDays"]),
|
||
},
|
||
}),
|
||
extra: () => <ImageCacheStatus />,
|
||
};
|
||
|
||
function ImageCacheStatus() {
|
||
const q = useImageCache();
|
||
const clear = useClearImageCache();
|
||
const d = q.data;
|
||
return (
|
||
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
|
||
<span className="tabular-nums">
|
||
{d ? `${d.files} 张 · ${(d.usedBytes / 1024 / 1024).toFixed(1)} MiB` : "读取中…"}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="xs"
|
||
className="hover:text-destructive"
|
||
disabled={clear.isPending}
|
||
onClick={async () => {
|
||
try {
|
||
const r = await clear.mutateAsync();
|
||
toast.success(`已删除 ${r.deleted} 张缓存图片`);
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "清空失败"));
|
||
}
|
||
}}
|
||
>
|
||
清空
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)
|
||
const IDENTITY_CARD: CardSpec = {
|
||
anchor: "identity",
|
||
title: "资源识别",
|
||
description:
|
||
"缺省只在资源命中订阅或本地作品别名、新作品收录规则前置条件通过、你手动识别或搜索时查 TMDB;其余进待识别池,不进待处理。",
|
||
fields: [
|
||
{
|
||
name: "identity.eagerTmdbLookup",
|
||
label: "每条新资源都查 TMDB",
|
||
kind: "switch",
|
||
hint: "旧行为。每条资源约 1–2 次搜索 + 命中时 1 次详情(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到",
|
||
wide: true,
|
||
},
|
||
{
|
||
name: "identity.poolArchiveDays",
|
||
label: "待识别池归档(天)",
|
||
kind: "number",
|
||
hint: "未命中订阅的条目超过这么多天没有新证据就归档,仍可筛出并手动指定",
|
||
},
|
||
{
|
||
name: "identity.autoEnrich",
|
||
label: "TMDB 查不到时自动补全元数据",
|
||
kind: "switch",
|
||
hint: "资源命中订阅或新作品收录规则、TMDB 却没有结果时,自动搜索网页出一份补全建议(每条资源一次,不建作品)。每次约 1–2 次网页搜索、最多读 3 个网页、1 次 Agent 上游调用",
|
||
wide: true,
|
||
},
|
||
{
|
||
name: "identity.enrichDailyBudget",
|
||
label: "元数据补全每日上限",
|
||
kind: "number",
|
||
hint: "手动和自动合计;7 天内同一标题直接用上次结果,不计次。0 = 停用",
|
||
},
|
||
{
|
||
name: "identity.enrichDomains",
|
||
label: "补全可读取的网站",
|
||
kind: "textarea",
|
||
hint: "每行一个域名,含子域名;只放平台和百科类网站。已登记站点的域名一律不读",
|
||
wide: true,
|
||
},
|
||
],
|
||
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"),
|
||
}),
|
||
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"] ?? "")
|
||
.split(/[\s,,]+/)
|
||
.map((d) => d.trim().toLowerCase())
|
||
.filter(Boolean),
|
||
},
|
||
}),
|
||
};
|
||
|
||
export function MetadataSection() {
|
||
return (
|
||
<SettingsGate>
|
||
{(s) => (
|
||
<>
|
||
<SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />
|
||
<SettingsCard section="metadata" spec={TMDB_CACHE_CARD} settings={s} />
|
||
<SettingsCard section="metadata" spec={IDENTITY_CARD} settings={s} />
|
||
<SettingsCard section="metadata" spec={IMAGES_CARD} settings={s} />
|
||
</>
|
||
)}
|
||
</SettingsGate>
|
||
);
|
||
}
|