feat(scrape): 任意目录就地刮削,按路径识别作品,每次刮削一条记录(SCR-2)
- POST /files/scrape 改为对目录 / 视频文件刮削:已登记用作品,否则按路径识别(本地 + TMDB), 认不准返回候选,选定后再提交;未登记直接用 TMDB 字段,addToLibrary 顺手建作品 - 作品详情重新刮削、入库后自动刮削改为对作品目录刮削,同一实现 - 迁移 0058_scrape_record 替代 media_scrape;GET /files/scrape-records - 与 MP ScrapingChain 的文件布局差分(7 个场景,3 处登记偏离) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f60254e265
commit
87bf33b42b
29 files changed
+2272
-653
No files matched your search
@@ -22,6 +22,7 @@ import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createLogger } from "../../src/logger.ts";
|
||||
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
|
||||
import { executeRecord, type FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||
import { createFileManagerModule, type FmOverrides } from "../../src/modules/file-manager/index.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
@@ -57,6 +58,17 @@ export async function pgAvailable() {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON
|
||||
type Loose = any;
|
||||
|
||||
/** catalog.lookup 远端候选(测试用) */
|
||||
export interface RemoteHit {
|
||||
provider: string;
|
||||
entityType: "movie" | "tv";
|
||||
externalId: string;
|
||||
title: string;
|
||||
originalTitle?: string | null;
|
||||
year: number | null;
|
||||
posterPath?: string | null;
|
||||
}
|
||||
|
||||
export interface FmHarness {
|
||||
db: Kysely<DB>;
|
||||
engine: ReturnType<typeof fakeEngine>;
|
||||
@@ -80,7 +92,11 @@ export async function createFmHarness(
|
||||
name: string,
|
||||
overrides: FmOverrides = {},
|
||||
/** runTransfers(默认 true):假引擎收到 /files 移动/复制的 verifyAndImport 时就地执行(模拟 worker) */
|
||||
opts: { catalog?: boolean; runTransfers?: boolean } = {},
|
||||
opts: {
|
||||
/** true = 内置假 catalog.lookup("Remote Film");函数 = 自定义远端候选(按查询词返回) */
|
||||
catalog?: boolean | ((query: string) => RemoteHit[]);
|
||||
runTransfers?: boolean;
|
||||
} = {},
|
||||
): Promise<FmHarness> {
|
||||
const schema = `fm_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const admin = new pg.Client({ connectionString: DB_URL });
|
||||
@@ -124,9 +140,12 @@ export async function createFmHarness(
|
||||
if (opts.catalog)
|
||||
capabilities.register(capabilitySpec("catalog.lookup"), async (input) => ({
|
||||
local: [],
|
||||
remote: String(input.query).includes("Remote")
|
||||
? [{ provider: "tmdb", entityType: "movie" as const, externalId: "999", title: "Remote Film", year: 2019 }]
|
||||
: [],
|
||||
remote:
|
||||
typeof opts.catalog === "function"
|
||||
? opts.catalog(String(input.query))
|
||||
: String(input.query).includes("Remote")
|
||||
? [{ provider: "tmdb", entityType: "movie" as const, externalId: "999", title: "Remote Film", year: 2019 }]
|
||||
: [],
|
||||
}));
|
||||
const ctx: ServerContext = {
|
||||
role: "all",
|
||||
@@ -173,6 +192,9 @@ export async function createFmHarness(
|
||||
engine.enqueue = async (task, input, o) => {
|
||||
const r = await enqueue(task, input, o);
|
||||
const recordId = (input as { recordId?: string }).recordId;
|
||||
// SCR-2 刮削:模拟 worker 就地执行 scrapeMedia
|
||||
if (task === "scrapeMedia" && recordId)
|
||||
setTimeout(() => void runScrapeRecord(module.deps().ao, recordId).catch(() => {}), 0);
|
||||
if (task === "verifyAndImport" && recordId)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./harness.ts";
|
||||
|
||||
// identify(只读,本地 + 远端召回前 5,不落库)、organize(按作品分组走 planImport,dryRun→execute 一致与源变更)、
|
||||
// probe(已登记写回 media_info)、scrape(仅已登记作品,入队 refreshMetadata;NFO 待 W5)。
|
||||
// probe(已登记写回 media_info)、刷新元数据(scrape-batch,入队 refreshMetadata)。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
|
||||
@@ -278,7 +278,7 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
|
||||
expect(r.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("probe:已登记资产写回 media_info;scrape 仅已登记作品", async () => {
|
||||
it("probe:已登记资产写回 media_info;刷新元数据按作品入队", async () => {
|
||||
await mkdir(join(h.lib, "Blue Film probe"));
|
||||
await writeFile(join(h.lib, "Blue Film probe", "Blue.Film.2021.mkv"), "x");
|
||||
const a = await seedAsset(h.db, h, { entityId: film, rel: "Blue Film probe/Blue.Film.2021.mkv" });
|
||||
@@ -303,21 +303,10 @@ describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
|
||||
const u2 = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] });
|
||||
expect(u2.body.data.items[0].cached).toBe(true);
|
||||
|
||||
const s = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: film, writeNfo: true });
|
||||
expect(s.status).toBe(200);
|
||||
// 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,
|
||||
});
|
||||
// 刷新元数据(scrape-batch):按作品入队 refreshMetadata;刮削(写 NFO)见 scrape.test.ts
|
||||
const b = await h.api("POST", "/files/scrape-batch", { idempotencyKey: key(), mediaEntityIds: [film] });
|
||||
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);
|
||||
expect(h.engine.enqueued.some((e) => e.task === "refreshMetadata")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import type { TmdbRichDetail, TmdbRichSeasonDetail } from "@umt/adapters";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { latestEntityScrape, scrapeEntity } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import {
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
key,
|
||||
pgAvailable,
|
||||
type RemoteHit,
|
||||
seedAsset,
|
||||
seedEntity,
|
||||
} from "./harness.ts";
|
||||
|
||||
// SCR-2 刮削照 MP:文件管理任意目录 / 视频文件 → 按路径识别作品(未登记走 TMDB,认不准返回候选)→ 就地写 NFO 与图片。
|
||||
// 场景:未登记的《人生切割术》S01 两集 + 《星际穿越》目录一次刮削;歧义候选(Solaris 1972 / 2002)选定后刮削并加入媒体库;
|
||||
// 只补缺失 / 全部覆盖;季目录与单集文件的范围;已登记目录直接用作品;只登记目录与非视频文件跳过。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]);
|
||||
|
||||
function detail(
|
||||
p: Partial<TmdbRichDetail> & Pick<TmdbRichDetail, "entityType" | "externalId" | "title">,
|
||||
): TmdbRichDetail {
|
||||
return {
|
||||
titleLanguage: "zh-CN",
|
||||
titleZh: p.title,
|
||||
titleZhLanguage: "zh-CN",
|
||||
originalTitle: null,
|
||||
originalLanguage: "en",
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
tagline: null,
|
||||
year: null,
|
||||
releaseDate: null,
|
||||
lastAirDate: null,
|
||||
genres: [],
|
||||
voteAverage: null,
|
||||
voteCount: null,
|
||||
status: null,
|
||||
runtime: null,
|
||||
seasonCount: null,
|
||||
episodeCount: null,
|
||||
networks: [],
|
||||
originCountry: [],
|
||||
posterPath: null,
|
||||
backdropPath: null,
|
||||
cast: [],
|
||||
crew: [],
|
||||
externalIds: { imdb: null, tvdb: null, wikidata: null },
|
||||
translations: [],
|
||||
alternativeTitles: [],
|
||||
seasons: [],
|
||||
languages: ["zh-CN"],
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
const DETAILS: Record<string, TmdbRichDetail> = {
|
||||
"tv:95396": detail({
|
||||
entityType: "tv",
|
||||
externalId: "95396",
|
||||
title: "人生切割术",
|
||||
originalTitle: "Severance",
|
||||
year: 2022,
|
||||
releaseDate: "2022-02-17",
|
||||
overview: "马克领导着一个办公室团队。",
|
||||
overviewLanguage: "zh-CN",
|
||||
genres: ["剧情", "悬疑"],
|
||||
voteAverage: 8.4,
|
||||
networks: ["Apple TV+"],
|
||||
originCountry: ["US"],
|
||||
posterPath: "/sev.jpg",
|
||||
backdropPath: "/sev-bg.jpg",
|
||||
externalIds: { imdb: "tt11280740", tvdb: "371980", wikidata: null },
|
||||
}),
|
||||
"movie:157336": detail({
|
||||
entityType: "movie",
|
||||
externalId: "157336",
|
||||
title: "星际穿越",
|
||||
originalTitle: "Interstellar",
|
||||
year: 2014,
|
||||
releaseDate: "2014-11-05",
|
||||
runtime: 169,
|
||||
posterPath: "/inter.jpg",
|
||||
backdropPath: "/inter-bg.jpg",
|
||||
crew: [{ id: 525, name: "克里斯托弗·诺兰", originalName: null, role: "Director", profilePath: null }],
|
||||
}),
|
||||
"movie:593": detail({
|
||||
entityType: "movie",
|
||||
externalId: "593",
|
||||
title: "飞向太空",
|
||||
originalTitle: "Solaris",
|
||||
year: 1972,
|
||||
}),
|
||||
"movie:2103": detail({
|
||||
entityType: "movie",
|
||||
externalId: "2103",
|
||||
title: "索拉里斯星",
|
||||
originalTitle: "Solaris",
|
||||
year: 2002,
|
||||
posterPath: "/solaris.jpg",
|
||||
}),
|
||||
};
|
||||
|
||||
const SEASON1: TmdbRichSeasonDetail = {
|
||||
tvId: "95396",
|
||||
seasonNumber: 1,
|
||||
name: "第 1 季",
|
||||
nameLanguage: "zh-CN",
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
airDate: "2022-02-17",
|
||||
posterPath: "/s1.jpg",
|
||||
fetchedLanguages: ["zh-CN"],
|
||||
episodes: [
|
||||
[1, "有关地狱的好消息", "/e1.jpg"],
|
||||
[2, "半循环", "/e2.jpg"],
|
||||
].map(([n, name, still]) => ({
|
||||
seasonNumber: 1,
|
||||
episodeNumber: n as number,
|
||||
name: name as string,
|
||||
nameLanguage: "zh-CN",
|
||||
overview: `第 ${n} 集简介`,
|
||||
overviewLanguage: "zh-CN",
|
||||
airDate: "2022-02-17",
|
||||
runtime: 55,
|
||||
stillPath: still as string,
|
||||
voteAverage: 8,
|
||||
voteCount: 10,
|
||||
})),
|
||||
};
|
||||
|
||||
const REMOTE: Record<string, RemoteHit[]> = {
|
||||
人生切割术: [
|
||||
{
|
||||
provider: "tmdb",
|
||||
entityType: "tv",
|
||||
externalId: "95396",
|
||||
title: "人生切割术",
|
||||
originalTitle: "Severance",
|
||||
year: 2022,
|
||||
},
|
||||
],
|
||||
星际穿越: [
|
||||
{
|
||||
provider: "tmdb",
|
||||
entityType: "movie",
|
||||
externalId: "157336",
|
||||
title: "星际穿越",
|
||||
originalTitle: "Interstellar",
|
||||
year: 2014,
|
||||
},
|
||||
],
|
||||
Solaris: [
|
||||
{
|
||||
provider: "tmdb",
|
||||
entityType: "movie",
|
||||
externalId: "593",
|
||||
title: "飞向太空",
|
||||
originalTitle: "Solaris",
|
||||
year: 1972,
|
||||
},
|
||||
{
|
||||
provider: "tmdb",
|
||||
entityType: "movie",
|
||||
externalId: "2103",
|
||||
title: "索拉里斯星",
|
||||
originalTitle: "Solaris",
|
||||
year: 2002,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function tree(root: string, under = ""): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const walk = async (d: string) => {
|
||||
for (const n of (await readdir(d)).sort()) {
|
||||
if (n.startsWith(".")) continue;
|
||||
const p = join(d, n);
|
||||
if ((await stat(p)).isDirectory()) await walk(p);
|
||||
else out.push(relative(root, p));
|
||||
}
|
||||
};
|
||||
await walk(join(root, under));
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const SHOW = "人生切割术 (2022)";
|
||||
const EP1 = `${SHOW}/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.mkv`;
|
||||
const EP2 = `${SHOW}/Season 1/人生切割术 - S01E02 - 半循环.mkv`;
|
||||
const MOVIE = "星际穿越 (2014)";
|
||||
const MOVIE_FILE = `${MOVIE}/星际穿越 (2014) - 1080p.mkv`;
|
||||
|
||||
describe.skipIf(skip)("files 刮削(SCR-2:任意目录就地刮削)", () => {
|
||||
let h: FmHarness;
|
||||
const images: string[] = [];
|
||||
const tmdbCalls: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness(
|
||||
"scrape2",
|
||||
{
|
||||
ao: {
|
||||
async scrapeImage(p) {
|
||||
images.push(p);
|
||||
return { content: JPEG, mime: "image/jpeg" };
|
||||
},
|
||||
async scrapeTmdbDetail(type, id) {
|
||||
tmdbCalls.push(`${type}:${id}`);
|
||||
const d = DETAILS[`${type}:${id}`];
|
||||
if (!d) throw new Error("TMDB 404");
|
||||
return d;
|
||||
},
|
||||
async scrapeTmdbSeason(tvId, n) {
|
||||
tmdbCalls.push(`season:${tvId}:${n}`);
|
||||
return tvId === "95396" && n === 1 ? SEASON1 : null;
|
||||
},
|
||||
async entityFromTmdb(ref) {
|
||||
const d = DETAILS[`${ref.type}:${ref.id}`]!;
|
||||
const e = await h.db
|
||||
.insertInto("media_entity")
|
||||
.values({
|
||||
kind: ref.type === "movie" ? "movie" : "series",
|
||||
title: d.title,
|
||||
year: d.year,
|
||||
status: "active",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: e.id,
|
||||
provider: "tmdb",
|
||||
entity_type: ref.type,
|
||||
external_id: ref.id,
|
||||
status: "accepted",
|
||||
})
|
||||
.execute();
|
||||
return e.id;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
catalog: (q) =>
|
||||
Object.entries(REMOTE)
|
||||
.filter(([k]) => q.includes(k) || k.includes(q))
|
||||
.flatMap(([, v]) => v),
|
||||
},
|
||||
);
|
||||
await mkdir(join(h.lib, SHOW, "Season 1"), { recursive: true });
|
||||
await mkdir(join(h.lib, MOVIE));
|
||||
await mkdir(join(h.lib, "Solaris"));
|
||||
for (const f of [EP1, EP2, MOVIE_FILE, "Solaris/Solaris.1080p.BluRay.x264-GRP.mkv"])
|
||||
await writeFile(join(h.lib, f), "video-bytes");
|
||||
await writeFile(join(h.lib, MOVIE, "readme.txt"), "x");
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
const scrape = (items: unknown[], extra: Record<string, unknown> = {}, k = EXEC_KEY) =>
|
||||
h.api("POST", "/files/scrape", { idempotencyKey: key(), items, ...extra }, k);
|
||||
|
||||
it("未登记的剧集目录 + 电影目录:按路径识别 TMDB,就地写 NFO 与图片,不建作品", async () => {
|
||||
const r = await scrape([
|
||||
{ root: h.libRoot, path: SHOW },
|
||||
{ root: h.libRoot, path: MOVIE },
|
||||
]);
|
||||
expect(r.status).toBe(200);
|
||||
const data = r.body.data;
|
||||
expect(data.needsChoice).toEqual([]);
|
||||
expect(data.pending).toBe(false);
|
||||
expect(data.records).toHaveLength(2);
|
||||
const [show, movie] = data.records;
|
||||
expect(show).toMatchObject({
|
||||
status: "succeeded",
|
||||
path: SHOW,
|
||||
mode: "missing",
|
||||
trigger: "manual",
|
||||
match: {
|
||||
mediaEntityId: null,
|
||||
provider: "tmdb",
|
||||
externalId: "95396",
|
||||
kind: "tv",
|
||||
source: "auto",
|
||||
title: "人生切割术",
|
||||
},
|
||||
});
|
||||
expect(movie).toMatchObject({ status: "succeeded", match: { externalId: "157336", kind: "movie" } });
|
||||
|
||||
const files = await tree(h.lib);
|
||||
expect(files).toEqual(
|
||||
[
|
||||
`${SHOW}/Season 1/poster.jpg`,
|
||||
`${SHOW}/Season 1/season.nfo`,
|
||||
EP1,
|
||||
EP1.replace(/\.mkv$/, "-thumb.jpg"),
|
||||
EP1.replace(/\.mkv$/, ".nfo"),
|
||||
EP2,
|
||||
EP2.replace(/\.mkv$/, "-thumb.jpg"),
|
||||
EP2.replace(/\.mkv$/, ".nfo"),
|
||||
`${SHOW}/fanart.jpg`,
|
||||
`${SHOW}/poster.jpg`,
|
||||
`${SHOW}/season01-poster.jpg`,
|
||||
`${SHOW}/tvshow.nfo`,
|
||||
`${MOVIE}/fanart.jpg`,
|
||||
`${MOVIE}/poster.jpg`,
|
||||
`${MOVIE}/readme.txt`,
|
||||
MOVIE_FILE,
|
||||
MOVIE_FILE.replace(/\.mkv$/, ".nfo"),
|
||||
"Solaris/Solaris.1080p.BluRay.x264-GRP.mkv",
|
||||
].sort(),
|
||||
);
|
||||
// 未登记:不建作品,字段直接取 TMDB
|
||||
expect(await h.db.selectFrom("media_entity").select("id").execute()).toHaveLength(0);
|
||||
const tvshow = await readFile(join(h.lib, SHOW, "tvshow.nfo"), "utf8");
|
||||
expect(tvshow).toContain("<title>人生切割术</title>");
|
||||
expect(tvshow).toContain('<uniqueid type="imdb" default="true">tt11280740</uniqueid>');
|
||||
const ep = await readFile(join(h.lib, EP1.replace(/\.mkv$/, ".nfo")), "utf8");
|
||||
expect(ep).toContain("<title>有关地狱的好消息</title>");
|
||||
expect(ep).toMatch(/<season>1<\/season>\n {2}<episode>1<\/episode>/);
|
||||
const nfo = await readFile(join(h.lib, MOVIE_FILE.replace(/\.mkv$/, ".nfo")), "utf8");
|
||||
expect(nfo).toContain('<director tmdbid="525">克里斯托弗·诺兰</director>');
|
||||
expect(tmdbCalls).toEqual(expect.arrayContaining(["tv:95396", "season:95396:1", "movie:157336"]));
|
||||
// 记录可按 id 回查;审计按记录
|
||||
const back = await h.api("GET", `/files/scrape-records?ids=${show.id},${movie.id}`);
|
||||
expect(back.body.data.records.map((x: { id: string }) => x.id)).toEqual([show.id, movie.id]);
|
||||
expect(back.body.data.records[0].files.filter((f: { action: string }) => f.action === "written")).toHaveLength(10);
|
||||
expect(h.events.some((e) => e.type === "files.changed" && e.payload.rootId === h.libRoot)).toBe(true);
|
||||
});
|
||||
|
||||
it("歧义:返回候选;选定 TMDB 条目后刮削,并按需加入媒体库", async () => {
|
||||
const first = await scrape([{ root: h.libRoot, path: "Solaris" }]);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.data.records).toEqual([]);
|
||||
expect(first.body.data.needsChoice).toHaveLength(1);
|
||||
const nc = first.body.data.needsChoice[0];
|
||||
expect(nc).toMatchObject({ root: h.libRoot, path: "Solaris", title: "Solaris" });
|
||||
expect(nc.candidates.map((c: { externalId: string }) => c.externalId).sort()).toEqual(["2103", "593"]);
|
||||
expect(await tree(h.lib, "Solaris")).toEqual(["Solaris/Solaris.1080p.BluRay.x264-GRP.mkv"]);
|
||||
|
||||
const second = await scrape(
|
||||
[{ root: h.libRoot, path: "Solaris", external: { provider: "tmdb", externalId: "2103", kind: "movie" } }],
|
||||
{ addToLibrary: true },
|
||||
);
|
||||
expect(second.status).toBe(200);
|
||||
const rec = second.body.data.records[0];
|
||||
expect(rec).toMatchObject({
|
||||
status: "succeeded",
|
||||
match: { provider: "tmdb", externalId: "2103", source: "chosen", title: "索拉里斯星", year: 2002 },
|
||||
});
|
||||
expect(second.body.data.entities).toMatchObject([{ title: "索拉里斯星", created: true }]);
|
||||
expect(rec.match.mediaEntityId).toBe(second.body.data.entities[0].mediaEntityId);
|
||||
expect(await tree(h.lib, "Solaris")).toEqual([
|
||||
"Solaris/Solaris.1080p.BluRay.x264-GRP.mkv",
|
||||
"Solaris/Solaris.1080p.BluRay.x264-GRP.nfo",
|
||||
"Solaris/poster.jpg",
|
||||
]);
|
||||
expect(await readFile(join(h.lib, "Solaris/Solaris.1080p.BluRay.x264-GRP.nfo"), "utf8")).toContain(
|
||||
"<title>索拉里斯星</title>",
|
||||
);
|
||||
// 作品详情取的是该作品最近一条刮削记录
|
||||
expect((await latestEntityScrape(h.module.deps().ao, rec.match.mediaEntityId))?.id).toBe(rec.id);
|
||||
});
|
||||
|
||||
it("只补缺失跳过已有文件(包括别人写的);全部覆盖改写", async () => {
|
||||
const show = join(h.lib, SHOW, "tvshow.nfo");
|
||||
await writeFile(show, "<tvshow>别的工具写的</tvshow>");
|
||||
const miss = await scrape([{ root: h.libRoot, path: SHOW }]);
|
||||
const rec = miss.body.data.records[0];
|
||||
expect(rec).toMatchObject({ status: "succeeded", written: 0 });
|
||||
expect(rec.skipped).toBeGreaterThan(0);
|
||||
expect(await readFile(show, "utf8")).toBe("<tvshow>别的工具写的</tvshow>");
|
||||
const over = await scrape([{ root: h.libRoot, path: SHOW }], { mode: "overwrite" });
|
||||
expect(over.body.data.records[0]).toMatchObject({ status: "succeeded", mode: "overwrite", skipped: 0 });
|
||||
expect(await readFile(show, "utf8")).toContain("<title>人生切割术</title>");
|
||||
});
|
||||
|
||||
it("季目录只写季与各集;单集文件只写该集", async () => {
|
||||
const season = await scrape([{ root: h.libRoot, path: `${SHOW}/Season 1` }], { mode: "overwrite" });
|
||||
const roles = season.body.data.records[0].files.map((f: { path: string; role: string }) => `${f.role}:${f.path}`);
|
||||
expect(roles.sort()).toEqual(
|
||||
[
|
||||
`season_nfo:${SHOW}/Season 1/season.nfo`,
|
||||
`season_poster:${SHOW}/season01-poster.jpg`,
|
||||
`season_poster:${SHOW}/Season 1/poster.jpg`,
|
||||
`episode_nfo:${EP1.replace(/\.mkv$/, ".nfo")}`,
|
||||
`episode_nfo:${EP2.replace(/\.mkv$/, ".nfo")}`,
|
||||
`episode_thumb:${EP1.replace(/\.mkv$/, "-thumb.jpg")}`,
|
||||
`episode_thumb:${EP2.replace(/\.mkv$/, "-thumb.jpg")}`,
|
||||
].sort(),
|
||||
);
|
||||
const one = await scrape([{ root: h.libRoot, path: EP2 }], { mode: "overwrite" });
|
||||
expect(one.body.data.records[0].files.map((f: { role: string }) => f.role).sort()).toEqual([
|
||||
"episode_nfo",
|
||||
"episode_thumb",
|
||||
]);
|
||||
});
|
||||
|
||||
it("已登记的目录直接用作品;作品详情「重新刮削」对作品目录刮削", async () => {
|
||||
const film = await seedEntity(h.db, { kind: "movie", title: "星际穿越", year: 2014 });
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: film,
|
||||
provider: "tmdb",
|
||||
entity_type: "movie",
|
||||
external_id: "157336",
|
||||
status: "accepted",
|
||||
})
|
||||
.execute();
|
||||
await seedAsset(h.db, h, { entityId: film, rel: MOVIE_FILE });
|
||||
const r = await scrape([{ root: h.libRoot, path: MOVIE }]);
|
||||
expect(r.body.data.records[0].match).toMatchObject({
|
||||
mediaEntityId: film,
|
||||
source: "library",
|
||||
externalId: "157336",
|
||||
});
|
||||
// 作品详情「重新刮削」(POST /library/{id}/scrape)的服务端做法
|
||||
const again = await scrapeEntity(h.module.deps().ao, film, { trigger: "manual", mode: "overwrite" });
|
||||
expect(again).toHaveLength(1);
|
||||
const recs = await h.api("GET", `/files/scrape-records?ids=${again[0]!.recordId}`);
|
||||
expect(recs.body.data.records[0]).toMatchObject({ path: MOVIE, root: h.libRoot, mode: "overwrite" });
|
||||
});
|
||||
|
||||
it("只登记目录与非视频文件跳过;没有视频的目录跳过", async () => {
|
||||
await mkdir(join(h.lib, "空目录"));
|
||||
const r = await scrape([
|
||||
{ root: h.libRoot, path: `${MOVIE}/readme.txt` },
|
||||
{ root: h.libRoot, path: "空目录" },
|
||||
]);
|
||||
expect(r.body.data.skipped).toEqual([
|
||||
{ root: h.libRoot, path: `${MOVIE}/readme.txt`, reason: "scrape_not_video" },
|
||||
{ root: h.libRoot, path: "空目录", reason: "scrape_no_videos" },
|
||||
]);
|
||||
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).execute();
|
||||
const ro = await scrape([{ root: h.libRoot, path: MOVIE }]);
|
||||
expect(ro.body.data.skipped).toEqual([{ root: h.libRoot, path: MOVIE, reason: "scrape_root_read_only" }]);
|
||||
await h.db.updateTable("library_root").set({ management_mode: "managed" }).execute();
|
||||
});
|
||||
});
|
||||
@@ -277,7 +277,7 @@ describe.skipIf(skip)("files v2(W9-H)", () => {
|
||||
expect(src.body.data.mediaEntityId).toBe(film);
|
||||
});
|
||||
|
||||
it("G5:批量刷新元数据按作品去重,未登记作品跳过", async () => {
|
||||
it("G5:批量刷新元数据按作品去重(不要求有已入库文件)", async () => {
|
||||
const reg = await seedEntity(h.db, { kind: "movie", title: "Registered Film", year: 2018 });
|
||||
await mkdir(join(h.lib, "Registered Film (2018)"), { recursive: true });
|
||||
await writeFile(join(h.lib, "Registered Film (2018)", "a.mkv"), big);
|
||||
@@ -299,8 +299,8 @@ describe.skipIf(skip)("files v2(W9-H)", () => {
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.submitted.map((s: Loose) => s.mediaEntityId)).toEqual([reg]);
|
||||
expect(r.body.data.skipped).toEqual([{ ref: bare, reason: "not_registered" }]);
|
||||
expect(r.body.data.submitted.map((s: Loose) => s.mediaEntityId)).toEqual([reg, bare]);
|
||||
expect(r.body.data.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("G8:目录摘要 — 上一级、子目录是否有下级、视频/已登记计数", async () => {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""SCR-2 刮削差分(文件布局):用 MP 上游 ScrapingChain.scrape_metadata 的真实决策代码,在临时目录里对同一组目录结构
|
||||
"就地刮削",记录 MP 写了哪些文件,写 scrape-layout-mp.json。与 apps/server/test/file-manager/scrape-layout.test.ts 对照。
|
||||
|
||||
用法(MP 仓库根目录,用其 .venv):
|
||||
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
|
||||
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-layout-mp.py
|
||||
|
||||
只记录行为输出,不复制 MP 源码。替身:
|
||||
- 存储:本地目录的最小实现(list_files / get_parent_item / get_file_item / upload_file / contains_bluray_subdirectories);
|
||||
- NFO 内容:固定占位(内容差分见 gen-scrape-mp.py);
|
||||
- 图片清单:按 MP themoviedb 模块 get_metadata_img 的规则给(作品 poster + backdrop;季 seasonNN-poster;集 episode-thumb);
|
||||
- 识别:recognize_media 原样返回传入的作品(路径识别不在此差分范围)。
|
||||
刮削策略用 MP 缺省(ScrapingConfig 默认全部 missingonly);手动刮削 overwrite=True(同 MP /media/scrape 接口)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import app.chain.scraping as scraping_mod
|
||||
from app.chain.scraping import ScrapingChain, ScrapingConfig
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
HERE = pathlib.Path(__file__).parent
|
||||
VIDEO_EXT = (".mkv", ".mp4", ".ts", ".m2ts", ".avi", ".iso")
|
||||
|
||||
|
||||
def item(p: pathlib.Path) -> FileItem:
|
||||
return FileItem(
|
||||
storage="local",
|
||||
type="dir" if p.is_dir() else "file",
|
||||
path=str(p) + ("/" if p.is_dir() else ""),
|
||||
name=p.name,
|
||||
basename=p.stem if p.is_file() else p.name,
|
||||
extension=p.suffix[1:] if p.is_file() else None,
|
||||
)
|
||||
|
||||
|
||||
class LocalStorage:
|
||||
def list_files(self, fileitem, recursion=False):
|
||||
return [item(c) for c in sorted(pathlib.Path(fileitem.path).iterdir())]
|
||||
|
||||
def get_parent_item(self, fileitem):
|
||||
return item(pathlib.Path(fileitem.path.rstrip("/")).parent)
|
||||
|
||||
def get_file_item(self, storage, path):
|
||||
p = pathlib.Path(path)
|
||||
return item(p) if p.exists() else None
|
||||
|
||||
def upload_file(self, fileitem, path, new_name=None):
|
||||
target = pathlib.Path(fileitem.path) / (new_name or path.name)
|
||||
shutil.copyfile(path, target)
|
||||
return item(target)
|
||||
|
||||
def contains_bluray_subdirectories(self, files):
|
||||
return any(f.type == "dir" and f.name.upper() in ("BDMV", "VIDEO_TS") for f in files)
|
||||
|
||||
|
||||
def make_chain() -> ScrapingChain:
|
||||
c = object.__new__(ScrapingChain)
|
||||
c.storagechain = LocalStorage()
|
||||
c.scraping_policies = ScrapingConfig()
|
||||
c.runtime_config = SimpleNamespace(
|
||||
video_extensions=VIDEO_EXT, audio_extensions=(".flac", ".mp3"), season_zero_names=("Specials", "SPs")
|
||||
)
|
||||
c.metadata_nfo = lambda **kw: "<nfo/>"
|
||||
|
||||
def metadata_img(mediainfo, season=None, episode=None):
|
||||
if season is not None:
|
||||
if episode:
|
||||
return {"episode-thumb.jpg": "https://img/still.jpg"}
|
||||
return {f"season{str(season).rjust(2, '0')}-poster.jpg": "https://img/season.jpg"}
|
||||
return {"poster.jpg": "https://img/poster.jpg", "backdrop.jpg": "https://img/backdrop.jpg"}
|
||||
|
||||
c.metadata_img = metadata_img
|
||||
|
||||
def save_image(fileitem, path, url):
|
||||
pathlib.Path(path).write_bytes(b"jpg")
|
||||
|
||||
c._download_and_save_image = save_image
|
||||
return c
|
||||
|
||||
|
||||
class FakeMediaChain:
|
||||
def recognize_media(self, meta=None, **kw):
|
||||
return CURRENT["media"]
|
||||
|
||||
def recognize_by_meta(self, meta):
|
||||
return CURRENT["media"]
|
||||
|
||||
|
||||
CURRENT: dict = {}
|
||||
scraping_mod.MediaChain = FakeMediaChain # 集文件重新识别季集后仍是同一作品
|
||||
|
||||
|
||||
def media(kind: MediaType, tmdb_id: int, title: str) -> MediaInfo:
|
||||
mi = MediaInfo()
|
||||
mi.type = kind
|
||||
mi.tmdb_id = tmdb_id
|
||||
mi.media_source = MediaSource.TMDB
|
||||
mi.media_id = str(tmdb_id)
|
||||
mi.title = title
|
||||
return mi
|
||||
|
||||
|
||||
SHOW = "人生切割术 (2022)"
|
||||
EP1 = f"{SHOW}/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.mkv"
|
||||
EP2 = f"{SHOW}/Season 1/人生切割术 - S01E02 - 半循环.mkv"
|
||||
MOVIE = "星际穿越 (2014)"
|
||||
MOVIE_FILE = f"{MOVIE}/星际穿越 (2014) - 1080p.mkv"
|
||||
LOOSE = "Interstellar.2014.1080p.BluRay.mkv"
|
||||
TREE = [EP1, EP2, MOVIE_FILE, LOOSE, "Heat (1995)/BDMV/index.bdmv"]
|
||||
|
||||
SCENARIOS = {
|
||||
"show_dir": (SHOW, MediaType.TV),
|
||||
"season_dir": (f"{SHOW}/Season 1", MediaType.TV),
|
||||
"episode_file": (EP2, MediaType.TV),
|
||||
"movie_dir": (MOVIE, MediaType.MOVIE),
|
||||
"movie_file": (MOVIE_FILE, MediaType.MOVIE),
|
||||
"movie_file_in_root": (LOOSE, MediaType.MOVIE),
|
||||
"bluray_dir": ("Heat (1995)", MediaType.MOVIE),
|
||||
}
|
||||
|
||||
out = {}
|
||||
for name, (target, kind) in SCENARIOS.items():
|
||||
root = pathlib.Path(tempfile.mkdtemp(prefix="mp-scr-"))
|
||||
for f in TREE:
|
||||
(root / f).parent.mkdir(parents=True, exist_ok=True)
|
||||
(root / f).write_bytes(b"video")
|
||||
before = {str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()}
|
||||
CURRENT["media"] = media(kind, 95396 if kind == MediaType.TV else 157336, "x")
|
||||
chain = make_chain()
|
||||
ok, msg = chain.scrape_metadata(fileitem=item(root / target), mediainfo=CURRENT["media"], overwrite=True)
|
||||
after = {str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()}
|
||||
out[name] = {"target": target, "ok": ok, "written": sorted(after - before)}
|
||||
shutil.rmtree(root)
|
||||
|
||||
(HERE / "scrape-layout-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
# 生成后在本仓库跑一次格式化(lint 要求):deno run -A npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"show_dir": {
|
||||
"target": "人生切割术 (2022)",
|
||||
"ok": true,
|
||||
"written": [
|
||||
"人生切割术 (2022)/Season 1/poster.jpg",
|
||||
"人生切割术 (2022)/Season 1/season.nfo",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.jpg",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.nfo",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 半循环.jpg",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 半循环.nfo",
|
||||
"人生切割术 (2022)/backdrop.jpg",
|
||||
"人生切割术 (2022)/fanart.jpg",
|
||||
"人生切割术 (2022)/poster.jpg",
|
||||
"人生切割术 (2022)/season01-poster.jpg",
|
||||
"人生切割术 (2022)/tvshow.nfo"
|
||||
]
|
||||
},
|
||||
"season_dir": {
|
||||
"target": "人生切割术 (2022)/Season 1",
|
||||
"ok": true,
|
||||
"written": [
|
||||
"人生切割术 (2022)/Season 1/poster.jpg",
|
||||
"人生切割术 (2022)/Season 1/season.nfo",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.jpg",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.nfo",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 半循环.jpg",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 半循环.nfo",
|
||||
"人生切割术 (2022)/season01-poster.jpg"
|
||||
]
|
||||
},
|
||||
"episode_file": {
|
||||
"target": "人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 半循环.mkv",
|
||||
"ok": true,
|
||||
"written": [
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 半循环.jpg",
|
||||
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 半循环.nfo"
|
||||
]
|
||||
},
|
||||
"movie_dir": {
|
||||
"target": "星际穿越 (2014)",
|
||||
"ok": true,
|
||||
"written": [
|
||||
"星际穿越 (2014)/backdrop.jpg",
|
||||
"星际穿越 (2014)/fanart.jpg",
|
||||
"星际穿越 (2014)/poster.jpg",
|
||||
"星际穿越 (2014)/星际穿越 (2014) - 1080p.nfo"
|
||||
]
|
||||
},
|
||||
"movie_file": {
|
||||
"target": "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv",
|
||||
"ok": true,
|
||||
"written": [
|
||||
"星际穿越 (2014)/backdrop.jpg",
|
||||
"星际穿越 (2014)/fanart.jpg",
|
||||
"星际穿越 (2014)/poster.jpg",
|
||||
"星际穿越 (2014)/星际穿越 (2014) - 1080p.nfo"
|
||||
]
|
||||
},
|
||||
"movie_file_in_root": {
|
||||
"target": "Interstellar.2014.1080p.BluRay.mkv",
|
||||
"ok": true,
|
||||
"written": ["Interstellar.2014.1080p.BluRay.nfo", "backdrop.jpg", "fanart.jpg", "poster.jpg"]
|
||||
},
|
||||
"bluray_dir": {
|
||||
"target": "Heat (1995)",
|
||||
"ok": true,
|
||||
"written": [
|
||||
"Heat (1995)/Heat (1995).nfo",
|
||||
"Heat (1995)/backdrop.jpg",
|
||||
"Heat (1995)/fanart.jpg",
|
||||
"Heat (1995)/poster.jpg"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import type { TmdbRichDetail, TmdbRichSeasonDetail } from "@umt/adapters";
|
||||
import type { ScrapeMatch } from "@umt/contracts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { createFmHarness, type FmHarness, pgAvailable } from "../file-manager/harness.ts";
|
||||
|
||||
// SCR-2 刮削文件布局差分:同一组目录结构,MP ScrapingChain.scrape_metadata(gen-scrape-layout-mp.py 生成
|
||||
// scrape-layout-mp.json)写哪些文件 ⇔ 我们就地刮削写哪些文件。偏离只允许下面 DEVIATIONS 里登记的几类
|
||||
// (理由见 docs/runbook/scrape.md「与 MP 的差分」)。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
const MP = JSON.parse(await readFile(new URL("./scrape-layout-mp.json", import.meta.url), "utf8")) as Record<
|
||||
string,
|
||||
{ target: string; ok: boolean; written: string[] }
|
||||
>;
|
||||
|
||||
const SHOW = "人生切割术 (2022)";
|
||||
const EP1 = `${SHOW}/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.mkv`;
|
||||
const EP2 = `${SHOW}/Season 1/人生切割术 - S01E02 - 半循环.mkv`;
|
||||
const MOVIE_FILE = "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv";
|
||||
const LOOSE = "Interstellar.2014.1080p.BluRay.mkv";
|
||||
const TREE = [EP1, EP2, MOVIE_FILE, LOOSE, "Heat (1995)/BDMV/index.bdmv"];
|
||||
|
||||
/** 登记的偏离:把 MP 的输出换算成我们应有的输出 */
|
||||
const DEVIATIONS: { id: string; apply: (files: string[], scenario: string) => string[] }[] = [
|
||||
// 1. 不写 backdrop.jpg 副本,只写 fanart.jpg(Emby / Jellyfin / Kodi 都认 fanart)
|
||||
{ id: "no_backdrop_copy", apply: (fs) => fs.filter((f) => !/(^|\/)backdrop\.jpg$/.test(f)) },
|
||||
// 2. 剧照 <视频名>-thumb.jpg(Kodi 约定;MP 写 <视频名>.jpg)
|
||||
{
|
||||
id: "episode_thumb_suffix",
|
||||
apply: (fs) => fs.map((f) => (/ - S\d+E\d+ - [^/]+\.jpg$/.test(f) ? f.replace(/\.jpg$/, "-thumb.jpg") : f)),
|
||||
},
|
||||
// 3. 视频直接在登记目录根:图片写 <视频名>-poster.jpg / -fanart.jpg,不往根里放 poster.jpg(MP 会)
|
||||
{
|
||||
id: "root_movie_images",
|
||||
apply: (fs, s) =>
|
||||
s !== "movie_file_in_root"
|
||||
? fs
|
||||
: fs.map((f) => (f === "poster.jpg" || f === "fanart.jpg" ? `${LOOSE.replace(/\.mkv$/, "")}-${f}` : f)),
|
||||
},
|
||||
];
|
||||
|
||||
const detail = (entityType: "movie" | "tv", externalId: string): TmdbRichDetail =>
|
||||
({
|
||||
entityType,
|
||||
externalId,
|
||||
title: "x",
|
||||
titleLanguage: "zh-CN",
|
||||
titleZh: "x",
|
||||
titleZhLanguage: "zh-CN",
|
||||
originalTitle: null,
|
||||
originalLanguage: "en",
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
tagline: null,
|
||||
year: 2022,
|
||||
releaseDate: null,
|
||||
lastAirDate: null,
|
||||
genres: [],
|
||||
voteAverage: null,
|
||||
voteCount: null,
|
||||
status: null,
|
||||
runtime: null,
|
||||
seasonCount: null,
|
||||
episodeCount: null,
|
||||
networks: [],
|
||||
originCountry: [],
|
||||
posterPath: "/poster.jpg",
|
||||
backdropPath: "/backdrop.jpg",
|
||||
cast: [],
|
||||
crew: [],
|
||||
externalIds: { imdb: null, tvdb: null, wikidata: null },
|
||||
translations: [],
|
||||
alternativeTitles: [],
|
||||
seasons: [],
|
||||
languages: ["zh-CN"],
|
||||
}) satisfies TmdbRichDetail;
|
||||
|
||||
const season1: TmdbRichSeasonDetail = {
|
||||
tvId: "95396",
|
||||
seasonNumber: 1,
|
||||
name: null,
|
||||
nameLanguage: null,
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
airDate: null,
|
||||
posterPath: "/season.jpg",
|
||||
fetchedLanguages: ["zh-CN"],
|
||||
episodes: [1, 2].map((n) => ({
|
||||
seasonNumber: 1,
|
||||
episodeNumber: n,
|
||||
name: null,
|
||||
nameLanguage: null,
|
||||
overview: null,
|
||||
overviewLanguage: null,
|
||||
airDate: null,
|
||||
runtime: null,
|
||||
stillPath: "/still.jpg",
|
||||
voteAverage: null,
|
||||
voteCount: null,
|
||||
})),
|
||||
};
|
||||
|
||||
async function files(root: string): Promise<Set<string>> {
|
||||
const out = new Set<string>();
|
||||
const walk = async (d: string) => {
|
||||
for (const n of await readdir(d).catch(() => [])) {
|
||||
if (n.startsWith(".")) continue;
|
||||
const p = join(d, n);
|
||||
if ((await stat(p)).isDirectory()) await walk(p);
|
||||
else out.add(relative(root, p));
|
||||
}
|
||||
};
|
||||
await walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe.skipIf(skip)("刮削文件布局与 MP 差分", () => {
|
||||
let h: FmHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness(
|
||||
"scrlayout",
|
||||
{
|
||||
ao: {
|
||||
scrapeImage: async () => ({ content: new Uint8Array([0xff, 0xd8, 0xff]), mime: "image/jpeg" }),
|
||||
scrapeTmdbDetail: async (type, id) => detail(type, id),
|
||||
scrapeTmdbSeason: async (_tv, n) => (n === 1 ? season1 : null),
|
||||
},
|
||||
},
|
||||
{ runTransfers: false },
|
||||
);
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
for (const [scenario, mp] of Object.entries(MP)) {
|
||||
it(`${scenario}:${mp.target}`, async () => {
|
||||
// 每个场景一份独立目录树;"视频直接在根"的场景用下载根(整个根就是这棵树)
|
||||
const inRoot = scenario === "movie_file_in_root";
|
||||
const base = inRoot ? h.dl : join(h.lib, scenario);
|
||||
for (const f of TREE) {
|
||||
await mkdir(dirname(join(base, f)), { recursive: true });
|
||||
await writeFile(join(base, f), "video");
|
||||
}
|
||||
const before = await files(base);
|
||||
const tv = mp.target.startsWith(SHOW);
|
||||
const match: ScrapeMatch = {
|
||||
mediaEntityId: null,
|
||||
provider: "tmdb",
|
||||
externalId: tv ? "95396" : "157336",
|
||||
kind: tv ? "tv" : "movie",
|
||||
title: "x",
|
||||
year: null,
|
||||
source: "chosen",
|
||||
};
|
||||
const q = await queueScrape(h.module.deps().ao, {
|
||||
...(inRoot
|
||||
? { rootId: h.dlRoot, rootPath: h.dl, libraryRootId: null }
|
||||
: { rootId: h.libRoot, rootPath: h.lib, libraryRootId: h.libraryRootId }),
|
||||
path: inRoot ? mp.target : `${scenario}/${mp.target}`,
|
||||
match,
|
||||
mode: "overwrite",
|
||||
trigger: "manual",
|
||||
});
|
||||
const rec = await runScrapeRecord(h.module.deps().ao, q.recordId);
|
||||
expect(rec.status).toBe("succeeded");
|
||||
const after = await files(base);
|
||||
const ours = [...after].filter((f) => !before.has(f)).sort();
|
||||
const expected = DEVIATIONS.reduce((fs, d) => d.apply(fs, scenario), [...mp.written]).sort();
|
||||
expect(ours).toEqual(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user