import { DEFAULT_SETTINGS } from "@umt/contracts"; import { toast } from "sonner"; import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts"; import { useEnrichStats } from "~/api/identity.ts"; import { DataTable } from "~/components/console/data-table.tsx"; import { ENRICH_SOURCE_KIND } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { secretPatch } from "~/lib/secret.ts"; import { str } from "./resource-section.tsx"; import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx"; // 元数据:TMDB API Key(只写,存 secret)、Base URL、语言回退链 const DEFAULT_LANGUAGES = DEFAULT_SETTINGS.metadata.languages; /** "zh-CN, zh_sg en" → ["zh-CN","zh-SG","en"];不合法的项丢弃,全空回缺省 */ function parseLanguages(v: string): string[] { const out = v .split(/[,,、\s]+/) .map((x) => x.trim().replace("_", "-")) .filter(Boolean) .map((x) => { const [l, r] = x.split("-"); return r ? `${l!.toLowerCase()}-${r.toUpperCase()}` : l!.toLowerCase(); }) .filter((x) => /^[a-z]{2,3}(-[A-Z]{2})?$/.test(x)); return out.length ? [...new Set(out)].slice(0, 8) : [...DEFAULT_LANGUAGES]; } 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: "secret", secret: { set: (v) => v["metadata.tmdbApiKey:set"] === true, clearable: true }, 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, }, { name: "metadata.languages", label: "语言回退链", kind: "text", placeholder: DEFAULT_LANGUAGES.join(", "), hint: "标题和每集信息按顺序取第一个有值的语言,简介默认只取链里的中文;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG", wide: true, }, { name: "metadata.overviewNonChineseFallback", label: "简介回退到非中文", kind: "switch", hint: "没有中文简介时,按回退链取英文等语言。关闭时留空,标题不受影响", wide: true, }, { name: "metadata.includeSpecials", label: "包含特别篇(季 0)", kind: "switch", hint: "全量刷新和元数据回刷时,TMDB 有季 0 就一并拉取每集信息。特别篇不计入正片季的总集数和订阅完成判定", wide: true, }, ], fromSettings: (s) => ({ "metadata.tmdbApiKey": "", "metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef, "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "", "metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "), "metadata.includeSpecials": s.metadata.includeSpecials !== false, "metadata.overviewNonChineseFallback": s.metadata.overviewNonChineseFallback === true, }), toPatch: (v) => { const key = secretPatch(v["metadata.tmdbApiKey"]); return { metadata: { tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null, languages: parseLanguages(str(v["metadata.languages"])), includeSpecials: v["metadata.includeSpecials"] === true, overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === true, ...(key !== undefined ? { tmdbApiKey: key } : {}), }, }; }, }; // 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.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" }], fromSettings: (s) => ({ "cache.tmdbMaxSize": n(s.cache.tmdbMaxSize), }), toPatch: (v) => ({ cache: { tmdbMaxSize: Number(v["cache.tmdbMaxSize"]), }, }), extra: () => , }; function TmdbCacheStatus() { const q = useTmdbCache(); const clear = useClearTmdbCache(); const c = q.data?.counts; return (
{c ? `识别 ${c.recognize} 条 · 请求 ${c.request} 条 · 上限各 ${q.data?.maxSize} 条` : "读取中…"}
); } // 图片代理与缓存(对照 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: () => , }; function ImageCacheStatus() { const q = useImageCache(); const clear = useClearImageCache(); const d = q.data; return (
{d ? `${d.files} 张 · ${(d.usedBytes / 1024 / 1024).toFixed(1)} MiB` : "读取中…"}
); } // 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别) const IDENTITY_CARD: CardSpec = { anchor: "identity", title: "资源识别", description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。", fields: [ { name: "identity.eagerTmdbLookup", label: "每条新资源都查 TMDB", kind: "switch", hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到", wide: true, }, { name: "identity.autoEnrich", label: "TMDB 查不到时自动补全元数据", kind: "switch", hint: "资源命中订阅或新作品规则、TMDB 却查不到时,搜索网页出一份补全建议(每条资源一次,不建作品)。每次最多读 3 个网页,调用一次模型服务", wide: true, }, { name: "identity.enrichDailyBudget", label: "元数据补全每日上限", kind: "number", hint: "手动和自动合计;7 天内同一标题直接用上次结果,不计次。0 = 停用", }, { name: "identity.enrichDomains", label: "补全可读取的网站", kind: "textarea", hint: "每行一个域名,含子域名;只放平台和百科类网站。豆瓣只用搜索摘要,不读页面;robots.txt 不允许的页面不读。已登记站点的域名一律不读", wide: true, }, { name: "identity.enrichSnippetAllSites", label: "也用其它网站的搜索摘要", kind: "switch", hint: "短剧多半只有聚合站收录。打开后,白名单以外网站的搜索摘要也用来抽年份、集数、主演、简介(只看搜索引擎给的摘要,不打开这些网页)", wide: true, }, ], fromSettings: (s) => ({ "identity.eagerTmdbLookup": s.identity.eagerTmdbLookup, "identity.autoEnrich": s.identity.autoEnrich, "identity.enrichDailyBudget": n(s.identity.enrichDailyBudget), "identity.enrichDomains": s.identity.enrichDomains.join("\n"), "identity.enrichSnippetAllSites": s.identity.enrichSnippetAllSites ?? true, }), toPatch: (v) => ({ identity: { eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true, autoEnrich: v["identity.autoEnrich"] === true, enrichDailyBudget: Number(v["identity.enrichDailyBudget"]), enrichDomains: String(v["identity.enrichDomains"] ?? "") .split(/[\s,,]+/) .map((d) => d.trim().toLowerCase()) .filter(Boolean), enrichSnippetAllSites: v["identity.enrichSnippetAllSites"] === true, }, }), extra: () => , }; type StatRow = { kind: string; withResults: number; hits: number; rate: number | null }; /** 最近 30 天补全命中率(按来源类别,META-2) */ function EnrichStatsTable() { const q = useEnrichStats(30); const d = q.data; return (

{d ? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}` : "读取补全命中率…"}

{d && d.tracked > 0 ? ( rows={d.sources} rowKey={(r) => r.kind} empty={{ title: "还没有补全记录" }} columns={[ { key: "kind", header: "来源", cell: (r) => ENRICH_SOURCE_KIND[r.kind] ?? r.kind }, { key: "withResults", header: "有结果", align: "right", cell: (r) => {r.withResults}, }, { key: "hits", header: "命中", align: "right", cell: (r) => {r.hits}, }, { key: "rate", header: "命中率", align: "right", cell: (r) => ( {r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`} ), }, ]} /> ) : null}
); } export function MetadataSection() { return ( {(s) => ( <> )} ); }