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:
commit
2e83107f68
45 files changed
+2867
-41
No files matched your search
@@ -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
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 同样识别,且不会和同名海报规则冲突"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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"
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("被状态阻止的项灰显带原因", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ export const WORKFLOW_LABEL: Record<string, string> = {
|
||||
evaluateAcquisition: "评估候选",
|
||||
submitDownload: "提交下载",
|
||||
verifyAndImport: "核验入库",
|
||||
scrapeMedia: "刮削",
|
||||
refreshMetadata: "刷新元数据",
|
||||
reconcileLibrary: "文件核对",
|
||||
cleanupDownloadCache: "清理缓存",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
|
||||
| DownloadTask | 下载任务 | 下载单 |
|
||||
| Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 |
|
||||
| 往媒体库目录写 NFO 与图片(scrapeMedia,SCR-1) | 刮削;按钮「刮削」「重新刮削」「覆盖重写」;覆盖方式叫「只补缺失 / 全部覆盖」;只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
|
||||
| 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move |
|
||||
| Library root | 媒体库目录 | 库根、媒体库根、根目录 |
|
||||
| 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 |
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 | 未识别资源的元数据补全 |
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
|
||||
@@ -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 条目 |
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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: "视频直接放在媒体库目录下,不写剧集和季的文件" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;");
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,3 @@
|
||||
// SCR-1 刮削:NFO 生成与目标文件布局(纯函数)
|
||||
export * from "./layout.ts";
|
||||
export * from "./nfo.ts";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const escAttr = (s: string) =>
|
||||
escText(s).replace(/"/g, """).replace(/\r/g, " ").replace(/\n/g, " ").replace(/\t/g, "	");
|
||||
/** 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 });
|
||||
}
|
||||
@@ -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 & <B> "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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user