Files
UHD_Media_Tool/apps/web/app/components/settings/metadata.tsx
T
rootandClaude Opus 5.5 94fd48bff2 feat(identity): lazy TMDB lookup and bounded identity pool (DEC-1)
RSS/list releases that match no subscription or local work no longer hit
TMDB or open a pending item; they go to the identity pool as `unmatched`.
TMDB is queried only on a local alias hit, a published new-title rule whose
prefilter passes, manual/Agent retry, search results, or when the new
setting identity.eagerTmdbLookup (default off) is on. Mirrors MoviePilot:
torrents are recognised only when matched to a subscription or searched,
and recognition failures are logged, not alerted.

- Pending items only for related releases (ambiguous after lookup, or
  related to a local work but unresolved); same source + normalized title
  merge into one decision ("来源「X」的 N 条资源待识别"), resolve/dismiss
  applies to the whole group, members detach as they get resolved.
- Pool governance: unmatched reviews capped (daily, 3x), title_alias
  insert trigger wakes similar entries, stale unmatched entries archived
  after identity.poolArchiveDays (30); review idempotency keys include the
  due time.
- Migration 0047_identity_pool: reason/outcome `unmatched`,
  identity_candidate.normalized_title (+trgm index), wake trigger, and
  cleanup of open identity_unresolved items with no related work
  (dismissed, resolution=moved_to_pool).
- Inbox: group by type + work/source with collapse, hide info-only items
  by default (minSeverity=warning), badge counts warning+ only; pool sheet
  gets manual TMDB/local-work assign and dismiss; settings card for the
  new identity settings.
- Runbook docs/runbook/identity.md.

Requires rebuild/restart (migration runs on start).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:05:39 +08:00

230 lines
7.7 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: "未命中订阅的条目超过这么多天没有新证据就归档,仍可筛出并手动指定",
},
],
fromSettings: (s) => ({
"identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
"identity.poolArchiveDays": n(s.identity.poolArchiveDays),
}),
toPatch: (v) => ({
identity: {
eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
poolArchiveDays: Number(v["identity.poolArchiveDays"]),
},
}),
};
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>
);
}