Files
UHD_Media_Tool/apps/web/app/components/settings/metadata.tsx
T

289 lines
10 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 { DEFAULT_SETTINGS } from "@umt/contracts";
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 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: "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,
},
{
name: "metadata.languages",
label: "语言回退链",
kind: "text",
placeholder: DEFAULT_LANGUAGES.join(", "),
hint: "标题、简介和每集信息按顺序取第一个有值的语言;中文名取链中第一个中文值。逗号分隔,如 zh-CN, zh-SG",
wide: true,
},
],
fromSettings: (s) => ({
"metadata.tmdbApiKey": "",
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
}),
toPatch: (v) => ({
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
languages: parseLanguages(str(v["metadata.languages"])),
...(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>
);
}