merge: fix/e-scrape
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
b3555c04b5
24 files changed
+1039
-79
No files matched your search
@@ -0,0 +1,327 @@
|
||||
import { readFile, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import type { TmdbRichSeasonDetail } from "@umt/adapters";
|
||||
import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts";
|
||||
|
||||
// 刮削准确度:
|
||||
// - 「更新」模式(缺省):不存在的写入;本工具上次写入且没被改过的按新内容覆盖(内容没变不重写);被人改过的跳过
|
||||
// (scrape_user_modified);不是本工具写的不动;旧版本没有 hash 的记录按修改时间兜底;
|
||||
// - 集 / 季缺元数据:先按 TMDB id 现取季详情补齐;取不到跳过(scrape_metadata_missing)、记录 partial,不写占位 NFO;
|
||||
// - 合集 / 其他类作品按电影刮,NFO 根元素是 <movie>;
|
||||
// - 兜底异常记 scrape_internal_error。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const at = new Date().toISOString();
|
||||
const f = (value: unknown, language: string | null = null) => ({ value, source: "tmdb", language, at, locked: false });
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
function season1(names: Record<number, string>): TmdbRichSeasonDetail {
|
||||
return {
|
||||
tvId: "95396",
|
||||
seasonNumber: 1,
|
||||
name: "第 1 季",
|
||||
nameLanguage: "zh-CN",
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
airDate: "2022-02-17",
|
||||
posterPath: null,
|
||||
fetchedLanguages: ["zh-CN"],
|
||||
episodes: Object.entries(names).map(([n, name]) => ({
|
||||
seasonNumber: 1,
|
||||
episodeNumber: Number(n),
|
||||
name,
|
||||
nameLanguage: "zh-CN",
|
||||
overview: `${name}的简介`,
|
||||
overviewLanguage: "zh-CN",
|
||||
airDate: "2022-02-25",
|
||||
runtime: 50,
|
||||
stillPath: null,
|
||||
voteAverage: null,
|
||||
voteCount: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPg)("刮削准确度:更新模式、缺元数据不写占位、作品类型", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let rootId = "";
|
||||
let seasonDetail: (n: number) => Promise<TmdbRichSeasonDetail | null> = async () => null;
|
||||
const seasonCalls: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree(
|
||||
{
|
||||
"Movies/星际穿越 (2014)/Interstellar.2014.mkv": 100,
|
||||
"Movies/星际穿越 (2014)/fanart.jpg": "别的工具放的背景图",
|
||||
"TV/人生切割术 (2022)/Season 1/Severance.S01E01.mkv": 100,
|
||||
"TV/人生切割术 (2022)/Season 1/Severance.S01E02.mkv": 100,
|
||||
"Movies/合集 (2020)/Collection.2020.mkv": 100,
|
||||
"Movies/其他 (2021)/Other.2021.mkv": 100,
|
||||
},
|
||||
{ sentinel: true, prefix: "umt-scracc-" },
|
||||
);
|
||||
h = await createHarness("scrape_acc", {
|
||||
scrapeImage: async (p) => ({ content: enc(p), mime: "image/jpeg" }),
|
||||
scrapeTmdbSeason: async (_tv, n) => {
|
||||
seasonCalls.push(n);
|
||||
return seasonDetail(n);
|
||||
},
|
||||
});
|
||||
rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" })).rootId;
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await lib?.cleanup();
|
||||
});
|
||||
|
||||
async function entity(kind: "movie" | "series" | "collection" | "other", title: string, meta: object) {
|
||||
return (
|
||||
await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind, title, year: 2020, status: "active", metadata: JSON.stringify(meta) })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
async function scrape(
|
||||
entityId: string,
|
||||
kind: "movie" | "tv",
|
||||
path: string,
|
||||
mode?: "missing" | "overwrite" | "update",
|
||||
externalId: string | null = null,
|
||||
) {
|
||||
const match: ScrapeMatch = {
|
||||
mediaEntityId: entityId,
|
||||
provider: externalId ? "tmdb" : null,
|
||||
externalId,
|
||||
kind,
|
||||
title: "x",
|
||||
year: null,
|
||||
source: "library",
|
||||
};
|
||||
const q = await queueScrape(h.d, {
|
||||
rootId: `lib:${rootId}`,
|
||||
rootPath: lib.root,
|
||||
libraryRootId: rootId,
|
||||
path,
|
||||
match,
|
||||
mode,
|
||||
trigger: "manual",
|
||||
});
|
||||
return runScrapeRecord(h.d, q.recordId);
|
||||
}
|
||||
const fileOf = (files: ScrapeFileResult[], role: string) => files.find((x) => x.role === role);
|
||||
const setTitle = (id: string, title: string) =>
|
||||
h.db
|
||||
.updateTable("media_entity")
|
||||
.set({ metadata: JSON.stringify({ title: f(title), posterPath: f("/inter.jpg") }) })
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
|
||||
const MOVIE_DIR = "Movies/星际穿越 (2014)";
|
||||
const MOVIE_NFO = `${MOVIE_DIR}/Interstellar.2014.nfo`;
|
||||
let movie = "";
|
||||
|
||||
it("更新模式:不存在的写入并记 hash;不是本工具写的不动", async () => {
|
||||
movie = await entity("movie", "星际穿越", { title: f("星际穿越"), posterPath: f("/inter.jpg") });
|
||||
const r = await scrape(movie, "movie", MOVIE_DIR);
|
||||
expect(r).toMatchObject({ status: "succeeded", mode: "update" });
|
||||
expect(fileOf(r.files, "movie_nfo")).toMatchObject({
|
||||
action: "written",
|
||||
hash: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
});
|
||||
expect(fileOf(r.files, "poster")).toMatchObject({ action: "written" });
|
||||
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越</title>");
|
||||
// fanart.jpg 是别的工具放的:元数据没有背景图,不在目标里;加上背景图后更新模式也不覆盖它
|
||||
await h.db
|
||||
.updateTable("media_entity")
|
||||
.set({
|
||||
metadata: JSON.stringify({ title: f("星际穿越"), posterPath: f("/inter.jpg"), backdropPath: f("/bg.jpg") }),
|
||||
})
|
||||
.where("id", "=", movie)
|
||||
.execute();
|
||||
const again = await scrape(movie, "movie", MOVIE_DIR, "update");
|
||||
expect(fileOf(again.files, "fanart")).toMatchObject({ action: "skipped", reason: "scrape_exists" });
|
||||
expect(await readFile(lib.path(`${MOVIE_DIR}/fanart.jpg`), "utf8")).toBe("别的工具放的背景图");
|
||||
// 内容没变的不重写
|
||||
expect(fileOf(again.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_unchanged" });
|
||||
expect(fileOf(again.files, "poster")).toMatchObject({ action: "skipped", reason: "scrape_unchanged" });
|
||||
expect(again.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("更新模式:本工具写的、没被改过的按新内容覆盖;被人改过的跳过并记原因", async () => {
|
||||
await setTitle(movie, "星际穿越(新译名)");
|
||||
const upd = await scrape(movie, "movie", MOVIE_DIR, "update");
|
||||
expect(fileOf(upd.files, "movie_nfo")).toMatchObject({ action: "written" });
|
||||
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(新译名)</title>");
|
||||
|
||||
await writeFile(lib.path(MOVIE_NFO), "<movie><title>我手改的</title></movie>");
|
||||
await setTitle(movie, "星际穿越(再改)");
|
||||
const kept = await scrape(movie, "movie", MOVIE_DIR, "update");
|
||||
expect(fileOf(kept.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
|
||||
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toBe("<movie><title>我手改的</title></movie>");
|
||||
expect(kept.status).toBe("succeeded");
|
||||
// 之后每次更新都还认得出是人改过的(跳过的结果不带 hash,不会把人改的内容当成本工具写的)
|
||||
const still = await scrape(movie, "movie", MOVIE_DIR, "update");
|
||||
expect(fileOf(still.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
|
||||
// 全部覆盖照旧改写
|
||||
const over = await scrape(movie, "movie", MOVIE_DIR, "overwrite");
|
||||
expect(fileOf(over.files, "movie_nfo")).toMatchObject({ action: "written" });
|
||||
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(再改)</title>");
|
||||
});
|
||||
|
||||
it("更新模式:旧版本记录没有 hash 时按修改时间判断", async () => {
|
||||
// 模拟旧版本:去掉所有记录里的 hash
|
||||
const rows = await h.db.selectFrom("scrape_record").select(["id", "files", "finished_at"]).execute();
|
||||
for (const r of rows)
|
||||
await h.db
|
||||
.updateTable("scrape_record")
|
||||
.set({
|
||||
files: JSON.stringify((r.files as ScrapeFileResult[]).map(({ hash: _h, ...x }) => x)),
|
||||
})
|
||||
.where("id", "=", r.id)
|
||||
.execute();
|
||||
const last = rows
|
||||
.filter((r) => r.finished_at)
|
||||
.sort((a, b) => +new Date(b.finished_at!) - +new Date(a.finished_at!))[0]!;
|
||||
const past = new Date(+new Date(last.finished_at!) - 60_000);
|
||||
await utimes(lib.path(MOVIE_NFO), past, past);
|
||||
await setTitle(movie, "星际穿越(旧记录)");
|
||||
const r = await scrape(movie, "movie", MOVIE_DIR, "update");
|
||||
expect(fileOf(r.files, "movie_nfo")).toMatchObject({ action: "written" });
|
||||
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(旧记录)</title>");
|
||||
|
||||
// 改动时间晚于那次刮削:当作人改过
|
||||
const rows2 = await h.db.selectFrom("scrape_record").select(["id", "files"]).execute();
|
||||
for (const x of rows2)
|
||||
await h.db
|
||||
.updateTable("scrape_record")
|
||||
.set({ files: JSON.stringify((x.files as ScrapeFileResult[]).map(({ hash: _h, ...y }) => y)) })
|
||||
.where("id", "=", x.id)
|
||||
.execute();
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
await utimes(lib.path(MOVIE_NFO), future, future);
|
||||
expect((await stat(lib.path(MOVIE_NFO))).mtimeMs).toBeGreaterThan(Date.now());
|
||||
await setTitle(movie, "星际穿越(不该写进去)");
|
||||
const kept = await scrape(movie, "movie", MOVIE_DIR, "update");
|
||||
expect(fileOf(kept.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
|
||||
});
|
||||
|
||||
const SHOW = "TV/人生切割术 (2022)";
|
||||
let series = "";
|
||||
|
||||
it("集单元没有元数据:按 TMDB id 现取季详情补齐,写出正确的集 NFO", async () => {
|
||||
series = await entity("series", "人生切割术", {
|
||||
title: f("人生切割术"),
|
||||
overview: f("马克领导着一个办公室团队。"),
|
||||
});
|
||||
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 季") }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: series,
|
||||
parent_unit_id: season.id,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: 1,
|
||||
status: "confirmed",
|
||||
title: "有关地狱的好消息",
|
||||
metadata: JSON.stringify({ name: f("有关地狱的好消息"), overview: f("第一集简介") }),
|
||||
})
|
||||
.execute();
|
||||
// 第 2 集是整理懒建的空单元
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: series,
|
||||
parent_unit_id: season.id,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: 2,
|
||||
status: "provisional",
|
||||
})
|
||||
.execute();
|
||||
seasonDetail = async (n) => (n === 1 ? season1({ 1: "TMDB 第一集", 2: "半循环" }) : null);
|
||||
const r = await scrape(series, "tv", SHOW, "update", "95396");
|
||||
expect(seasonCalls).toEqual([1]);
|
||||
expect(r.status).toBe("succeeded");
|
||||
const ep2 = await readFile(lib.path(`${SHOW}/Season 1/Severance.S01E02.nfo`), "utf8");
|
||||
expect(ep2).toContain("<title>半循环</title>");
|
||||
expect(ep2).toContain("<plot><![CDATA[半循环的简介]]></plot>");
|
||||
// 本地已有的集字段不被 TMDB 现取的值替换
|
||||
expect(await readFile(lib.path(`${SHOW}/Season 1/Severance.S01E01.nfo`), "utf8")).toContain(
|
||||
"<title>有关地狱的好消息</title>",
|
||||
);
|
||||
});
|
||||
|
||||
it("TMDB 季详情失败:缺元数据的集跳过(scrape_metadata_missing)、记录 partial,不写占位 NFO", async () => {
|
||||
await writeFile(lib.path(`${SHOW}/Season 1/Severance.S01E03.mkv`), "video");
|
||||
seasonDetail = async () => {
|
||||
throw new Error("TMDB 超时");
|
||||
};
|
||||
const r = await scrape(series, "tv", SHOW, "update", "95396");
|
||||
expect(r).toMatchObject({ status: "partial", reason: "scrape_metadata_missing" });
|
||||
expect(r.files.find((x) => x.path.endsWith("S01E03.nfo"))).toMatchObject({
|
||||
action: "skipped",
|
||||
reason: "scrape_metadata_missing",
|
||||
});
|
||||
await expect(stat(lib.path(`${SHOW}/Season 1/Severance.S01E03.nfo`))).rejects.toThrow();
|
||||
|
||||
// 季单元也没有元数据时同样不写「季 N」占位
|
||||
await h.db
|
||||
.updateTable("content_unit")
|
||||
.set({ title: null, metadata: JSON.stringify({}) })
|
||||
.where("media_entity_id", "=", series)
|
||||
.where("kind", "=", "season")
|
||||
.execute();
|
||||
const r2 = await scrape(series, "tv", SHOW, "overwrite", "95396");
|
||||
expect(fileOf(r2.files, "season_nfo")).toMatchObject({ action: "skipped", reason: "scrape_metadata_missing" });
|
||||
expect(r2.status).toBe("partial");
|
||||
});
|
||||
|
||||
it("合集 / 其他类作品按电影刮削,NFO 根元素是 <movie>", async () => {
|
||||
expect(nfoWork({ kind: "collection", title: "x", original_title: null, year: null, metadata: {} }, {}).kind).toBe(
|
||||
"movie",
|
||||
);
|
||||
for (const [kind, dir, nfo] of [
|
||||
["collection", "Movies/合集 (2020)", "Collection.2020.nfo"],
|
||||
["other", "Movies/其他 (2021)", "Other.2021.nfo"],
|
||||
] as const) {
|
||||
const id = await entity(kind, `${kind} 作品`, { title: f(`${kind} 作品`) });
|
||||
const r = await scrape(id, "movie", dir);
|
||||
expect(r.status).toBe("succeeded");
|
||||
const xml = await readFile(lib.path(`${dir}/${nfo}`), "utf8");
|
||||
expect(xml).toContain("\n<movie>");
|
||||
expect(xml).not.toContain("<tvshow>");
|
||||
}
|
||||
});
|
||||
|
||||
it("兜底异常记 scrape_internal_error(不是写文件失败)", async () => {
|
||||
h.d.ports.scrapeTmdbSeason = (() => {
|
||||
throw new Error("意外错误");
|
||||
}) as never;
|
||||
await h.db
|
||||
.updateTable("content_unit")
|
||||
.set({ title: null, metadata: JSON.stringify({}) })
|
||||
.where("media_entity_id", "=", series)
|
||||
.execute();
|
||||
const r = await scrape(series, "tv", SHOW, "update", "95396");
|
||||
expect(r).toMatchObject({ status: "failed", reason: "scrape_internal_error" });
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,7 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
}
|
||||
|
||||
/** 作品详情「重新刮削」的服务端做法:对作品目录排刮削并执行 */
|
||||
async function rescrape(entity: string, mode?: "missing" | "overwrite") {
|
||||
async function rescrape(entity: string, mode?: "missing" | "overwrite" | "update") {
|
||||
const q = await scrapeEntity(h.d, entity, { trigger: "manual", mode });
|
||||
expect(q.length).toBe(1);
|
||||
return runScrapeRecord(h.d, q[0]!.recordId);
|
||||
@@ -193,7 +193,7 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
expect(first).toMatchObject({
|
||||
media_entity_id: series,
|
||||
trigger: "auto",
|
||||
mode: "missing",
|
||||
mode: "update", // 缺省覆盖方式:更新
|
||||
organization_record_id: rec,
|
||||
});
|
||||
|
||||
|
||||
@@ -124,6 +124,12 @@ describe.skipIf(skip)("file manager in-place import", () => {
|
||||
.where("organization_record_id", "=", r.recordId)
|
||||
.execute();
|
||||
expect(recs).toMatchObject([{ path: title, videos: [ep1] }]);
|
||||
// 季 / 集单元缺元数据时不写占位 NFO:模拟刷新元数据已填过整理懒建的单元
|
||||
await h.db
|
||||
.updateTable("content_unit")
|
||||
.set({ metadata: JSON.stringify({ name: { value: "名称", source: "tmdb", language: null, at, locked: false } }) })
|
||||
.where("media_entity_id", "=", entity)
|
||||
.execute();
|
||||
await runScrapeRecord(h.module.deps().ao, recs[0]!.id);
|
||||
const show = await readdir(join(h.lib, title));
|
||||
const season = await readdir(join(h.lib, title, "Season 1"));
|
||||
|
||||
@@ -277,7 +277,7 @@ describe.skipIf(skip)("files 刮削(SCR-2:任意目录就地刮削)", () => {
|
||||
expect(show).toMatchObject({
|
||||
status: "succeeded",
|
||||
path: SHOW,
|
||||
mode: "missing",
|
||||
mode: "update",
|
||||
trigger: "manual",
|
||||
match: {
|
||||
mediaEntityId: null,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createMockTmdb, createTempTree, type MockTmdb, type TempTree, TMDB_FIXTURES } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { AoPorts } from "../../src/modules/acquisition-organize/ports.ts";
|
||||
import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts";
|
||||
import {
|
||||
RESCRAPE_DELAY_MS,
|
||||
runScrapeRecord,
|
||||
SCRAPE_TASK,
|
||||
scrapeEntity,
|
||||
} from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts";
|
||||
import { setEntityIdentity } from "../../src/modules/sources-identity/service/identity-admin.ts";
|
||||
import { refreshMetadata, setFieldOverride } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
|
||||
// 改作品的 TMDB 映射后:清掉旧作品的 tmdb 来源字段(人工锁定的保留)→ full 刷新 → 刮削过的作品延后排一次「更新」模式重刮;
|
||||
// 重刮后的 NFO 标题 / 简介 / IMDb / 海报都是新作品的,不残留旧作品;刷新期间再刷新合进同一条排队记录(防抖)。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
const VIDEO = "Movies/奥本海默 (2023)/Oppenheimer.2023.1080p.mkv";
|
||||
const DIR = "Movies/奥本海默 (2023)";
|
||||
const NFO = `${DIR}/Oppenheimer.2023.1080p.nfo`;
|
||||
type Final = { value: unknown; source: string; locked: boolean };
|
||||
|
||||
describe.skipIf(!up)("改映射后不再新旧混写:清字段 → 刷新 → 更新模式重刮", () => {
|
||||
let h: Harness;
|
||||
let tmdb: MockTmdb;
|
||||
let lib: TempTree;
|
||||
let d: AoDeps;
|
||||
let id = "";
|
||||
const actor = { type: "user" as const, id: "tester" };
|
||||
const enqueueOpts: { task: string; availableAt?: Date | undefined }[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// 沙丘 2 补一张海报,验证改映射后海报换成新作品的
|
||||
const fixtures = TMDB_FIXTURES.map((f) => (f.id === 693134 ? { ...f, poster_path: "/dune.jpg" } : f));
|
||||
tmdb = await createMockTmdb({ fixtures });
|
||||
h = await createHarness("idrescrape", { tmdbUrl: tmdb.url });
|
||||
lib = await createTempTree({ [VIDEO]: 1000 }, { sentinel: true, prefix: "umt-idscr-" });
|
||||
const orig = h.engine.enqueue.bind(h.engine);
|
||||
h.engine.enqueue = async (task, input, o) => {
|
||||
enqueueOpts.push({ task, availableAt: o.availableAt });
|
||||
return orig(task, input, o);
|
||||
};
|
||||
d = {
|
||||
ctx: h.ctx,
|
||||
ports: {
|
||||
now: () => new Date(),
|
||||
scrapeImage: async (p: string) => ({ content: enc(p), mime: "image/jpeg" }),
|
||||
scrapeTmdbDetail: async () => null,
|
||||
scrapeTmdbSeason: async () => null,
|
||||
} as unknown as AoPorts,
|
||||
};
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
await lib?.cleanup();
|
||||
});
|
||||
|
||||
const queuedUpdates = () =>
|
||||
h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["id", "mode", "trigger", "status"])
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("status", "=", "queued")
|
||||
.execute();
|
||||
|
||||
it("先按奥本海默建档、刷新、刮削;没刮削过时刷新不排重刮", async () => {
|
||||
const r = await createEntityFromTmdbCandidate(
|
||||
h.db,
|
||||
() => h.rt.tmdb(),
|
||||
{ externalId: "872585", kind: "movie" },
|
||||
actor,
|
||||
);
|
||||
id = r.mediaEntityId;
|
||||
expect((await refreshMetadata(h.rt, id, { full: true })).status).toBe("refreshed");
|
||||
expect(h.engine.enqueued.some((x) => x.task === SCRAPE_TASK)).toBe(false);
|
||||
await setFieldOverride(h.rt, id, "tagline", { value: "我的标语", lock: true }, actor);
|
||||
|
||||
const root = await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "lib-idscr", media_kind: "mixed", local_path: lib.root, management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const asset = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: id, kind: "video" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({ media_asset_id: asset.id, library_root_id: root.id, relative_path: VIDEO })
|
||||
.execute();
|
||||
|
||||
const q = await scrapeEntity(d, id, { trigger: "manual" });
|
||||
expect(q).toHaveLength(1);
|
||||
const rec = await runScrapeRecord(d, q[0]!.recordId);
|
||||
expect(rec).toMatchObject({ status: "succeeded", mode: "update" });
|
||||
expect(rec.files.every((f) => f.action !== "written" || /^[0-9a-f]{64}$/.test(f.hash ?? ""))).toBe(true);
|
||||
const nfo = await readFile(join(lib.root, NFO), "utf8");
|
||||
expect(nfo).toContain("<title>奥本海默</title>");
|
||||
expect(nfo).toContain("tt15398776");
|
||||
expect(nfo).toContain("<tagline>我的标语</tagline>");
|
||||
expect(await readFile(join(lib.root, DIR, "poster.jpg"), "utf8")).toBe("/oppenheimer.jpg");
|
||||
}, 60_000);
|
||||
|
||||
it("改映射到沙丘 2:tmdb 来源字段清空,锁定字段保留,排 full 刷新", async () => {
|
||||
const before = h.engine.enqueued.length;
|
||||
const r = await setEntityIdentity(
|
||||
h.rt,
|
||||
id,
|
||||
{ provider: "tmdb", entityType: "movie", externalId: "693134", lock: true },
|
||||
actor,
|
||||
);
|
||||
expect(r.changed).toBe(true);
|
||||
const e = await h.db.selectFrom("media_entity").select(["metadata"]).where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const meta = e.metadata as Record<string, Final>;
|
||||
expect(meta.overview).toBeUndefined();
|
||||
expect(meta.posterPath).toBeUndefined();
|
||||
expect(meta.externalIds).toBeUndefined();
|
||||
expect(meta.tagline).toMatchObject({ value: "我的标语", locked: true });
|
||||
const tmdbRows = await h.db
|
||||
.selectFrom("metadata_field")
|
||||
.select("field")
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("source", "=", "tmdb")
|
||||
.execute();
|
||||
expect(tmdbRows).toEqual([]);
|
||||
expect(h.engine.enqueued.slice(before)).toEqual([
|
||||
expect.objectContaining({ task: "refreshMetadata", input: { mediaEntityId: id, full: true } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("刷新后延后排一次更新模式重刮;期间再刷新合进同一条", async () => {
|
||||
const t0 = Date.now();
|
||||
expect((await refreshMetadata(h.rt, id, { full: true })).status).toBe("refreshed");
|
||||
const queued = await queuedUpdates();
|
||||
expect(queued).toEqual([expect.objectContaining({ mode: "update", trigger: "auto" })]);
|
||||
const opt = enqueueOpts.filter((x) => x.task === SCRAPE_TASK).at(-1);
|
||||
expect(opt?.availableAt?.getTime()).toBeGreaterThanOrEqual(t0 + RESCRAPE_DELAY_MS - 1000);
|
||||
|
||||
// 防抖:还没开始时再来一次刷新(哪怕字段又变了)不另建记录、不另排任务
|
||||
await h.db
|
||||
.updateTable("metadata_field")
|
||||
.set({ value: JSON.stringify("旧值") })
|
||||
.where("media_entity_id", "=", id)
|
||||
.where("field", "=", "overview")
|
||||
.execute();
|
||||
const scrapes = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length;
|
||||
await refreshMetadata(h.rt, id, { full: true });
|
||||
expect(await queuedUpdates()).toHaveLength(1);
|
||||
expect(h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length).toBe(scrapes);
|
||||
}, 60_000);
|
||||
|
||||
it("重刮后 NFO 的标题 / 简介 / IMDb / 海报都是沙丘 2,不残留奥本海默;锁定字段照写", async () => {
|
||||
const [q] = await queuedUpdates();
|
||||
const rec = await runScrapeRecord(d, q!.id);
|
||||
expect(rec.status).toBe("succeeded");
|
||||
expect(rec.files.find((f) => f.role === "movie_nfo")).toMatchObject({ action: "written" });
|
||||
const nfo = await readFile(join(lib.root, NFO), "utf8");
|
||||
expect(nfo).toContain("<title>沙丘2</title>");
|
||||
expect(nfo).toContain("<tmdbid>693134</tmdbid>");
|
||||
expect(nfo).toContain("tt15239678");
|
||||
expect(nfo).not.toContain("tt15398776");
|
||||
expect(nfo).not.toContain("奥本海默");
|
||||
expect(nfo).not.toContain("原子弹之父");
|
||||
expect(nfo).toContain("<tagline>我的标语</tagline>");
|
||||
expect(await readFile(join(lib.root, DIR, "poster.jpg"), "utf8")).toBe("/dune.jpg");
|
||||
|
||||
// 字段没变的刷新不再排重刮
|
||||
const scrapes = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length;
|
||||
await refreshMetadata(h.rt, id, { full: true });
|
||||
expect(h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length).toBe(scrapes);
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in new issue
Block a user