feat(web): 设置页按新分类重排,加搜索、缓存分区与旧锚点跳转
- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊 - 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦 - 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置 - 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数) - 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面 - 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示 - 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入 - 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
93a6c8a910
commit
8071ad33f9
23 files changed
+1499
-642
No files matched your search
@@ -1,16 +1,9 @@
|
||||
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";
|
||||
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 元数据:TMDB API Key(只写,存 secret)、Base URL、语言回退链
|
||||
// 元数据:TMDB 接入(API Key 只写,存 secret;Base URL)与语言回退、特别篇。缓存在「缓存」分区,资源识别在「获取」分区。
|
||||
|
||||
const DEFAULT_LANGUAGES = DEFAULT_SETTINGS.metadata.languages;
|
||||
|
||||
@@ -28,7 +21,7 @@ function parseLanguages(v: string): string[] {
|
||||
return out.length ? [...new Set(out)].slice(0, 8) : [...DEFAULT_LANGUAGES];
|
||||
}
|
||||
|
||||
const TMDB_CARD: CardSpec = {
|
||||
export const TMDB_CARD: CardSpec = {
|
||||
anchor: "tmdb",
|
||||
title: "TMDB",
|
||||
description: "留空时用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。",
|
||||
@@ -49,6 +42,28 @@ const TMDB_CARD: CardSpec = {
|
||||
hint: "自建反代时填写;留空用官方地址",
|
||||
wide: true,
|
||||
},
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"metadata.tmdbApiKey": "",
|
||||
"metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
|
||||
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
|
||||
}),
|
||||
toPatch: (v) => {
|
||||
const key = secretPatch(v["metadata.tmdbApiKey"]);
|
||||
return {
|
||||
metadata: {
|
||||
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
|
||||
...(key !== undefined ? { tmdbApiKey: key } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const LANGUAGE_CARD: CardSpec = {
|
||||
anchor: "language",
|
||||
title: "语言与季集",
|
||||
description: "作品、季、集的标题和简介按语言回退链取值;特别篇决定是否拉取季 0。",
|
||||
fields: [
|
||||
{
|
||||
name: "metadata.languages",
|
||||
label: "语言回退链",
|
||||
@@ -73,254 +88,26 @@ const TMDB_CARD: CardSpec = {
|
||||
},
|
||||
],
|
||||
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"]),
|
||||
metadata: {
|
||||
languages: parseLanguages(str(v["metadata.languages"])),
|
||||
includeSpecials: v["metadata.includeSpecials"] === true,
|
||||
overviewNonChineseFallback: v["metadata.overviewNonChineseFallback"] === true,
|
||||
},
|
||||
}),
|
||||
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: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "identity.detailIdsPerSiteHour",
|
||||
label: "识别时每站每小时最多抓详情页",
|
||||
kind: "number",
|
||||
hint: "标题认不准时,抓站点详情页取 IMDb、TMDB 或豆瓣 ID 再识别;只在搜索、订阅相关资源、补全和手动重新识别时抓,RSS 入库不抓。0 = 不抓",
|
||||
},
|
||||
{
|
||||
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,
|
||||
"identity.detailIdsPerSiteHour": n(s.identity.detailIdsPerSiteHour ?? 10),
|
||||
}),
|
||||
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,
|
||||
detailIdsPerSiteHour: Number(v["identity.detailIdsPerSiteHour"]),
|
||||
},
|
||||
}),
|
||||
extra: () => <EnrichStatsTable />,
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="mb-3 space-y-2">
|
||||
<p className="text-meta text-muted-foreground tabular-nums">
|
||||
{d
|
||||
? `最近 ${d.days} 天元数据补全 ${d.total} 次,出了建议 ${d.suggested} 次${d.tracked < d.total ? `;有来源明细的 ${d.tracked} 次` : ""}`
|
||||
: "读取补全命中率…"}
|
||||
</p>
|
||||
{d && d.tracked > 0 ? (
|
||||
<DataTable<StatRow>
|
||||
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) => <span className="tabular-nums">{r.withResults}</span>,
|
||||
},
|
||||
{
|
||||
key: "hits",
|
||||
header: "命中",
|
||||
align: "right",
|
||||
cell: (r) => <span className="tabular-nums">{r.hits}</span>,
|
||||
},
|
||||
{
|
||||
key: "rate",
|
||||
header: "命中率",
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<span className="tabular-nums">{r.rate === null ? "—" : `${Math.round(r.rate * 100)}%`}</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
<SettingsCard section="metadata" spec={LANGUAGE_CARD} settings={s} />
|
||||
</>
|
||||
)}
|
||||
</SettingsGate>
|
||||
|
||||
Reference in new issue
Block a user