Merge branch 'scr2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # CHANGELOG.md # apps/server/src/modules/file-manager/routes.ts
This commit is contained in:
commit
44f7c88be6
41 files changed
+2810
-826
No files matched your search
@@ -3,11 +3,11 @@ 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 { runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../services/scrape.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// SCR-1 刮削:入库成功后自动排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;图片失败不阻塞 NFO;
|
||||
// 只登记库根不写;作品详情「重新刮削」接口与刮削状态。
|
||||
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
|
||||
// 图片失败不阻塞 NFO;只登记库根不写;作品详情「重新刮削」接口与刮削状态(最近一条刮削记录)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
@@ -75,13 +75,26 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
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]);
|
||||
const recs = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["id"])
|
||||
.where("media_entity_id", "=", entity)
|
||||
.where("status", "=", "queued")
|
||||
.orderBy("requested_at", "desc")
|
||||
.execute();
|
||||
expect(recs.length).toBeGreaterThan(0);
|
||||
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
|
||||
expect(q.some((x) => (x.input as { recordId: string }).recordId === recs[0]!.id)).toBe(true);
|
||||
return runScrapeRecord(h.d, recs[0]!.id);
|
||||
}
|
||||
|
||||
/** 作品详情「重新刮削」的服务端做法:对作品目录排刮削并执行 */
|
||||
async function rescrape(entity: string, mode?: "missing" | "overwrite") {
|
||||
const q = await scrapeEntity(h.d, entity, { trigger: "manual", mode });
|
||||
expect(q.length).toBe(1);
|
||||
return runScrapeRecord(h.d, q[0]!.recordId);
|
||||
}
|
||||
|
||||
let series = "";
|
||||
@@ -165,8 +178,18 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
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 });
|
||||
expect(q[0]!.key).toBe(`${SCRAPE_TASK}:record:${rec}:0`);
|
||||
const first = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.selectAll()
|
||||
.where("id", "=", (q[0]!.input as { recordId: string }).recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(first).toMatchObject({
|
||||
media_entity_id: series,
|
||||
trigger: "auto",
|
||||
mode: "missing",
|
||||
organization_record_id: rec,
|
||||
});
|
||||
|
||||
const r = await runQueued(series);
|
||||
expect(r.status).toBe("partial"); // 背景图下载失败
|
||||
@@ -213,23 +236,23 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
});
|
||||
const audit = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["action", "target_id"])
|
||||
.select(["action", "target_id", "target_type"])
|
||||
.where("action", "=", "organize.scrape")
|
||||
.execute();
|
||||
expect(audit.some((a) => a.target_id === series)).toBe(true);
|
||||
expect(audit.some((a) => a.target_type === "scrape_record" && a.target_id === st.body.data.scrape.id)).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" });
|
||||
const again = await rescrape(series, "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" });
|
||||
const over = await rescrape(series, "overwrite");
|
||||
expect(over.written).toBeGreaterThan(0);
|
||||
expect(await readFile(show, "utf8")).toContain("<title>人生切割术</title>");
|
||||
}, 60_000);
|
||||
@@ -276,24 +299,23 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
expect(nfo).toContain("<runtime>169</runtime>");
|
||||
}, 60_000);
|
||||
|
||||
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 409", async () => {
|
||||
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", 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" });
|
||||
const r = await rescrape(movie, "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" });
|
||||
const ro = await rescrape(movie, "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" });
|
||||
expect(await scrapeEntity(h.d, empty, { trigger: "manual" })).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -5,7 +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 { runScrapeRecord, SCRAPE_TASK } from "./services/scrape.ts";
|
||||
import { errMessage } from "./util.ts";
|
||||
|
||||
// 持久任务处理器。输入只放业务 id;业务状态写业务表;副作用经 withAttemptToken(在 services 里)。
|
||||
@@ -188,22 +188,16 @@ export function registerAoTasks(registry: TaskRegistry, d: AoDeps) {
|
||||
);
|
||||
},
|
||||
}),
|
||||
// SCR-1 刮削:写 NFO 与图片到媒体库目录(文件道);结果写 media_scrape,不重试(失败的文件在作品详情里重新刮削)
|
||||
// 刮削(SCR-1 / SCR-2):按刮削记录在目录里就地写 NFO 与图片(文件道);结果写回 scrape_record,不重试(失败的重新刮削)
|
||||
defineTask({
|
||||
name: SCRAPE_TASK,
|
||||
version: 1,
|
||||
version: 2,
|
||||
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(),
|
||||
}),
|
||||
inputSchema: z.object({ recordId: id }),
|
||||
retryPolicy: { maximumAttempts: 1 },
|
||||
handler: async (t) =>
|
||||
t.step.run("scrape", async () => {
|
||||
const r = await runScrape(d, { ...t.input, runId: t.runId }, t.abortSignal);
|
||||
const r = await runScrapeRecord(d, t.input.recordId, t.abortSignal);
|
||||
return { status: r.status, written: r.written, skipped: r.skipped, failed: r.failed, reason: r.reason };
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type DownloaderClient,
|
||||
diskUsage,
|
||||
type EmbyClient,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
ffprobe,
|
||||
getSiteAdapter,
|
||||
getSiteDefinition,
|
||||
@@ -13,6 +15,8 @@ import {
|
||||
qbAuthConfig,
|
||||
type SecretResolver,
|
||||
SiteError,
|
||||
type TmdbRichDetail,
|
||||
type TmdbRichSeasonDetail,
|
||||
type TorrentSource,
|
||||
type VerifyMethod,
|
||||
} from "@umt/adapters";
|
||||
@@ -88,6 +92,12 @@ export interface AoPorts {
|
||||
* 失败抛错(由刮削记为该图片失败,不影响 NFO)。
|
||||
*/
|
||||
scrapeImage(tmdbPath: string): Promise<{ content: Uint8Array; mime: string }>;
|
||||
/**
|
||||
* SCR-2 刮削未登记的目录:直接取 TMDB 富详情 / 季详情(按设置的元数据语言链,经 TMDB 缓存)。
|
||||
* TMDB 没配置返回 null;条目不存在或上游失败抛错。
|
||||
*/
|
||||
scrapeTmdbDetail(type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null>;
|
||||
scrapeTmdbSeason(tvId: string, season: number): Promise<TmdbRichSeasonDetail | null>;
|
||||
options: AoOptions;
|
||||
}
|
||||
|
||||
@@ -197,6 +207,16 @@ export function createPorts(
|
||||
});
|
||||
return images.bytes("original", tmdbPath);
|
||||
},
|
||||
async scrapeTmdbDetail(type, id) {
|
||||
const client = await rt().tmdb();
|
||||
if (!client) return null;
|
||||
return fetchRichDetail(client, type, id, await metadataLanguages(ctx), await metadataTextOptions(ctx));
|
||||
},
|
||||
async scrapeTmdbSeason(tvId, season) {
|
||||
const client = await rt().tmdb();
|
||||
if (!client) return null;
|
||||
return fetchRichSeason(client, tvId, season, await metadataLanguages(ctx), await metadataTextOptions(ctx));
|
||||
},
|
||||
diskUsage: (p) => diskUsage(p),
|
||||
probe: (p) => ffprobe(p),
|
||||
async mediaServer(inst) {
|
||||
|
||||
@@ -27,7 +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 { latestEntityScrape, scrapeEntity } from "../services/scrape.ts";
|
||||
import { notFound, toApi, toApiList } from "../util.ts";
|
||||
import { mount, paginate } from "./helpers.ts";
|
||||
import { registerLibraryVersionRoutes } from "./library-versions.ts";
|
||||
@@ -407,21 +407,21 @@ 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),
|
||||
scrape: await latestEntityScrape(d, id),
|
||||
};
|
||||
return detail;
|
||||
},
|
||||
{ tag: TAG },
|
||||
);
|
||||
|
||||
// SCR-1 刮削:作品详情「重新刮削」;与入库后自动刮削、文件管理「刮削」同一任务 scrapeMedia
|
||||
// 作品详情「重新刮削」「覆盖重写」:对作品的目录刮削(SCR-2:与文件管理「刮削」、入库后自动刮削同一实现)
|
||||
mount(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/library/{id}/scrape",
|
||||
scope: "organize.execute",
|
||||
summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)",
|
||||
summary: "刮削:对作品目录就地写 NFO 与图片(后台任务 scrapeMedia;覆盖方式缺省按设置)",
|
||||
params: IdParam,
|
||||
body: LibraryScrapeRequest,
|
||||
response: LibraryScrapeResult,
|
||||
@@ -429,22 +429,20 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
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,
|
||||
const recs = await scrapeEntity(d, 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 };
|
||||
if (!recs.length) throw new ApiException("precondition_failed", "作品没有已入库文件。到文件管理里对目录刮削");
|
||||
return {
|
||||
mediaEntityId: params.id,
|
||||
taskId: recs[0]?.taskId ?? null,
|
||||
deduplicated: recs.every((r) => r.deduplicated),
|
||||
recordIds: recs.map((r) => r.recordId),
|
||||
};
|
||||
},
|
||||
{ tag: TAG, status: 202 },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { TmdbRichDetail, TmdbRichSeasonDetail } from "@umt/adapters";
|
||||
import type { NfoEpisode, NfoIds, NfoPerson, NfoSeason, NfoWork } from "@umt/domain";
|
||||
import { richEntityFields } from "../../sources-identity/service/metadata-rich.ts";
|
||||
|
||||
// SCR-1:本地作品 / 季 / 集的最终字段(media_entity.metadata、content_unit.metadata,字段级 {value, …})→ NFO 输入。
|
||||
// 纯函数,不查库;差分测试(apps/server/test/scrape/nfo-mp-diff.test.ts)直接调用。
|
||||
@@ -134,3 +136,58 @@ export function imagePathsOf(entityMeta: unknown) {
|
||||
}
|
||||
export const unitPosterPath = (m: unknown) => str(val(m, "posterPath"));
|
||||
export const unitStillPath = (m: unknown) => str(val(m, "stillPath"));
|
||||
|
||||
// ── SCR-2:未登记目录直接用 TMDB 字段(与刷新元数据同一套映射,形状同 media_entity.metadata / content_unit.metadata) ──
|
||||
|
||||
/** TMDB 富详情 → 作品(NFO 输入);字段映射同刷新元数据(richEntityFields) */
|
||||
export function tmdbEntity(r: TmdbRichDetail): { entity: EntityForNfo; ids: IdentityMap } {
|
||||
return {
|
||||
entity: {
|
||||
kind: r.entityType === "movie" ? "movie" : "series",
|
||||
title: r.title,
|
||||
original_title: r.originalTitle,
|
||||
year: r.year,
|
||||
metadata: richEntityFields(r),
|
||||
},
|
||||
ids: {
|
||||
tmdb: r.externalId,
|
||||
...(r.externalIds.imdb ? { imdb: r.externalIds.imdb } : {}),
|
||||
...(r.externalIds.tvdb ? { tvdb: r.externalIds.tvdb } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fv = (value: unknown) => ({ value });
|
||||
|
||||
/** TMDB 季详情 → 季与各集(NFO 输入) */
|
||||
export function tmdbSeasonUnits(sd: TmdbRichSeasonDetail): { season: UnitForNfo; episodes: Map<number, UnitForNfo> } {
|
||||
const season: UnitForNfo = {
|
||||
season_number: sd.seasonNumber,
|
||||
episode_number: null,
|
||||
title: sd.name,
|
||||
air_date: sd.airDate,
|
||||
metadata: {
|
||||
name: fv(sd.name),
|
||||
overview: fv(sd.overview),
|
||||
airDate: fv(sd.airDate),
|
||||
posterPath: fv(sd.posterPath),
|
||||
},
|
||||
};
|
||||
const episodes = new Map<number, UnitForNfo>();
|
||||
for (const e of sd.episodes)
|
||||
episodes.set(e.episodeNumber, {
|
||||
season_number: e.seasonNumber,
|
||||
episode_number: e.episodeNumber,
|
||||
title: e.name,
|
||||
air_date: e.airDate,
|
||||
metadata: {
|
||||
name: fv(e.name),
|
||||
overview: fv(e.overview),
|
||||
airDate: fv(e.airDate?.slice(0, 10) ?? null),
|
||||
runtime: fv(e.runtime),
|
||||
stillPath: fv(e.stillPath),
|
||||
voteAverage: fv(e.voteAverage),
|
||||
},
|
||||
});
|
||||
return { season, episodes };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,7 +11,14 @@ import {
|
||||
type OrganizationStatus,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { type IdentityCandidate, type ParsedRelease, parseReleasePath, queryTitles, rankCandidates } from "@umt/domain";
|
||||
import {
|
||||
type IdentityCandidate,
|
||||
type ParsedRelease,
|
||||
parseReleasePath,
|
||||
queryTitles,
|
||||
rankCandidates,
|
||||
resolveIdentity,
|
||||
} from "@umt/domain";
|
||||
import { localIdentityCandidate } from "@umt/mapping";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
|
||||
@@ -177,13 +184,24 @@ export async function runIdentifyRemote(
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function identifyCore(
|
||||
/** 候选键:本地作品 id 或 `provider:externalId`(与 FileIdentifyCandidate 对应) */
|
||||
export const candidateKey = (c: { mediaEntityId: string | null; provider: string; externalId: string | null }) =>
|
||||
c.mediaEntityId ?? `${c.provider}:${c.externalId}`;
|
||||
|
||||
export async function identifyCore(
|
||||
d: FmDeps,
|
||||
body: FilesIdentifyRequest,
|
||||
opts: CoreOpts,
|
||||
): Promise<{ result: FilesIdentifyResult; remotePending: number; pendingPaths: Set<string> }> {
|
||||
): Promise<{
|
||||
result: FilesIdentifyResult;
|
||||
remotePending: number;
|
||||
pendingPaths: Set<string>;
|
||||
/** SCR-2:每个视频按 resolveIdentity 能否自动确认(root\0path → 候选键;不能为 null) */
|
||||
autoPicks: Map<string, string | null>;
|
||||
}> {
|
||||
const db = d.ctx.db;
|
||||
const pendingPaths = new Set<string>();
|
||||
const autoPicks = new Map<string, string | null>();
|
||||
const { videos, truncated } = await collectVideos(d, body);
|
||||
// G2:最小体积(请求/向导 > settings.organize.minSizeBytes,缺省 50 MiB;设置读不到时不过滤)
|
||||
const orgSettings = (await d.ctx.settings.get("organize").catch(() => null)) as { minSizeBytes?: unknown } | null;
|
||||
@@ -411,6 +429,14 @@ async function identifyCore(
|
||||
),
|
||||
};
|
||||
});
|
||||
const res = resolveIdentity(parsed, all);
|
||||
const pick = res.status === "auto" && res.best ? res.best.candidate : null;
|
||||
autoPicks.set(
|
||||
`${v.root}\u0000${v.rel}`,
|
||||
pick && (!kindHint || (kindHint === "movie") === (pick.entityType === "movie"))
|
||||
? (pick.localEntityId ?? `${pick.provider}:${pick.externalId}`)
|
||||
: null,
|
||||
);
|
||||
const best = candidates[0];
|
||||
let contentUnitId: string | null = null;
|
||||
let contentUnit: string | null = null;
|
||||
@@ -454,7 +480,7 @@ async function identifyCore(
|
||||
}
|
||||
if (videos.length === 0 && body.items.length > 0 && !truncated)
|
||||
throw new FilesError("bad_request", "所选项中没有视频文件");
|
||||
return { result: { items, truncated, excludedBySize }, remotePending: remoteQueries, pendingPaths };
|
||||
return { result: { items, truncated, excludedBySize }, remotePending: remoteQueries, pendingPaths, autoPicks };
|
||||
}
|
||||
|
||||
/** TMDB 图片 CDN 缩略图(w154);已是完整 URL 的原样返回 */
|
||||
|
||||
@@ -8,21 +8,17 @@ import type {
|
||||
FilesProbeResult,
|
||||
FilesScrapeBatchRequest,
|
||||
FilesScrapeBatchResult,
|
||||
FilesScrapeRequest,
|
||||
FilesScrapeResult,
|
||||
} 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";
|
||||
import { entitiesUnder } from "./records.ts";
|
||||
|
||||
// probe:ffprobe 摘要;已登记资产写回 media_asset.media_info.probe,未登记只缓存 24h(DATA_DIR/cache/probe)。
|
||||
// scrape:只对已登记作品,等价 POST /media-entities/{id}/refresh(入队 refreshMetadata,字段锁优先、不动路径);
|
||||
// writeNfo / images(SCR-1)另排 scrapeMedia,把 NFO 与图片写进媒体库目录(与作品详情「重新刮削」同一任务)。
|
||||
// scrape-batch write=true:文件管理「刮削」,按作品去重各排一次 scrapeMedia(不刷新元数据)。
|
||||
// scrape-batch:文件管理「刷新元数据」,按作品去重各排一次 refreshMetadata(字段锁优先、不动路径、不写文件)。
|
||||
// 刮削(写 NFO 与图片)见 scrape.ts。
|
||||
|
||||
const CACHE_TTL_MS = 24 * 3600_000;
|
||||
|
||||
@@ -104,62 +100,8 @@ export async function probeFiles(d: FmDeps, body: FilesProbeRequest): Promise<Fi
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function scrape(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
body: FilesScrapeRequest,
|
||||
): Promise<FilesScrapeResult> {
|
||||
const db = d.ctx.db;
|
||||
const e = await db
|
||||
.selectFrom("media_entity")
|
||||
.select(["id", "status", "field_locks"])
|
||||
.where("id", "=", body.mediaEntityId)
|
||||
.executeTakeFirst();
|
||||
if (!e) throw new FilesError("not_found", "作品不存在");
|
||||
const registered = await db
|
||||
.selectFrom("media_asset")
|
||||
.select(["id"])
|
||||
.where("media_entity_id", "=", e.id)
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirst();
|
||||
if (!registered) throw new FilesError("bad_request", "作品没有已入库文件。先识别并整理入库,再刮削");
|
||||
const { runId } = await d.ctx.engine.enqueue(
|
||||
"refreshMetadata",
|
||||
{ mediaEntityId: e.id },
|
||||
{ idempotencyKey: `refreshMetadata:files:${body.idempotencyKey}`, traceId: randomUUID() },
|
||||
);
|
||||
const overrides = await db
|
||||
.selectFrom("field_override")
|
||||
.select(["field"])
|
||||
.where("media_entity_id", "=", e.id)
|
||||
.where("locked", "=", true)
|
||||
.execute();
|
||||
const lockedFields = [...new Set([...(e.field_locks ?? []), ...overrides.map((o) => o.field)])].sort();
|
||||
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 ? "queued" : "not_requested",
|
||||
images: body.images ? "queued" : "not_requested",
|
||||
scrapeTaskId,
|
||||
};
|
||||
await audit(d, principal, "scrape", { ...result });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* G5 批量刷新元数据:文件/目录归到其下已登记资产的作品,与显式作品合并去重,逐个入队 refreshMetadata。
|
||||
* 只对有 active 资产的作品提交;其余进 skipped(前端汇总"已提交 n 个作品,锁定字段不变")。
|
||||
*/
|
||||
export async function scrapeBatch(
|
||||
d: FmDeps,
|
||||
@@ -183,18 +125,6 @@ export async function scrapeBatch(
|
||||
const ents = list.length
|
||||
? await db.selectFrom("media_entity").select(["id", "title", "field_locks"]).where("id", "in", list).execute()
|
||||
: [];
|
||||
const active = new Set(
|
||||
list.length
|
||||
? (
|
||||
await db
|
||||
.selectFrom("media_asset")
|
||||
.select(["media_entity_id"])
|
||||
.where("media_entity_id", "in", list)
|
||||
.where("status", "=", "active")
|
||||
.execute()
|
||||
).map((a) => a.media_entity_id)
|
||||
: [],
|
||||
);
|
||||
const locks = list.length
|
||||
? await db
|
||||
.selectFrom("field_override")
|
||||
@@ -210,34 +140,16 @@ export async function scrapeBatch(
|
||||
skipped.push({ ref: id, reason: "not_found" });
|
||||
continue;
|
||||
}
|
||||
if (!active.has(id)) {
|
||||
skipped.push({ ref: id, reason: "not_registered" });
|
||||
continue;
|
||||
}
|
||||
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 { runId } = await d.ctx.engine.enqueue(
|
||||
"refreshMetadata",
|
||||
{ mediaEntityId: id },
|
||||
{ idempotencyKey: `refreshMetadata:files-batch:${body.idempotencyKey}:${id}`, traceId: randomUUID() },
|
||||
);
|
||||
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,
|
||||
});
|
||||
await audit(d, principal, "scrape_batch", { submitted: submitted.map((s) => s.mediaEntityId), skipped });
|
||||
return { submitted, skipped };
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import { listDir, listRoots, statEntry } from "./list.ts";
|
||||
import { mkdirIn, renameEntry } from "./mutate.ts";
|
||||
import { organizeFiles } from "./organize.ts";
|
||||
import { previewFile } from "./preview.ts";
|
||||
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
|
||||
import { probeFiles, scrapeBatch } from "./probe.ts";
|
||||
import { deleteRecords } from "./record-delete.ts";
|
||||
import { findRecords } from "./records.ts";
|
||||
import { scrapeFiles, scrapeRecords } from "./scrape.ts";
|
||||
import { suggestName } from "./suggest-name.ts";
|
||||
import { transfer } from "./transfer.ts";
|
||||
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
|
||||
@@ -111,7 +112,11 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
|
||||
mount(app, F.identify, async (c, { body }) =>
|
||||
identify(d, c.get("principal"), body, { wait: /^(1|true)$/i.test(c.req.query("wait") ?? "") }),
|
||||
);
|
||||
mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body)));
|
||||
// SCR-2:任意目录 / 视频文件就地刮削(识别 → 写 NFO 与图片);认不准的项返回候选
|
||||
mount(app, F.scrape, async (c, { body }) =>
|
||||
idem(d, c, "scrape", body, () => scrapeFiles(d, c.get("principal"), body)),
|
||||
);
|
||||
mount(app, F.scrapeRecords, async (_c, { query }) => scrapeRecords(d, query.ids));
|
||||
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
|
||||
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
|
||||
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
FileIdentifyCandidate,
|
||||
FilesScrapeItem,
|
||||
FilesScrapeNeedsChoice,
|
||||
FilesScrapeRequest,
|
||||
FilesScrapeResult,
|
||||
ScrapeMatch,
|
||||
} from "@umt/contracts";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import {
|
||||
entityScrapeMatch,
|
||||
loadScrapeRecords,
|
||||
queueScrape,
|
||||
scrapeSettings,
|
||||
waitScrapeRecords,
|
||||
} from "../acquisition-organize/services/scrape.ts";
|
||||
import { actorOfP, FilesError, type FmDeps, resolveIn } from "./common.ts";
|
||||
import { candidateKey, identifyCore } from "./identify.ts";
|
||||
import { audit } from "./ledger.ts";
|
||||
import { kindOfName } from "./list.ts";
|
||||
import { entitiesUnder } from "./records.ts";
|
||||
|
||||
// SCR-2 文件管理「刮削」(照 MP 文件管理的刮削):任意目录 / 视频文件 → 识别作品 → 就地写 NFO 与图片。
|
||||
// 识别顺序:请求指定(选定的候选)> 目录里已登记文件所属的唯一作品 > 按路径识别(本地召回 + TMDB,resolveIdentity
|
||||
// 能自动确认且各视频一致时自动用)。认不准的项进 needsChoice(前 5 候选),选定后带 mediaEntityId / external 再提交。
|
||||
// 未登记也能刮削(直接用 TMDB 字段);addToLibrary 时顺手把 TMDB 条目加入媒体库。
|
||||
// 每项一条刮削记录,排 scrapeMedia;请求内最多等 transferWaitMs,没刮完的 pending=true,前端按 id 轮询。
|
||||
|
||||
const RECOGNIZE_TIMEOUT_MS = 15_000;
|
||||
|
||||
type Actor = { type: string; id: string | null };
|
||||
const actorOf = (p: Principal | undefined): Actor => ({
|
||||
type: p?.kind === "api_key" ? "api_key" : "user",
|
||||
id: p?.id ?? null,
|
||||
});
|
||||
|
||||
/** 路径识别:各视频都能自动确认且是同一作品时返回它;否则返回候选(按出现次数与等级合并,前 5) */
|
||||
async function recognize(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
it: FilesScrapeItem,
|
||||
): Promise<{ pick: FileIdentifyCandidate | null; choice: Omit<FilesScrapeNeedsChoice, "root" | "path"> }> {
|
||||
const r = await identifyCore(
|
||||
d,
|
||||
{ items: [{ root: it.root, path: it.path }], minSizeBytes: 0 },
|
||||
{ mode: "remote", actorId: actorOfP(principal), signal: AbortSignal.timeout(RECOGNIZE_TIMEOUT_MS) },
|
||||
);
|
||||
const items = r.result.items;
|
||||
const picks = items.map((i) => r.autoPicks.get(`${i.root}\u0000${i.path}`) ?? null);
|
||||
const agreed = picks.length > 0 && picks.every((p) => p && p === picks[0]) ? picks[0] : null;
|
||||
const LV = { high: 3, medium: 2, low: 1 } as const;
|
||||
const merged = new Map<string, { c: FileIdentifyCandidate; n: number }>();
|
||||
for (const i of items)
|
||||
for (const c of i.candidates) {
|
||||
const k = candidateKey(c);
|
||||
const cur = merged.get(k);
|
||||
if (!cur) merged.set(k, { c, n: 1 });
|
||||
else {
|
||||
cur.n += 1;
|
||||
if (LV[c.level] > LV[cur.c.level]) cur.c = { ...c, posterUrl: c.posterUrl ?? cur.c.posterUrl };
|
||||
}
|
||||
}
|
||||
const ranked = [...merged.values()].sort((a, b) => b.n - a.n || LV[b.c.level] - LV[a.c.level]).map((x) => x.c);
|
||||
const pick = agreed ? (ranked.find((c) => candidateKey(c) === agreed) ?? null) : null;
|
||||
const first = items[0]?.parsed;
|
||||
return {
|
||||
pick,
|
||||
choice: {
|
||||
title: first?.title ?? null,
|
||||
candidates: ranked.slice(0, 5),
|
||||
warnings: [...new Set(items.flatMap((i) => i.warnings))].slice(0, 5),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** TMDB 条目 → 识别结果:已有本地作品(已接受的 TMDB 映射)就用它;addToLibrary 时建作品 */
|
||||
async function externalMatch(
|
||||
d: FmDeps,
|
||||
ext: { externalId: string; kind: "movie" | "tv"; title?: string; year?: number | null },
|
||||
source: ScrapeMatch["source"],
|
||||
addToLibrary: boolean,
|
||||
actor: Actor,
|
||||
entities: FilesScrapeResult["entities"],
|
||||
): Promise<ScrapeMatch> {
|
||||
const local = await d.ctx.db
|
||||
.selectFrom("external_identity")
|
||||
.select(["media_entity_id"])
|
||||
.where("provider", "=", "tmdb")
|
||||
.where("entity_type", "=", ext.kind)
|
||||
.where("external_id", "=", ext.externalId)
|
||||
.where("status", "=", "accepted")
|
||||
.where("content_unit_id", "is", null)
|
||||
.executeTakeFirst();
|
||||
let entityId = local?.media_entity_id ?? null;
|
||||
if (!entityId && addToLibrary) {
|
||||
entityId = await d.ao.ports.entityFromTmdb({ type: ext.kind, id: ext.externalId }, actor);
|
||||
const e = await d.ctx.db.selectFrom("media_entity").select(["title"]).where("id", "=", entityId).executeTakeFirst();
|
||||
entities.push({ mediaEntityId: entityId, title: e?.title ?? ext.title ?? ext.externalId, created: true });
|
||||
}
|
||||
if (entityId) {
|
||||
const m = await entityScrapeMatch(d.ao, entityId, source);
|
||||
if (m) return { ...m, provider: "tmdb", externalId: ext.externalId };
|
||||
}
|
||||
return {
|
||||
mediaEntityId: null,
|
||||
provider: "tmdb",
|
||||
externalId: ext.externalId,
|
||||
kind: ext.kind,
|
||||
title: ext.title ?? `TMDB ${ext.externalId}`,
|
||||
year: ext.year ?? null,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrapeFiles(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
body: FilesScrapeRequest,
|
||||
): Promise<FilesScrapeResult> {
|
||||
const db = d.ctx.db;
|
||||
const actor = actorOf(principal);
|
||||
const mode = body.mode ?? (await scrapeSettings(d.ao)).scrapeMode;
|
||||
const recordIds: string[] = [];
|
||||
const needsChoice: FilesScrapeResult["needsChoice"] = [];
|
||||
const skipped: FilesScrapeResult["skipped"] = [];
|
||||
const entities: FilesScrapeResult["entities"] = [];
|
||||
for (const it of body.items) {
|
||||
if (it.mediaEntityId && it.external) throw new FilesError("bad_request", "mediaEntityId 与 external 只能给一个");
|
||||
const r = await resolveIn(d, it.root, it.path);
|
||||
const ref = { root: it.root, path: r.rel };
|
||||
if (r.def.kind === "library" && !r.def.modifiable) {
|
||||
skipped.push({ ...ref, reason: "scrape_root_read_only" });
|
||||
continue;
|
||||
}
|
||||
const isDir = !!r.lst?.isDirectory();
|
||||
if (!isDir && !(r.lst?.isFile() && kindOfName(r.abs) === "video")) {
|
||||
skipped.push({ ...ref, reason: "scrape_not_video" });
|
||||
continue;
|
||||
}
|
||||
let match: ScrapeMatch | null = null;
|
||||
if (it.mediaEntityId) {
|
||||
match = await entityScrapeMatch(d.ao, it.mediaEntityId, "chosen");
|
||||
if (!match) throw new FilesError("not_found", `作品不存在:${it.mediaEntityId}`);
|
||||
} else if (it.external) {
|
||||
match = await externalMatch(d, it.external, "chosen", body.addToLibrary, actor, entities);
|
||||
} else {
|
||||
// 目录里的文件已登记到唯一作品:直接用(重新刮削已入库的)
|
||||
const owners = r.def.libraryRootId ? await entitiesUnder(db, r.def.libraryRootId, r.rel, isDir) : [];
|
||||
if (owners.length === 1) match = await entityScrapeMatch(d.ao, owners[0]!, "library");
|
||||
if (!match) {
|
||||
let rec: Awaited<ReturnType<typeof recognize>>;
|
||||
try {
|
||||
rec = await recognize(d, principal, { ...it, path: r.rel });
|
||||
} catch (e) {
|
||||
if (e instanceof FilesError && e.reason === "bad_request") {
|
||||
skipped.push({ ...ref, reason: "scrape_no_videos" });
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const c = rec.pick;
|
||||
if (c?.mediaEntityId) match = await entityScrapeMatch(d.ao, c.mediaEntityId, "auto");
|
||||
else if (c && c.provider === "tmdb" && c.externalId)
|
||||
match = await externalMatch(
|
||||
d,
|
||||
{ externalId: c.externalId, kind: c.kind === "movie" ? "movie" : "tv", title: c.title, year: c.year },
|
||||
"auto",
|
||||
body.addToLibrary,
|
||||
actor,
|
||||
entities,
|
||||
);
|
||||
if (!match) {
|
||||
needsChoice.push({ ...ref, ...rec.choice });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const q = await queueScrape(d.ao, {
|
||||
rootId: r.def.id,
|
||||
rootPath: r.def.hostPath,
|
||||
libraryRootId: r.def.libraryRootId,
|
||||
path: r.rel,
|
||||
match,
|
||||
mode,
|
||||
trigger: "manual",
|
||||
actor,
|
||||
});
|
||||
recordIds.push(q.recordId);
|
||||
}
|
||||
const records = recordIds.length ? await waitScrapeRecords(d.ao, recordIds, d.options.transferWaitMs) : [];
|
||||
await audit(d, principal, "scrape", {
|
||||
items: body.items.map((i) => `${i.root}:${i.path}`).slice(0, 100),
|
||||
mode,
|
||||
recordIds,
|
||||
needsChoice: needsChoice.map((n) => `${n.root}:${n.path}`),
|
||||
skipped,
|
||||
});
|
||||
return {
|
||||
records,
|
||||
needsChoice,
|
||||
skipped,
|
||||
pending: records.some((r) => r.status === "queued" || r.status === "running"),
|
||||
entities,
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrapeRecords(d: FmDeps, idsParam: string) {
|
||||
const ids = [...new Set(idsParam.split(",").map((s) => s.trim()))].filter((s) => /^[0-9a-f-]{36}$/i.test(s));
|
||||
if (!ids.length || ids.length > 100) throw new FilesError("bad_request", "ids 需要 1–100 个刮削记录 id");
|
||||
return { records: await loadScrapeRecords(d.ao, ids) };
|
||||
}
|
||||
@@ -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