feat(metadata): 作品进入关注时预热元数据缓存

- 新任务 prewarmMetadata(网络道):拉基础详情、富详情(含标志)、各季详情、选中的剧集组,
  海报 / 背景 / 标志原图进图片缓存;请求键与刷新元数据 / 刮削相同,之后直接命中且 pinned
- enqueueWatchedRefresh(建订阅、用户搜索 / 手动识别、提交下载)每次额外入队预热,幂等键按作品 + 日期

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 19:37:34 +08:00
1 parent 680419bb64
commit 1535d8664e
7 files changed
+291 -5

No files matched your search

@@ -0,0 +1,122 @@
import { join } from "node:path";
import { fetchEpisodeGroup, fetchRichDetail, fetchRichSeason, TmdbError } from "@umt/adapters";
import { dataPaths } from "../../../config.ts";
import { externalFetch } from "../../../egress.ts";
import { resetTrackedTmdbRefs } from "../../../shared-cache.ts";
import { ImageCache } from "../../cache/images.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { entityEpisodeGroup } from "./episode-structure.ts";
import { metadataIncludeSpecials, metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
// 关注作品预热(统一元数据缓存,docs/runbook/cache.md):作品进入关注时(建订阅、用户搜索 / 手动识别、开始下载)
// 把之后刷新 / 刮削会用到的 TMDB 条目先拉进缓存:详情(含标志)、各季详情、选中的剧集组,海报 / 背景 / 标志图片进图片缓存。
// 请求键与刷新元数据 / 刮削完全相同(同一套 fetchRichDetail / fetchRichSeason / fetchEpisodeGroup,同样的语言链),
// 所以后面直接命中;关注中作品的条目写入时 pinned(条数上限淘汰不清掉),命中时续期。
/** 一部作品最多预热多少季(长寿剧) */
const MAX_SEASONS = 60;
export interface PrewarmResult {
status: "warmed" | "skipped";
reason?: string;
seasons: number;
episodeGroup: boolean;
images: number;
errors: string[];
}
/** 图片预热用的 fetch:测试注入优先;测试进程没注入就不出外网 */
function imageFetch(rt: SourcesRuntime): typeof globalThis.fetch | null {
if (rt.overrides.fetch) return rt.overrides.fetch;
return Deno.env.get("VITEST") ? null : externalFetch;
}
export async function prewarmMetadata(rt: SourcesRuntime, mediaEntityId: string): Promise<PrewarmResult> {
const { ctx } = rt;
const out: PrewarmResult = { status: "skipped", seasons: 0, episodeGroup: false, images: 0, errors: [] };
const ident = await ctx.db
.selectFrom("external_identity as x")
.innerJoin("media_entity as e", "e.id", "x.media_entity_id")
.select(["x.entity_type", "x.external_id", "e.status"])
.where("x.media_entity_id", "=", mediaEntityId)
.where("x.provider", "=", "tmdb")
.where("x.status", "=", "accepted")
.executeTakeFirst();
if (!ident || ident.status === "merged") return { ...out, reason: ident ? "merged" : "no_tmdb_identity" };
if (ident.entity_type !== "movie" && ident.entity_type !== "tv") return { ...out, reason: "unsupported_type" };
const tmdb = await rt.tmdb();
if (!tmdb) return { ...out, reason: "tmdb_not_configured" };
// 刚进入关注:关注集合按进程缓存 5 分钟,清掉让这次写入就标 pinned
resetTrackedTmdbRefs(ctx);
const type = ident.entity_type;
const id = ident.external_id;
const langs = await metadataLanguages(ctx);
const textOpts = await metadataTextOptions(ctx);
const note = (what: string, e: unknown) => out.errors.push(`${what}: ${(e as Error).message}`);
// 刷新元数据的基础详情与富详情(含标志)
await tmdb.details(type, id).catch((e) => note("details", e));
// 注入的简化客户端(测试)没有 getJson:只预热基础详情
if (typeof tmdb.getJson !== "function") return { ...out, status: "warmed" };
let rich: Awaited<ReturnType<typeof fetchRichDetail>> | null = null;
try {
rich = await fetchRichDetail(tmdb, type, id, langs, textOpts);
} catch (e) {
// 详情都拿不到(404 / 鉴权 / 网络):网络类交给任务重试,其余记下结束
if (e instanceof TmdbError && ["network", "timeout", "rate"].includes(e.kind)) throw e;
note("detail", e);
return { ...out, reason: "detail_unavailable" };
}
out.status = "warmed";
if (type === "tv") {
const specials = await metadataIncludeSpecials(ctx);
const seasons = rich.seasons
.map((s) => s.seasonNumber)
.filter((n) => specials || n > 0)
.slice(0, MAX_SEASONS);
for (const n of seasons) {
try {
await fetchRichSeason(tmdb, id, n, langs, {
now: rt.now(),
...textOpts,
originalLanguage: rich.originalLanguage,
});
out.seasons++;
} catch (e) {
note(`season ${n}`, e);
}
}
const group = await entityEpisodeGroup(ctx.db, mediaEntityId);
if (group) {
try {
await fetchEpisodeGroup(tmdb, group.id, langs[0]);
out.episodeGroup = true;
} catch (e) {
note("episode group", e);
}
}
}
// 图片:刮削取原图(original),这里预先拉进图片缓存目录
const f = imageFetch(rt);
if (f) {
const images = new ImageCache({
dir: join(dataPaths(ctx.config).dataDir, "cache", "images"),
fetch: f,
timeoutMs: 30_000,
});
for (const p of [rich.posterPath, rich.backdropPath, rich.logoPath]) {
if (!p) continue;
try {
await images.bytes("original", p);
out.images++;
} catch (e) {
note(`image ${p}`, e);
}
}
}
if (out.errors.length)
ctx.logger.debug({ entityId: mediaEntityId, errors: out.errors }, "关注作品预热有部分条目没拉到");
return out;
}
@@ -62,9 +62,12 @@ export async function isEntityWatched(exec: Exec, entityId: string): Promise<boo
}
/**
* 作品进入关注范围(建订阅、用户搜索、开始下载)时补排一次 full 元数据刷新:只对从没刷新过的作品排
* (自动建档时因不在关注范围而没排的),刷新完成会重算待确认 / 滞后放行资源的集数(必要时开集数待确认)。
* 入队失败只记日志;同一作品同一原因只排一次(幂等键)。
* 作品进入关注范围(建订阅、用户搜索 / 手动识别、开始下载)时:
* - 补排一次 full 元数据刷新:只对从没刷新过的作品排(自动建档时因不在关注范围而没排的),
* 刷新完成会重算待确认 / 滞后放行资源的集数(必要时开集数待确认);同一作品只排一次(幂等键);
* - 每次都排一次缓存预热(prewarmMetadata:详情、各季、剧集组、海报 / 背景 / 标志进缓存),幂等键按作品 + 日期,
* 同一天重复进入只跑一次;条目已在缓存里时预热只是命中,不发请求。
* 入队失败只记日志。返回是否排了刷新。
*/
export async function enqueueWatchedRefresh(ctx: ServerContext, entityId: string, reason: string): Promise<boolean> {
const e = await ctx.db
@@ -72,7 +75,9 @@ export async function enqueueWatchedRefresh(ctx: ServerContext, entityId: string
.select(["id", "last_metadata_refresh_at", "status"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e || e.last_metadata_refresh_at || e.status === "merged") return false;
if (!e || e.status === "merged") return false;
await enqueuePrewarm(ctx, entityId, reason);
if (e.last_metadata_refresh_at) return false;
try {
await ctx.engine.enqueue(
"refreshMetadata",
@@ -88,3 +93,22 @@ export async function enqueueWatchedRefresh(ctx: ServerContext, entityId: string
return false;
}
}
/** 关注作品预热入队(幂等键按作品 + 日期);失败只记日志 */
export async function enqueuePrewarm(ctx: ServerContext, entityId: string, reason: string): Promise<boolean> {
const day = new Date().toISOString().slice(0, 10);
try {
const r = await ctx.engine.enqueue(
"prewarmMetadata",
{ mediaEntityId: entityId },
{ idempotencyKey: `prewarmMetadata:${entityId}:${day}`, traceId: entityId },
);
return !r.deduplicated;
} catch (err) {
ctx.logger.debug(
{ entityId, reason, err: { message: err instanceof Error ? err.message : String(err) } },
"关注作品预热入队失败",
);
return false;
}
}
@@ -6,6 +6,7 @@ import { refreshSiteAccount } from "./service/account.ts";
import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts";
import { ingestFeed } from "./service/feeds.ts";
import { refreshMetadata } from "./service/metadata.ts";
import { prewarmMetadata } from "./service/metadata-prewarm.ts";
import { resolveRelease } from "./service/resolve.ts";
import { snapshotSite } from "./service/snapshot.ts";
import { liveSearch } from "./service/workbench.ts";
@@ -77,6 +78,15 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
handler: (t) =>
t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full })),
}),
// 关注作品预热(统一元数据缓存):详情 / 各季 / 剧集组 / 图片先进缓存;网络道,走 TMDB 客户端限流
defineTask({
name: "prewarmMetadata",
version: 1,
lane: "network",
inputSchema: z.object({ mediaEntityId: id }),
retryPolicy: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2, maximumInterval: "60s" },
handler: (t) => t.step.run("prewarm", () => prewarmMetadata(rt(), t.input.mediaEntityId)),
}),
defineTask({
name: "searchSubscription",
version: 1,