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:
rootandClaude Opus 5.5 committed 2026-10-01 00:08:17 +08:00
1 parent f60254e265
commit 87bf33b42b
29 files changed
+2272 -653

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 -96
View File
@@ -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,8 +15,9 @@ 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 { 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";
@@ -110,7 +111,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) };
}
+26 -4
View File
@@ -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();
});
});
+3 -3
View File
@@ -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);
});
}
});
+13 -3
View File
@@ -21,6 +21,7 @@ import * as organization from "./organization.ts";
import * as pi from "./pi.ts";
import * as policy from "./policy.ts";
import * as schedules from "./schedules.ts";
import * as scrape from "./scrape.ts";
import * as search from "./search.ts";
import * as settingsNs from "./settings.ts";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts";
@@ -1551,10 +1552,19 @@ export const Api = {
method: "post",
path: "/files/scrape",
scope: "organize.execute",
summary: "刮削已登记作品(字段级刷新,遵守字段锁;NFO 投影 W5)",
summary:
"刮削:对目录或视频文件就地写 NFO 与图片(按路径识别作品,不要求已登记;认不准时返回候选,选定后再提交)",
body: files.FilesScrapeRequest,
response: files.FilesScrapeResult,
}),
scrapeRecords: ep({
method: "get",
path: "/files/scrape-records",
scope: "organize.read",
summary: "按 id 查刮削记录(目录、识别结果、逐文件结果)",
query: files.FilesScrapeRecordsQuery,
response: files.FilesScrapeRecordsResult,
}),
probe: ep({
method: "post",
path: "/files/probe",
@@ -2244,8 +2254,8 @@ export const Api = {
scope: "organize.execute",
summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)",
params: IdParam,
body: organization.LibraryScrapeRequest,
response: organization.LibraryScrapeResult,
body: scrape.LibraryScrapeRequest,
response: scrape.LibraryScrapeResult,
}),
reconcile: ep({
method: "post",
+47 -19
View File
@@ -5,6 +5,7 @@ import { DownloadStatus } from "./execution.ts";
import { InstanceHealth, LibraryMediaKind } from "./instances.ts";
import { OrganizationStatus, PublicOrganizationRecord } from "./organization.ts";
import { SlotStatus } from "./policy.ts";
import { ScrapeMode, ScrapeRecord } from "./scrape.ts";
// 文件管理(`/files`,docs/plan/06-file-manager-spec.md)。
// 只能浏览已登记的库根与下载根;路径一律用 {root, path}:root 为 FileRootId,path 为根内相对路径("" = 根)。
@@ -680,30 +681,59 @@ export const IdentifyCompletedPayload = z.object({
});
export type IdentifyCompletedPayload = z.infer<typeof IdentifyCompletedPayload>;
// 刮削(仅已登记作品)
// 刮削(SCR-2,照 MP):任意目录 / 视频文件就地写 NFO 与图片;按路径识别作品(已登记直接用,未登记走 TMDB 匹配),
// 认不准时返回候选(needsChoice),选定后带 mediaEntityId / external 再提交一次。
export const FilesScrapeItem = FileRef.extend({
/** 指定本地作品(选定候选后回传);与 external 二选一 */
mediaEntityId: Id.optional(),
/** 指定 TMDB 条目(候选里只有 TMDB 的);不登记也能刮削 */
external: FilesExternalCandidate.optional(),
});
export type FilesScrapeItem = z.infer<typeof FilesScrapeItem>;
export const FilesScrapeRequest = z.object({
idempotencyKey: IdempotencyKey,
mediaEntityId: Id,
/** SCR-1:true 时另排 scrapeMedia,把 NFO(与 images 时的图片)写进媒体库目录 */
writeNfo: z.boolean().default(false),
images: z.boolean().default(false),
items: z.array(FilesScrapeItem).min(1).max(100),
/** 缺省按设置 organize.scrapeMode */
mode: ScrapeMode.optional(),
/** 指定的 TMDB 条目顺手加入媒体库(建作品);不影响刮削本身 */
addToLibrary: z.boolean().default(false),
});
export type FilesScrapeRequest = z.infer<typeof FilesScrapeRequest>;
export const FilesScrapeNeedsChoice = z.object({
root: FileRootId,
path: z.string(),
/** 从路径解析出的标题 */
title: z.string().nullable(),
/** 前 5 个候选(可能为空:让用户搜索) */
candidates: z.array(FileIdentifyCandidate).max(5),
warnings: z.array(z.string()),
});
export type FilesScrapeNeedsChoice = z.infer<typeof FilesScrapeNeedsChoice>;
export const FilesScrapeResult = z.object({
mediaEntityId: Id,
/** refreshMetadata 引擎 run id(异步;字段级刷新,用户锁优先,不动路径) */
taskId: z.string(),
/** 当前被用户锁定、刷新不会改动的字段 */
lockedFields: z.array(z.string()),
/** NFO/图片投影(SCR-1):queued = 已排 scrapeMedia,结果看作品刮削状态;not_implemented 已不再返回 */
nfo: z.enum(["written", "skipped", "queued", "not_requested", "not_implemented"]),
images: z.enum(["written", "skipped", "queued", "not_requested", "not_implemented"]),
/** scrapeMedia 引擎 run id(writeNfo / images 时) */
scrapeTaskId: z.string().nullable().optional(),
/** 已提交的刮削(每项一条记录);pending=true 时仍有排队 / 进行中的,按 id 查 /files/scrape-records */
records: z.array(ScrapeRecord),
/** 认不准作品的项:选一个候选后带 mediaEntityId / external 重新提交 */
needsChoice: z.array(FilesScrapeNeedsChoice),
/** 没提交的项:scrape_root_read_only(只登记目录)/ scrape_not_video(不是视频或目录) */
skipped: z.array(z.object({ root: FileRootId, path: z.string(), reason: z.string() })),
pending: z.boolean(),
/** addToLibrary 时加入媒体库的作品 */
entities: z.array(z.object({ mediaEntityId: Id, title: z.string(), created: z.boolean() })),
});
export type FilesScrapeResult = z.infer<typeof FilesScrapeResult>;
export const FilesScrapeRecordsQuery = z.object({
/** 逗号分隔的刮削记录 id(≤100) */
ids: z.string().min(1).max(4000),
});
export type FilesScrapeRecordsQuery = z.infer<typeof FilesScrapeRecordsQuery>;
export const FilesScrapeRecordsResult = z.object({ records: z.array(ScrapeRecord) });
export type FilesScrapeRecordsResult = z.infer<typeof FilesScrapeRecordsResult>;
// 探测
export const FilesProbeRequest = z.object({ items: z.array(FileRef).min(1).max(20) });
@@ -843,20 +873,18 @@ export const FilesEntityFromCandidateResult = z.object({
});
export type FilesEntityFromCandidateResult = z.infer<typeof FilesEntityFromCandidateResult>;
/** G5:批量刷新元数据(刮削):按作品去重;items 里的文件/目录归到其已登记资产的作品 */
/** G5:批量刷新元数据:按作品去重;items 里的文件/目录归到其已登记资产的作品(刮削见 /files/scrape) */
export const FilesScrapeBatchRequest = z.object({
idempotencyKey: IdempotencyKey,
items: z.array(FileRef).max(500).optional(),
mediaEntityIds: z.array(Id).max(200).optional(),
/** SCR-1:true = 刮削(排 scrapeMedia 写 NFO 与图片,覆盖方式按设置);false(缺省)= 刷新元数据 */
write: z.boolean().default(false),
});
export type FilesScrapeBatchRequest = z.infer<typeof FilesScrapeBatchRequest>;
export const FilesScrapeBatchResult = z.object({
submitted: z.array(
z.object({ mediaEntityId: Id, title: z.string(), taskId: z.string(), lockedFields: z.array(z.string()) }),
),
/** 未提交:not_registered(作品没有已登记资产)/ not_found / no_entity(文件不属于任何已登记作品) */
/** 未提交:not_found / no_entity(文件不属于任何已登记作品) */
skipped: z.array(z.object({ ref: z.string(), reason: z.string() })),
});
export type FilesScrapeBatchResult = z.infer<typeof FilesScrapeBatchResult>;
+1
View File
@@ -21,5 +21,6 @@ export * from "./pi.ts";
export * from "./policy.ts";
export * from "./reasons.ts";
export * from "./redact.ts";
export * from "./scrape.ts";
export * from "./settings.ts";
export * from "./sources.ts";
+1 -75
View File
@@ -6,6 +6,7 @@ import { MediaEntity, MediaKind } from "./content.ts";
import { AcquisitionPlan, DownloadStatus, DownloadTask } from "./execution.ts";
import { ExternalIdentity } from "./identity.ts";
import { AcquisitionSlot, CandidateEvaluation, SlotStatus } from "./policy.ts";
import { LibraryScrapeState } from "./scrape.ts";
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
@@ -370,81 +371,6 @@ export const LibraryDroppedPlanView = z.object({
});
export type LibraryDroppedPlanView = z.infer<typeof LibraryDroppedPlanView>;
/** SCR-1 刮削:一个写入目标(NFO / 图片)的结果;path 为库根内相对路径 */
export const ScrapeFileResult = z.object({
path: z.string(),
kind: z.enum(["nfo", "image"]),
/** movie_nfo / tvshow_nfo / season_nfo / episode_nfo / poster / fanart / season_poster / episode_thumb */
role: z.string(),
action: z.enum(["written", "skipped", "failed"]),
/** 跳过 / 失败原因码(reasons.ts:scrape_exists / scrape_write_failed / scrape_image_failed …) */
reason: z.string().nullable(),
});
export type ScrapeFileResult = z.infer<typeof ScrapeFileResult>;
export const ScrapeMode = z.enum(["missing", "overwrite"]);
export type ScrapeMode = z.infer<typeof ScrapeMode>;
export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]);
export type ScrapeStatus = z.infer<typeof ScrapeStatus>;
export const ScrapeTrigger = z.enum(["auto", "manual"]);
export type ScrapeTrigger = z.infer<typeof ScrapeTrigger>;
/** 表 media_scrape 的行(一部作品一行,最近一次刮削) */
export const MediaScrape = z.object({
mediaEntityId: Id,
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
recordId: Id.nullable(),
engineRunId: z.string().nullable(),
requestedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
written: NonNegInt,
skipped: NonNegInt,
failed: NonNegInt,
reason: z.string().nullable(),
files: z.array(ScrapeFileResult),
createdAt: Timestamp,
updatedAt: Timestamp,
});
export type MediaScrape = z.infer<typeof MediaScrape>;
/** SCR-1 作品刮削状态(最近一次;表 media_scrape) */
export const LibraryScrapeState = z.object({
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
requestedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
written: NonNegInt,
skipped: NonNegInt,
failed: NonNegInt,
/** 整体失败原因码(没有文件、没有元数据等) */
reason: z.string().nullable(),
files: z.array(ScrapeFileResult),
/** 触发它的整理记录(入库后自动刮削) */
recordId: Id.nullable(),
/** 引擎 run id */
taskId: z.string().nullable(),
});
export type LibraryScrapeState = z.infer<typeof LibraryScrapeState>;
export const LibraryScrapeRequest = z.object({
/** 缺省按设置 organize.scrapeMode */
mode: ScrapeMode.optional(),
});
export type LibraryScrapeRequest = z.infer<typeof LibraryScrapeRequest>;
export const LibraryScrapeResult = z.object({
mediaEntityId: Id,
taskId: z.string(),
/** 同一作品已有排队中的刮削,直接复用 */
deduplicated: z.boolean(),
});
export type LibraryScrapeResult = z.infer<typeof LibraryScrapeResult>;
export const LibraryDetail = z.object({
entity: MediaEntity,
/** 身份映射状态(含 proposed/conflicted,前端区分展示) */
+14 -6
View File
@@ -87,13 +87,21 @@ const REASONS: Record<string, ReasonText> = {
read_only_reconcile: { text: "只读对账中,暂不提交下载" },
rejected: { text: "下载器拒绝了这个种子" },
source_failed: { text: "从站点取种子失败", action: "检查站点登录后重新下载" },
// SCR-1 刮削(作品刮削状态、逐文件结果)
scrape_no_files: { text: "作品没有已入库文件", action: "先整理入库" },
scrape_no_metadata: { text: "作品还没有元数据", action: "先刷新元数据" },
scrape_root_read_only: { text: "媒体库目录是只登记模式,不写文件" },
scrape_exists: { text: "文件已存在,按设置只补缺失" },
scrape_write_failed: { text: "写入媒体库目录失败", action: "检查目录权限后重新刮削" },
// SCR-1 / SCR-2 刮削(刮削记录、逐文件结果)
scrape_no_files: { text: "作品没有已入库文件", action: "到文件管理里对目录刮削" },
scrape_no_metadata: { text: "没有可用的元数据", action: "指定 TMDB 条目后重新刮削" },
scrape_root_read_only: { text: "只登记模式的媒体库目录,不写文件" },
scrape_root_offline: { text: "目录不可访问或未挂载" },
scrape_not_video: { text: "不是视频文件或目录" },
scrape_path_missing: { text: "目录或文件已不存在" },
path_escape: { text: "路径指向登记目录之外" },
task_enqueue_failed: { text: "后台任务没排上", action: "稍后重试" },
scrape_no_videos: { text: "目录里没有视频", action: "对作品或季目录刮削" },
scrape_tmdb_unavailable: { text: "TMDB 不可用", action: "检查 TMDB 设置后重新刮削" },
scrape_exists: { text: "文件已存在,只补缺失时不改" },
scrape_write_failed: { text: "写入目录失败", action: "检查目录权限后重新刮削" },
scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" },
scrape_no_episode: { text: "文件名里认不出集号" },
scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" },
};
+119
View File
@@ -0,0 +1,119 @@
import { z } from "zod";
import { Id, NonNegInt, Timestamp } from "./common.ts";
// 刮削(SCR-1 / SCR-2):在目录里就地写 NFO 与图片(Emby / Jellyfin / Kodi 读取)。
// SCR-2 起照 MP 简化:文件管理里任意目录 / 视频文件都能刮削(按路径识别作品,不要求先登记);
// 作品详情「重新刮削」、入库后自动刮削走同一实现(对作品目录刮削)。每次刮削一条记录(表 scrape_record)。
/** 一个写入目标(NFO / 图片)的结果;path 为所在登记目录(库根 / 下载根)内的相对路径 */
export const ScrapeFileResult = z.object({
path: z.string(),
kind: z.enum(["nfo", "image"]),
/** movie_nfo / tvshow_nfo / season_nfo / episode_nfo / poster / fanart / season_poster / episode_thumb */
role: z.string(),
action: z.enum(["written", "skipped", "failed"]),
/** 跳过 / 失败原因码(reasons.ts:scrape_exists / scrape_write_failed / scrape_image_failed …) */
reason: z.string().nullable(),
});
export type ScrapeFileResult = z.infer<typeof ScrapeFileResult>;
/** missing = 只补缺失;overwrite = 全部覆盖 */
export const ScrapeMode = z.enum(["missing", "overwrite"]);
export type ScrapeMode = z.infer<typeof ScrapeMode>;
export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]);
export type ScrapeStatus = z.infer<typeof ScrapeStatus>;
export const ScrapeTrigger = z.enum(["auto", "manual"]);
export type ScrapeTrigger = z.infer<typeof ScrapeTrigger>;
/** 识别结果:library = 目录里的文件已登记到该作品;auto = 按路径唯一识别;chosen = 用户从候选里选的 */
export const ScrapeMatchSource = z.enum(["library", "auto", "chosen"]);
export type ScrapeMatchSource = z.infer<typeof ScrapeMatchSource>;
export const ScrapeMatch = z.object({
/** 本地作品(没有登记时为 null,直接用 TMDB 字段) */
mediaEntityId: Id.nullable(),
provider: z.literal("tmdb").nullable(),
externalId: z.string().nullable(),
kind: z.enum(["movie", "tv"]),
title: z.string(),
year: z.number().int().nullable(),
source: ScrapeMatchSource,
});
export type ScrapeMatch = z.infer<typeof ScrapeMatch>;
/** 一次刮削(表 scrape_record):目录、识别结果、写了哪些文件、跳过 / 失败原因 */
export const ScrapeRecord = z.object({
id: Id,
/** 登记目录 id(`lib:<id>` / `dl:<id>:<n>`) */
root: z.string(),
/** 目录内相对路径(刮削的目录或视频文件) */
path: z.string(),
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
match: ScrapeMatch.nullable(),
written: NonNegInt,
skipped: NonNegInt,
failed: NonNegInt,
/** 整体失败原因码(没有元数据、目录不可访问等) */
reason: z.string().nullable(),
files: z.array(ScrapeFileResult),
/** 触发它的整理记录(入库后自动刮削) */
organizationRecordId: Id.nullable(),
/** 引擎 run id */
taskId: z.string().nullable(),
requestedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type ScrapeRecord = z.infer<typeof ScrapeRecord>;
/** 表 scrape_record 的行(0058) */
export const ScrapeRecordRow = z.object({
id: Id,
rootId: z.string(),
rootPath: z.string(),
libraryRootId: Id.nullable(),
path: z.string(),
mediaEntityId: Id.nullable(),
match: ScrapeMatch.nullable(),
status: ScrapeStatus,
trigger: ScrapeTrigger,
mode: ScrapeMode,
organizationRecordId: Id.nullable(),
engineRunId: z.string().nullable(),
actorType: z.string().nullable(),
actorId: z.string().nullable(),
requestedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
written: NonNegInt,
skipped: NonNegInt,
failed: NonNegInt,
reason: z.string().nullable(),
files: z.array(ScrapeFileResult),
createdAt: Timestamp,
updatedAt: Timestamp,
});
export type ScrapeRecordRow = z.infer<typeof ScrapeRecordRow>;
/** 作品详情的刮削状态 = 该作品最近一次刮削记录 */
export const LibraryScrapeState = ScrapeRecord;
export type LibraryScrapeState = ScrapeRecord;
export const LibraryScrapeRequest = z.object({
/** 缺省按设置 organize.scrapeMode */
mode: ScrapeMode.optional(),
});
export type LibraryScrapeRequest = z.infer<typeof LibraryScrapeRequest>;
export const LibraryScrapeResult = z.object({
mediaEntityId: Id,
/** 第一条记录的引擎 run id */
taskId: z.string().nullable(),
/** 同一目录已有排队中的刮削,直接复用 */
deduplicated: z.boolean(),
/** 每个作品目录一条刮削记录 */
recordIds: z.array(Id),
});
export type LibraryScrapeResult = z.infer<typeof LibraryScrapeResult>;
+4 -4
View File
@@ -63,7 +63,6 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
LibraryRoot: { row: C.LibraryRoot },
MediaAsset: { row: C.MediaAsset },
MediaEntity: { row: C.MediaEntity },
MediaScrape: { row: C.MediaScrape },
MediaServerInstance: { row: C.MediaServerInstance },
Observation: { row: C.Observation },
OrganizationFileResult: { row: C.OrganizationFileResult },
@@ -76,6 +75,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
PreferenceTag: { row: C.PreferenceTag },
ReconcileRun: { row: C.ReconcileRun },
Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] },
ScrapeRecord: { row: C.ScrapeRecordRow },
ServerMapping: { row: C.ServerMapping },
Settings: { row: C.SettingEntry },
// deleted_at:软删除只在服务端过滤(已删站点不出 API);capabilities 由类型 ∩ 站点定义算出(IA-2)
@@ -210,9 +210,6 @@ const ENUMS: Record<string, z.ZodEnum> = {
MediaAssetStatus: C.AssetStatus,
MediaEntityKind: C.MediaKind,
MediaEntityStatus: C.MediaEntityStatus,
MediaScrapeMode: C.ScrapeMode,
MediaScrapeStatus: C.ScrapeStatus,
MediaScrapeTrigger: C.ScrapeTrigger,
MediaServerInstanceKind: C.MediaServerKind,
MediaServerInstanceHealthStatus: C.InstanceHealth,
ObservationStatus: C.ObservationStatus,
@@ -246,6 +243,9 @@ const ENUMS: Record<string, z.ZodEnum> = {
ScheduledJobLastStatus: C.ScheduleRunStatus,
ScheduledJobRunTrigger: C.ScheduleTrigger,
ScheduledJobRunStatus: C.ScheduleRunStatus,
ScrapeRecordStatus: C.ScrapeStatus,
ScrapeRecordTrigger: C.ScrapeTrigger,
ScrapeRecordMode: C.ScrapeMode,
PiUpstreamAuthKind: C.PiAuthKind,
PiUpstreamStatus: C.PiUpstreamStatus,
PiUpstreamThinkingLevel: C.PiThinkingLevel,
@@ -0,0 +1,72 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// SCR-2 刮削照 MP 简化:刮削对象从"作品"改为"目录 / 视频文件"(不要求先登记),每次刮削一条记录:
// 目录(登记目录 id + 相对路径)、识别结果(本地作品 / TMDB 条目)、写了哪些文件、跳过 / 失败原因。
// 替代 SCR-1 的 media_scrape(一部作品一行);作品详情取该作品最近一条记录。
const MEDIA_SCRAPE = `create table if not exists media_scrape (
media_entity_id uuid primary key references media_entity (id) on delete cascade,
status text not null default 'queued' check (status in ('queued', 'running', 'succeeded', 'partial', 'failed')),
trigger text not null default 'manual' check (trigger in ('auto', 'manual')),
mode text not null default 'missing' check (mode in ('missing', 'overwrite')),
record_id uuid references organization_record (id) on delete set null,
engine_run_id text,
requested_at timestamptz not null default now(),
started_at timestamptz,
finished_at timestamptz,
written integer not null default 0,
skipped integer not null default 0,
failed integer not null default 0,
reason text,
files jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
drop trigger if exists media_scrape_touch_updated_at on media_scrape;
create trigger media_scrape_touch_updated_at before update on media_scrape
for each row execute function umt_touch_updated_at();`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`create table if not exists scrape_record (
id uuid primary key default gen_random_uuid(),
root_id text not null,
root_path text not null,
library_root_id uuid references library_root (id) on delete set null,
path text not null default '',
media_entity_id uuid references media_entity (id) on delete set null,
match jsonb,
status text not null default 'queued' check (status in ('queued', 'running', 'succeeded', 'partial', 'failed')),
trigger text not null default 'manual' check (trigger in ('auto', 'manual')),
mode text not null default 'missing' check (mode in ('missing', 'overwrite')),
organization_record_id uuid references organization_record (id) on delete set null,
engine_run_id text,
actor_type text,
actor_id text,
requested_at timestamptz not null default now(),
started_at timestamptz,
finished_at timestamptz,
written integer not null default 0,
skipped integer not null default 0,
failed integer not null default 0,
reason text,
files jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists scrape_record_entity_idx on scrape_record (media_entity_id, requested_at desc);
create index if not exists scrape_record_path_idx on scrape_record (root_id, path, requested_at desc);
drop trigger if exists scrape_record_touch_updated_at on scrape_record;
create trigger scrape_record_touch_updated_at before update on scrape_record
for each row execute function umt_touch_updated_at();
drop table if exists media_scrape;`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, `drop table if exists scrape_record; ${MEDIA_SCRAPE}`);
}
+34 -26
View File
@@ -159,9 +159,6 @@ export type MediaAssetKind = "video" | "bluray_dir" | "subtitle" | "audio" | "nf
export type MediaAssetStatus = "active" | "replaced" | "retired" | "missing";
export type MediaEntityKind = "movie" | "series" | "variety" | "collection" | "other";
export type MediaEntityStatus = "provisional" | "active" | "merged" | "archived";
export type MediaScrapeStatus = "queued" | "running" | "succeeded" | "partial" | "failed";
export type MediaScrapeTrigger = "auto" | "manual";
export type MediaScrapeMode = "missing" | "overwrite";
export type MediaServerInstanceKind = "emby" | "jellyfin";
export type MediaServerInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
export type MetadataEnrichmentStatus = "suggested" | "applied" | "failed";
@@ -226,6 +223,9 @@ export type ScheduledJobKind = "task" | "agent_check";
export type ScheduledJobLastStatus = "ok" | "failed" | "skipped" | "running";
export type ScheduledJobRunTrigger = "schedule" | "manual" | "skip";
export type ScheduledJobRunStatus = "ok" | "failed" | "skipped" | "running";
export type ScrapeRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed";
export type ScrapeRecordTrigger = "auto" | "manual";
export type ScrapeRecordMode = "missing" | "overwrite";
export type SecretPurpose =
| "site_cookie"
| "site_passkey"
@@ -918,25 +918,6 @@ export interface MediaEntityTable {
naming_override: Json | null;
}
export interface MediaScrapeTable {
media_entity_id: string;
status: Generated<MediaScrapeStatus>;
trigger: Generated<MediaScrapeTrigger>;
mode: Generated<MediaScrapeMode>;
record_id: string | null;
engine_run_id: string | null;
requested_at: GeneratedTimestamptz;
started_at: Timestamptz | null;
finished_at: Timestamptz | null;
written: Generated<number>;
skipped: Generated<number>;
failed: Generated<number>;
reason: string | null;
files: Generated<Json>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface MediaServerInstanceTable {
id: Generated<string>;
name: string;
@@ -1345,6 +1326,33 @@ export interface ScheduledJobRunTable {
created_at: GeneratedTimestamptz;
}
export interface ScrapeRecordTable {
id: Generated<string>;
root_id: string;
root_path: string;
library_root_id: string | null;
path: Generated<string>;
media_entity_id: string | null;
match: Json | null;
status: Generated<ScrapeRecordStatus>;
trigger: Generated<ScrapeRecordTrigger>;
mode: Generated<ScrapeRecordMode>;
organization_record_id: string | null;
engine_run_id: string | null;
actor_type: string | null;
actor_id: string | null;
requested_at: GeneratedTimestamptz;
started_at: Timestamptz | null;
finished_at: Timestamptz | null;
written: Generated<number>;
skipped: Generated<number>;
failed: Generated<number>;
reason: string | null;
files: Generated<Json>;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface SecretTable {
id: Generated<string>;
name: string;
@@ -1720,7 +1728,6 @@ export interface DB {
mcp_server_config: McpServerConfigTable;
media_asset: MediaAssetTable;
media_entity: MediaEntityTable;
media_scrape: MediaScrapeTable;
media_server_instance: MediaServerInstanceTable;
metadata_enrichment: MetadataEnrichmentTable;
metadata_field: MetadataFieldTable;
@@ -1744,6 +1751,7 @@ export interface DB {
rule_group: RuleGroupTable;
scheduled_job: ScheduledJobTable;
scheduled_job_run: ScheduledJobRunTable;
scrape_record: ScrapeRecordTable;
secret: SecretTable;
server_mapping: ServerMappingTable;
settings: SettingsTable;
@@ -1881,9 +1889,6 @@ export type MediaAssetUpdate = Updateable<MediaAssetTable>;
export type MediaEntityRow = Selectable<MediaEntityTable>;
export type NewMediaEntity = Insertable<MediaEntityTable>;
export type MediaEntityUpdate = Updateable<MediaEntityTable>;
export type MediaScrapeRow = Selectable<MediaScrapeTable>;
export type NewMediaScrape = Insertable<MediaScrapeTable>;
export type MediaScrapeUpdate = Updateable<MediaScrapeTable>;
export type MediaServerInstanceRow = Selectable<MediaServerInstanceTable>;
export type NewMediaServerInstance = Insertable<MediaServerInstanceTable>;
export type MediaServerInstanceUpdate = Updateable<MediaServerInstanceTable>;
@@ -1953,6 +1958,9 @@ export type ScheduledJobUpdate = Updateable<ScheduledJobTable>;
export type ScheduledJobRunRow = Selectable<ScheduledJobRunTable>;
export type NewScheduledJobRun = Insertable<ScheduledJobRunTable>;
export type ScheduledJobRunUpdate = Updateable<ScheduledJobRunTable>;
export type ScrapeRecordRow = Selectable<ScrapeRecordTable>;
export type NewScrapeRecord = Insertable<ScrapeRecordTable>;
export type ScrapeRecordUpdate = Updateable<ScrapeRecordTable>;
export type SecretRow = Selectable<SecretTable>;
export type NewSecret = Insertable<SecretTable>;
export type SecretUpdate = Updateable<SecretTable>;
+2 -3
View File
@@ -26,7 +26,6 @@ const EXPECTED_TABLES = [
"path_mapping",
// 内容 / 身份
"media_entity",
"media_scrape",
"content_unit",
"title_alias",
"external_identity",
@@ -124,6 +123,8 @@ const EXPECTED_TABLES = [
"kv_cache",
// 站点请求计数(0046,按小时聚合)
"site_request_count",
// SCR-2 刮削记录(0058,替代 0057 media_scrape)
"scrape_record",
].sort();
/** 不走 entityTable() 的表:追加型/引擎自有表,没有 uuid id + created_at/updated_at 三件套 */
@@ -154,8 +155,6 @@ const NON_ENTITY_TABLES = [
"kv_cache",
// 站点请求计数:(site_id, hour) 主键
"site_request_count",
// 作品最近一次刮削:media_entity_id 主键(SCR-1)
"media_scrape",
];
describe.skipIf(skip)("schema migrations (real PG)", () => {
+33 -12
View File
@@ -61,6 +61,11 @@ export interface ScrapeLayoutInput {
videos: readonly ScrapeVideo[];
images: ScrapeImages;
options: { images: boolean; episodeThumbs: boolean };
/**
* SCR-2 就地刮削的范围(对照 MP scrape_metadata 的 init_folder):work = 作品级(剧集 tvshow.nfo / 海报 / 背景图,
* 电影海报 / 背景图);season = 季级(season.nfo / 季海报)。缺省都写。选中单集文件时两者都关,选中季目录时只关 work。
*/
parts?: { work?: boolean; season?: boolean };
}
export interface ScrapeTarget {
@@ -76,7 +81,18 @@ export interface ScrapeTarget {
imagePath?: string;
}
const SEASON_DIR_RE = /^(season\s*\d+|s\d{1,3}|specials?|特别篇|第\s*\d+\s*季)$/i;
const SEASON_DIR_RE = /^(season\s*\d+|s\d{1,3}|specials?|sps?|特别篇|第\s*\d+\s*季)$/i;
/** 目录名像季目录(Season 1 / S01 / Specials / 第 1 季);对照 MP season_zero_names 与 MetaInfo(dir).begin_season */
export const isSeasonDirName = (name: string): boolean => SEASON_DIR_RE.test(name.trim());
/** 季目录名 → 季号(Specials / SPs / 特别篇 = 0);认不出为 null */
export function seasonOfDirName(name: string): number | null {
const n = name.trim();
if (/^(specials?|sps?|特别篇)$/i.test(n)) return 0;
const m = /^(?:season\s*|s)(\d{1,4})$/i.exec(n) ?? /^第\s*(\d{1,4})\s*季$/.exec(n);
return m ? Number(m[1]) : null;
}
/** 没有作品绑定时从视频路径推作品目录:电影取父目录;剧集父目录像季目录时取祖父目录 */
export function inferWorkDir(kind: "movie" | "tv", videoPath: string): string | null {
@@ -127,7 +143,7 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
const dir = normDir(posix.dirname(v.path));
add({ path: join(dir, `${stemOf(v.path)}.nfo`), kind: "nfo", role: "movie_nfo", video: v.path });
}
if (!input.options.images) return out;
if (!input.options.images || input.parts?.work === false) return out;
const inWork = workDir && videos.some((v) => isUnder(v.path, workDir));
const art = (role: "poster" | "fanart", p: string | null | undefined) => {
if (!p) return;
@@ -150,8 +166,10 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
}
// 剧集
const wantWork = input.parts?.work !== false;
const wantSeason = input.parts?.season !== false;
const underWork = workDir ? videos.filter((v) => isUnder(v.path, workDir)) : [];
if (workDir && underWork.length) {
if (workDir && underWork.length && wantWork) {
add({ path: join(workDir, "tvshow.nfo"), kind: "nfo", role: "tvshow_nfo" });
if (input.options.images) {
if (img.posterPath)
@@ -169,9 +187,11 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
imagePath: img.backdropPath,
});
}
// 季:按视频所在目录(不等于作品目录的才算季目录)
}
// 季:按视频所在目录(不等于作品目录的才算季目录);没有作品目录时只写季目录里的 season.nfo / poster
if (wantSeason) {
const seasons = new Map<number, Set<string>>();
for (const v of underWork) {
for (const v of workDir ? underWork : videos) {
if (v.season === null || v.season === undefined) continue;
const dir = normDir(posix.dirname(v.path));
const set = seasons.get(v.season) ?? new Set<string>();
@@ -182,13 +202,14 @@ export function planScrapeTargets(input: ScrapeLayoutInput): ScrapeTarget[] {
for (const d of [...dirs].sort()) add({ path: `${d}/season.nfo`, kind: "nfo", role: "season_nfo", season });
const sp = img.seasonPosters?.get(season);
if (input.options.images && sp) {
add({
path: join(workDir, seasonPosterName(season, extOf(sp))),
kind: "image",
role: "season_poster",
season,
imagePath: sp,
});
if (workDir)
add({
path: join(workDir, seasonPosterName(season, extOf(sp))),
kind: "image",
role: "season_poster",
season,
imagePath: sp,
});
for (const d of [...dirs].sort())
add({ path: `${d}/poster${extOf(sp)}`, kind: "image", role: "season_poster", season, imagePath: sp });
}
+49 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { episodeNfo, inferWorkDir, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "../src/index.ts";
import {
episodeNfo,
inferWorkDir,
isSeasonDirName,
planScrapeTargets,
seasonNfo,
seasonOfDirName,
seasonPosterName,
workNfo,
} from "../src/index.ts";
describe("NFO 生成", () => {
it("电影:MP 元素顺序,缺值不写,IMDb 为 default", () => {
@@ -198,4 +207,43 @@ describe("刮削目标布局", () => {
});
expect(t.map((x) => x.path)).toEqual(["a.S01E01.nfo", "a.S01E01-thumb.jpg"]);
});
it("SCR-2 季目录名:Season 1 / S01 / Specials / SPs / 第 1 季", () => {
for (const n of ["Season 1", "season 12", "S01", "Specials", "SPs", "特别篇", "第 2 季"])
expect(isSeasonDirName(n)).toBe(true);
for (const n of ["人生切割术 (2022)", "Extras", "Season"]) expect(isSeasonDirName(n)).toBe(false);
expect(seasonOfDirName("Season 3")).toBe(3);
expect(seasonOfDirName("S01")).toBe(1);
expect(seasonOfDirName("Specials")).toBe(0);
expect(seasonOfDirName("第 2 季")).toBe(2);
expect(seasonOfDirName("Extras")).toBeNull();
});
it("SCR-2 范围:选中季目录只写季级与各集(季海报仍写上级),选中单集只写该集", () => {
const videos = [{ path: "人生切割术 (2022)/Season 1/a.S01E01.mkv", season: 1, episode: 1 }];
const season = planScrapeTargets({
kind: "tv",
workDir: "人生切割术 (2022)",
videos,
images,
options: { images: true, episodeThumbs: true },
parts: { work: false },
});
expect(season.map((x) => `${x.role}:${x.path}`)).toEqual([
"season_nfo:人生切割术 (2022)/Season 1/season.nfo",
"season_poster:人生切割术 (2022)/season01-poster.jpg",
"season_poster:人生切割术 (2022)/Season 1/poster.jpg",
"episode_nfo:人生切割术 (2022)/Season 1/a.S01E01.nfo",
"episode_thumb:人生切割术 (2022)/Season 1/a.S01E01-thumb.jpg",
]);
const ep = planScrapeTargets({
kind: "tv",
workDir: null,
videos,
images,
options: { images: true, episodeThumbs: true },
parts: { work: false, season: false },
});
expect(ep.map((x) => x.role)).toEqual(["episode_nfo", "episode_thumb"]);
});
});