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:
root committed 2026-10-01 00:09:01 +08:00
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 -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,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) };
}
+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);
});
}
});
+19 -2
View File
@@ -33,6 +33,7 @@ import {
FilesRenameResult,
type FilesScrapeBatchRequest,
FilesScrapeBatchResult,
FilesScrapeRecordsResult,
type FilesScrapeRequest,
FilesScrapeResult,
type FilesSuggestNameRequest,
@@ -70,11 +71,14 @@ export type {
FilesOrganizeItem,
FilesOrganizeRequest,
FilesScrapeBatchResult,
FilesScrapeNeedsChoice,
FilesScrapeResult,
FilesSuggestNameResult,
FilesTransferResult,
FilesTreeResult,
IdentifyCompletedPayload,
NamingTemplateSource,
ScrapeRecord,
TmdbCandidate,
TrashItem,
} from "@umt/contracts";
@@ -278,6 +282,19 @@ export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enable
});
}
/** 刮削记录回查:排队 / 进行中时每 2 秒刷新 */
export function useScrapeRecords(ids: string[]) {
return useQuery({
queryKey: ["files", "scrape-records", ids.join(",")],
enabled: ids.length > 0,
retry: false,
queryFn: ({ signal }) =>
call(FilesScrapeRecordsResult, "/files/scrape-records", { params: { ids: ids.join(",") }, signal }),
refetchInterval: (query) =>
query.state.data?.records.some((r) => r.status === "queued" || r.status === "running") ? 2_000 : false,
});
}
/** 整理记录 → 源 FileRef(整理记录页"重新整理") */
export function useRecordSources(recordId: string | null) {
return useQuery({
@@ -401,8 +418,8 @@ export const useOrganizeFiles = () =>
useFilesMutation((b: FilesOrganizeRequest) => post(FilesOrganizeResult, "/files/organize", b), {
invalidate: notDry,
});
export const useScrape = () =>
useMutation({ mutationFn: (b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b) });
/** 刮削(SCR-2):任意目录 / 视频文件就地写 NFO 与图片;认不准的项在 needsChoice 里,选定后再提交 */
export const useScrape = () => useFilesMutation((b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b));
export const useProbe = () => useFilesMutation((b: FilesProbeRequest) => post(FilesProbeResult, "/files/probe", b));
export const useScrapeBatch = () =>
useMutation({ mutationFn: (b: FilesScrapeBatchRequest) => post(FilesScrapeBatchResult, "/files/scrape-batch", b) });
+1 -1
View File
@@ -82,7 +82,7 @@ export function useLibraryDetail(id: string | undefined) {
});
}
/** SCR-1 刮削:写 NFO 与图片到媒体库目录(后台任务);mode 缺省按设置 */
/** 刮削:对作品目录就地写 NFO 与图片(后台任务,SCR-2 与文件管理「刮削」同一实现);mode 缺省按设置 */
export function useScrapeEntity(id: string) {
const qc = useQueryClient();
return useMutation({
+201 -41
View File
@@ -1,6 +1,7 @@
import {
ArrowRight,
Copy,
FileDown,
FolderInput,
FolderPlus,
LoaderCircle,
@@ -19,6 +20,7 @@ import {
type FileRef,
type FileRoot,
type FilesScrapeBatchResult,
type FilesScrapeNeedsChoice,
type FilesSuggestNameResult,
type FilesTransferResult,
filesErrorReason,
@@ -28,11 +30,17 @@ import {
useMkdir,
useMove,
useRename,
useScrape,
useScrapeBatch,
useScrapeRecords,
useSuggestName,
} from "~/api/files.ts";
import { useSettings } from "~/api/settings.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ScrapeRecordView } from "~/components/library/scrape-panel.tsx";
import { EntityPicker } from "~/components/organize-wizard/entity-picker.tsx";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { PathSegments } from "~/components/organize-wizard/path-segments.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
@@ -986,21 +994,12 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
);
}
// ─── 刷新元数据 / 刮削 ─────────────────────────────────────
// ─── 刷新元数据 ─────────────────────────────────────────────
/**
* 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。
* write = 刮削(SCR-1):同一接口 write=true,按作品排 scrapeMedia,把 NFO 与图片写进媒体库目录(覆盖方式按设置)。
* 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。只更新本工具的字段,不写文件。
*/
export function RefreshMetadataDialog({
items,
write = false,
onClose,
}: {
items: FileEntry[];
write?: boolean | undefined;
onClose: () => void;
}) {
export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) {
const batch = useScrapeBatch();
const key = useMemo(() => newIdempotencyKey(), []);
const [result, setResult] = useState<FilesScrapeBatchResult | null>(null);
@@ -1008,15 +1007,13 @@ export function RefreshMetadataDialog({
const single = entities.length === 1 ? entities[0] : null;
const run = () =>
batch.mutate(
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string), write },
{ idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) },
{
onSuccess: (r) => {
setResult(r);
const skipped = r.skipped.length ? `;${r.skipped.length} 个未提交` : "";
(r.skipped.length ? toast.warning : toast.success)(
write
? `已提交 ${r.submitted.length} 个作品的刮削${skipped}`
: `已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
`已提交 ${r.submitted.length} 个作品,锁定字段不变${skipped}`,
);
},
onError: (e) => toast.error(filesErrorText(e, "提交失败")),
@@ -1027,14 +1024,10 @@ export function RefreshMetadataDialog({
<DialogContent>
<DialogHeader>
<DialogTitle>
{write ? "刮削" : "刷新元数据"}
刷新元数据
{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}
</DialogTitle>
<DialogDescription>
{write
? "把 NFO 和图片写进媒体库目录,已有文件按设置只补缺失或全部覆盖。结果在作品详情的「刮削」里。"
: "按字段从 TMDB 刷新,锁定的字段和路径不变。不写媒体库目录里的文件。"}
</DialogDescription>
<DialogDescription>按字段从 TMDB 刷新,锁定的字段和路径不变。不写目录里的文件。</DialogDescription>
</DialogHeader>
{!single && !result ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
@@ -1052,30 +1045,19 @@ export function RefreshMetadataDialog({
<div key={s.mediaEntityId} className="rounded-lg border border-border/60 px-3 py-2">
<p className="flex items-center justify-between gap-2">
<span className="truncate">{s.title}</span>
<Link
className="shrink-0 text-meta underline"
to={write ? `/console/library/${s.mediaEntityId}` : `/console/engine/${s.taskId}`}
>
{write ? "查看结果" : "查看运行"}
<Link className="shrink-0 text-meta underline" to={`/console/engine/${s.taskId}`}>
查看运行
</Link>
</p>
{write ? null : (
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
)}
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
</div>
))}
{result.skipped.map((s) => (
<p key={s.ref} className="text-meta text-warning">
{s.ref}:
{s.reason === "not_registered"
? "没有已入库文件"
: s.reason === "no_entity"
? "不属于媒体库里的作品"
: s.reason === "not_found"
? "作品不存在"
: s.reason}
{s.ref}:
{s.reason === "no_entity" ? "不属于媒体库里的作品" : s.reason === "not_found" ? "作品不存在" : s.reason}
</p>
))}
</div>
@@ -1087,7 +1069,7 @@ export function RefreshMetadataDialog({
{result ? null : (
<Button onClick={run} disabled={batch.isPending || !entities.length}>
{batch.isPending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{write ? "刮削" : "刷新"}
刷新
</Button>
)}
</DialogFooter>
@@ -1095,3 +1077,181 @@ export function RefreshMetadataDialog({
</Dialog>
);
}
// ─── 刮削(SCR-2) ─────────────────────────────────────────
const SKIP_TEXT: Record<string, string> = {
scrape_root_read_only: "只登记模式的目录,不写文件",
scrape_not_video: "不是视频文件或目录",
scrape_no_videos: "目录里没有视频",
};
/**
* 刮削(照 MP 文件管理的刮削):任意目录 / 视频文件 → 服务端按路径识别作品 → 在所在目录写 NFO 与图片。
* 不要求已登记;认不准的项列出候选,选一个后「继续刮削」(可顺手加入媒体库)。覆盖方式缺省按设置,这里可临时改。
*/
export function ScrapeDialog({ items, root, onClose }: { items: FileEntry[]; root: FileRoot; onClose: () => void }) {
const settings = useSettings();
const scrape = useScrape();
const [mode, setMode] = useState<"missing" | "overwrite" | null>(null);
const curMode = mode ?? settings.data?.organize.scrapeMode ?? "missing";
const [recordIds, setRecordIds] = useState<string[]>([]);
const [choices, setChoices] = useState<FilesScrapeNeedsChoice[]>([]);
const [picked, setPicked] = useState<Record<string, WizardEntity | null>>({});
const [skipped, setSkipped] = useState<{ path: string; reason: string }[]>([]);
const [addToLibrary, setAddToLibrary] = useState(false);
const [started, setStarted] = useState(false);
const records = useScrapeRecords(recordIds);
const single = items.length === 1 ? items[0] : null;
const submit = (list: { path: string; pick?: WizardEntity | null }[]) =>
scrape.mutate(
{
idempotencyKey: newIdempotencyKey(),
mode: curMode,
addToLibrary,
items: list.map((x) => ({
root: root.id,
path: x.path,
...(x.pick?.id ? { mediaEntityId: x.pick.id } : {}),
...(x.pick && !x.pick.id && x.pick.external ? { external: x.pick.external } : {}),
})),
},
{
onSuccess: (r) => {
setStarted(true);
const done = new Set(list.map((x) => x.path));
setRecordIds((ids) => [...ids, ...r.records.map((x) => x.id)]);
setChoices((cur) => [...cur.filter((c) => !done.has(c.path)), ...r.needsChoice]);
setSkipped((cur) => [...cur, ...r.skipped.map((x) => ({ path: x.path, reason: x.reason }))]);
if (r.entities.length) toast.success(`已加入媒体库:${r.entities.map((e) => e.title).join("、")}`);
},
onError: (e) => toast.error(filesErrorText(e, "刮削没有提交")),
},
);
const ready = choices.filter((c) => picked[c.path]);
const recs = records.data?.records ?? [];
const pending = recs.some((r) => r.status === "queued" || r.status === "running");
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>刮削{single ? ` · ${single.name}` : ` · ${items.length} 项`}</DialogTitle>
<DialogDescription>
按路径识别作品,在所在目录写 NFO 和图片,Emby、Jellyfin、Kodi 直接读取。没入库的也能刮削。
</DialogDescription>
</DialogHeader>
{!started ? (
<div className="grid gap-3">
{!single ? (
<ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
{items.map((e) => (
<li key={e.path} className="truncate">
{e.path}
</li>
))}
</ul>
) : null}
<FormField label="已有文件">
<fieldset aria-label="已有文件" className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
{(
[
["missing", "只补缺失"],
["overwrite", "全部覆盖"],
] as const
).map(([v, label]) => (
<button
key={v}
type="button"
aria-pressed={curMode === v}
onClick={() => setMode(v)}
className={cn(
"inline-flex h-7 cursor-pointer items-center rounded-full border px-2.5 text-meta font-medium",
curMode === v
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
{label}
</button>
))}
</fieldset>
</FormField>
{curMode === "overwrite" ? (
<p className="text-meta text-warning">目录里已有的 NFO 和图片会被替换,包括其他工具写的。</p>
) : null}
</div>
) : (
<div className="grid max-h-[60vh] gap-4 overflow-auto">
{choices.length ? (
<section className="grid gap-2">
<p className="text-sm font-medium">认不准作品,选一个再刮削</p>
{choices.map((c) => (
<div key={c.path} className="grid gap-1.5 rounded-lg border border-border/60 px-3 py-2">
<p className="text-caption break-all">{c.path || "目录根"}</p>
<EntityPicker
candidates={c.candidates}
value={picked[c.path] ?? null}
onChange={(e) => setPicked((p) => ({ ...p, [c.path]: e }))}
placeholder={c.candidates.length ? "选择作品…" : "搜索作品…"}
/>
{c.warnings.map((w) => (
<p key={w} className="text-meta text-muted-foreground">
{w}
</p>
))}
</div>
))}
<div className="flex items-center gap-2 text-caption">
<Switch id="scrape-add-to-library" checked={addToLibrary} onCheckedChange={setAddToLibrary} />
<label htmlFor="scrape-add-to-library">同时加入媒体库</label>
</div>
</section>
) : null}
{recs.map((r) => (
<div key={r.id} className="rounded-lg border border-border/60 px-3 py-2">
<ScrapeRecordView record={r} showPath />
{r.match?.mediaEntityId ? (
<Link className="text-meta underline" to={`/console/library/${r.match.mediaEntityId}`}>
打开作品
</Link>
) : null}
</div>
))}
{recordIds.length && !recs.length ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
{skipped.map((x) => (
<p key={x.path} className="text-meta text-warning break-all">
{x.path || "目录根"}:{SKIP_TEXT[x.reason] ?? x.reason}
</p>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{started ? "完成" : "取消"}
</Button>
{!started ? (
<Button onClick={() => submit(items.map((e) => ({ path: e.path })))} disabled={scrape.isPending}>
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <FileDown />}
{scrape.isPending ? "识别中…" : "刮削"}
</Button>
) : choices.length ? (
<Button
onClick={() => submit(ready.map((c) => ({ path: c.path, pick: picked[c.path] })))}
disabled={scrape.isPending || !ready.length}
>
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <FileDown />}
继续刮削
</Button>
) : pending ? (
<Button disabled>
<LoaderCircle className="animate-spin" />
刮削中…
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+84 -6
View File
@@ -12,6 +12,7 @@ import type {
FilesListResponse,
FilesOrganizeGroup,
FilesTransferResult,
ScrapeRecord,
TrashItem,
} from "@umt/contracts";
import type { MediaSearchResult } from "~/api/files.ts";
@@ -699,6 +700,82 @@ function identify(
let seq = 0;
// SCR-2 刮削:认得出的(KNOWN)直接写,认不出的给两个 TMDB 候选
const SCRAPES = new Map<string, ScrapeRecord>();
function mockScrape(b: {
items: { root: string; path: string; mediaEntityId?: string; external?: { externalId: string } }[];
mode?: "missing" | "overwrite";
}) {
const records: ScrapeRecord[] = [];
const needsChoice: unknown[] = [];
for (const it of b.items) {
const known = KNOWN.find((k) => k.match.test(it.path) || k.id === it.mediaEntityId);
if (!known && !it.external) {
needsChoice.push({
root: it.root,
path: it.path,
title: it.path.split("/").pop() ?? it.path,
warnings: [],
candidates: [1972, 2002].map((year, i) => ({
mediaEntityId: null,
provider: "tmdb",
externalId: String(593 + i),
title: it.path.split("/").pop() ?? it.path,
year,
kind: "movie",
level: "medium",
score: 5 - i,
reasons: ["title:exact"],
posterUrl: null,
})),
});
continue;
}
const stem = it.path.replace(/\.[^./]+$/, "");
const now = new Date().toISOString();
const files = [
{ path: `${stem}.nfo`, kind: "nfo" as const, role: "movie_nfo", action: "written" as const, reason: null },
{
path: `${it.path}/poster.jpg`,
kind: "image" as const,
role: "poster",
action: "written" as const,
reason: null,
},
];
const rec: ScrapeRecord = {
id: `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`,
root: it.root,
path: it.path,
status: "succeeded",
trigger: "manual",
mode: b.mode ?? "missing",
match: {
mediaEntityId: known?.id ?? null,
provider: "tmdb",
externalId: it.external?.externalId ?? "0",
kind: known?.kind === "series" ? "tv" : "movie",
title: known?.title ?? it.path,
year: known?.year ?? null,
source: it.external || it.mediaEntityId ? "chosen" : known ? "auto" : "chosen",
},
written: files.length,
skipped: 0,
failed: 0,
reason: null,
files,
organizationRecordId: null,
taskId: `run-${seq}`,
requestedAt: now,
startedAt: now,
finishedAt: now,
};
SCRAPES.set(rec.id, rec);
records.push(rec);
}
return { records, needsChoice, skipped: [], pending: false, entities: [] };
}
function doMove(body: {
items: ItemRef[];
target: ItemRef;
@@ -1119,13 +1196,14 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
await delay(400);
return organize(b);
case "/files/scrape":
await delay(500);
return mockScrape(b);
case "/files/scrape-records":
return {
mediaEntityId: b.mediaEntityId,
taskId: `run-${++seq}`,
lockedFields: ["title"],
nfo: b.writeNfo ? "queued" : "not_requested",
images: b.images ? "queued" : "not_requested",
scrapeTaskId: b.writeNfo || b.images ? `run-${++seq}` : null,
records: String(p.ids ?? "")
.split(",")
.map((id) => SCRAPES.get(id))
.filter(Boolean),
};
case "/files/probe":
await delay(600);
+9 -5
View File
@@ -111,7 +111,7 @@ describe("状态与禁用原因", () => {
expect(entryStatusView(base)).toBeNull();
});
it("做种中不能改名/删除;只读登记根不能移动;未登记不能刮削", () => {
it("做种中不能改名/删除;只读登记根不能移动;未登记不能刷新元数据但能刮削", () => {
const lib = { kind: "library" as const, writable: true };
const seeding = {
...base,
@@ -124,6 +124,8 @@ describe("状态与禁用原因", () => {
expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/);
expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull();
expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/);
expect(opDisabledReason("writeNfo", base, lib)).toBeNull();
expect(opDisabledReason("writeNfo", base, { kind: "library", writable: false })).toMatch(/只读/);
expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull();
expect(opDisabledReason("rename", { ...base, escape: true }, lib)).toMatch(/登记目录之外/);
});
@@ -193,16 +195,18 @@ describe("v2 菜单与副行", () => {
const registered = { ...video, status: "registered" as const, asset };
const keys = (xs: ReturnType<typeof menuModel>) => xs.map((x) => ("separator" in x ? "|" : x.key)).join(" ");
it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,顺序固定", () => {
expect(keys(menuModel([video], lib))).toBe("open | organize identify rename move copy | probe | copyPath | delete");
it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,刮削照 MP 都有;顺序固定", () => {
expect(keys(menuModel([video], lib))).toBe(
"open | organize identify rename move copy | writeNfo probe | copyPath | delete",
);
expect(keys(menuModel([registered], lib))).toBe(
"open | identify rename move copy | scrape writeNfo probe | copyPath openMedia records | delete",
);
expect(opApplies("organize", registered)).toBe(false);
});
it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据", () => {
expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe | delete");
it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据,刮削不要求登记", () => {
expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe writeNfo | delete");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape writeNfo");
});
+10 -7
View File
@@ -98,8 +98,11 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null;
case "scrape":
case "writeNfo":
return e.asset ? null : "未登记,先整理入库";
case "writeNfo":
if (root.kind === "library" && !root.writable) return "只读目录,不写 NFO";
if (e.status === "temp") return "未完成的文件不能刮削";
return null;
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
case "copy":
@@ -355,8 +358,10 @@ export function opApplies(op: MenuKey, e: FileEntry): boolean {
return !e.asset && (isVideo(e) || e.type === "dir") && e.status !== "temp";
case "identify":
return isVideo(e) || e.type === "dir";
case "scrape":
case "writeNfo":
// SCR-2:照 MP,任意视频文件或目录都能刮削(不要求已登记)
return isVideo(e) || e.type === "dir";
case "scrape":
case "openMedia":
return !!e.asset;
case "records":
@@ -417,10 +422,8 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
add("move", why("move"));
add("copy", list.some((x) => x.escape) ? "含指向登记目录之外的链接" : null);
if (any("probe")) add("probe", null);
if (list.every((x) => !!x.asset)) {
add("scrape", null);
add("writeNfo", null);
}
if (list.every((x) => !!x.asset)) add("scrape", null);
if (all("writeNfo")) add("writeNfo", why("writeNfo"));
sep();
add("delete", why("delete"));
return out;
@@ -441,7 +444,7 @@ export const OP_LABEL: Record<MenuKey, string> = {
move: "移动到…",
copy: "复制到…",
scrape: "刷新元数据",
writeNfo: "刮削",
writeNfo: "刮削…",
probe: "探测",
copyPath: "复制路径…",
openMedia: "打开作品",
@@ -1,4 +1,4 @@
import type { LibraryDetail, LibraryScrapeState, ScrapeFileResult } from "@umt/contracts";
import type { LibraryDetail, ScrapeFileResult, ScrapeRecord } from "@umt/contracts";
import { FileText, Image as ImageIcon, RefreshCw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
@@ -12,10 +12,10 @@ import { reasonCode, reasonLine } from "~/lib/reasons.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { Panel } from "./parts.tsx";
// SCR-1 刮削状态:最近一次往媒体库目录写的 NFO 与图片(逐文件结果)+「重新刮削」「覆盖重写」。
// 与入库后自动刮削、文件管理「刮削」同一个后台任务。
// 刮削状态:作品最近一次刮削记录(目录、识别结果、逐文件结果)+「重新刮削」「覆盖重写」。
// SCR-2 起与文件管理「刮削」、入库后自动刮削同一实现(对作品目录就地刮削)。
const STATUS: Record<LibraryScrapeState["status"], { label: string; tone: StatusTone }> = {
export const SCRAPE_STATUS: Record<ScrapeRecord["status"], { label: string; tone: StatusTone }> = {
queued: { label: "排队中", tone: "neutral" },
running: { label: "刮削中…", tone: "neutral" },
succeeded: { label: "已完成", tone: "success" },
@@ -25,7 +25,7 @@ const STATUS: Record<LibraryScrapeState["status"], { label: string; tone: Status
const ACTION: Record<ScrapeFileResult["action"], { label: string; tone: StatusTone }> = {
written: { label: "已写入", tone: "success" },
skipped: { label: "已存在", tone: "neutral" },
skipped: { label: "跳过", tone: "neutral" },
failed: { label: "失败", tone: "destructive" },
};
@@ -40,10 +40,12 @@ const ROLE: Record<string, string> = {
episode_thumb: "剧照",
};
function counts(s: LibraryScrapeState): string {
const SOURCE: Record<string, string> = { library: "已入库的作品", auto: "按路径识别", chosen: "手动指定" };
export function scrapeCounts(s: Pick<ScrapeRecord, "written" | "skipped" | "failed">): string {
return [
s.written ? `写入 ${s.written} 个文件` : null,
s.skipped ? `跳过 ${s.skipped} 个已有文件` : null,
s.skipped ? `跳过 ${s.skipped} 个` : null,
s.failed ? `${s.failed} 个失败` : null,
]
.filter(Boolean)
@@ -68,18 +70,66 @@ function FileRow({ f }: { f: ScrapeFileResult }) {
)}
</span>
<span className="text-meta text-muted-foreground">{ROLE[f.role] ?? f.role}</span>
{f.action === "failed" ? (
<span className="text-meta text-destructive">{reasonLine(f.reason, "写入失败")}</span>
{f.action !== "written" && f.reason && f.reason !== "scrape_exists" ? (
<span className={f.action === "failed" ? "text-meta text-destructive" : "text-meta text-muted-foreground"}>
{reasonLine(f.reason, "写入失败")}
</span>
) : null}
<StatusBadge tone={a.tone}>{a.label}</StatusBadge>
</li>
);
}
/** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */
export function ScrapeRecordView({ record: s, showPath = false }: { record: ScrapeRecord; showPath?: boolean }) {
const [showAll, setShowAll] = useState(false);
const files = s.files;
const important = files.filter((f) => f.action !== "skipped" || f.reason !== "scrape_exists");
const shown = showAll ? files : important.slice(0, 12);
const hidden = files.length - shown.length;
const done = s.status !== "queued" && s.status !== "running";
return (
<div className="grid gap-2">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={SCRAPE_STATUS[s.status].tone}>{SCRAPE_STATUS[s.status].label}</StatusBadge>
{showPath ? <span className="min-w-0 text-sm break-all text-foreground">{s.path || "目录根"}</span> : null}
{done && (files.length || s.status === "succeeded") ? (
<span className="text-sm text-muted-foreground">{scrapeCounts(s) || "没有要写的文件"}</span>
) : null}
{s.mode === "overwrite" ? <span className="text-meta text-muted-foreground">覆盖已有文件</span> : null}
</div>
{s.match ? (
<p className="text-meta text-muted-foreground">
{s.match.title}
{s.match.year ? ` (${s.match.year})` : ""} · {s.match.kind === "movie" ? "电影" : "剧集"} ·{" "}
{SOURCE[s.match.source] ?? s.match.source}
{s.match.mediaEntityId ? "" : " · 不在媒体库"}
</p>
) : null}
{s.reason && s.status !== "succeeded" ? (
<p className="text-sm text-muted-foreground">{reasonLine(s.reason, "刮削失败")}</p>
) : null}
{shown.length ? (
<ul className="divide-y divide-border/60">
{shown.map((f) => (
<FileRow key={`${f.path}:${f.role}`} f={f} />
))}
</ul>
) : null}
{hidden > 0 ? (
<div>
<Button variant="ghost" size="sm" onClick={() => setShowAll(true)}>
{important.length === 0 ? `查看 ${hidden} 个已有文件` : `显示全部 ${files.length} 个文件`}
</Button>
</div>
) : null}
</div>
);
}
export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
const s = detail.scrape ?? null;
const scrape = useScrapeEntity(detail.entity.id);
const [showAll, setShowAll] = useState(false);
const hasFiles = detail.assets.some((a) => a.asset.status === "active");
if (!s && !hasFiles) return null;
const busy = scrape.isPending || s?.status === "queued" || s?.status === "running";
@@ -91,10 +141,6 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
throw err;
},
);
const files = s?.files ?? [];
const important = files.filter((f) => f.action !== "skipped");
const shown = showAll ? files : important.slice(0, 12);
const hidden = files.length - shown.length;
return (
<Panel
@@ -102,61 +148,42 @@ export function ScrapePanel({ detail }: { detail: LibraryDetail }) {
description={
s?.finishedAt
? `上次刮削 ${formatDateTime(s.finishedAt)} · ${s.trigger === "auto" ? "入库后自动" : "手动"}`
: "NFO 和图片写进媒体库目录,Emby、Jellyfin、Kodi 直接读取。"
: "NFO 和图片写进作品目录,Emby、Jellyfin、Kodi 直接读取。"
}
actions={
<>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" disabled={busy}>
覆盖重写
</Button>
}
title="覆盖重写 NFO 和图片?"
description="媒体库目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。"
confirmLabel="覆盖重写"
onConfirm={() => run("overwrite")}
/>
<Button variant="outline" size="sm" disabled={busy} onClick={() => void run().catch(() => {})}>
<RefreshCw className={busy ? "animate-spin" : undefined} />
{s ? "重新刮削" : "刮削"}
</Button>
</>
hasFiles ? (
<>
<ConfirmDialog
trigger={
<Button variant="ghost" size="sm" disabled={busy}>
覆盖重写
</Button>
}
title="覆盖重写 NFO 和图片?"
description="作品目录里已有的 NFO 和图片会被替换,包括其他工具写的。视频和字幕不动。"
confirmLabel="覆盖重写"
onConfirm={() => run("overwrite")}
/>
<Button variant="outline" size="sm" disabled={busy} onClick={() => void run().catch(() => {})}>
<RefreshCw className={busy ? "animate-spin" : undefined} />
{s ? "重新刮削" : "刮削"}
</Button>
</>
) : null
}
>
{!s ? (
<p className="text-sm text-muted-foreground">还没刮削过。</p>
) : (
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={STATUS[s.status].tone}>{STATUS[s.status].label}</StatusBadge>
{s.status !== "queued" && s.status !== "running" ? (
<span className="text-sm text-foreground">{counts(s) || "没有要写的文件"}</span>
) : null}
{s.mode === "overwrite" ? <span className="text-meta text-muted-foreground">覆盖已有文件</span> : null}
</div>
{s.reason && s.status !== "succeeded" ? (
<p className="text-sm text-muted-foreground">{reasonLine(s.reason, "刮削失败")}</p>
) : null}
{shown.length ? (
<ul className="divide-y divide-border/60">
{shown.map((f) => (
<FileRow key={`${f.path}:${f.role}`} f={f} />
))}
</ul>
) : null}
{hidden > 0 ? (
<div>
<Button variant="ghost" size="sm" onClick={() => setShowAll(true)}>
{important.length === 0 ? `查看 ${hidden} 个已有文件` : `显示全部 ${files.length} 个文件`}
</Button>
</div>
) : null}
<ScrapeRecordView record={s} showPath />
<TechInfo
items={[
{ label: "覆盖方式", value: s.mode === "overwrite" ? "全部覆盖" : "只补缺失" },
{ label: "目录", value: `${s.root}:${s.path}`, mono: true },
s.match?.externalId ? { label: "TMDB", value: s.match.externalId, mono: true } : null,
s.taskId ? { label: "任务", value: s.taskId, mono: true } : null,
s.recordId ? { label: "整理记录", value: s.recordId, mono: true } : null,
s.organizationRecordId ? { label: "整理记录", value: s.organizationRecordId, mono: true } : null,
s.reason ? { label: "原因码", value: reasonCode(s.reason) ?? s.reason, mono: true } : null,
{ label: "提交于", value: formatDateTime(s.requestedAt) },
]}
+18 -7
View File
@@ -61,6 +61,7 @@ import {
MkdirDialog,
RefreshMetadataDialog,
RenameDialog,
ScrapeDialog,
TransferDialog,
} from "~/components/files/dialogs.tsx";
import { DirTree } from "~/components/files/dir-tree.tsx";
@@ -109,7 +110,8 @@ type Dialog =
| { kind: "delete"; items: FileRef[] }
| { kind: "mkdir" }
| { kind: "copyPath"; entry: FileEntry }
| { kind: "refresh"; items: FileEntry[]; write?: boolean }
| { kind: "refresh"; items: FileEntry[] }
| { kind: "scrape"; items: FileEntry[] }
| { kind: "wizard"; source: OrganizeSource }
| { kind: "shortcuts" };
@@ -459,7 +461,7 @@ export default function FilesPage() {
case "scrape":
return setDialog({ kind: "refresh", items: list });
case "writeNfo":
return setDialog({ kind: "refresh", items: list, write: true });
return setDialog({ kind: "scrape", items: list });
case "probe":
return doProbe(list);
case "copyPath":
@@ -709,9 +711,9 @@ export default function FilesPage() {
});
items.push({
key: "writeNfo",
label: "刮削",
label: "刮削…",
icon: FileDown,
disabled: selected.every((e) => e.asset) ? null : "只对已入库的文件",
disabled: selected.every((e) => opApplies("writeNfo", e)) ? blockedTip("writeNfo") : "只能刮削视频文件或目录",
onSelect: () => act("writeNfo", selected),
});
items.push({ key: "copyPath", label: "复制路径", icon: ClipboardCopy, onSelect: () => void copyPaths(selected) });
@@ -1151,8 +1153,14 @@ export default function FilesPage() {
刷新元数据
</Button>
) : null}
{detail.asset ? (
<Button size="sm" variant="outline" onClick={() => act("writeNfo", [detail])}>
{opApplies("writeNfo", detail) ? (
<Button
size="sm"
variant="outline"
onClick={() => act("writeNfo", [detail])}
disabled={!!opDisabledReason("writeNfo", detail, root)}
title={opDisabledReason("writeNfo", detail, root) ?? undefined}
>
<FileDown />
刮削
</Button>
@@ -1224,7 +1232,10 @@ export default function FilesPage() {
<CopyPathDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "refresh" ? (
<RefreshMetadataDialog items={dialog.items} write={dialog.write} onClose={() => setDialog(null)} />
<RefreshMetadataDialog items={dialog.items} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "scrape" && root ? (
<ScrapeDialog items={dialog.items} root={root} onClose={() => setDialog(null)} />
) : null}
{dialog?.kind === "wizard" ? <OrganizeWizard source={dialog.source} onClose={() => setDialog(null)} /> : null}
<ShortcutsDialog open={dialog?.kind === "shortcuts"} onOpenChange={(o) => !o && setDialog(null)} />