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

@@ -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}