Merge branch 'scr1'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	CHANGELOG.md
#	docs/plan/00-first-version-plan.md
This commit is contained in:
root committed 2026-09-30 22:36:46 +08:00
commit 2e83107f68
45 files changed
+2867 -41

No files matched your search

+6
View File
@@ -6,6 +6,12 @@
## [0.3.5] - 2026-09-30
### 新增(SCR-1 刮削:NFO 与图片写进媒体库目录)
- 整理入库成功后可自动刮削:按作品写 NFO(电影 `<视频名>.nfo`,剧集 `tvshow.nfo` / 季目录 `season.nfo` / 每集 `<视频名>.nfo`,UTF-8、临时文件 + 改名原子写入),下载海报 `poster.jpg`、背景图 `fanart.jpg`、季海报(作品目录 `season01-poster.jpg` + 季目录 `poster.jpg`)和剧照 `<视频名>-thumb.jpg`。Emby、Jellyfin、Kodi 都能识别。元素与 MP `TmdbScraper` 对照(差分见 `apps/server/test/scrape/`),缺值不写。
- 设置 → 媒体库与目录 →「刮削」:入库后自动刮削(缺省关,同 MP)、已有文件「只补缺失 / 全部覆盖」、下载图片、下载剧照。
- 作品详情新增「刮削」:上次刮削时间、写了哪些文件、失败原因,「重新刮削」「覆盖重写」;文件管理菜单与详情新增「刮削」(与「刷新元数据」分开)。三处都排同一个后台任务 `scrapeMedia`(文件道),图片经图片缓存与出口代理下载、并发 4,失败只记该文件,不影响 NFO。
- 接口:`POST /library/{id}/scrape`、`GET /library/{id}` 的 `scrape`;`POST /files/scrape` 的 `writeNfo` / `images` 不再返回 `not_implemented`(排 `scrapeMedia`,返回 `queued`);`POST /files/scrape-batch` 新增 `write`。迁移 `0057_media_scrape`。见 `docs/runbook/scrape.md`。
### 新增(CF-2 Cloudflare 验证处理)
- 站点请求遇 Cloudflare 挑战(`cf-mitigated: challenge`,或 403 / 503 / 429 的挑战页)不再算凭据错误:不置账号失效、不开「登录失效」事项、不排自动重登;503 挑战不再算维护;健康检测记「需要人机验证」。
- 过挑战级联(只处理有登录态账号的站点;同站 30 分钟最多一次、同一时刻只处理一次;不接打码服务):浏览器用该账号的 profile 打开同一地址 → 等它自己过(≤ 15 秒)→ 「我是人类」复选框 / Turnstile 由系统发起视觉子流程点一下(最多 2 次,预算取设置,自动化总开关关闭时跳过)→ 选图 / 输码类转人工,待处理事项「<站点>需要你在原站视图里过一次验证」,接管过完交还即继续。
@@ -0,0 +1,299 @@
import { chmod, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord, planImport } from "../services/organize.ts";
import { runScrape, SCRAPE_TASK } from "../services/scrape.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// SCR-1 刮削:入库成功后自动排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;图片失败不阻塞 NFO;
// 只登记库根不写;作品详情「重新刮削」接口与刮削状态。
const hasPg = await pgAvailable();
const SIZE = 200_000;
/** 最小 JPEG 头(按文件头认图) */
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]);
const at = new Date().toISOString();
const f = (value: unknown, language: string | null = null) => ({ value, source: "tmdb", language, at, locked: false });
async function tree(root: string): Promise<string[]> {
const out: string[] = [];
const walk = async (d: string) => {
for (const n of (await readdir(d)).sort()) {
const p = join(d, n);
if ((await stat(p)).isDirectory()) await walk(p);
else out.push(relative(root, p));
}
};
await walk(root);
return out.filter((p) => !p.startsWith(".")).sort();
}
describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
const fetched: string[] = [];
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-scrlib-" });
src = await createTempTree(
{
"Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv": SIZE,
"Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv": SIZE,
"Interstellar.2014.1080p.BluRay.x264-GRP.mkv": SIZE,
},
{ prefix: "umt-scrsrc-" },
);
h = await createHarness("scrape", {
async scrapeImage(p) {
fetched.push(p);
if (p === "/broken.jpg") throw new Error("TMDB 图片返回 HTTP 404");
return { content: JPEG, mime: "image/jpeg" };
},
});
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
await h.setSettings("organize", { scrapeAfterImport: true });
}, 60_000);
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
async function organize(entity: string, file: string) {
const plan = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
});
expect(plan.status).toBe("running");
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
return plan.recordId!;
}
/** 按引擎记录的入队项跑刮削(假引擎不执行) */
async function runQueued(entity: string) {
const q = h.engine.enqueued.filter(
(x) => x.task === SCRAPE_TASK && (x.input as { mediaEntityId: string }).mediaEntityId === entity,
);
expect(q.length).toBeGreaterThan(0);
return runScrape(h.d, q[q.length - 1]!.input as Parameters<typeof runScrape>[1]);
}
let series = "";
let movie = "";
it("剧集:入库后自动排刮削,写 tvshow / 季 / 集 NFO、海报、季海报、剧照", async () => {
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
await h.db
.updateTable("media_entity")
.set({
original_title: "Severance",
metadata: JSON.stringify({
title: f("人生切割术", "zh-CN"),
originalTitle: f("Severance", "en"),
year: f(2022),
releaseDate: f("2022-02-17"),
overview: f("马克领导着一个办公室团队。", "zh-CN"),
genres: f(["剧情", "悬疑"]),
voteAverage: f(8.4),
originCountry: f(["US"]),
platform: f("Apple TV+"),
posterPath: f("/sev.jpg"),
backdropPath: f("/broken.jpg"),
cast: f([
{ id: 4495, name: "亚当·斯科特", originalName: null, role: "Mark Scout", profilePath: "/adam.jpg" },
]),
crew: [],
externalIds: f({ imdb: "tt11280740", tvdb: "371980", wikidata: null }),
}),
})
.where("id", "=", series)
.execute();
await h.db
.insertInto("external_identity")
.values({
media_entity_id: series,
provider: "tmdb",
entity_type: "tv",
external_id: "95396",
status: "accepted",
})
.execute();
const season = await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
kind: "season",
season_number: 1,
status: "confirmed",
title: "第 1 季",
metadata: JSON.stringify({ name: f("第 1 季", "zh-CN"), airDate: f("2022-02-17"), posterPath: f("/s1.jpg") }),
})
.returning("id")
.executeTakeFirstOrThrow();
for (const [n, name, still] of [
[1, "有关地狱的好消息", "/e1.jpg"],
[2, "半循环", null],
] as const)
await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: n,
status: "confirmed",
title: name,
air_date: "2022-02-17",
metadata: JSON.stringify({
name: f(name, "zh-CN"),
overview: f(`第 ${n} 集简介`, "zh-CN"),
airDate: f("2022-02-17"),
runtime: f(55),
voteAverage: f(8),
...(still ? { stillPath: f(still) } : {}),
}),
})
.execute();
const rec = await organize(series, "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv");
await organize(series, "Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv");
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
expect(q[0]!.key).toBe(`${SCRAPE_TASK}:record:${rec}`);
expect(q[0]!.input).toMatchObject({ mediaEntityId: series, trigger: "auto", mode: "missing", recordId: rec });
const r = await runQueued(series);
expect(r.status).toBe("partial"); // 背景图下载失败
const files = await tree(lib.root);
const show = files.find((p) => p.endsWith("tvshow.nfo"))!;
const dir = show.slice(0, -"/tvshow.nfo".length);
const ep1 = files.find((p) => p.includes("S01E01") && p.endsWith(".mkv"))!;
const stem1 = ep1.slice(0, -4);
expect(files).toEqual(
expect.arrayContaining([
`${dir}/tvshow.nfo`,
`${dir}/poster.jpg`,
`${dir}/season01-poster.jpg`,
`${dir}/Season 1/season.nfo`,
`${dir}/Season 1/poster.jpg`,
`${stem1}.nfo`,
`${stem1}-thumb.jpg`,
]),
);
expect(files.some((p) => p.endsWith("fanart.jpg"))).toBe(false);
expect(files.filter((p) => p.endsWith(".nfo"))).toHaveLength(4);
expect(files.some((p) => p.includes(".umt-scrape.tmp"))).toBe(false);
const nfo = await readFile(join(lib.root, show), "utf8");
expect(nfo.startsWith('<?xml version="1.0" encoding="utf-8"?>\n<tvshow>')).toBe(true);
expect(nfo).toContain('<uniqueid type="imdb" default="true">tt11280740</uniqueid>');
expect(nfo).toContain("<country>美国</country>");
const epNfo = await readFile(join(lib.root, `${stem1}.nfo`), "utf8");
expect(epNfo).toContain("<title>有关地狱的好消息</title>");
expect(epNfo).toMatch(/<season>1<\/season>\n {2}<episode>1<\/episode>/);
// 同一张季海报只取一次
expect(fetched.filter((p) => p === "/s1.jpg")).toHaveLength(1);
const st = await h.api("GET", `/library/${series}`);
expect(st.status).toBe(200);
expect(st.body.data.scrape).toMatchObject({
status: "partial",
trigger: "auto",
failed: 1,
reason: "scrape_image_failed",
});
expect(st.body.data.scrape.files.find((x: { role: string }) => x.role === "fanart")).toMatchObject({
action: "failed",
reason: "scrape_image_failed",
});
const audit = await h.db
.selectFrom("audit_event")
.select(["action", "target_id"])
.where("action", "=", "organize.scrape")
.execute();
expect(audit.some((a) => a.target_id === series)).toBe(true);
}, 60_000);
it("只补缺失:已有文件(包括别人写的)不动;全部覆盖:改写", async () => {
const files = await tree(lib.root);
const show = join(lib.root, files.find((p) => p.endsWith("tvshow.nfo"))!);
await writeFile(show, "<tvshow>别的工具写的</tvshow>");
const again = await runScrape(h.d, { mediaEntityId: series, trigger: "manual", mode: "missing" });
expect(await readFile(show, "utf8")).toBe("<tvshow>别的工具写的</tvshow>");
expect(again.files.find((x) => x.role === "tvshow_nfo")).toMatchObject({
action: "skipped",
reason: "scrape_exists",
});
const over = await runScrape(h.d, { mediaEntityId: series, trigger: "manual", mode: "overwrite" });
expect(over.written).toBeGreaterThan(0);
expect(await readFile(show, "utf8")).toContain("<title>人生切割术</title>");
}, 60_000);
it("电影:<视频名>.nfo + 作品目录 poster / fanart;手动「重新刮削」接口排任务", async () => {
await h.setSettings("organize", { scrapeAfterImport: false });
movie = await seedEntity(h.db, { kind: "movie", title: "星际穿越", year: 2014 });
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
title: f("星际穿越", "zh-CN"),
overview: f("穿越虫洞。", "zh-CN"),
releaseDate: f("2014-11-05"),
runtime: f(169),
posterPath: f("/inter.jpg"),
backdropPath: f("/inter-bg.jpg"),
crew: f([{ id: 525, name: "克里斯托弗·诺兰", originalName: null, role: "Director", profilePath: null }]),
}),
})
.where("id", "=", movie)
.execute();
const before = h.engine.enqueued.length;
await organize(movie, "Interstellar.2014.1080p.BluRay.x264-GRP.mkv");
expect(h.engine.enqueued.slice(before).some((x) => x.task === SCRAPE_TASK)).toBe(false); // 关了自动刮削
const res = await h.api("POST", `/library/${movie}/scrape`, {});
expect(res.status).toBe(202);
expect(res.body.data).toMatchObject({ mediaEntityId: movie, deduplicated: false });
const dup = await h.api("POST", `/library/${movie}/scrape`, {});
expect(dup.body.data.deduplicated).toBe(true);
expect((await h.api("GET", `/library/${movie}`)).body.data.scrape.status).toBe("queued");
const r = await runQueued(movie);
expect(r.status).toBe("succeeded");
const files = await tree(lib.root);
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
const dir = video.slice(0, video.lastIndexOf("/"));
expect(files.filter((p) => p.startsWith(`${dir}/`))).toEqual(
[video, `${video.slice(0, -4)}.nfo`, `${dir}/fanart.jpg`, `${dir}/poster.jpg`].sort(),
);
const nfo = await readFile(join(lib.root, `${video.slice(0, -4)}.nfo`), "utf8");
expect(nfo).toContain('<director tmdbid="525">克里斯托弗·诺兰</director>');
expect(nfo).toContain("<runtime>169</runtime>");
}, 60_000);
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 409", async () => {
const files = await tree(lib.root);
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
const dirAbs = join(lib.root, video.slice(0, video.lastIndexOf("/")));
if (process.getuid?.() !== 0) {
await chmod(dirAbs, 0o555);
const r = await runScrape(h.d, { mediaEntityId: movie, trigger: "manual", mode: "overwrite" });
await chmod(dirAbs, 0o755);
expect(r.files.some((x) => x.reason === "scrape_write_failed")).toBe(true);
}
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).execute();
const ro = await runScrape(h.d, { mediaEntityId: movie, trigger: "manual", mode: "overwrite" });
expect(ro).toMatchObject({ status: "failed", reason: "scrape_root_read_only", written: 0 });
await h.db.updateTable("library_root").set({ management_mode: "managed" }).execute();
const empty = await seedEntity(h.db, { kind: "movie", title: "没入库", year: 2020 });
expect((await h.api("POST", `/library/${empty}/scrape`, {})).status).toBe(412);
const none = await runScrape(h.d, { mediaEntityId: empty, trigger: "manual" });
expect(none).toMatchObject({ status: "failed", reason: "scrape_no_metadata" });
}, 60_000);
});
@@ -5,6 +5,7 @@ import { type AoDeps, automationBlock, ensureTaskRow, markTask } from "./service
import { cleanupDownloadCache, submitDownload } from "./services/downloads.ts";
import { executeRecord, planImport } from "./services/organize.ts";
import { reconcileLibrary } from "./services/reconcile.ts";
import { runScrape, SCRAPE_TASK } from "./services/scrape.ts";
import { errMessage } from "./util.ts";
// 持久任务处理器。输入只放业务 id;业务状态写业务表;副作用经 withAttemptToken(在 services 里)。
@@ -187,6 +188,25 @@ export function registerAoTasks(registry: TaskRegistry, d: AoDeps) {
);
},
}),
// SCR-1 刮削:写 NFO 与图片到媒体库目录(文件道);结果写 media_scrape,不重试(失败的文件在作品详情里重新刮削)
defineTask({
name: SCRAPE_TASK,
version: 1,
lane: "file",
inputSchema: z.object({
mediaEntityId: id,
trigger: z.enum(["auto", "manual"]),
mode: z.enum(["missing", "overwrite"]).optional(),
recordId: id.optional(),
actor: z.object({ type: z.string(), id: z.string().nullable() }).optional(),
}),
retryPolicy: { maximumAttempts: 1 },
handler: async (t) =>
t.step.run("scrape", async () => {
const r = await runScrape(d, { ...t.input, runId: t.runId }, t.abortSignal);
return { status: r.status, written: r.written, skipped: r.skipped, failed: r.failed, reason: r.reason };
}),
}),
defineTask({
name: "reconcileLibrary",
version: 1,
@@ -1,3 +1,4 @@
import { join } from "node:path";
import {
createEmbyClient,
createQbClient,
@@ -18,6 +19,9 @@ import {
import type { ActorType } from "@umt/contracts";
import type { DownloaderInstanceRow, MediaServerInstanceRow, SiteRow, SourceReleaseRow } from "@umt/db";
import type { ParsedRelease } from "@umt/domain";
import { dataPaths } from "../../config.ts";
import { externalFetch } from "../../egress.ts";
import { ImageCache } from "../cache/images.ts";
import { SourcesRuntime } from "../sources-identity/runtime.ts";
import {
createEntityFromTmdbCandidate,
@@ -79,6 +83,11 @@ export interface AoPorts {
ref: { type: "movie" | "tv"; id: string },
actor: { type: string; id: string | null },
): Promise<string>;
/**
* SCR-1 刮削取图:TMDB 图片路径 → 字节(原图,经图片缓存 DATA_DIR/cache/images 与出口代理)。
* 失败抛错(由刮削记为该图片失败,不影响 NFO)。
*/
scrapeImage(tmdbPath: string): Promise<{ content: Uint8Array; mime: string }>;
options: AoOptions;
}
@@ -113,6 +122,7 @@ export function createPorts(
}
};
let images: ImageCache | null = null;
let sources: SourcesRuntime | null = null;
const rt = () => {
sources ??= new SourcesRuntime(ctx);
@@ -179,6 +189,14 @@ export function createPorts(
if (t.kind === "magnet") return { kind: "magnet", uri: t.uri };
return { kind: "url", url: t.url };
},
async scrapeImage(tmdbPath) {
images ??= new ImageCache({
dir: join(dataPaths(ctx.config).dataDir, "cache", "images"),
fetch: externalFetch,
timeoutMs: 30_000,
});
return images.bytes("original", tmdbPath);
},
diskUsage: (p) => diskUsage(p),
probe: (p) => ffprobe(p),
async mediaServer(inst) {
@@ -14,6 +14,8 @@ import {
type LibraryItem,
LibraryItem as LibraryItemSchema,
LibraryListQuery,
LibraryScrapeRequest,
LibraryScrapeResult,
type MediaAsset,
type MediaEntity,
MediaServerInventory,
@@ -25,6 +27,7 @@ import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { type AoDeps, CANDIDATE_ARTIFACT_TITLE } from "../services/common.ts";
import { enqueueScrape, loadScrapeState } from "../services/scrape.ts";
import { notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
import { registerLibraryVersionRoutes } from "./library-versions.ts";
@@ -404,12 +407,48 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
createdAt: new Date(r.created_at).toISOString(),
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
})),
scrape: await loadScrapeState(d, id),
};
return detail;
},
{ tag: TAG },
);
// SCR-1 刮削:作品详情「重新刮削」;与入库后自动刮削、文件管理「刮削」同一任务 scrapeMedia
mount(
app,
{
method: "post",
path: "/library/{id}/scrape",
scope: "organize.execute",
summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)",
params: IdParam,
body: LibraryScrapeRequest,
response: LibraryScrapeResult,
},
async (c, { params, body }) => {
const e = await db().selectFrom("media_entity").select(["id"]).where("id", "=", params.id).executeTakeFirst();
if (!e) notFound("作品");
const has = await db()
.selectFrom("media_asset")
.select(["id"])
.where("media_entity_id", "=", params.id)
.where("status", "=", "active")
.executeTakeFirst();
if (!has) throw new ApiException("precondition_failed", "作品没有已入库文件。先整理入库,再刮削");
const actor = c.get("actor");
const req = (body ?? {}) as LibraryScrapeRequest;
const r = await enqueueScrape(d, {
mediaEntityId: params.id,
trigger: "manual",
mode: req.mode,
actor: { type: actor?.type === "api_key" ? "api_key" : "user", id: actor?.id ?? null },
});
return { mediaEntityId: params.id, ...r };
},
{ tag: TAG, status: 202 },
);
mount(
app,
{
@@ -47,6 +47,7 @@ import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion
import { PathGuardError } from "./organize-guard.ts";
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
import { hnrSatisfied } from "./retention.ts";
import { scrapeAfterImport } from "./scrape.ts";
import { slotComplete as sharedSlotComplete, subscriptionLibraryRoot } from "./subscriptions.ts";
import {
CONFLICT_OPTIONS,
@@ -1710,6 +1711,9 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
return { recordId, status, succeeded: counts.file_succeeded, failed: counts.file_failed, move, dt: dt ?? null };
});
// SCR-1:有资产入库时按设置自动刮削(写 NFO 与图片;排任务,不在这里等)
if (result.status === "succeeded" || result.status === "partial")
await scrapeAfterImport(d, frozenPre.entityId ?? null, recordId);
// Move 完成:移除 qB 任务记录,结束下载侧管理(不删数据)
if (result.move && result.dt) {
const topo = (await loadInstances(db, { enabledOnly: false, ids: [result.dt.instance_id] }))[0];
@@ -0,0 +1,136 @@
import type { NfoEpisode, NfoIds, NfoPerson, NfoSeason, NfoWork } from "@umt/domain";
// SCR-1:本地作品 / 季 / 集的最终字段(media_entity.metadata、content_unit.metadata,字段级 {value, …})→ NFO 输入。
// 纯函数,不查库;差分测试(apps/server/test/scrape/nfo-mp-diff.test.ts)直接调用。
type Final = Record<string, { value?: unknown } | undefined>;
const val = (m: unknown, k: string): unknown => ((m ?? {}) as Final)[k]?.value;
const str = (v: unknown): string | null => (typeof v === "string" && v.trim() ? v.trim() : null);
const numOf = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const strs = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.trim().length > 0) : [];
interface Credit {
id?: unknown;
name?: unknown;
role?: unknown;
profilePath?: unknown;
}
const credits = (v: unknown): Credit[] => (Array.isArray(v) ? (v as Credit[]) : []);
const person = (c: Credit): NfoPerson | null => {
const name = str(c.name);
if (!name) return null;
return { tmdbId: numOf(c.id), name, role: str(c.role), profilePath: str(c.profilePath) };
};
/** 制片国:ISO 3166-1 代码 → 中文名(Intl);认不出的保留原码 */
export function countryNames(codes: readonly string[], locale = "zh-CN"): string[] {
let dn: Intl.DisplayNames | null = null;
try {
dn = new Intl.DisplayNames([locale], { type: "region" });
} catch {
dn = null;
}
return codes.map((c) => {
if (!/^[A-Z]{2}$/.test(c)) return c;
try {
return dn?.of(c) ?? c;
} catch {
return c;
}
});
}
export interface EntityForNfo {
kind: string;
title: string;
original_title: string | null;
year: number | null;
metadata: unknown;
}
/** 已接受的外部身份:provider → id(tmdb / imdb / tvdb / douban) */
export type IdentityMap = Partial<Record<"tmdb" | "imdb" | "tvdb" | "douban", string>>;
export function nfoWork(e: EntityForNfo, ids: IdentityMap): NfoWork {
const m = e.metadata;
const ext = (val(m, "externalIds") ?? {}) as { imdb?: unknown; tvdb?: unknown };
const nfoIds: NfoIds = {
tmdb: ids.tmdb ?? null,
imdb: ids.imdb ?? str(ext.imdb),
tvdb: ids.tvdb ?? str(ext.tvdb),
douban: ids.douban ?? null,
};
const crew = credits(val(m, "crew"));
const isTv = e.kind !== "movie";
return {
kind: isTv ? "tvshow" : "movie",
title: str(val(m, "title")) ?? e.title,
originalTitle: str(val(m, "originalTitle")) ?? e.original_title,
year: numOf(val(m, "year")) ?? e.year,
premiered: str(val(m, "releaseDate")),
overview: str(val(m, "overview")),
tagline: str(val(m, "tagline")),
rating: numOf(val(m, "voteAverage")),
genres: strs(val(m, "genres")),
countries: countryNames(strs(val(m, "originCountry"))),
studios: isTv
? (str(val(m, "platform")) ?? "")
.split(" / ")
.map((s) => s.trim())
.filter(Boolean)
: [],
runtime: isTv ? null : numOf(val(m, "runtime")),
directors: crew
.filter((c) => c.role === "Director")
.map(person)
.filter((p): p is NfoPerson => !!p),
actors: credits(val(m, "cast"))
.map(person)
.filter((p): p is NfoPerson => !!p),
ids: nfoIds,
};
}
export interface UnitForNfo {
season_number: number | null;
episode_number: number | null;
title: string | null;
air_date: string | null;
metadata: unknown;
}
export function nfoSeason(season: number, u: UnitForNfo | undefined): NfoSeason {
const m = u?.metadata;
return {
season,
title: str(val(m, "name")) ?? u?.title ?? null,
overview: str(val(m, "overview")),
airDate: str(val(m, "airDate")) ?? u?.air_date ?? null,
};
}
/** TMDB 占位集名(「第 N 集」「Episode N」)按没有标题处理,NFO 里由生成器补「第 N 集」 */
const PLACEHOLDER = /^(第\s*\d+\s*集|Episode\s+\d+)$/i;
export function nfoEpisode(season: number, episode: number, u: UnitForNfo | undefined): NfoEpisode {
const m = u?.metadata;
const title = str(val(m, "name")) ?? u?.title ?? null;
return {
season,
episode,
title: title && !PLACEHOLDER.test(title) ? title : null,
overview: str(val(m, "overview")),
airDate: str(val(m, "airDate")) ?? u?.air_date ?? null,
rating: numOf(val(m, "voteAverage")),
runtime: numOf(val(m, "runtime")),
};
}
/** 图片路径:作品海报 / 背景图,季海报,集剧照 */
export function imagePathsOf(entityMeta: unknown) {
return { posterPath: str(val(entityMeta, "posterPath")), backdropPath: str(val(entityMeta, "backdropPath")) };
}
export const unitPosterPath = (m: unknown) => str(val(m, "posterPath"));
export const unitStillPath = (m: unknown) => str(val(m, "stillPath"));
@@ -0,0 +1,484 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
import {
DEFAULT_SETTINGS,
type LibraryScrapeState,
type OrganizeSettings,
type ScrapeFileResult,
type ScrapeMode,
} from "@umt/contracts";
import { sql } from "@umt/db";
import {
episodeNfo,
inferWorkDir,
planScrapeTargets,
type ScrapeTarget,
type ScrapeVideo,
seasonNfo,
workNfo,
} from "@umt/domain";
import { errMessage, jsonb } from "../util.ts";
import type { AoDeps } from "./common.ts";
import {
type IdentityMap,
imagePathsOf,
nfoEpisode,
nfoSeason,
nfoWork,
type UnitForNfo,
unitPosterPath,
unitStillPath,
} from "./scrape-input.ts";
// SCR-1 刮削:把作品的最终字段写成 NFO,下载海报 / 背景图 / 季海报 / 剧照,放进媒体库目录(Emby / Jellyfin / Kodi 读取)。
// - 触发:整理入库成功后(设置 organize.scrapeAfterImport)、作品详情「重新刮削」、文件管理「刮削」,都排同一个任务 scrapeMedia(文件道)。
// - 目标文件与命名:packages/domain/src/scrape/layout.ts(文件名跟随已入库视频,即命名模板的结果);内容:scrape/nfo.ts。
// - 覆盖:missing 只补缺失(已存在的跳过)/ overwrite 全部覆盖;NFO 先写,图片后下(并发 4,失败只记该文件,不影响 NFO)。
// - 写入:同目录临时文件 + rename(原子),UTF-8;只写 managed 库根,库根离线整体失败;目标必须仍在库根内。
// - 结果:media_scrape 一部作品一行(最近一次,逐文件结果)+ 审计 organize.scrape;SSE files.changed 让文件管理刷新。
export const SCRAPE_TASK = "scrapeMedia";
const IMAGE_CONCURRENCY = 4;
export interface ScrapeInput {
mediaEntityId: string;
trigger: "auto" | "manual";
mode?: ScrapeMode | undefined;
recordId?: string | undefined;
/** 手动刮削的发起人(审计) */
actor?: { type: string; id: string | null } | undefined;
}
export async function scrapeSettings(d: AoDeps): Promise<OrganizeSettings> {
const s = await d.ctx.settings.get("organize").catch(() => DEFAULT_SETTINGS.organize);
return { ...DEFAULT_SETTINGS.organize, ...s };
}
/**
* 排刮削任务。同一作品已有排队中(还没开始)的刮削时直接复用(deduplicated);
* 自动刮削按整理记录幂等(同一记录只排一次)。
*/
export async function enqueueScrape(d: AoDeps, input: ScrapeInput): Promise<{ taskId: string; deduplicated: boolean }> {
const db = d.ctx.db;
const mode = input.mode ?? (await scrapeSettings(d)).scrapeMode;
const cur = await db
.selectFrom("media_scrape")
.select(["status", "engine_run_id", "requested_at"])
.where("media_entity_id", "=", input.mediaEntityId)
.executeTakeFirst();
if (cur?.status === "queued" && cur.engine_run_id && Date.now() - new Date(cur.requested_at).getTime() < 30 * 60_000)
return { taskId: cur.engine_run_id, deduplicated: true };
const key =
input.trigger === "auto" && input.recordId
? `${SCRAPE_TASK}:record:${input.recordId}`
: `${SCRAPE_TASK}:manual:${input.mediaEntityId}:${randomUUID()}`;
const { runId, deduplicated } = await d.ctx.engine.enqueue(
SCRAPE_TASK,
{
mediaEntityId: input.mediaEntityId,
trigger: input.trigger,
mode,
...(input.recordId ? { recordId: input.recordId } : {}),
...(input.actor ? { actor: input.actor } : {}),
},
{ idempotencyKey: key, traceId: randomUUID() },
);
if (!deduplicated)
await sql`insert into media_scrape (media_entity_id, status, trigger, mode, record_id, engine_run_id, requested_at,
started_at, finished_at, written, skipped, failed, reason, files)
values (${input.mediaEntityId}, 'queued', ${input.trigger}, ${mode}, ${input.recordId ?? null}, ${runId}, now(),
null, null, 0, 0, 0, null, '[]'::jsonb)
on conflict (media_entity_id) do update set status = 'queued', trigger = excluded.trigger, mode = excluded.mode,
record_id = excluded.record_id, engine_run_id = excluded.engine_run_id, requested_at = now(), updated_at = now()`.execute(
db,
);
return { taskId: runId, deduplicated: !!deduplicated };
}
/** 整理入库成功后:设置打开时自动排刮削(失败只记日志,不影响整理结果) */
export async function scrapeAfterImport(d: AoDeps, entityId: string | null, recordId: string): Promise<void> {
if (!entityId) return;
try {
const s = await scrapeSettings(d);
if (!s.scrapeAfterImport) return;
await enqueueScrape(d, { mediaEntityId: entityId, trigger: "auto", recordId });
} catch (err) {
d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
}
}
export async function loadScrapeState(d: AoDeps, entityId: string): Promise<LibraryScrapeState | null> {
const r = await d.ctx.db
.selectFrom("media_scrape")
.selectAll()
.where("media_entity_id", "=", entityId)
.executeTakeFirst();
if (!r) return null;
const iso = (t: Date | string | null) => (t ? new Date(t).toISOString() : null);
return {
status: r.status as LibraryScrapeState["status"],
trigger: r.trigger as LibraryScrapeState["trigger"],
mode: r.mode as ScrapeMode,
requestedAt: iso(r.requested_at)!,
startedAt: iso(r.started_at),
finishedAt: iso(r.finished_at),
written: r.written,
skipped: r.skipped,
failed: r.failed,
reason: r.reason,
files: (r.files ?? []) as ScrapeFileResult[],
recordId: r.record_id,
taskId: r.engine_run_id,
};
}
// ── 执行 ──
interface RootGroup {
rootId: string;
rootPath: string;
managed: boolean;
sentinel: string | null;
videos: ScrapeVideo[];
}
/** 原子写:同目录临时文件 → rename;目录不存在时建(只在库根内) */
async function atomicWrite(abs: string, content: Uint8Array | string): Promise<void> {
await mkdir(dirname(abs), { recursive: true });
const tmp = join(dirname(abs), `.${randomUUID()}.umt-scrape.tmp`);
try {
await writeFile(tmp, content);
await rename(tmp, abs);
} catch (e) {
await rm(tmp, { force: true }).catch(() => {});
throw e;
}
}
const inside = (root: string, abs: string) => {
const r = relative(root, abs);
return !!r && !r.startsWith("..") && !isAbsolute(r);
};
/** 有限并发 map */
async function pool<T, R>(items: readonly T[], n: number, fn: (t: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let i = 0;
const worker = async () => {
while (i < items.length) {
const k = i++;
out[k] = await fn(items[k]!);
}
};
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
return out;
}
export interface ScrapeRunResult {
status: LibraryScrapeState["status"];
written: number;
skipped: number;
failed: number;
reason: string | null;
files: ScrapeFileResult[];
}
export async function runScrape(
d: AoDeps,
input: ScrapeInput & { runId?: string },
signal?: AbortSignal,
): Promise<ScrapeRunResult> {
const db = d.ctx.db;
const settings = await scrapeSettings(d);
const mode: ScrapeMode = input.mode ?? settings.scrapeMode;
const entityId = input.mediaEntityId;
const now = () => d.ports.now();
await sql`insert into media_scrape (media_entity_id, status, trigger, mode, record_id, engine_run_id, started_at)
values (${entityId}, 'running', ${input.trigger}, ${mode}, ${input.recordId ?? null}, ${input.runId ?? null}, ${now()})
on conflict (media_entity_id) do update set status = 'running', trigger = excluded.trigger, mode = excluded.mode,
record_id = coalesce(excluded.record_id, media_scrape.record_id),
engine_run_id = coalesce(excluded.engine_run_id, media_scrape.engine_run_id),
started_at = excluded.started_at, finished_at = null, reason = null, updated_at = now()`
.execute(db)
.catch(() => {});
const finish = async (r: ScrapeRunResult, rootDirs: Map<string, Set<string>> = new Map()) => {
await db
.updateTable("media_scrape")
.set({
status: r.status,
finished_at: now(),
written: r.written,
skipped: r.skipped,
failed: r.failed,
reason: r.reason,
files: jsonb(r.files),
updated_at: now(),
})
.where("media_entity_id", "=", entityId)
.execute()
.catch(() => {});
await db
.insertInto("audit_event")
.values({
actor_type: input.actor?.type ?? "system",
actor_id: input.actor?.id ?? null,
action: "organize.scrape",
target_type: "media_entity",
target_id: entityId,
payload: jsonb({
trigger: input.trigger,
mode,
recordId: input.recordId ?? null,
status: r.status,
written: r.written,
skipped: r.skipped,
failed: r.failed,
reason: r.reason,
files: r.files.filter((f) => f.action !== "skipped").slice(0, 200),
}),
})
.execute()
.catch(() => {});
for (const [rootId, dirs] of rootDirs)
if (dirs.size) void d.ctx.events.publish("files.changed", { rootId: `lib:${rootId}`, dirs: [...dirs] });
return r;
};
const fail = (reason: string) => finish({ status: "failed", written: 0, skipped: 0, failed: 0, reason, files: [] });
const e = await db
.selectFrom("media_entity")
.select(["id", "kind", "title", "original_title", "year", "metadata", "status"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e || e.status === "merged") return fail("entity_not_found");
const kind: "movie" | "tv" = e.kind === "movie" || e.kind === "collection" || e.kind === "other" ? "movie" : "tv";
const meta = (e.metadata ?? {}) as Record<string, unknown>;
if (!Object.keys(meta).length) return fail("scrape_no_metadata");
// 已入库的视频位置(库根内)
const locs = await db
.selectFrom("media_asset as a")
.innerJoin("asset_location as l", "l.media_asset_id", "a.id")
.innerJoin("library_root as r", "r.id", "l.library_root_id")
.select([
"a.id as asset_id",
"l.relative_path",
"r.id as root_id",
"r.local_path",
"r.management_mode",
"r.enabled",
"r.sentinel_file",
])
.where("a.media_entity_id", "=", entityId)
.where("a.status", "=", "active")
.where("a.kind", "=", "video")
.where("l.location_kind", "=", "library")
.where("l.status", "in", ["available", "unavailable", "suspected_missing"])
.execute();
if (!locs.length) return fail("scrape_no_files");
const assetIds = [...new Set(locs.map((l) => l.asset_id))];
const cov = await db
.selectFrom("asset_unit_coverage")
.select(["media_asset_id", "content_unit_id"])
.where("media_asset_id", "in", assetIds)
.execute();
const units = await db
.selectFrom("content_unit")
.select(["id", "kind", "season_number", "episode_number", "title", "air_date", "metadata"])
.where("media_entity_id", "=", entityId)
.where("status", "!=", "retired")
.orderBy("created_at")
.execute();
const unitById = new Map(units.map((u) => [u.id, u]));
const seasonUnit = new Map<number, UnitForNfo>();
const episodeUnit = new Map<string, UnitForNfo>();
for (const u of units) {
if (u.kind === "season" && u.season_number !== null && !seasonUnit.has(u.season_number))
seasonUnit.set(u.season_number, u);
if (u.kind === "episode" && u.season_number !== null && u.episode_number !== null) {
const k = `${u.season_number}:${u.episode_number}`;
if (!episodeUnit.has(k)) episodeUnit.set(k, u);
}
}
const groups = new Map<string, RootGroup>();
for (const l of locs) {
if (!l.enabled) continue;
const g = groups.get(l.root_id) ?? {
rootId: l.root_id,
rootPath: l.local_path,
managed: l.management_mode === "managed",
sentinel: l.sentinel_file,
videos: [],
};
let season: number | null = null;
let episode: number | null = null;
if (kind === "tv") {
const eps = cov
.filter((c) => c.media_asset_id === l.asset_id)
.map((c) => unitById.get(c.content_unit_id))
.filter((u) => u?.kind === "episode" && u.season_number !== null && u.episode_number !== null)
.sort((a, b) => a!.season_number! - b!.season_number! || a!.episode_number! - b!.episode_number!);
season = eps[0]?.season_number ?? null;
episode = eps[0]?.episode_number ?? null;
}
g.videos.push({ path: l.relative_path, season, episode });
groups.set(l.root_id, g);
}
if (!groups.size) return fail("scrape_no_files");
const idRows = await db
.selectFrom("external_identity")
.select(["provider", "external_id"])
.where("media_entity_id", "=", entityId)
.where("content_unit_id", "is", null)
.where("status", "=", "accepted")
.orderBy("updated_at", "desc")
.execute()
.catch(() => [] as { provider: string; external_id: string }[]);
const ids: IdentityMap = {};
for (const r of idRows) {
const p = r.provider.toLowerCase() as keyof IdentityMap;
if (["tmdb", "imdb", "tvdb", "douban"].includes(p) && !ids[p]) ids[p] = r.external_id;
}
const bindings = await db
.selectFrom("path_binding")
.select(["library_root_id", "relative_path"])
.where("media_entity_id", "=", entityId)
.where("binding_kind", "=", "entity")
.where("status", "=", "active")
.execute();
const work = nfoWork(e, ids);
const art = imagePathsOf(meta);
const seasonPosters = new Map<number, string | null>();
for (const [n, u] of seasonUnit) seasonPosters.set(n, unitPosterPath(u.metadata));
const stills = new Map<string, string | null>();
for (const [k, u] of episodeUnit) stills.set(k, unitStillPath(u.metadata));
const files: ScrapeFileResult[] = [];
const rootDirs = new Map<string, Set<string>>();
const imageCache = new Map<string, Promise<Uint8Array>>();
const fetchImage = (p: string) => {
let hit = imageCache.get(p);
if (!hit) {
hit = d.ports.scrapeImage(p).then((r) => r.content);
imageCache.set(p, hit);
}
return hit;
};
for (const g of groups.values()) {
if (!g.managed) {
files.push(
...g.videos.map((v) => ({
path: v.path,
kind: "nfo" as const,
role: kind === "movie" ? "movie_nfo" : "episode_nfo",
action: "skipped" as const,
reason: "scrape_root_read_only",
})),
);
continue;
}
const health = await checkRoot(g.rootPath, g.sentinel ?? DEFAULT_ROOT_SENTINEL);
if (health.kind !== "online") {
return finish({
status: "failed",
written: 0,
skipped: 0,
failed: 0,
reason: "library_root_offline",
files,
});
}
const bound = bindings.find((b) => b.library_root_id === g.rootId)?.relative_path ?? null;
const workDir = bound ?? inferWorkDir(kind, g.videos[0]!.path);
const targets = planScrapeTargets({
kind,
workDir,
videos: g.videos,
images: { ...art, seasonPosters, stills },
options: { images: settings.scrapeImages, episodeThumbs: settings.scrapeEpisodeThumbs },
});
if (kind === "tv" && !workDir)
files.push({ path: "", kind: "nfo", role: "tvshow_nfo", action: "skipped", reason: "scrape_no_work_dir" });
const rootAbs = resolve(g.rootPath);
const dirs = rootDirs.get(g.rootId) ?? new Set<string>();
rootDirs.set(g.rootId, dirs);
const decide = async (t: ScrapeTarget): Promise<{ abs: string } | ScrapeFileResult> => {
const abs = resolve(rootAbs, t.path);
const base = { path: t.path, kind: t.kind, role: t.role };
if (!inside(rootAbs, abs)) return { ...base, action: "failed", reason: "path_escape" };
const st = await lstat(abs).catch(() => null);
if (st && (!st.isFile() || st.isSymbolicLink())) return { ...base, action: "skipped", reason: "scrape_exists" };
if (st && mode === "missing") return { ...base, action: "skipped", reason: "scrape_exists" };
return { abs };
};
const write = async (t: ScrapeTarget, abs: string, content: Uint8Array | string): Promise<ScrapeFileResult> => {
const base = { path: t.path, kind: t.kind, role: t.role };
try {
await atomicWrite(abs, content);
dirs.add(dirname(t.path) === "." ? "" : dirname(t.path));
return { ...base, action: "written", reason: null };
} catch (err) {
d.ctx.logger.warn({ entityId, path: t.path, err: { message: errMessage(err) } }, "刮削文件写入失败");
return { ...base, action: "failed", reason: "scrape_write_failed" };
}
};
// NFO 先写(不依赖网络)
for (const t of targets.filter((x) => x.kind === "nfo")) {
if (signal?.aborted) throw new Error("aborted");
const dec = await decide(t);
if (!("abs" in dec)) {
files.push(dec);
continue;
}
let xml: string;
if (t.role === "movie_nfo" || t.role === "tvshow_nfo") xml = workNfo(work);
else if (t.role === "season_nfo") xml = seasonNfo(nfoSeason(t.season!, seasonUnit.get(t.season!)));
else xml = episodeNfo(nfoEpisode(t.season!, t.episode!, episodeUnit.get(`${t.season}:${t.episode}`)));
files.push(await write(t, dec.abs, xml));
}
// 图片:限并发,失败不影响 NFO
const imgs = targets.filter((x) => x.kind === "image" && x.imagePath);
const results = await pool(imgs, IMAGE_CONCURRENCY, async (t): Promise<ScrapeFileResult> => {
if (signal?.aborted) throw new Error("aborted");
const dec = await decide(t);
if (!("abs" in dec)) return dec;
let bytes: Uint8Array;
try {
bytes = await fetchImage(t.imagePath!);
} catch (err) {
d.ctx.logger.warn({ entityId, image: t.imagePath, err: { message: errMessage(err) } }, "刮削图片下载失败");
return { path: t.path, kind: t.kind, role: t.role, action: "failed", reason: "scrape_image_failed" };
}
return write(t, dec.abs, bytes);
});
files.push(...results);
}
const written = files.filter((f) => f.action === "written").length;
const failed = files.filter((f) => f.action === "failed").length;
const skipped = files.filter((f) => f.action === "skipped").length;
const onlyReadOnly = files.length > 0 && files.every((f) => f.reason === "scrape_root_read_only");
const status: ScrapeRunResult["status"] = onlyReadOnly
? "failed"
: failed === 0
? "succeeded"
: written > 0 || skipped > 0
? "partial"
: "failed";
const reason = onlyReadOnly
? "scrape_root_read_only"
: failed
? (files.find((f) => f.action === "failed")?.reason ?? null)
: null;
return finish({ status, written, skipped, failed, reason, files }, rootDirs);
}
+8
View File
@@ -107,6 +107,14 @@ export class ImageCache {
return new Response(content as BodyInit, { status: 200, headers });
}
/**
* SCR-1 刮削取图:TMDB 路径(`/xx.jpg`)→ 图片字节。同一缓存目录与出口代理;命中磁盘缓存就不再请求
* (TMDB 图片路径内容不变,刮削不看 GLOBAL_IMAGE_CACHE 开关)。
*/
async bytes(size: string, tmdbPath: string): Promise<{ content: Uint8Array; mime: string }> {
return this.fetchImage(this.upstream(size, tmdbPath.replace(/^\/+/, "")), true);
}
/** ImageHelper.fetch_image_with_mime_type */
private async fetchImage(url: string, useCache: boolean): Promise<{ content: Uint8Array; mime: string }> {
const path = join(this.opts.dir, imageCachePath(url));
+32 -9
View File
@@ -13,6 +13,7 @@ import type {
} from "@umt/contracts";
import { sql } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import { enqueueScrape } from "../acquisition-organize/services/scrape.ts";
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts";
import { kindOfName, mediaSummary } from "./list.ts";
@@ -20,7 +21,8 @@ import { entitiesUnder } from "./records.ts";
// probe:ffprobe 摘要;已登记资产写回 media_asset.media_info.probe,未登记只缓存 24h(DATA_DIR/cache/probe)。
// scrape:只对已登记作品,等价 POST /media-entities/{id}/refresh(入队 refreshMetadata,字段锁优先、不动路径);
// NFO/图片投影 W5 实现(TODO),请求时返回 not_implemented。
// writeNfo / images(SCR-1)另排 scrapeMedia,把 NFO 与图片写进媒体库目录(与作品详情「重新刮削」同一任务)。
// scrape-batch write=true:文件管理「刮削」,按作品去重各排一次 scrapeMedia(不刷新元数据)。
const CACHE_TTL_MS = 24 * 3600_000;
@@ -133,13 +135,23 @@ export async function scrape(
.where("locked", "=", true)
.execute();
const lockedFields = [...new Set([...(e.field_locks ?? []), ...overrides.map((o) => o.field)])].sort();
// TODO(W5):writeNfo/images → 把最终字段投影成 NFO/海报写到绑定目录(临时文件 + 原子提交;非本系统 NFO 不覆盖)
const scrapeTaskId =
body.writeNfo || body.images
? (
await enqueueScrape(d.ao, {
mediaEntityId: e.id,
trigger: "manual",
actor: { type: principal?.kind === "api_key" ? "api_key" : "user", id: principal?.id ?? null },
})
).taskId
: null;
const result: FilesScrapeResult = {
mediaEntityId: e.id,
taskId: runId,
lockedFields,
nfo: body.writeNfo ? "not_implemented" : "not_requested",
images: body.images ? "not_implemented" : "not_requested",
nfo: body.writeNfo ? "queued" : "not_requested",
images: body.images ? "queued" : "not_requested",
scrapeTaskId,
};
await audit(d, principal, "scrape", { ...result });
return result;
@@ -202,17 +214,28 @@ export async function scrapeBatch(
skipped.push({ ref: id, reason: "not_registered" });
continue;
}
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: id },
{ idempotencyKey: `refreshMetadata:files-batch:${body.idempotencyKey}:${id}`, traceId: randomUUID() },
);
const runId = body.write
? (
await enqueueScrape(d.ao, {
mediaEntityId: id,
trigger: "manual",
actor: { type: principal?.kind === "api_key" ? "api_key" : "user", id: principal?.id ?? null },
})
).taskId
: (
await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: id },
{ idempotencyKey: `refreshMetadata:files-batch:${body.idempotencyKey}:${id}`, traceId: randomUUID() },
)
).runId;
const lockedFields = [
...new Set([...(e.field_locks ?? []), ...locks.filter((l) => l.media_entity_id === id).map((l) => l.field)]),
].sort();
submitted.push({ mediaEntityId: id, title: e.title, taskId: runId, lockedFields });
}
await audit(d, principal, "scrape_batch", {
write: body.write,
submitted: submitted.map((s) => s.mediaEntityId),
skipped,
});
@@ -305,8 +305,18 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
const s = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: film, writeNfo: true });
expect(s.status).toBe(200);
expect(s.body.data).toMatchObject({ mediaEntityId: film, nfo: "not_implemented", images: "not_requested" });
// SCR-1:writeNfo 另排 scrapeMedia(写 NFO 与图片到媒体库目录)
expect(s.body.data).toMatchObject({ mediaEntityId: film, nfo: "queued", images: "not_requested" });
expect(s.body.data.scrapeTaskId).toBeTruthy();
expect(h.engine.enqueued.some((e) => e.task === "refreshMetadata")).toBe(true);
expect(h.engine.enqueued.some((e) => e.task === "scrapeMedia")).toBe(true);
const b = await h.api("POST", "/files/scrape-batch", {
idempotencyKey: key(),
mediaEntityIds: [film],
write: true,
});
expect(b.status).toBe(200);
expect(b.body.data.submitted).toHaveLength(1);
const none = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: show });
expect(none.status).toBe(400);
});
+51
View File
@@ -0,0 +1,51 @@
"""SCR-1 刮削差分:用 MP 上游的 TmdbScraper(themoviedb/scraper.py)从同一份 TMDB 原始响应生成 NFO,写 scrape-mp.json。
用法(MP 仓库根目录,用其 .venv):
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-mp.py
只记录行为输出,不复制 MP 源码。MediaInfo 用 MP 自己的 set_tmdb_info(TMDB 详情 → 统一字段),
季 / 集直接喂 TMDB 季详情(与 MP get_metadata_nfo 取到的一致)。图片命名按 MP 代码行为登记在 nfo-mp-diff.test.ts。
"""
import json
import pathlib
from app.domain.context import MediaInfo
from app.domain.projection.tmdb import configure_image_url_builder
from app.modules.themoviedb.scraper import TmdbScraper
HERE = pathlib.Path(__file__).parent
raw = json.loads((HERE / "tmdb-raw.json").read_text(encoding="utf-8"))
# 启动组合根里注入的 TMDB 图片地址(settings.TMDB_IMAGE_URL,原图)
configure_image_url_builder(lambda p: f"https://image.tmdb.org/t/p/original/{p.removeprefix('/')}")
s = TmdbScraper()
def xml(doc) -> str:
return doc.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8")
def media(info: dict, media_type: str) -> MediaInfo:
mi = MediaInfo()
mi.set_tmdb_info({**info, "media_type": media_type})
return mi
out = {}
out["movie"] = xml(s._TmdbScraper__gen_movie_nfo_file(mediainfo=media(raw["/movie/157336"], "movie")))
out["tvshow"] = xml(s._TmdbScraper__gen_tv_nfo_file(mediainfo=media(raw["/tv/95396"], "tv")))
season = raw["/tv/95396/season/1"]
out["season1"] = xml(s._TmdbScraper__gen_tv_season_nfo_file(seasoninfo=season, season=1))
for ep in season["episodes"]:
n = ep["episode_number"]
out[f"s01e{n:02d}"] = xml(
s._TmdbScraper__gen_tv_episode_nfo_file(tmdbid=95396, episodeinfo=ep, season=1, episode=n)
)
out["seasonPosterName"] = TmdbScraper.get_season_poster(season, 1)[0]
out["seasonSpecialsPosterName"] = TmdbScraper.get_season_poster({"poster_path": "/x.jpg"}, 0)[0]
(HERE / "scrape-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"写入 {len(out)} 项 → scrape-mp.json")
@@ -0,0 +1,69 @@
{
"_note": "SCR-1 NFO 与 MP 的已登记差异(按样例、按元素)。测试先去掉双方的空元素(MP 缺值写 <x/>、空 CDATA、评分 0,我们不写),再去掉这里登记的元素后逐行比较;登记的元素必须确实不同,差异消除后删掉登记。",
"deviations": [
{
"case": "movie",
"tag": "studio",
"reason": "本地不存 TMDB production_companies,电影不写出品公司(Emby / Jellyfin 刷新时会自己补)"
},
{
"case": "movie",
"tag": "country",
"reason": "制片国取 TMDB origin_country(代码转中文名);MP 取 production_countries(本例多一个英国)"
},
{
"case": "movie",
"tag": "tagline",
"reason": "我们额外写标语,MP 不写"
},
{
"case": "tvshow",
"tag": "studio",
"reason": "剧集写播出平台(TMDB networks,本例 Apple TV+);MP 写出品公司 production_companies"
},
{
"case": "s01e01",
"tag": "uniqueid",
"reason": "本地不存 TMDB 集 id,集 NFO 不写 uniqueid,媒体服务器按季号集号匹配"
},
{
"case": "s01e01",
"tag": "tmdbid",
"reason": "同上"
},
{
"case": "s01e01",
"tag": "runtime",
"reason": "我们额外写集时长,MP 不写"
},
{
"case": "s01e02",
"tag": "uniqueid",
"reason": "同 s01e01"
},
{
"case": "s01e02",
"tag": "tmdbid",
"reason": "同 s01e01"
},
{
"case": "s01e02",
"tag": "runtime",
"reason": "同 s01e01"
}
],
"images": [
{
"role": "movie poster / fanart",
"mp": "poster.jpg、backdrop.jpg、fanart.jpg(作品目录)",
"ours": "poster.jpg、fanart.jpg",
"reason": "backdrop.jpg 是 fanart.jpg 的副本,Emby / Jellyfin / Kodi 都认 fanart.jpg,不写重复文件"
},
{
"role": "episode thumb",
"mp": "<视频名>.jpg",
"ours": "<视频名>-thumb.jpg",
"reason": "Kodi 约定 -thumb 后缀;Jellyfin / Emby 同样识别,且不会和同名海报规则冲突"
}
]
}
+149
View File
@@ -0,0 +1,149 @@
import { readFileSync } from "node:fs";
import { fetchRichDetail, fetchRichSeason } from "@umt/adapters";
import { episodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain";
import { describe, expect, it } from "vitest";
import { nfoEpisode, nfoSeason, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
import { richEntityFields } from "../../src/modules/sources-identity/service/metadata-rich.ts";
// SCR-1 NFO 差分:同一份 TMDB 原始响应(tmdb-raw.json),MP 走 set_tmdb_info + TmdbScraper(gen-scrape-mp.py → scrape-mp.json),
// 我们走 fetchRichDetail / fetchRichSeason → 字段投影(metadata)→ scrape-input → domain NFO 生成器。
// 双方去掉空元素后逐行比较,已登记的差异(nfo-deviations.json)按元素排除并要求确实不同。
const read = (f: string) => JSON.parse(readFileSync(new URL(f, import.meta.url), "utf8"));
const raw = read("./tmdb-raw.json") as Record<string, unknown>;
const mp = read("./scrape-mp.json") as Record<string, string>;
const devs = read("./nfo-deviations.json").deviations as { case: string; tag: string }[];
const LANGS = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
const client = {
async getJson<T>(path: string): Promise<T> {
const r = raw[path];
if (!r) throw new Error(`fixture 缺 ${path}`);
return structuredClone(r) as T;
},
};
const final = (fields: Record<string, { value: unknown }>) =>
Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v.value }]));
async function ours(): Promise<Record<string, string>> {
const out: Record<string, string> = {};
const movie = await fetchRichDetail(client, "movie", 157336, LANGS);
out.movie = workNfo(
nfoWork(
{
kind: "movie",
title: movie.title,
original_title: movie.originalTitle,
year: movie.year,
metadata: final(richEntityFields(movie) as never),
},
{ tmdb: "157336" },
),
);
const tv = await fetchRichDetail(client, "tv", 95396, LANGS);
out.tvshow = workNfo(
nfoWork(
{
kind: "series",
title: tv.title,
original_title: tv.originalTitle,
year: tv.year,
metadata: final(richEntityFields(tv) as never),
},
{ tmdb: "95396" },
),
);
const sd = await fetchRichSeason(client, 95396, 1, LANGS, { now: new Date("2026-01-01") });
out.season1 = seasonNfo(
nfoSeason(1, {
season_number: 1,
episode_number: null,
title: sd.name,
air_date: sd.airDate,
metadata: { name: { value: sd.name }, overview: { value: sd.overview }, airDate: { value: sd.airDate } },
}),
);
for (const e of sd.episodes) {
out[`s01e${String(e.episodeNumber).padStart(2, "0")}`] = episodeNfo(
nfoEpisode(1, e.episodeNumber, {
season_number: 1,
episode_number: e.episodeNumber,
title: e.name,
air_date: e.airDate,
metadata: {
name: { value: e.name },
overview: { value: e.overview },
airDate: { value: e.airDate },
runtime: { value: e.runtime },
voteAverage: { value: e.voteAverage },
},
}),
);
}
return out;
}
/** 去空元素(MP 缺值写 <x/>、空 CDATA、评分 0) */
const normalize = (xml: string) =>
xml
.split("\n")
.filter((l) => l.trim() !== "")
.filter((l) => !/^\s*<[\w]+(\s[^>]*)?\/>$/.test(l))
.filter((l) => !/<!\[CDATA\[\]\]>/.test(l))
.filter((l) => !/^\s*<rating>0<\/rating>$/.test(l));
const tagOf = (l: string) => /^ {2}<(\w+)/.exec(l)?.[1] ?? null;
describe("NFO 与 MP 差分", async () => {
const mine = await ours();
const cases = ["movie", "tvshow", "season1", "s01e01", "s01e02"];
it.each(cases)("%s:结构与取值一致(已登记差异除外)", (c) => {
const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag));
const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? ""));
const b = normalize(mine[c]!).filter((l) => !skip.has(tagOf(l) ?? ""));
expect(b).toEqual(a);
for (const tag of skip) {
const ma = normalize(mp[c]!).filter((l) => tagOf(l) === tag);
const mb = normalize(mine[c]!).filter((l) => tagOf(l) === tag);
expect(mb, `登记的差异 ${c}.${tag} 已消失,删掉登记`).not.toEqual(ma);
}
});
it("编码与声明:UTF-8、两空格缩进、末尾换行,和 MP 一致", () => {
for (const c of cases) {
expect(mine[c]!.split("\n")[0]).toBe(mp[c]!.split("\n")[0]);
expect(mine[c]!.endsWith("\n")).toBe(true);
}
});
it("季海报命名与 MP get_season_poster 一致", () => {
expect(seasonPosterName(1)).toBe(mp.seasonPosterName);
expect(seasonPosterName(0)).toBe(mp.seasonSpecialsPosterName);
});
it("文件命名:对照 MP scraping.py 的目标路径(差异见 nfo-deviations.json images)", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: "人生切割术 (2022)",
videos: [{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.mkv", season: 1, episode: 1 }],
images: {
posterPath: "/p.jpg",
backdropPath: "/b.jpg",
seasonPosters: new Map([[1, "/s.jpg"]]),
stills: new Map([["1:1", "/e.jpg"]]),
},
options: { images: true, episodeThumbs: true },
});
// MP:tvshow.nfo(TV)、season.nfo(SEASON)、<stem>.nfo(EPISODE);季海报根目录 seasonXX-poster + 季目录 poster
expect(t.map((x) => x.path)).toEqual([
"人生切割术 (2022)/tvshow.nfo",
"人生切割术 (2022)/poster.jpg",
"人生切割术 (2022)/fanart.jpg",
"人生切割术 (2022)/Season 1/season.nfo",
"人生切割术 (2022)/season01-poster.jpg",
"人生切割术 (2022)/Season 1/poster.jpg",
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.nfo",
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集-thumb.jpg",
]);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"movie": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<movie>\n <tmdbid>157336</tmdbid>\n <uniqueid type=\"tmdb\" default=\"false\">157336</uniqueid>\n <imdbid>tt0816692</imdbid>\n <uniqueid type=\"imdb\" default=\"true\">tt0816692</uniqueid>\n <plot><![CDATA[在不远的未来,随着地球自然环境的恶化,人类面临着无法生存的威胁。一群宇航员穿越虫洞,为人类寻找新家园。]]></plot>\n <outline><![CDATA[在不远的未来,随着地球自然环境的恶化,人类面临着无法生存的威胁。一群宇航员穿越虫洞,为人类寻找新家园。]]></outline>\n <director tmdbid=\"525\">克里斯托弗·诺兰</director>\n <actor>\n <name>马修·麦康纳</name>\n <type>Actor</type>\n <role>Cooper</role>\n <tmdbid>10297</tmdbid>\n <thumb>https://image.tmdb.org/t/p/original/lCySuYjhXix3FzQdS4oceDDrXKI.jpg</thumb>\n <profile>https://www.themoviedb.org/person/10297</profile>\n </actor>\n <actor>\n <name>安妮·海瑟薇</name>\n <type>Actor</type>\n <role>Brand</role>\n <tmdbid>1813</tmdbid>\n <thumb>https://image.tmdb.org/t/p/original/s6tflSD20MGz04ZR2R1lZvhmC4Y.jpg</thumb>\n <profile>https://www.themoviedb.org/person/1813</profile>\n </actor>\n <actor>\n <name>杰西卡·查斯坦</name>\n <type>Actor</type>\n <role>Murph</role>\n <tmdbid>83002</tmdbid>\n <profile>https://www.themoviedb.org/person/83002</profile>\n </actor>\n <genre>冒险</genre>\n <genre>剧情</genre>\n <genre>科幻</genre>\n <rating>8.5</rating>\n <studio>Legendary Pictures</studio>\n <country>英国</country>\n <country>美国</country>\n <runtime>169</runtime>\n <title>星际穿越</title>\n <originaltitle>Interstellar</originaltitle>\n <premiered>2014-11-05</premiered>\n <year>2014</year>\n</movie>\n",
"tvshow": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<tvshow>\n <tmdbid>95396</tmdbid>\n <uniqueid type=\"tmdb\" default=\"false\">95396</uniqueid>\n <tvdbid>371980</tvdbid>\n <uniqueid type=\"tvdb\">371980</uniqueid>\n <imdbid>tt11280740</imdbid>\n <uniqueid type=\"imdb\" default=\"true\">tt11280740</uniqueid>\n <plot><![CDATA[马克领导着一个办公室团队,他们的记忆被手术分割,工作与生活从此一分为二。]]></plot>\n <outline><![CDATA[马克领导着一个办公室团队,他们的记忆被手术分割,工作与生活从此一分为二。]]></outline>\n <actor>\n <name>亚当·斯科特</name>\n <type>Actor</type>\n <role>Mark Scout</role>\n <tmdbid>4495</tmdbid>\n <thumb>https://image.tmdb.org/t/p/original/9ynbGSZ7O7stcCYx1ElmFhUgCKj.jpg</thumb>\n <profile>https://www.themoviedb.org/person/4495</profile>\n </actor>\n <actor>\n <name>布丽特·洛薇尔</name>\n <type>Actor</type>\n <role>Helly R.</role>\n <tmdbid>1319469</tmdbid>\n <profile>https://www.themoviedb.org/person/1319469</profile>\n </actor>\n <genre>剧情</genre>\n <genre>悬疑</genre>\n <genre>Sci-Fi &amp; Fantasy</genre>\n <rating>8.4</rating>\n <studio>Red Hour Productions</studio>\n <country>美国</country>\n <title>人生切割术</title>\n <originaltitle>Severance</originaltitle>\n <premiered>2022-02-17</premiered>\n <year>2022</year>\n <season>-1</season>\n <episode>-1</episode>\n</tvshow>\n",
"season1": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<season>\n <plot><![CDATA[在卢蒙工业公司,员工们接受了一种名为“分割”的手术 & 工作记忆与生活记忆被彻底隔开。]]></plot>\n <outline><![CDATA[在卢蒙工业公司,员工们接受了一种名为“分割”的手术 & 工作记忆与生活记忆被彻底隔开。]]></outline>\n <title>第 1 季</title>\n <premiered>2022-02-17</premiered>\n <releasedate>2022-02-17</releasedate>\n <year>2022</year>\n <seasonnumber>1</seasonnumber>\n</season>\n",
"s01e01": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<episodedetails>\n <uniqueid type=\"tmdb\" default=\"true\">1830836</uniqueid>\n <tmdbid>1830836</tmdbid>\n <title>有关地狱的好消息</title>\n <plot><![CDATA[马克升职成为宏观数据精炼部的主管,并迎来一位新员工。]]></plot>\n <outline><![CDATA[马克升职成为宏观数据精炼部的主管,并迎来一位新员工。]]></outline>\n <aired>2022-02-17</aired>\n <year>2022</year>\n <season>1</season>\n <episode>1</episode>\n <rating>8.2</rating>\n</episodedetails>\n",
"s01e02": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<episodedetails>\n <uniqueid type=\"tmdb\" default=\"true\">3435543</uniqueid>\n <tmdbid>3435543</tmdbid>\n <title>半循环</title>\n <plot><![CDATA[马克带海莉参观办公室。]]></plot>\n <outline><![CDATA[马克带海莉参观办公室。]]></outline>\n <aired>2022-02-18</aired>\n <year>2022</year>\n <season>1</season>\n <episode>2</episode>\n <rating>8.0</rating>\n</episodedetails>\n",
"seasonPosterName": "season01-poster.jpg",
"seasonSpecialsPosterName": "season-specials-poster.jpg"
}
+215
View File
@@ -0,0 +1,215 @@
{
"_note": "SCR-1 差分用 TMDB 原始响应(zh-CN,形态照 TMDB v3;数值只保证合理,不作权威数据)。MP 与我们都从这里出发生成 NFO。",
"/movie/157336": {
"id": 157336,
"title": "星际穿越",
"original_title": "Interstellar",
"original_language": "en",
"origin_country": ["US"],
"release_date": "2014-11-05",
"overview": "在不远的未来,随着地球自然环境的恶化,人类面临着无法生存的威胁。一群宇航员穿越虫洞,为人类寻找新家园。",
"tagline": "我们注定要离开地球",
"runtime": 169,
"vote_average": 8.464,
"vote_count": 37000,
"status": "Released",
"poster_path": "/nCbkOyOMTEwlEV0LtCOvCnwEONA.jpg",
"backdrop_path": "/xJHokMbljvjADYdit5fK5VQsXEG.jpg",
"genres": [{ "id": 12, "name": "冒险" }, { "id": 18, "name": "剧情" }, { "id": 878, "name": "科幻" }],
"production_countries": [{ "iso_3166_1": "GB", "name": "英国" }, { "iso_3166_1": "US", "name": "美国" }],
"production_companies": [{ "id": 923, "name": "Legendary Pictures" }],
"external_ids": { "imdb_id": "tt0816692", "wikidata_id": "Q13417189" },
"credits": {
"cast": [
{
"id": 10297,
"name": "马修·麦康纳",
"original_name": "Matthew McConaughey",
"character": "Cooper",
"known_for_department": "Acting",
"order": 0,
"profile_path": "/lCySuYjhXix3FzQdS4oceDDrXKI.jpg"
},
{
"id": 1813,
"name": "安妮·海瑟薇",
"original_name": "Anne Hathaway",
"character": "Brand",
"known_for_department": "Acting",
"order": 1,
"profile_path": "/s6tflSD20MGz04ZR2R1lZvhmC4Y.jpg"
},
{
"id": 83002,
"name": "杰西卡·查斯坦",
"original_name": "Jessica Chastain",
"character": "Murph",
"known_for_department": "Acting",
"order": 2,
"profile_path": null
}
],
"crew": [
{
"id": 525,
"name": "克里斯托弗·诺兰",
"original_name": "Christopher Nolan",
"job": "Director",
"department": "Directing",
"known_for_department": "Directing",
"profile_path": "/xuAIuYSmsUzKlUMBFGVZaWsY3DZ.jpg"
}
]
},
"translations": {
"translations": [
{
"iso_639_1": "zh",
"iso_3166_1": "CN",
"data": {
"title": "星际穿越",
"overview": "在不远的未来,随着地球自然环境的恶化,人类面临着无法生存的威胁。一群宇航员穿越虫洞,为人类寻找新家园。",
"tagline": "我们注定要离开地球"
}
},
{
"iso_639_1": "en",
"iso_3166_1": "US",
"data": {
"title": "Interstellar",
"overview": "The adventures of a group of explorers.",
"tagline": "Mankind was born on Earth. It was never meant to die here."
}
}
]
},
"alternative_titles": { "titles": [{ "iso_3166_1": "CN", "title": "星际启示录", "type": "" }] }
},
"/tv/95396": {
"id": 95396,
"name": "人生切割术",
"original_name": "Severance",
"original_language": "en",
"origin_country": ["US"],
"first_air_date": "2022-02-17",
"last_air_date": "2025-03-21",
"overview": "马克领导着一个办公室团队,他们的记忆被手术分割,工作与生活从此一分为二。",
"tagline": "",
"vote_average": 8.395,
"vote_count": 2400,
"status": "Returning Series",
"episode_run_time": [],
"number_of_seasons": 2,
"number_of_episodes": 19,
"poster_path": "/pPHpeI2X1qEd1CS1SeyrdhZ4qnT.jpg",
"backdrop_path": "/hS8cOSG9m8H9dGaIxkRYZ7rRl8F.jpg",
"genres": [
{ "id": 18, "name": "剧情" },
{ "id": 9648, "name": "悬疑" },
{ "id": 10765, "name": "Sci-Fi & Fantasy" }
],
"networks": [{ "id": 2552, "name": "Apple TV+" }],
"production_countries": [{ "iso_3166_1": "US", "name": "美国" }],
"production_companies": [{ "id": 1, "name": "Red Hour Productions" }],
"created_by": [{ "id": 3442409, "name": "丹·埃里克森", "profile_path": null }],
"external_ids": { "imdb_id": "tt11280740", "tvdb_id": 371980 },
"credits": {
"cast": [
{
"id": 4495,
"name": "亚当·斯科特",
"original_name": "Adam Scott",
"character": "Mark Scout",
"known_for_department": "Acting",
"order": 0,
"profile_path": "/9ynbGSZ7O7stcCYx1ElmFhUgCKj.jpg"
},
{
"id": 1319469,
"name": "布丽特·洛薇尔",
"original_name": "Britt Lower",
"character": "Helly R.",
"known_for_department": "Acting",
"order": 1,
"profile_path": null
}
],
"crew": []
},
"seasons": [
{
"season_number": 1,
"episode_count": 9,
"name": "第 1 季",
"air_date": "2022-02-17",
"overview": "",
"poster_path": "/lFf6LLrQjYldcZItzOkGmMMigP7.jpg"
},
{
"season_number": 2,
"episode_count": 10,
"name": "第 2 季",
"air_date": "2025-01-16",
"overview": "",
"poster_path": "/5n9UHSJp9KcHSTnkBPkcQqgWUgL.jpg"
}
],
"translations": {
"translations": [
{
"iso_639_1": "zh",
"iso_3166_1": "CN",
"data": {
"name": "人生切割术",
"overview": "马克领导着一个办公室团队,他们的记忆被手术分割,工作与生活从此一分为二。",
"tagline": ""
}
},
{
"iso_639_1": "en",
"iso_3166_1": "US",
"data": { "name": "Severance", "overview": "Mark leads a team of office workers.", "tagline": "" }
}
]
},
"alternative_titles": { "results": [{ "iso_3166_1": "TW", "title": "人生切割術", "type": "" }] }
},
"/tv/95396/season/1": {
"id": 186498,
"season_number": 1,
"name": "第 1 季",
"overview": "在卢蒙工业公司,员工们接受了一种名为“分割”的手术 & 工作记忆与生活记忆被彻底隔开。",
"air_date": "2022-02-17",
"poster_path": "/lFf6LLrQjYldcZItzOkGmMMigP7.jpg",
"episodes": [
{
"id": 1830836,
"episode_number": 1,
"season_number": 1,
"name": "有关地狱的好消息",
"overview": "马克升职成为宏观数据精炼部的主管,并迎来一位新员工。",
"air_date": "2022-02-17",
"runtime": 57,
"still_path": "/jM3lJqrr6uD17rBHCM7qc2rH4rM.jpg",
"vote_average": 8.2,
"vote_count": 171,
"crew": [],
"guest_stars": []
},
{
"id": 3435543,
"episode_number": 2,
"season_number": 1,
"name": "半循环",
"overview": "马克带海莉参观办公室。",
"air_date": "2022-02-18",
"runtime": 53,
"still_path": "/kg2cNi5r1hhvF5GWWyi3wYO7rqE.jpg",
"vote_average": 8.0,
"vote_count": 150,
"crew": [],
"guest_stars": []
}
]
}
}
+17
View File
@@ -7,9 +7,11 @@ import {
LibraryItem,
type LibraryListQuery,
LibraryRoot,
LibraryScrapeResult,
MediaEntity,
MediaEntityDetail,
type MediaKind,
type ScrapeMode,
type UnitMetadataField,
} from "@umt/contracts";
import type { z } from "zod";
@@ -72,6 +74,21 @@ export function useLibraryDetail(id: string | undefined) {
queryKey: libraryKeys.detail(id ?? ""),
enabled: !!id,
queryFn: ({ signal }) => getParsed(LibraryDetail, `/library/${id}`, undefined, signal),
// 刮削排队 / 进行中时每 3 秒取一次,拿到结果就停
refetchInterval: (q) => {
const s = q.state.data?.scrape?.status;
return s === "queued" || s === "running" ? 3_000 : false;
},
});
}
/** SCR-1 刮削:写 NFO 与图片到媒体库目录(后台任务);mode 缺省按设置 */
export function useScrapeEntity(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (v: { mode?: ScrapeMode } = {}) =>
parseOrThrow(LibraryScrapeResult, await api.post<unknown>(`/library/${id}/scrape`, v), "/library/{id}/scrape"),
onSuccess: () => void qc.invalidateQueries({ queryKey: libraryKeys.detail(id) }),
});
}
+36 -13
View File
@@ -986,13 +986,21 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
);
}
// ─── 刷新元数据(MP 的"刮削") ─────────────────────────────────────
// ─── 刷新元数据 / 刮削 ─────────────────────────────────────
/**
* 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。
* NFO / 图片投影还没实现,不显示这两项。
* write = 刮削(SCR-1):同一接口 write=true,按作品排 scrapeMedia,把 NFO 与图片写进媒体库目录(覆盖方式按设置)。
*/
export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) {
export function RefreshMetadataDialog({
items,
write = false,
onClose,
}: {
items: FileEntry[];
write?: boolean | undefined;
onClose: () => void;
}) {
const batch = useScrapeBatch();
const key = useMemo(() => newIdempotencyKey(), []);
const [result, setResult] = useState<FilesScrapeBatchResult | null>(null);
@@ -1000,12 +1008,15 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
const single = entities.length === 1 ? entities[0] : null;
const run = () =>
batch.mutate(
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) },
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string), write },
{
onSuccess: (r) => {
setResult(r);
const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : "";
(r.skipped.length ? toast.warning : toast.success)(
`已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
write
? `已提交 ${r.submitted.length} 个作品的刮削${skipped}`
: `已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
);
},
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
@@ -1015,8 +1026,15 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}</DialogTitle>
<DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的「刮削」。</DialogDescription>
<DialogTitle>
{write ? "刮削" : "刷新元数据"}
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
</DialogTitle>
<DialogDescription>
{write
? "把 NFO 和图片写进媒体库目录,已有文件按设置只补缺失或全部覆盖。结果在作品详情的「刮削」里。"
: "按字段从 TMDB 刷新,锁定的字段和路径不变。不写媒体库目录里的文件。"}
</DialogDescription>
</DialogHeader>
{!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
@@ -1034,13 +1052,18 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<div key={s.mediaEntityId} className="rounded-lg border border-border/60 px-3 py-2">
<p className="flex items-center justify-between gap-2">
<span className="truncate">{s.title}</span>
<Link className="shrink-0 text-meta underline" to={`/console/engine/${s.taskId}`}>
查看运行
<Link
className="shrink-0 text-meta underline"
to={write ? `/console/library/${s.mediaEntityId}` : `/console/engine/${s.taskId}`}
>
{write ? "查看结果" : "查看运行"}
</Link>
</p>
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
{write ? null : (
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
)}
</div>
))}
{result.skipped.map((s) => (
@@ -1064,7 +1087,7 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
{result ? null : (
<Button onClick={run} disabled={batch.isPending || !entities.length}>
{batch.isPending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
刷新
{write ? "刮削" : "刷新"}
</Button>
)}
</DialogFooter>
+3 -2
View File
@@ -1123,8 +1123,9 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
mediaEntityId: b.mediaEntityId,
taskId: `run-${++seq}`,
lockedFields: ["title"],
nfo: b.writeNfo ? "not_implemented" : "not_requested",
images: b.images ? "not_implemented" : "not_requested",
nfo: b.writeNfo ? "queued" : "not_requested",
images: b.images ? "queued" : "not_requested",
scrapeTaskId: b.writeNfo || b.images ? `run-${++seq}` : null,
};
case "/files/probe":
await delay(600);
+2 -2
View File
@@ -196,14 +196,14 @@ describe("v2 菜单与副行", () => {
it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,顺序固定", () => {
expect(keys(menuModel([video], lib))).toBe("open | organize identify rename move copy | probe | copyPath | delete");
expect(keys(menuModel([registered], lib))).toBe(
"open | identify rename move copy | scrape probe | copyPath openMedia records | delete",
"open | identify rename move copy | scrape writeNfo probe | copyPath openMedia records | delete",
);
expect(opApplies("organize", registered)).toBe(false);
});
it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据", () => {
expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe | delete");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape writeNfo");
});
it("被状态阻止的项灰显带原因", () => {
+12 -3
View File
@@ -64,6 +64,7 @@ export type FileOp =
| "organize"
| "identify"
| "scrape"
| "writeNfo"
| "probe"
| "delete"
| "copyPath"
@@ -97,6 +98,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null;
case "scrape":
case "writeNfo":
return e.asset ? null : "未登记,先整理入库";
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
@@ -334,6 +336,7 @@ export type MenuKey =
| "move"
| "copy"
| "scrape"
| "writeNfo"
| "probe"
| "copyPath"
| "openMedia"
@@ -353,6 +356,7 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
case "identify":
return isVideo(e) || e.type === "dir";
case "scrape":
case "writeNfo":
case "openMedia":
return !!e.asset;
case "records":
@@ -366,8 +370,8 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
/**
* 单项 / 多选菜单的项与顺序(规格 §5.2):
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 ─ 删除 n 项
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 刮削 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 刮削 ─ 删除 n 项
*/
export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writable">): MenuModelItem[] {
const multi = list.length > 1;
@@ -391,6 +395,7 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
add("copy", e.escape ? "链接指向登记目录之外" : null);
sep();
if (opApplies("scrape", e)) add("scrape", why("scrape"));
if (opApplies("writeNfo", e)) add("writeNfo", why("writeNfo"));
if (opApplies("probe", e)) add("probe", null);
sep();
add("copyPath", null);
@@ -412,7 +417,10 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
add("move", why("move"));
add("copy", list.some((x) => x.escape) ? "含指向登记目录之外的链接" : null);
if (any("probe")) add("probe", null);
if (list.every((x) => !!x.asset)) add("scrape", null);
if (list.every((x) => !!x.asset)) {
add("scrape", null);
add("writeNfo", null);
}
sep();
add("delete", why("delete"));
return out;
@@ -433,6 +441,7 @@ export const OP_LABEL: Record<MenuKey, string> = {
move: "移动到…",
copy: "复制到…",
scrape: "刷新元数据",
writeNfo: "刮削",
probe: "探测",
copyPath: "复制路径…",
openMedia: "打开作品",
@@ -0,0 +1,168 @@
import type { LibraryDetail, LibraryScrapeState, ScrapeFileResult } from "@umt/contracts";
import { FileText, Image as ImageIcon, RefreshCw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useScrapeEntity } from "~/api/library.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { Panel } from "./parts.tsx";
// SCR-1 刮削状态:最近一次往媒体库目录写的 NFO 与图片(逐文件结果)+「重新刮削」「覆盖重写」。
// 与入库后自动刮削、文件管理「刮削」同一个后台任务。
const STATUS: Record<LibraryScrapeState["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "刮削中…", tone: "neutral" },
succeeded: { label: "已完成", tone: "success" },
partial: { label: "部分完成", tone: "warning" },
failed: { label: "失败", tone: "destructive" },
};
const ACTION: Record<ScrapeFileResult["action"], { label: string; tone: StatusTone }> = {
written: { label: "已写入", tone: "success" },
skipped: { label: "已存在", tone: "neutral" },
failed: { label: "失败", tone: "destructive" },
};
const ROLE: Record<string, string> = {
movie_nfo: "电影 NFO",
tvshow_nfo: "剧集 NFO",
season_nfo: "季 NFO",
episode_nfo: "集 NFO",
poster: "海报",
fanart: "背景图",
season_poster: "季海报",
episode_thumb: "剧照",
};
function counts(s: LibraryScrapeState): string {
return [
s.written ? `写入 ${s.written} 个文件` : null,
s.skipped ? `跳过 ${s.skipped} 个已有文件` : null,
s.failed ? `${s.failed} 个失败` : null,
]
.filter(Boolean)
.join(",");
}
function FileRow({ f }: { f: ScrapeFileResult }) {
const slash = f.path.lastIndexOf("/");
const Icon = f.kind === "nfo" ? FileText : ImageIcon;
const a = ACTION[f.action];
return (
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 basis-56 text-sm break-all text-foreground">
{f.path ? (
<>
{slash >= 0 ? <span className="text-muted-foreground">{f.path.slice(0, slash + 1)}</span> : null}
{f.path.slice(slash + 1)}
</>
) : (
(ROLE[f.role] ?? f.role)
)}
</span>
<span className="text-meta text-muted-foreground">{ROLE[f.role] ?? f.role}</span>
{f.action === "failed" ? (
<span className="text-meta text-destructive">{reasonLine(f.reason, "写入失败")}</span>
) : null}
<StatusBadge tone={a.tone}>{a.label}</StatusBadge>
</li>
);
}
export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
const s = detail.scrape ?? null;
const scrape = useScrapeEntity(detail.entity.id);
const [showAll, setShowAll] = useState(false);
const hasFiles = detail.assets.some((a) => a.asset.status === "active");
if (!s && !hasFiles) return null;
const busy = scrape.isPending || s?.status === "queued" || s?.status === "running";
const run = (mode?: "overwrite") =>
scrape.mutateAsync(mode ? { mode } : {}).then(
(r) => toast.success(r.deduplicated ? "已在排队" : "已提交刮削"),
(err) => {
toast.error(errorMessage(err, "刮削没有提交"));
throw err;
},
);
const files = s?.files ?? [];
const important = files.filter((f) => f.action !== "skipped");
const shown = showAll ? files : important.slice(0, 12);
const hidden = files.length - shown.length;
return (
<Panel
title="刮削"
description={
s?.finishedAt
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
: "NFO 和图片写进媒体库目录,Emby、Jellyfin、Kodi 直接读取。"
}
actions={
<>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" disabled={busy}>
覆盖重写
</Button>
}
title="覆盖重写 NFO 和图片?"
description="媒体库目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。"
confirmLabel="覆盖重写"
onConfirm={() => run("overwrite")}
/>
<Button variant="outline" size="sm" disabled={busy} onClick={() => void run().catch(() => {})}>
<RefreshCw className={busy ? "animate-spin" : undefined} />
{s ? "重新刮削" : "刮削"}
</Button>
</>
}
>
{!s ? (
<p className="text-sm text-muted-foreground">还没刮削过。</p>
) : (
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={STATUS[s.status].tone}>{STATUS[s.status].label}</StatusBadge>
{s.status !== "queued" && s.status !== "running" ? (
<span className="text-sm text-foreground">{counts(s) || "没有要写的文件"}</span>
) : null}
{s.mode === "overwrite" ? <span className="text-meta text-muted-foreground">覆盖已有文件</span> : null}
</div>
{s.reason && s.status !== "succeeded" ? (
<p className="text-sm text-muted-foreground">{reasonLine(s.reason, "刮削失败")}</p>
) : null}
{shown.length ? (
<ul className="divide-y divide-border/60">
{shown.map((f) => (
<FileRow key={`${f.path}:${f.role}`} f={f} />
))}
</ul>
) : null}
{hidden > 0 ? (
<div>
<Button variant="ghost" size="sm" onClick={() => setShowAll(true)}>
{important.length === 0 ? `查看 ${hidden} 个已有文件` : `显示全部 ${files.length} 个文件`}
</Button>
</div>
) : null}
<TechInfo
items={[
{ label: "覆盖方式", value: s.mode === "overwrite" ? "全部覆盖" : "只补缺失" },
s.taskId ? { label: "任务", value: s.taskId, mono: true } : null,
s.recordId ? { label: "整理记录", value: s.recordId, mono: true } : null,
s.reason ? { label: "原因码", value: reasonCode(s.reason) ?? s.reason, mono: true } : null,
{ label: "提交于", value: formatDateTime(s.requestedAt) },
]}
/>
</div>
)}
</Panel>
);
}
+53 -1
View File
@@ -4,7 +4,7 @@ import { str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 命名模板
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 刮削 + 命名模板
const ORGANIZE_CARD: CardSpec = {
anchor: "organize",
@@ -39,6 +39,57 @@ const ORGANIZE_CARD: CardSpec = {
}),
};
// SCR-1 刮削:入库后写 NFO 与图片到媒体库目录(Emby / Jellyfin / Kodi 读取)
const SCRAPE_CARD: CardSpec = {
anchor: "scrape",
title: "刮削",
description: "把作品信息写成 NFO,连同海报、背景图放进媒体库目录,Emby、Jellyfin、Kodi 直接读取。",
fields: [
{
name: "organize.scrapeAfterImport",
label: "入库后自动刮削",
kind: "switch",
hint: "关闭时可在作品详情或文件管理里手动刮削",
},
{
name: "organize.scrapeMode",
label: "已有文件",
kind: "select",
options: [
{ value: "missing", label: "只补缺失" },
{ value: "overwrite", label: "全部覆盖" },
],
hint: "全部覆盖会替换目录里已有的 NFO 和图片,包括其他工具写的",
},
{
name: "organize.scrapeImages",
label: "下载图片",
kind: "switch",
hint: "海报、背景图、季海报",
},
{
name: "organize.scrapeEpisodeThumbs",
label: "下载剧照",
kind: "switch",
hint: "每集一张,存为「视频名-thumb.jpg」;关闭下载图片时不生效",
},
],
fromSettings: (s) => ({
"organize.scrapeAfterImport": s.organize.scrapeAfterImport,
"organize.scrapeMode": s.organize.scrapeMode,
"organize.scrapeImages": s.organize.scrapeImages,
"organize.scrapeEpisodeThumbs": s.organize.scrapeEpisodeThumbs,
}),
toPatch: (v) => ({
organize: {
scrapeAfterImport: v["organize.scrapeAfterImport"] === true,
scrapeMode: str(v["organize.scrapeMode"]) === "overwrite" ? "overwrite" : "missing",
scrapeImages: v["organize.scrapeImages"] === true,
scrapeEpisodeThumbs: v["organize.scrapeEpisodeThumbs"] === true,
},
}),
};
export function LibrarySection() {
return (
<div className="grid grid-cols-1 gap-8">
@@ -46,6 +97,7 @@ export function LibrarySection() {
<LibraryRootsTable />
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
<SettingsGate>{(s) => <SettingsCard section="library" spec={SCRAPE_CARD} settings={s} />}</SettingsGate>
<NamingSection />
</div>
);
+1
View File
@@ -45,6 +45,7 @@ export const WORKFLOW_LABEL: Record<string, string> = {
evaluateAcquisition: "评估候选",
submitDownload: "提交下载",
verifyAndImport: "核验入库",
scrapeMedia: "刮削",
refreshMetadata: "刷新元数据",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
+19 -2
View File
@@ -9,6 +9,7 @@ import {
Download,
Eye,
EyeOff,
FileDown,
FileStack,
FolderInput,
FolderOpen,
@@ -108,7 +109,7 @@ type Dialog =
| { kind: "delete"; items: FileRef[] }
| { kind: "mkdir" }
| { kind: "copyPath"; entry: FileEntry }
| { kind: "refresh"; items: FileEntry[] }
| { kind: "refresh"; items: FileEntry[]; write?: boolean }
| { kind: "wizard"; source: OrganizeSource }
| { kind: "shortcuts" };
@@ -131,6 +132,7 @@ const MENU_ICON: Record<MenuKey, typeof FolderOpen> = {
move: FolderInput,
copy: Copy,
scrape: RefreshCw,
writeNfo: FileDown,
probe: ScanSearch,
copyPath: ClipboardCopy,
openMedia: Library,
@@ -456,6 +458,8 @@ export default function FilesPage() {
return setDialog({ kind: "transfer", items: refs(list), mode: op });
case "scrape":
return setDialog({ kind: "refresh", items: list });
case "writeNfo":
return setDialog({ kind: "refresh", items: list, write: true });
case "probe":
return doProbe(list);
case "copyPath":
@@ -703,6 +707,13 @@ export default function FilesPage() {
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
onSelect: () => act("scrape", selected),
});
items.push({
key: "writeNfo",
label: "刮削",
icon: FileDown,
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
onSelect: () => act("writeNfo", selected),
});
items.push({ key: "copyPath", label: "复制路径", icon: ClipboardCopy, onSelect: () => void copyPaths(selected) });
return items;
};
@@ -1140,6 +1151,12 @@ export default function FilesPage() {
刷新元数据
</Button>
) : null}
{detail.asset ? (
<Button size="sm" variant="outline" onClick={() => act("writeNfo", [detail])}>
<FileDown />
刮削
</Button>
) : null}
<Button
variant="outline"
size="sm"
@@ -1207,7 +1224,7 @@ export default function FilesPage() {
<CopyPathDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "refresh" ? (
<RefreshMetadataDialog items={dialog.items} onClose={() => setDialog(null)} />
<RefreshMetadataDialog items={dialog.items} write={dialog.write} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "wizard" ? <OrganizeWizard source={dialog.source} onClose={() => setDialog(null)} /> : null}
<ShortcutsDialog open={dialog?.kind === "shortcuts"} onOpenChange={(o) => !o && setDialog(null)} />
@@ -63,6 +63,7 @@ import {
VERIFY_STATUS,
} from "~/components/library/labels.ts";
import { Chip, ChipList, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx";
import { ScrapePanel } from "~/components/library/scrape-panel.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.tsx";
import {
@@ -1185,6 +1186,7 @@ export default function LibraryDetailPage() {
<MetadataPanel detail={d} entity={entity.data} />
</div>
<PathsPanel detail={d} />
<ScrapePanel detail={d} />
{e.kind !== "movie" || (entity.data?.contentUnits.length ?? 0) > 0 ? (
<UnitsPanel entity={entity} detail={d} />
) : null}
+1
View File
@@ -25,6 +25,7 @@
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
| DownloadTask | 下载任务 | 下载单 |
| Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 |
| 往媒体库目录写 NFO 与图片(scrapeMedia,SCR-1) | 刮削;按钮「刮削」「重新刮削」「覆盖重写」;覆盖方式叫「只补缺失 / 全部覆盖」;只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
| 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move |
| Library root | 媒体库目录 | 库根、媒体库根、根目录 |
| 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 |
+1
View File
@@ -217,3 +217,4 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
cf_clearance 有条件给 HTTP 重试、浏览器代抓与 6 小时浏览器代理期、站点页展示;见 `docs/runbook/sites.md`「Cloudflare(CF-2)」。
- **刮削(SCR-1,用户 2026-09-30 问"刮削测试过了吗")**:协调者核对——**没做**:文件管理里的 scrape 只是把元数据刷进本工具的库,不往媒体目录写 NFO / 海报 / 剧照(runbook files.md 已注明"NFO/图片投影仍未做");示例库里只有 mkv/srt。照 MP 的刮削实现(tvshow.nfo / season.nfo / 每集 nfo / movie.nfo,poster / fanart / season-poster / 剧照,Emby/Jellyfin/Kodi 兼容),入库后自动刮削 + 手动刮削 + 覆盖策略,再在示例库上验证。
- **搜索筛选对齐 MP(SEARCH-4,用户 2026-09-30)**:筛选维度太少,对齐 MP 搜索结果页的全部筛选(站点、季集、促销、分辨率、质量/来源、特效 HDR/DV、视频编码、音频编码、制作组/字幕组、字幕/语言、分类、年份、大小/做种等,以 MP 前端为准逐项核对);筛选弹层半透明看不清 → 改不透明实底。
**状态(分支 scr1)**:已实现——任务 `scrapeMedia`(文件道)、设置「刮削」、作品详情刮削状态与「重新刮削」、文件管理「刮削」;NFO 与 MP `TmdbScraper` 差分(登记差异见 `apps/server/test/scrape/nfo-deviations.json`);测试库 + 临时目录验证《人生切割术》S01 两集 + 《星际穿越》自动刮削。见 `docs/runbook/scrape.md`。
+1
View File
@@ -95,6 +95,7 @@ ci 逐行核对这张表与任务注册表(`apps/server/test/worker-lanes.test.t
|---|---|---|
| `verifyAndImport` | file | 下载完成后的校验、ffprobe、复制/硬链接/移动入库(含接管整理) |
| `cleanupDownloadCache` | file | 下载盘到暂停线时的清理;要尽快腾空间,不放维护道 |
| `scrapeMedia` | file | 刮削:写 NFO 与图片到媒体库目录(入库后自动 / 作品详情 / 文件管理),图片下载限并发 4 |
| `ingestFeed` | network | RSS 抓取 |
| `resolveObservation` | network | 标题识别 + TMDB |
| `enrichPoolEntry` | network | 未识别资源的元数据补全 |
+3 -3
View File
@@ -34,7 +34,7 @@
| 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记;有成功项时写 `organization_record(action=restore)`,返回 `recordId`);`purge` 仅管理员会话,缺省只清过期项。"还原到…"的目标必须是可写的 managed 库根(否则 403 `not_writable`);原位还原时缺失的父目录在根内逐级重建(已存在的必须是真目录,符号链接拒绝 `path_escape`);跨卷还原受 `FILES_TRASH_CROSS_MAX_BYTES` 上限(超出该项 `too_large`),复制经 fsproxy。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 |
| identify | 只读:`parseReleasePath`(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ `catalog.lookup` 能力取远端候选(经进程共享的 TMDB 信号量与 15 s 超时;单次请求去重后最多 20 个远端查询,超出的项只本地召回并给 warning;失败只给 warning)→ `rankCandidates` 前 5;目录展开视频上限 200;不建作品、不进待识别池 |
| probe | 需要 `organize.execute`;≤20 项;已登记资产写回 `media_asset.media_info.probe`;未登记缓存 24 h(`DATA_DIR/cache/probe`) |
| scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo/images` 目前返回 `not_implemented` |
| scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo` / `images` 另排 `scrapeMedia` 写 NFO 与图片(返回 `queued` + `scrapeTaskId`,见 `scrape.md`)。界面「刷新元数据」只刷字段,「刮削」走 `scrape-batch` `write=true` |
| download | `GET /files/download?root=&path=&inline=`:仅管理员会话(API Key 含 admin scope 也 403);只允许 NFO/字幕/图片(视频 403),≤ `FILES_DOWNLOAD_MAX_BYTES`(默认 20 MiB,超出 413 `too_large:`);路径守卫同其它接口,打开前再 realpath 复核、按文件句柄 stat;流式返回(非 JSON 信封),`inline=true` 仅对图片生效(SVG 一律附件),带 `nosniff` + `CSP sandbox`;写审计 `files.download` |
## 文件管理 v2 后端缺口(W9-H,`docs/design/file-manager-v2.md` §15)
@@ -45,7 +45,7 @@
| G2 | organize `minSizeBytes`;identify `minSizeBytes` | 缺省取 `settings.organize.minSizeBytes`(缺省 50 MiB,W10-pre2 起进契约);设置读不到时 organize 退回端口 `minVideoBytes`,identify 不过滤。请求 / 向导给的值优先,给 0 表示不过滤。自动入库的 `planImport` 同样按"请求 > 设置 > 端口"取值。设置页还没有这个字段(前端后续在「媒体库与目录 → 整理方式」加),目前只能 `PATCH /settings` `{"organize":{"minSizeBytes":<字节>}}` 修改。organize:低于阈值的视频进组 `excluded[]`,`reason=below_min_size`(sample/no_episode 等照原因名);identify:该项 `skipped="below_min_size"`、不查候选,结果 `excludedBySize` 计数;每项带 `size` |
| G3 | organize `onExisting: skip\|conflict\|replace_with_verify`(缺省 conflict) | 透传 `planImport.onExisting`;选 replace 即视为人工授权(`grantReplace`),执行走现有 replace 分支(新文件核验通过后旧文件进回收站)。预览逐文件 `action`(transfer/replace/skip/hold)+ `existing`(same 同一文件已在库 / skip / replace / conflict / null)+ `skipReason`;conflict 时该组 `error.code=plan_blocked`。策略进请求指纹,换策略须重新预览 |
| G4 | organize `naming: template\|keep`(缺省 template) | keep = 保留源文件名(目录仍按模板/绑定/targetDir;改名后撞名 → hold + duplicate_target)。`planImport` 新参数 `namingMode` / `namingSource`,冻结计划 `organization_record.plan.naming = {mode, templateSource: entity\|root\|global\|override, templateVersion}`(模板来源按 作品 > 库根 > 全局 回落;keep = override)。组响应同名 `naming` + `targetSegments[{text, role: root\|season\|dir, bound}]`(bound = 沿用已有路径绑定) |
| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch` | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch:`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;无 active 资产 `not_registered`、不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 |
| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch`(SCR-1 起 `write=true` 改排 `scrapeMedia`) | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch:`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;无 active 资产 `not_registered`、不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 |
| G6 | `GET /files/roots` 每项 `pendingCount` / `pendingPartial` | 仅下载目录:递归数待识别视频(与列表 `unidentified` 同口径:视频、非临时名、不在纳管下载任务内容内),上限 `FILES_LIST_MAX_ENTRIES` 项,超出 `pendingPartial=true`(计数为下限)。按根缓存 5 分钟,该根的 `files.changed` 事件使缓存失效;库根与离线根为 `null` |
| G7 | 列表项 `parsedHint` | 待识别视频行:`parseReleasePath`(文件名 + 父/祖父目录)的轻量结果 `标题 (年) · S01E02 · 1080p`(剧集不带年);不查库,解析不出标题或非待识别行为 `null`。前端副行直接显示,不再自己解析 |
| G8(海报) | identify 候选 `posterUrl` | TMDB `poster_path` 拼 `https://image.tmdb.org/t/p/w154…`(TMDB 适配器候选带 `posterPath`,`catalog.lookup` 远端项透传);本地作品取 `metadata.posterPath`,没有时用同一请求里其已关联 TMDB 候选的海报;都没有为 `null`。前端按 `no-referrer` 懒加载 |
@@ -76,7 +76,7 @@
## 已知缺口
- NFO/图片投影(scrape 的 `writeNfo/images`)、硬链接、蓝光原盘整目录操作仍未做。
- 硬链接、蓝光原盘整目录操作仍未做(刮削也跳过蓝光原盘目录)。NFO / 图片投影已由 SCR-1 实现,见 `scrape.md`。
- 搬运 move 在终态提交前删源侧空目录;若进程恰在两者之间退出,重跑 finalize 幂等,但极端情况下可能留下空目录(无数据风险)。
- 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。
+112
View File
@@ -0,0 +1,112 @@
# 刮削运行手册(SCR-1:NFO 与图片写进媒体库目录)
「刮削」= 把作品的最终字段写成 NFO,并下载海报、背景图、季海报、剧照,放进媒体库目录,Emby / Jellyfin / Kodi 直接读取。
「刷新元数据」只更新本工具库里的字段,不写媒体库目录;两者分开。
## 实现位置
| 位置 | 内容 |
| --- | --- |
| `packages/domain/src/scrape/nfo.ts` | NFO 生成(电影 / 剧集 / 季 / 集),纯函数 |
| `packages/domain/src/scrape/layout.ts` | 目标文件清单与命名 `planScrapeTargets`、作品目录推断 `inferWorkDir` |
| `apps/server/src/modules/acquisition-organize/services/scrape-input.ts` | 最终字段(`media_entity.metadata` / `content_unit.metadata`)→ NFO 输入 |
| `apps/server/src/modules/acquisition-organize/services/scrape.ts` | 排任务 `enqueueScrape`、执行 `runScrape`、状态 `loadScrapeState`、入库后钩子 `scrapeAfterImport` |
| `handlers.ts` 任务 `scrapeMedia` | 文件道;不重试(失败的文件在作品详情里重新刮削) |
| 迁移 `0057_media_scrape` | 每部作品最近一次刮削:状态、覆盖方式、逐文件结果 |
| 端口 `AoPorts.scrapeImage` | TMDB 图片路径 → 字节:`ImageCache.bytes("original", path)`,缓存目录 `DATA_DIR/cache/images`,出口按环境变量代理(`externalFetch`) |
## 触发
| 触发 | 做法 |
| --- | --- |
| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport` 打开时排 `scrapeMedia`,幂等键 `scrapeMedia:record:<整理记录 id>`(同一记录只排一次;季包一次整理只刮一次) |
| 作品详情「重新刮削」「覆盖重写」 | `POST /library/{id}/scrape {mode?}`(`organize.execute`,202);没有已入库文件 412 |
| 文件管理「刮削」(单项菜单、多选、详情按钮) | `POST /files/scrape-batch {write: true}`,按作品去重各排一次 |
| `POST /files/scrape {writeNfo / images}` | 在原有 `refreshMetadata` 之外再排一次 `scrapeMedia`,返回 `queued` 与 `scrapeTaskId` |
同一作品已有排队中(未开始,30 分钟内)的刮削时直接复用(`deduplicated: true`)。
## 设置(设置 → 媒体库与目录 →「刮削」)
| 字段 | 缺省 | 说明 |
| --- | --- | --- |
| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) |
| `organize.scrapeMode` | `missing` | `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖(替换别的工具写的 NFO 和图片) |
| `organize.scrapeImages` | 开 | 海报、背景图、季海报 |
| `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 |
## 写哪些文件
文件名跟随已入库的视频(视频名本身由命名模板生成);作品目录优先取作品的路径绑定,没有时从视频路径推(剧集父目录像
`Season 1` / `S01` / `Specials` 时取上一级)。
```
人生切割术 (2022)/
├── tvshow.nfo
├── poster.jpg
├── fanart.jpg
├── season01-poster.jpg # 特别篇:season-specials-poster.jpg
└── Season 1/
├── season.nfo
├── poster.jpg # 季海报(与作品目录那张同一张)
├── 人生切割术 - S01E01 - 第 1 集.mkv
├── 人生切割术 - S01E01 - 第 1 集.nfo
└── 人生切割术 - S01E01 - 第 1 集-thumb.jpg
星际穿越 (2014)/
├── 星际穿越 (2014) - 1080p.mkv
├── 星际穿越 (2014) - 1080p.nfo
├── poster.jpg
└── fanart.jpg
```
- 电影视频直接放在库根(没有作品目录)时,图片改为 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`,不往库根放 poster.jpg;
剧集视频直接在库根时只写每集文件,不写 tvshow / 季文件(结果里记 `scrape_no_work_dir`)。
- 多集文件(S01E01-E02)按首集写 NFO 与剧照(同 MP `begin_episode`)。蓝光原盘目录不刮削。
- 只写 managed 库根;只登记(`register_only`)库根整体跳过(`scrape_root_read_only`)。库根离线整体失败(`library_root_offline`)。
- 目标路径必须仍在库根内;已存在的目标是目录 / 符号链接时跳过。
## NFO 内容
对照 MP `app/modules/themoviedb/scraper.py`,元素名、顺序、属性一致,格式同 minidom `toprettyxml(indent=" ", encoding="utf-8")`:
- 公共(电影 / 剧集):`tmdbid` + `uniqueid type=tmdb`;有 TVDB / IMDb 时追加(有 IMDb 时 IMDb 为 `default`);`plot` / `outline`(CDATA);
`director`(带 `tmdbid`);`actor{name,type,role,tmdbid,thumb,profile}`;`genre`;`rating`(一位小数);`studio`;`country`;`runtime`;
`title`、`originaltitle`、`premiered`、`year`;剧集另有 `season=-1`、`episode=-1`。
- 季:`plot`、`outline`、`title`(没有时「季 N」)、`premiered`、`releasedate`、`year`、`seasonnumber`。
- 集:`title`(没有或 TMDB 占位名时「第 N 集」)、`plot`、`outline`、`aired`、`year`、`season`、`episode`、`rating`。
- 取值:标题 / 简介 / 标语按元数据语言链(见 `metadata.md`);字段锁与人工覆盖优先(用的是投影后的最终值)。
与 MP 的差异(差分登记 `apps/server/test/scrape/nfo-deviations.json`):
| 差异 | 理由 |
| --- | --- |
| 缺值不写元素(MP 写 `<premiered/>`、评分 0、空 CDATA) | 空元素不带信息;评分 0 会被当成真实评分 |
| 额外写豆瓣 id(`doubanid` + `uniqueid type=douban`)、`tagline`、集 `runtime` | 本地有这些字段 |
| 制片国取 TMDB `origin_country`(代码转中文名);剧集 `studio` 写播出平台 | 本地不存 `production_countries` / `production_companies` |
| 电影不写 `studio` | 同上 |
| 集 NFO 不写 `uniqueid` / `tmdbid` | 本地不存 TMDB 集 id;媒体服务器按季号集号匹配 |
| 简介含 `]]>` 时拆成两段 CDATA | MP 的 minidom 直接抛错 |
| 不写 `backdrop.jpg` 副本,只写 `fanart.jpg` | 三家都认 fanart.jpg |
| 剧照 `<视频名>-thumb.jpg`(MP `<视频名>.jpg`) | Kodi 约定,Jellyfin / Emby 同样识别 |
重新生成 MP 输出(改了样例或跟进上游后):
```sh
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-mp.py
```
## 结果与排查
- 作品详情「刮削」卡片:状态(排队中 / 刮削中 / 已完成 / 部分完成 / 失败)、写入 / 跳过 / 失败计数、逐文件结果;排队或进行中时每 3 秒刷新。
- `GET /library/{id}` 的 `scrape`:`status`、`trigger`(auto / manual)、`mode`、`written` / `skipped` / `failed`、`reason`、`files[{path, kind, role, action, reason}]`、`recordId`、`taskId`。
- 审计 `organize.scrape`(目标作品;载荷含写入与失败的文件,最多 200 条);写入后发 `files.changed`,文件管理自动刷新。
- 原因码(`packages/contracts/src/reasons.ts`):`scrape_no_files`、`scrape_no_metadata`、`scrape_root_read_only`、`scrape_exists`、
`scrape_write_failed`、`scrape_image_failed`、`scrape_no_work_dir`、`library_root_offline`、`entity_not_found`。
| 现象 | 看哪里 |
| --- | --- |
| 入库后没有 NFO | 设置里「入库后自动刮削」是否打开;作品详情「刮削」是否有记录;引擎运行 `scrapeMedia` |
| 全部「已存在」 | 覆盖方式是只补缺失;要替换用「覆盖重写」 |
| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后「重新刮削」只补缺失的图片 |
| 「作品还没有元数据」 | 作品没有 TMDB 字段,先刷新元数据或指定 TMDB 条目 |
+9
View File
@@ -2238,6 +2238,15 @@ export const Api = {
query: libraryVersions.LibraryVersionsQuery,
response: libraryVersions.LibraryVersions,
}),
scrape: ep({
method: "post",
path: "/library/{id}/scrape",
scope: "organize.execute",
summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)",
params: IdParam,
body: organization.LibraryScrapeRequest,
response: organization.LibraryScrapeResult,
}),
reconcile: ep({
method: "post",
path: "/library/reconcile",
+8 -3
View File
@@ -685,6 +685,7 @@ export type IdentifyCompletedPayload = z.infer<typeof IdentifyCompletedPayload>;
export const FilesScrapeRequest = z.object({
idempotencyKey: IdempotencyKey,
mediaEntityId: Id,
/** SCR-1:true 时另排 scrapeMedia,把 NFO(与 images 时的图片)写进媒体库目录 */
writeNfo: z.boolean().default(false),
images: z.boolean().default(false),
});
@@ -695,9 +696,11 @@ export const FilesScrapeResult = z.object({
taskId: z.string(),
/** 当前被用户锁定、刷新不会改动的字段 */
lockedFields: z.array(z.string()),
/** NFO/图片投影:W5 实现前请求时为 not_implemented */
nfo: z.enum(["written", "skipped", "not_requested", "not_implemented"]),
images: z.enum(["written", "skipped", "not_requested", "not_implemented"]),
/** NFO/图片投影(SCR-1):queued = 已排 scrapeMedia,结果看作品刮削状态;not_implemented 已不再返回 */
nfo: z.enum(["written", "skipped", "queued", "not_requested", "not_implemented"]),
images: z.enum(["written", "skipped", "queued", "not_requested", "not_implemented"]),
/** scrapeMedia 引擎 run id(writeNfo / images 时) */
scrapeTaskId: z.string().nullable().optional(),
});
export type FilesScrapeResult = z.infer<typeof FilesScrapeResult>;
@@ -845,6 +848,8 @@ export const FilesScrapeBatchRequest = z.object({
idempotencyKey: IdempotencyKey,
items: z.array(FileRef).max(500).optional(),
mediaEntityIds: z.array(Id).max(200).optional(),
/** SCR-1:true = 刮削(排 scrapeMedia 写 NFO 与图片,覆盖方式按设置);false(缺省)= 刷新元数据 */
write: z.boolean().default(false),
});
export type FilesScrapeBatchRequest = z.infer<typeof FilesScrapeBatchRequest>;
export const FilesScrapeBatchResult = z.object({
+77
View File
@@ -370,6 +370,81 @@ export const LibraryDroppedPlanView = z.object({
});
export type LibraryDroppedPlanView = z.infer<typeof LibraryDroppedPlanView>;
/** SCR-1 刮削:一个写入目标(NFO / 图片)的结果;path 为库根内相对路径 */
export const ScrapeFileResult = z.object({
path: z.string(),
kind: z.enum(["nfo", "image"]),
/** movie_nfo / tvshow_nfo / season_nfo / episode_nfo / poster / fanart / season_poster / episode_thumb */
role: z.string(),
action: z.enum(["written", "skipped", "failed"]),
/** 跳过 / 失败原因码(reasons.ts:scrape_exists / scrape_write_failed / scrape_image_failed …) */
reason: z.string().nullable(),
});
export type ScrapeFileResult = z.infer<typeof ScrapeFileResult>;
export const ScrapeMode = z.enum(["missing", "overwrite"]);
export type ScrapeMode = z.infer<typeof ScrapeMode>;
export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]);
export type ScrapeStatus = z.infer<typeof ScrapeStatus>;
export const ScrapeTrigger = z.enum(["auto", "manual"]);
export type ScrapeTrigger = z.infer<typeof ScrapeTrigger>;
/** 表 media_scrape 的行(一部作品一行,最近一次刮削) */
export const MediaScrape = z.object({
mediaEntityId: Id,
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
recordId: Id.nullable(),
engineRunId: z.string().nullable(),
requestedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
written: NonNegInt,
skipped: NonNegInt,
failed: NonNegInt,
reason: z.string().nullable(),
files: z.array(ScrapeFileResult),
createdAt: Timestamp,
updatedAt: Timestamp,
});
export type MediaScrape = z.infer<typeof MediaScrape>;
/** SCR-1 作品刮削状态(最近一次;表 media_scrape) */
export const LibraryScrapeState = z.object({
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
requestedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
written: NonNegInt,
skipped: NonNegInt,
failed: NonNegInt,
/** 整体失败原因码(没有文件、没有元数据等) */
reason: z.string().nullable(),
files: z.array(ScrapeFileResult),
/** 触发它的整理记录(入库后自动刮削) */
recordId: Id.nullable(),
/** 引擎 run id */
taskId: z.string().nullable(),
});
export type LibraryScrapeState = z.infer<typeof LibraryScrapeState>;
export const LibraryScrapeRequest = z.object({
/** 缺省按设置 organize.scrapeMode */
mode: ScrapeMode.optional(),
});
export type LibraryScrapeRequest = z.infer<typeof LibraryScrapeRequest>;
export const LibraryScrapeResult = z.object({
mediaEntityId: Id,
taskId: z.string(),
/** 同一作品已有排队中的刮削,直接复用 */
deduplicated: z.boolean(),
});
export type LibraryScrapeResult = z.infer<typeof LibraryScrapeResult>;
export const LibraryDetail = z.object({
entity: MediaEntity,
/** 身份映射状态(含 proposed/conflicted,前端区分展示) */
@@ -403,6 +478,8 @@ export const LibraryDetail = z.object({
}),
)
.optional(),
/** SCR-1:最近一次刮削(写 NFO 与图片);没刮削过为 null */
scrape: LibraryScrapeState.nullable().optional(),
});
export type LibraryDetail = z.infer<typeof LibraryDetail>;
+8
View File
@@ -87,6 +87,14 @@ const REASONS: Record<string, ReasonText> = {
read_only_reconcile: { text: "只读对账中,暂不提交下载" },
rejected: { text: "下载器拒绝了这个种子" },
source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" },
// SCR-1 刮削(作品刮削状态、逐文件结果)
scrape_no_files: { text: "作品没有已入库文件", action: "先整理入库" },
scrape_no_metadata: { text: "作品还没有元数据", action: "先刷新元数据" },
scrape_root_read_only: { text: "媒体库目录是只登记模式,不写文件" },
scrape_exists: { text: "文件已存在,按设置只补缺失" },
scrape_write_failed: { text: "写入媒体库目录失败", action: "检查目录权限后重新刮削" },
scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" },
scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" },
};
/**
+23 -2
View File
@@ -72,6 +72,14 @@ export const OrganizeSettings = z.object({
allowCrossVolumeMove: z.boolean(),
/** 整理 / 文件管理识别时视频文件的最小体积(字节),低于它的视为样片跳过;请求(向导)里给的值优先 */
minSizeBytes: z.number().int().nonnegative(),
/** SCR-1:整理入库成功后自动刮削(往媒体库目录写 NFO 与图片);缺省关(同 MP 目录「刮削」缺省) */
scrapeAfterImport: z.boolean(),
/** SCR-1:目标文件已存在时 missing = 只补缺失(MP「仅缺失」)/ overwrite = 全部覆盖 */
scrapeMode: z.enum(["missing", "overwrite"]),
/** SCR-1:下载海报、背景图、季海报 */
scrapeImages: z.boolean(),
/** SCR-1:下载每集剧照(`<视频名>-thumb.jpg`) */
scrapeEpisodeThumbs: z.boolean(),
});
export type OrganizeSettings = z.infer<typeof OrganizeSettings>;
@@ -618,7 +626,15 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
globalImageCache: false,
globalImageCacheDays: 7,
},
organize: { defaultMode: "copy", allowCrossVolumeMove: true, minSizeBytes: 50 * 1024 * 1024 },
organize: {
defaultMode: "copy",
allowCrossVolumeMove: true,
minSizeBytes: 50 * 1024 * 1024,
scrapeAfterImport: false,
scrapeMode: "missing",
scrapeImages: true,
scrapeEpisodeThumbs: true,
},
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
pi: {
defaultUpstreamId: null,
@@ -927,17 +943,22 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "library",
label: "媒体库与目录",
description: "媒体库目录、默认整理方式、命名模板",
description: "媒体库目录、默认整理方式、刮削、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "roots", label: "媒体库目录" },
{ id: "organize", label: "整理方式" },
{ id: "scrape", label: "刮削" },
{ id: "naming", label: "命名模板" },
],
fields: [
"organize.defaultMode",
"organize.allowCrossVolumeMove",
"organize.minSizeBytes",
"organize.scrapeAfterImport",
"organize.scrapeMode",
"organize.scrapeImages",
"organize.scrapeEpisodeThumbs",
"naming.movie",
"naming.tv",
"naming.variety",
+4
View File
@@ -63,6 +63,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
LibraryRoot: { row: C.LibraryRoot },
MediaAsset: { row: C.MediaAsset },
MediaEntity: { row: C.MediaEntity },
MediaScrape: { row: C.MediaScrape },
MediaServerInstance: { row: C.MediaServerInstance },
Observation: { row: C.Observation },
OrganizationFileResult: { row: C.OrganizationFileResult },
@@ -209,6 +210,9 @@ const ENUMS: Record<string, z.ZodEnum> = {
MediaAssetStatus: C.AssetStatus,
MediaEntityKind: C.MediaKind,
MediaEntityStatus: C.MediaEntityStatus,
MediaScrapeMode: C.ScrapeMode,
MediaScrapeStatus: C.ScrapeStatus,
MediaScrapeTrigger: C.ScrapeTrigger,
MediaServerInstanceKind: C.MediaServerKind,
MediaServerInstanceHealthStatus: C.InstanceHealth,
ObservationStatus: C.ObservationStatus,
@@ -0,0 +1,38 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// SCR-1 刮削:每部作品最近一次刮削(往媒体库目录写 NFO 与图片)的状态与逐文件结果。
// 一部作品一行,重新刮削覆盖;历史看审计(organize.scrape)与引擎运行记录。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`create table if not exists media_scrape (
media_entity_id uuid primary key references media_entity (id) on delete cascade,
status text not null default 'queued' check (status in ('queued', 'running', 'succeeded', 'partial', 'failed')),
trigger text not null default 'manual' check (trigger in ('auto', 'manual')),
mode text not null default 'missing' check (mode in ('missing', 'overwrite')),
record_id uuid references organization_record (id) on delete set null,
engine_run_id text,
requested_at timestamptz not null default now(),
started_at timestamptz,
finished_at timestamptz,
written integer not null default 0,
skipped integer not null default 0,
failed integer not null default 0,
reason text,
files jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
drop trigger if exists media_scrape_touch_updated_at on media_scrape;
create trigger media_scrape_touch_updated_at before update on media_scrape
for each row execute function umt_touch_updated_at();`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, "drop table if exists media_scrape;");
}
+26
View File
@@ -159,6 +159,9 @@ export type MediaAssetKind = "video" | "bluray_dir" | "subtitle" | "audio" | "nf
export type MediaAssetStatus = "active" | "replaced" | "retired" | "missing";
export type MediaEntityKind = "movie" | "series" | "variety" | "collection" | "other";
export type MediaEntityStatus = "provisional" | "active" | "merged" | "archived";
export type MediaScrapeStatus = "queued" | "running" | "succeeded" | "partial" | "failed";
export type MediaScrapeTrigger = "auto" | "manual";
export type MediaScrapeMode = "missing" | "overwrite";
export type MediaServerInstanceKind = "emby" | "jellyfin";
export type MediaServerInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
export type MetadataEnrichmentStatus = "suggested" | "applied" | "failed";
@@ -915,6 +918,25 @@ export interface MediaEntityTable {
naming_override: Json | null;
}
export interface MediaScrapeTable {
media_entity_id: string;
status: Generated<MediaScrapeStatus>;
trigger: Generated<MediaScrapeTrigger>;
mode: Generated<MediaScrapeMode>;
record_id: string | null;
engine_run_id: string | null;
requested_at: GeneratedTimestamptz;
started_at: Timestamptz | null;
finished_at: Timestamptz | null;
written: Generated<number>;
skipped: Generated<number>;
failed: Generated<number>;
reason: string | null;
files: Generated<Json>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface MediaServerInstanceTable {
id: Generated<string>;
name: string;
@@ -1698,6 +1720,7 @@ export interface DB {
mcp_server_config: McpServerConfigTable;
media_asset: MediaAssetTable;
media_entity: MediaEntityTable;
media_scrape: MediaScrapeTable;
media_server_instance: MediaServerInstanceTable;
metadata_enrichment: MetadataEnrichmentTable;
metadata_field: MetadataFieldTable;
@@ -1858,6 +1881,9 @@ export type MediaAssetUpdate = Updateable<MediaAssetTable>;
export type MediaEntityRow = Selectable<MediaEntityTable>;
export type NewMediaEntity = Insertable<MediaEntityTable>;
export type MediaEntityUpdate = Updateable<MediaEntityTable>;
export type MediaScrapeRow = Selectable<MediaScrapeTable>;
export type NewMediaScrape = Insertable<MediaScrapeTable>;
export type MediaScrapeUpdate = Updateable<MediaScrapeTable>;
export type MediaServerInstanceRow = Selectable<MediaServerInstanceTable>;
export type NewMediaServerInstance = Insertable<MediaServerInstanceTable>;
export type MediaServerInstanceUpdate = Updateable<MediaServerInstanceTable>;
+3
View File
@@ -26,6 +26,7 @@ const EXPECTED_TABLES = [
"path_mapping",
// 内容 / 身份
"media_entity",
"media_scrape",
"content_unit",
"title_alias",
"external_identity",
@@ -153,6 +154,8 @@ const NON_ENTITY_TABLES = [
"kv_cache",
// 站点请求计数:(site_id, hour) 主键
"site_request_count",
// 作品最近一次刮削:media_entity_id 主键(SCR-1)
"media_scrape",
];
describe.skipIf(skip)("schema migrations (real PG)", () => {
+1
View File
@@ -11,4 +11,5 @@ export * from "./path/index.ts";
export * from "./release-facets/index.ts";
export * from "./release-parser/index.ts";
export * from "./rules/index.ts";
export * from "./scrape/index.ts";
export * from "./slot/index.ts";
+3
View File
@@ -0,0 +1,3 @@
// SCR-1 刮削:NFO 生成与目标文件布局(纯函数)
export * from "./layout.ts";
export * from "./nfo.ts";
+222
View File
@@ -0,0 +1,222 @@
// SCR-1 刮削:算出一部作品要写哪些 NFO / 图片、写到哪里。纯函数,路径都是库根内相对路径(POSIX)。
// 文件名跟随已入库视频(视频名本身按命名模板生成),图片名按 Emby / Jellyfin / Kodi 通用约定。
// 对照 MP app/chain/scraping.py `_get_target_fileitem_and_path` / `_get_target_fileitems_and_paths` /
// `_expand_with_aliases` 与 themoviedb/scraper.py `get_metadata_img` / `get_season_poster`:
// - 电影:`<视频名>.nfo` 与视频同目录;海报 / 背景图放作品目录:poster.jpg、fanart.jpg
// (MP 另写 backdrop.jpg 副本,三家都认 fanart,我们不写副本);
// - 剧集:作品目录 tvshow.nfo、poster.jpg、fanart.jpg;季目录 season.nfo;季海报同时写作品目录
// `season01-poster.jpg`(特别篇 `season-specials-poster.jpg`)与季目录 `poster.jpg`(MP 同);
// - 每集:`<视频名>.nfo`;剧照 `<视频名>-thumb.jpg`(Kodi / Jellyfin / Emby 都认;MP 写 `<视频名>.jpg`)。
// 视频直接在库根(没有作品目录)时,电影图片改用 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`,
// 剧集不写 tvshow / 季文件(不能往库根里放 tvshow.nfo)。
/** 纯字符串的 POSIX dirname / basename / extname(domain 不依赖 node:path,前端也能打包) */
const posix = {
dirname(p: string): string {
const i = p.replace(/\/+$/, "").lastIndexOf("/");
return i < 0 ? "." : i === 0 ? "/" : p.slice(0, i);
},
basename(p: string): string {
const t = p.replace(/\/+$/, "");
return t.slice(t.lastIndexOf("/") + 1);
},
extname(p: string): string {
const b = posix.basename(p);
const i = b.lastIndexOf(".");
return i > 0 ? b.slice(i) : "";
},
};
export type ScrapeTargetRole =
| "movie_nfo"
| "tvshow_nfo"
| "season_nfo"
| "episode_nfo"
| "poster"
| "fanart"
| "season_poster"
| "episode_thumb";
export interface ScrapeVideo {
/** 库根内相对路径 */
path: string;
season?: number | null;
/** 首集集号(多集文件按首集,同 MP begin_episode) */
episode?: number | null;
}
export interface ScrapeImages {
posterPath?: string | null;
backdropPath?: string | null;
/** 季号 → 季海报路径 */
seasonPosters?: ReadonlyMap<number, string | null>;
/** "季:集" → 剧照路径 */
stills?: ReadonlyMap<string, string | null>;
}
export interface ScrapeLayoutInput {
kind: "movie" | "tv";
/** 作品目录(库根内相对路径);null / "" = 视频直接放在库根 */
workDir: string | null;
videos: readonly ScrapeVideo[];
images: ScrapeImages;
options: { images: boolean; episodeThumbs: boolean };
}
export interface ScrapeTarget {
/** 库根内相对路径 */
path: string;
kind: "nfo" | "image";
role: ScrapeTargetRole;
season?: number;
episode?: number;
/** 这个文件对应的视频(电影 / 集 NFO、剧照) */
video?: string;
/** 图片:TMDB 图片路径 */
imagePath?: string;
}
const SEASON_DIR_RE = /^(season\s*\d+|s\d{1,3}|specials?|特别篇|第\s*\d+\s*季)$/i;
/** 没有作品绑定时从视频路径推作品目录:电影取父目录;剧集父目录像季目录时取祖父目录 */
export function inferWorkDir(kind: "movie" | "tv", videoPath: string): string | null {
const parent = posix.dirname(videoPath);
if (parent === "." || parent === "") return null;
if (kind === "tv" && SEASON_DIR_RE.test(posix.basename(parent))) {
const gp = posix.dirname(parent);
return gp === "." || gp === "" ? null : gp;
}
return parent;
}
const stemOf = (p: string) => {
const base = posix.basename(p);
const i = base.lastIndexOf(".");
return i > 0 ? base.slice(0, i) : base;
};
const extOf = (imagePath: string) => {
const e = posix.extname(imagePath.split("?")[0] ?? "").toLowerCase();
return /^\.(jpe?g|png|webp)$/.test(e) ? e : ".jpg";
};
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 季海报在作品目录下的文件名(MP get_season_poster) */
export function seasonPosterName(season: number, ext = ".jpg"): string {
return season === 0 ? `season-specials-poster${ext}` : `season${pad2(season)}-poster${ext}`;
}
const normDir = (d: string | null) => (d && d !== "." ? d.replace(/\/+$/, "") : null);
const isUnder = (p: string, dir: string) => p === dir || p.startsWith(`${dir}/`);
/** 一部作品的刮削目标清单(去重,顺序:作品 → 季 → 集) */
export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
const out: ScrapeTarget[] = [];
const seen = new Set<string>();
const add = (t: ScrapeTarget) => {
if (seen.has(t.path)) return;
seen.add(t.path);
out.push(t);
};
const workDir = normDir(input.workDir);
const videos = [...input.videos].sort((a, b) => a.path.localeCompare(b.path));
const img = input.images;
const join = (dir: string | null, name: string) => (dir ? `${dir}/${name}` : name);
if (input.kind === "movie") {
for (const v of videos) {
const dir = normDir(posix.dirname(v.path));
add({ path: join(dir, `${stemOf(v.path)}.nfo`), kind: "nfo", role: "movie_nfo", video: v.path });
}
if (!input.options.images) return out;
const inWork = workDir && videos.some((v) => isUnder(v.path, workDir));
const art = (role: "poster" | "fanart", p: string | null | undefined) => {
if (!p) return;
if (inWork) add({ path: join(workDir, `${role}${extOf(p)}`), kind: "image", role, imagePath: p });
else
for (const v of videos) {
const dir = normDir(posix.dirname(v.path));
add({
path: join(dir, `${stemOf(v.path)}-${role}${extOf(p)}`),
kind: "image",
role,
imagePath: p,
video: v.path,
});
}
};
art("poster", img.posterPath);
art("fanart", img.backdropPath);
return out;
}
// 剧集
const underWork = workDir ? videos.filter((v) => isUnder(v.path, workDir)) : [];
if (workDir && underWork.length) {
add({ path: join(workDir, "tvshow.nfo"), kind: "nfo", role: "tvshow_nfo" });
if (input.options.images) {
if (img.posterPath)
add({
path: join(workDir, `poster${extOf(img.posterPath)}`),
kind: "image",
role: "poster",
imagePath: img.posterPath,
});
if (img.backdropPath)
add({
path: join(workDir, `fanart${extOf(img.backdropPath)}`),
kind: "image",
role: "fanart",
imagePath: img.backdropPath,
});
}
// 季:按视频所在目录(不等于作品目录的才算季目录)
const seasons = new Map<number, Set<string>>();
for (const v of underWork) {
if (v.season === null || v.season === undefined) continue;
const dir = normDir(posix.dirname(v.path));
const set = seasons.get(v.season) ?? new Set<string>();
if (dir && dir !== workDir) set.add(dir);
seasons.set(v.season, set);
}
for (const [season, dirs] of [...seasons].sort((a, b) => a[0] - b[0])) {
for (const d of [...dirs].sort()) add({ path: `${d}/season.nfo`, kind: "nfo", role: "season_nfo", season });
const sp = img.seasonPosters?.get(season);
if (input.options.images && sp) {
add({
path: join(workDir, seasonPosterName(season, extOf(sp))),
kind: "image",
role: "season_poster",
season,
imagePath: sp,
});
for (const d of [...dirs].sort())
add({ path: `${d}/poster${extOf(sp)}`, kind: "image", role: "season_poster", season, imagePath: sp });
}
}
}
for (const v of videos) {
if (v.season === null || v.season === undefined || v.episode === null || v.episode === undefined) continue;
const dir = normDir(posix.dirname(v.path));
const stem = stemOf(v.path);
add({
path: join(dir, `${stem}.nfo`),
kind: "nfo",
role: "episode_nfo",
season: v.season,
episode: v.episode,
video: v.path,
});
const still = img.stills?.get(`${v.season}:${v.episode}`);
if (input.options.images && input.options.episodeThumbs && still)
add({
path: join(dir, `${stem}-thumb${extOf(still)}`),
kind: "image",
role: "episode_thumb",
season: v.season,
episode: v.episode,
video: v.path,
imagePath: still,
});
}
return out;
}
+263
View File
@@ -0,0 +1,263 @@
// SCR-1 刮削:作品 / 季 / 集元数据 → NFO XML(Emby / Jellyfin / Kodi 通用)。纯函数,无 IO。
// 对照 MP app/modules/themoviedb/scraper.py(__gen_common_nfo / __gen_movie_nfo_file / __gen_tv_nfo_file /
// __gen_tv_season_nfo_file / __gen_tv_episode_nfo_file)的元素名、顺序与属性;输出格式对照 minidom
// toprettyxml(indent=" ", encoding="utf-8"):UTF-8 声明、两空格缩进、只有文本的元素写在一行、简介用 CDATA。
// 与 MP 的差异(差分登记见 apps/server/test/scrape/nfo-deviations.json):
// - 缺值不写元素(MP 写空元素 `<premiered/>`、评分缺省写 0、空 CDATA);
// - 简介里的 `]]>` 拆成两段 CDATA(MP 的 minidom 直接抛错);
// - 额外写 豆瓣 id(doubanid + uniqueid type=douban)、标语 tagline、集时长 runtime;
// - 集 NFO 没有 TMDB 集 id(本地不存),不写 uniqueid / tmdbid,媒体服务器按季号集号匹配。
export const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/original";
/** 人物:演员(role = 角色名)或导演 */
export interface NfoPerson {
tmdbId?: number | null;
name: string;
role?: string | null;
/** TMDB profile_path(`/xx.jpg`)或完整 URL */
profilePath?: string | null;
}
export interface NfoIds {
tmdb?: string | null;
imdb?: string | null;
tvdb?: string | null;
douban?: string | null;
}
/** 电影 / 剧集(tvshow.nfo) */
export interface NfoWork {
kind: "movie" | "tvshow";
title: string;
originalTitle?: string | null;
year?: number | null;
/** 首映 / 首播日期 YYYY-MM-DD */
premiered?: string | null;
overview?: string | null;
tagline?: string | null;
/** 0–10 */
rating?: number | null;
genres?: readonly string[];
/** 制片国(显示名) */
countries?: readonly string[];
/** 出品 / 播出平台 */
studios?: readonly string[];
/** 分钟 */
runtime?: number | null;
directors?: readonly NfoPerson[];
actors?: readonly NfoPerson[];
ids: NfoIds;
}
export interface NfoSeason {
season: number;
title?: string | null;
overview?: string | null;
airDate?: string | null;
}
export interface NfoEpisode {
season: number;
episode: number;
title?: string | null;
overview?: string | null;
airDate?: string | null;
rating?: number | null;
runtime?: number | null;
directors?: readonly NfoPerson[];
actors?: readonly NfoPerson[];
}
// ── 极简 XML 树(只覆盖 NFO 用到的形态) ──
type Node = { tag: string; attrs?: [string, string][]; text?: string; cdata?: string; children?: Node[] };
const escText = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escAttr = (s: string) =>
escText(s).replace(/"/g, "&quot;").replace(/\r/g, "&#13;").replace(/\n/g, "&#10;").replace(/\t/g, "&#9;");
/** XML 1.0 不允许的控制字符去掉(TMDB 简介偶见) */
// biome-ignore lint/suspicious/noControlCharactersInRegex: 就是要删控制字符
const clean = (s: string) => s.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, "");
const cdata = (s: string) => `<![CDATA[${s.replace(/]]>/g, "]]]]><![CDATA[>")}]]>`;
function render(n: Node, indent: string, out: string[]) {
const attrs = (n.attrs ?? []).map(([k, v]) => ` ${k}="${escAttr(clean(v))}"`).join("");
if (n.children?.length) {
out.push(`${indent}<${n.tag}${attrs}>`);
for (const c of n.children) render(c, `${indent} `, out);
out.push(`${indent}</${n.tag}>`);
} else if (n.cdata !== undefined) out.push(`${indent}<${n.tag}${attrs}>${cdata(clean(n.cdata))}</${n.tag}>`);
else if (n.text !== undefined) out.push(`${indent}<${n.tag}${attrs}>${escText(clean(n.text))}</${n.tag}>`);
else out.push(`${indent}<${n.tag}${attrs}/>`);
}
function toXml(root: Node): string {
const out = ['<?xml version="1.0" encoding="utf-8"?>'];
render(root, "", out);
return `${out.join("\n")}\n`;
}
const has = (v: string | null | undefined): v is string => typeof v === "string" && v.trim().length > 0;
const num = (v: number | null | undefined): v is number => typeof v === "number" && Number.isFinite(v);
/** TMDB 图片路径 → 完整 URL(MP settings.TMDB_IMAGE_URL,原图) */
export function tmdbImageFullUrl(path: string): string {
return /^https?:\/\//.test(path) ? path : `${TMDB_IMAGE_BASE}/${path.replace(/^\/+/, "")}`;
}
/** 评分:MP 写 round(x, 1) 的 Python 字符串形式(8.0 → "8.0") */
const ratingText = (v: number) => {
const r = Math.round(v * 10) / 10;
return Number.isInteger(r) ? r.toFixed(1) : String(r);
};
class Builder {
readonly nodes: Node[] = [];
text(tag: string, v: string | number | null | undefined, attrs?: [string, string][]) {
if (typeof v === "number") {
if (num(v)) this.nodes.push({ tag, text: String(v), ...(attrs ? { attrs } : {}) });
} else if (has(v)) this.nodes.push({ tag, text: v.trim(), ...(attrs ? { attrs } : {}) });
return this;
}
plot(v: string | null | undefined) {
if (has(v)) {
this.nodes.push({ tag: "plot", cdata: v.trim() });
this.nodes.push({ tag: "outline", cdata: v.trim() });
}
return this;
}
directors(list: readonly NfoPerson[] | undefined) {
for (const d of list ?? []) {
if (!has(d.name)) continue;
this.nodes.push({ tag: "director", text: d.name, attrs: [["tmdbid", num(d.tmdbId) ? String(d.tmdbId) : ""]] });
}
return this;
}
actors(list: readonly NfoPerson[] | undefined, withRole: boolean) {
for (const a of list ?? []) {
if (!has(a.name)) continue;
const children: Node[] = [
{ tag: "name", text: a.name },
{ tag: "type", text: "Actor" },
];
if (withRole && has(a.role)) children.push({ tag: "role", text: a.role });
if (num(a.tmdbId)) children.push({ tag: "tmdbid", text: String(a.tmdbId) });
if (has(a.profilePath)) children.push({ tag: "thumb", text: tmdbImageFullUrl(a.profilePath) });
if (num(a.tmdbId)) children.push({ tag: "profile", text: `https://www.themoviedb.org/person/${a.tmdbId}` });
this.nodes.push({ tag: "actor", children });
}
return this;
}
list(tag: string, values: readonly string[] | undefined) {
const seen = new Set<string>();
for (const v of values ?? []) {
if (!has(v) || seen.has(v.trim())) continue;
seen.add(v.trim());
this.nodes.push({ tag, text: v.trim() });
}
return this;
}
}
/**
* 外部 id 块(MP __gen_common_nfo):tmdbid + uniqueid(tmdb);有 TVDB / IMDb 时追加,IMDb 存在时 IMDb 为 default。
* 豆瓣是我们加的(uniqueid type=douban,不做 default)。
*/
function idBlock(b: Builder, ids: NfoIds) {
const tmdb: Node | null = has(ids.tmdb)
? {
tag: "uniqueid",
text: ids.tmdb,
attrs: [
["type", "tmdb"],
["default", "true"],
],
}
: null;
if (tmdb) {
b.text("tmdbid", ids.tmdb);
b.nodes.push(tmdb);
}
if (has(ids.tvdb)) {
b.text("tvdbid", ids.tvdb);
b.nodes.push({ tag: "uniqueid", text: ids.tvdb, attrs: [["type", "tvdb"]] });
}
if (has(ids.imdb)) {
b.text("imdbid", ids.imdb);
b.nodes.push({
tag: "uniqueid",
text: ids.imdb,
attrs: [
["type", "imdb"],
["default", "true"],
],
});
if (tmdb)
tmdb.attrs = [
["type", "tmdb"],
["default", "false"],
];
}
if (has(ids.douban)) {
b.text("doubanid", ids.douban);
b.nodes.push({ tag: "uniqueid", text: ids.douban, attrs: [["type", "douban"]] });
}
}
/** movie.nfo(`<视频名>.nfo`)/ tvshow.nfo */
export function workNfo(w: NfoWork): string {
const b = new Builder();
idBlock(b, w.ids);
b.plot(w.overview);
b.directors(w.directors);
b.actors(w.actors, true);
b.list("genre", w.genres);
if (num(w.rating) && w.rating > 0) b.text("rating", ratingText(w.rating));
b.list("studio", w.studios);
b.list("country", w.countries);
if (num(w.runtime) && w.runtime > 0) b.text("runtime", w.runtime);
b.text("title", w.title);
b.text("originaltitle", w.originalTitle);
b.text("premiered", w.premiered);
const year = num(w.year)
? w.year
: has(w.premiered) && /^\d{4}/.test(w.premiered)
? Number(w.premiered.slice(0, 4))
: null;
b.text("year", year);
if (w.kind === "tvshow") {
b.text("season", "-1");
b.text("episode", "-1");
}
b.text("tagline", w.tagline);
return toXml({ tag: w.kind === "movie" ? "movie" : "tvshow", children: b.nodes });
}
/** season.nfo(季目录内) */
export function seasonNfo(s: NfoSeason): string {
const b = new Builder();
b.plot(s.overview);
b.text("title", has(s.title) ? s.title : `季 ${s.season}`);
b.text("premiered", s.airDate);
b.text("releasedate", s.airDate);
b.text("year", has(s.airDate) && /^\d{4}/.test(s.airDate) ? s.airDate.slice(0, 4) : null);
b.text("seasonnumber", String(s.season));
return toXml({ tag: "season", children: b.nodes });
}
/** 每集 `<视频名>.nfo` */
export function episodeNfo(e: NfoEpisode): string {
const b = new Builder();
b.text("title", has(e.title) ? e.title : `第 ${e.episode} 集`);
b.plot(e.overview);
b.text("aired", e.airDate);
b.text("year", has(e.airDate) && /^\d{4}/.test(e.airDate) ? e.airDate.slice(0, 4) : null);
b.text("season", String(e.season));
b.text("episode", String(e.episode));
if (num(e.rating) && e.rating > 0) b.text("rating", ratingText(e.rating));
b.directors(e.directors);
b.actors(e.actors, false);
if (num(e.runtime) && e.runtime > 0) b.text("runtime", e.runtime);
return toXml({ tag: "episodedetails", children: b.nodes });
}
+201
View File
@@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import { episodeNfo, inferWorkDir, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "../src/index.ts";
describe("NFO 生成", () => {
it("电影:MP 元素顺序,缺值不写,IMDb 为 default", () => {
const xml = workNfo({
kind: "movie",
title: "星际穿越",
originalTitle: "Interstellar",
year: 2014,
premiered: "2014-11-05",
overview: "穿越虫洞。",
rating: 8.46,
genres: ["科幻", "剧情", "科幻"],
countries: ["美国"],
runtime: 169,
directors: [{ tmdbId: 525, name: "克里斯托弗·诺兰" }],
actors: [{ tmdbId: 10297, name: "马修·麦康纳", role: "Cooper", profilePath: "/mm.jpg" }],
ids: { tmdb: "157336", imdb: "tt0816692" },
});
expect(xml).toBe(
[
'<?xml version="1.0" encoding="utf-8"?>',
"<movie>",
" <tmdbid>157336</tmdbid>",
' <uniqueid type="tmdb" default="false">157336</uniqueid>',
" <imdbid>tt0816692</imdbid>",
' <uniqueid type="imdb" default="true">tt0816692</uniqueid>',
" <plot><![CDATA[穿越虫洞。]]></plot>",
" <outline><![CDATA[穿越虫洞。]]></outline>",
' <director tmdbid="525">克里斯托弗·诺兰</director>',
" <actor>",
" <name>马修·麦康纳</name>",
" <type>Actor</type>",
" <role>Cooper</role>",
" <tmdbid>10297</tmdbid>",
" <thumb>https://image.tmdb.org/t/p/original/mm.jpg</thumb>",
" <profile>https://www.themoviedb.org/person/10297</profile>",
" </actor>",
" <genre>科幻</genre>",
" <genre>剧情</genre>",
" <rating>8.5</rating>",
" <country>美国</country>",
" <runtime>169</runtime>",
" <title>星际穿越</title>",
" <originaltitle>Interstellar</originaltitle>",
" <premiered>2014-11-05</premiered>",
" <year>2014</year>",
"</movie>",
"",
].join("\n"),
);
});
it("剧集 tvshow.nfo:season/episode = -1,豆瓣 id、标语、平台", () => {
const xml = workNfo({
kind: "tvshow",
title: "人生切割术",
premiered: "2022-02-17",
tagline: "工作与生活,彻底分开",
studios: ["Apple TV+"],
ids: { tmdb: "95396", tvdb: "371980", douban: "30448896" },
});
expect(xml).toContain('<uniqueid type="tmdb" default="true">95396</uniqueid>');
expect(xml).toContain('<uniqueid type="tvdb">371980</uniqueid>');
expect(xml).toContain('<uniqueid type="douban">30448896</uniqueid>');
expect(xml).toContain("<studio>Apple TV+</studio>");
expect(xml).toMatch(/<year>2022<\/year>\n {2}<season>-1<\/season>\n {2}<episode>-1<\/episode>\n {2}<tagline>/);
expect(xml).not.toContain("<plot>");
expect(xml).not.toContain("<rating>");
});
it("转义与 CDATA:& < > 转义,]]> 拆段,控制字符删除", () => {
const xml = workNfo({
kind: "movie",
title: 'A & <B> "c"\u0001',
overview: "x]]>y",
ids: { tmdb: "1" },
});
expect(xml).toContain('<title>A &amp; &lt;B&gt; "c"</title>');
expect(xml).toContain("<plot><![CDATA[x]]]]><![CDATA[>y]]></plot>");
});
it("季与集", () => {
expect(seasonNfo({ season: 1, title: "第 1 季", airDate: "2022-02-17" })).toBe(
[
'<?xml version="1.0" encoding="utf-8"?>',
"<season>",
" <title>第 1 季</title>",
" <premiered>2022-02-17</premiered>",
" <releasedate>2022-02-17</releasedate>",
" <year>2022</year>",
" <seasonnumber>1</seasonnumber>",
"</season>",
"",
].join("\n"),
);
const ep = episodeNfo({ season: 1, episode: 2, overview: "参观。", airDate: "2022-02-18", rating: 8, runtime: 53 });
expect(ep).toContain("<title>第 2 集</title>");
expect(ep).toContain("<rating>8.0</rating>");
expect(ep).toMatch(/<season>1<\/season>\n {2}<episode>2<\/episode>/);
expect(ep).toContain("<runtime>53</runtime>");
});
});
describe("刮削目标布局", () => {
const images = {
posterPath: "/p.jpg",
backdropPath: "/b.png",
seasonPosters: new Map([[1, "/s1.jpg"]]),
stills: new Map([
["1:1", "/e1.jpg"],
["1:2", null],
]),
};
it("作品目录推断", () => {
expect(inferWorkDir("tv", "人生切割术 (2022)/Season 1/a.mkv")).toBe("人生切割术 (2022)");
expect(inferWorkDir("tv", "剧集/人生切割术 (2022)/S01/a.mkv")).toBe("剧集/人生切割术 (2022)");
expect(inferWorkDir("movie", "星际穿越 (2014)/a.mkv")).toBe("星际穿越 (2014)");
expect(inferWorkDir("movie", "a.mkv")).toBeNull();
expect(seasonPosterName(0)).toBe("season-specials-poster.jpg");
});
it("剧集:tvshow / 季 / 集,季海报写两处,缺剧照不写", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: "人生切割术 (2022)",
videos: [
{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.mkv", season: 1, episode: 2 },
{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.mkv", season: 1, episode: 1 },
],
images,
options: { images: true, episodeThumbs: true },
});
expect(t.map((x) => x.path)).toEqual([
"人生切割术 (2022)/tvshow.nfo",
"人生切割术 (2022)/poster.jpg",
"人生切割术 (2022)/fanart.png",
"人生切割术 (2022)/Season 1/season.nfo",
"人生切割术 (2022)/season01-poster.jpg",
"人生切割术 (2022)/Season 1/poster.jpg",
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.nfo",
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集-thumb.jpg",
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.nfo",
]);
});
it("关图片 / 关剧照", () => {
const base = {
kind: "tv" as const,
workDir: "S",
videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }],
images,
};
expect(planScrapeTargets({ ...base, options: { images: false, episodeThumbs: true } }).map((x) => x.role)).toEqual([
"tvshow_nfo",
"season_nfo",
"episode_nfo",
]);
expect(
planScrapeTargets({ ...base, options: { images: true, episodeThumbs: false } }).some(
(x) => x.role === "episode_thumb",
),
).toBe(false);
});
it("电影:作品目录里写 poster/fanart;在库根时用 <视频名>-poster", () => {
const inDir = planScrapeTargets({
kind: "movie",
workDir: "星际穿越 (2014)",
videos: [{ path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv" }],
images,
options: { images: true, episodeThumbs: true },
});
expect(inDir.map((x) => x.path)).toEqual([
"星际穿越 (2014)/星际穿越 (2014) - 1080p.nfo",
"星际穿越 (2014)/poster.jpg",
"星际穿越 (2014)/fanart.png",
]);
const atRoot = planScrapeTargets({
kind: "movie",
workDir: null,
videos: [{ path: "x.mkv" }],
images,
options: { images: true, episodeThumbs: true },
});
expect(atRoot.map((x) => x.path)).toEqual(["x.nfo", "x-poster.jpg", "x-fanart.png"]);
});
it("剧集视频在库根:不写 tvshow / 季文件,只写集 NFO", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: null,
videos: [{ path: "a.S01E01.mkv", season: 1, episode: 1 }],
images,
options: { images: true, episodeThumbs: true },
});
expect(t.map((x) => x.path)).toEqual(["a.S01E01.nfo", "a.S01E01-thumb.jpg"]);
});
});