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 (