feat(cache): TMDB image proxy, posters, cache admin API and settings cards

GET /img/tmdb/{size}/{file} ported from MP ImageHelper / fetch_image
(sanitized cache path under DATA_DIR/cache/images, GLOBAL_IMAGE_CACHE
read switch, md5 ETag / 304, day-based cleanup job). Cache module adds
cache.sweep / cache.images_clean jobs and /cache/tmdb, /cache/images
list and clear endpoints. Library list/detail and the search palette
show posters with the neutral placeholder as fallback; metadata settings
get TMDB cache and image cache cards. Runbook docs/runbook/cache.md and
porting notes docs/porting/moviepilot-tmdb-cache.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 22:26:29 +08:00
1 parent 946fca5e55
commit 7862573944
16 files changed
+1028 -9

No files matched your search

+27 -2
View File
@@ -1,6 +1,7 @@
import { tmdbImageUrl } from "@umt/contracts";
import type { LucideIcon } from "lucide-react";
import { Clapperboard, Film, Layers, Tv } from "lucide-react";
import type { ReactNode } from "react";
import { type ReactNode, useState } from "react";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { cn } from "~/lib/utils.ts";
@@ -117,19 +118,43 @@ export function ChipToggleGroup<T extends string>({
const KIND_ICON: Record<string, LucideIcon> = { movie: Film, series: Tv, variety: Clapperboard, collection: Layers };
/** 封面占位:无彩材质 + 类型图标 + 首字(不引入海报色彩) */
/** media_entity.metadata 里的最终海报路径(metadata.posterPath.value) */
export function posterPathOf(metadata: unknown): string | null {
const v = (metadata as { posterPath?: { value?: unknown } } | null)?.posterPath?.value;
return typeof v === "string" && v ? v : null;
}
/**
* 封面:有 TMDB 海报路径时经图片代理显示海报,加载失败或没有海报时回退无彩占位(类型图标 + 首字)。
*/
export function CoverPlaceholder({
title,
kind,
small = false,
className,
posterPath,
}: {
title: string;
kind: string;
/** 列表缩略:只显示首字,不叠类型图标 */
small?: boolean;
className?: string;
/** TMDB poster_path */
posterPath?: string | null | undefined;
}) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(posterPath, small ? "w154" : "w342");
if (src) {
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className={cn("shrink-0 rounded-lg border border-border bg-foreground/[0.05] object-cover", className)}
/>
);
}
const Icon = KIND_ICON[kind] ?? Film;
const glyph = title.replace(/^w32-/, "").trim().charAt(0) || "?";
return (
@@ -1,4 +1,4 @@
import type { LocalMediaHit, SearchAction, TmdbCandidate } from "@umt/contracts";
import { type LocalMediaHit, type SearchAction, type TmdbCandidate, tmdbImageUrl } from "@umt/contracts";
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
@@ -33,6 +33,8 @@ type Item = {
key: string;
group: string;
icon: LucideIcon;
/** TMDB 海报路径:有则以小海报代替图标(失败回退图标) */
posterPath?: string | null | undefined;
title: ReactNode;
meta?: ReactNode;
/** 右侧动作提示 */
@@ -216,7 +218,7 @@ function PaletteBody({ initial }: { initial: string }) {
i === active ? "bg-foreground/[0.06]" : "",
)}
>
<it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<ItemIcon it={it} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-foreground">{it.title}</div>
{it.meta ? <div className="truncate text-meta text-muted-foreground">{it.meta}</div> : null}
@@ -287,12 +289,30 @@ function localItem(h: LocalMediaHit, run: () => void): Item {
};
}
function ItemIcon({ it }: { it: Item }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(it.posterPath, "w92");
if (src) {
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="h-9 w-6 shrink-0 rounded-tight border border-border object-cover"
/>
);
}
return <it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />;
}
function tmdbItem(c: TmdbCandidate, view: () => void, subscribe: (c: TmdbCandidate) => void): Item {
const local = !!c.localEntityId;
return {
key: `tmdb-${c.entityType}-${c.externalId}`,
group: "TMDB",
icon: local ? Globe : Plus,
posterPath: c.posterPath,
title: (
<span>
{c.title}
+125 -2
View File
@@ -1,10 +1,11 @@
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, SettingsCard, SettingsGate } from "./settings-card.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 元数据:TMDB API Key(只写,存 secret)与 Base URL
@@ -67,6 +68,128 @@ function TmdbKeyStatus({ saved }: { saved: boolean }) {
);
}
// 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>
);
}
export function MetadataSection() {
return <SettingsGate>{(s) => <SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />}</SettingsGate>;
return (
<SettingsGate>
{(s) => (
<>
<SettingsCard section="metadata" spec={TMDB_CARD} settings={s} />
<SettingsCard section="metadata" spec={TMDB_CACHE_CARD} settings={s} />
<SettingsCard section="metadata" spec={IMAGES_CARD} settings={s} />
</>
)}
</SettingsGate>
);
}