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

+8
View File
@@ -13,6 +13,14 @@
- 详情加海报、大小、下载任务与删除;种子 hash 在「技术信息」。
- 接口:`GET /organization-records/stats`、`POST /organization-records/delete`;列表查询新增 `kind`、`since`、`organizeOnly`;公开记录新增 `media.posterPath`、`sizeBytes`、`download`(只加字段)。MP 功能对照表见 `docs/runbook/organize.md`。
### 变更(SCR-2 刮削照 MP 简化:在目录里就地刮削)
- 文件管理里任意视频文件或目录都能「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情),不要求先整理入库:服务端按路径识别作品(目录里已入库的直接用;没入库的按文件名与目录名查本地作品和 TMDB),在该目录里就地写 NFO 和图片。剧集目录递归到季目录与每集,季目录只写季与各集,单集文件只写该集,电影目录写每个视频的 NFO 与目录海报,蓝光原盘目录写 `<目录名>.nfo`。
- 认不准作品时对话框列出候选(可搜索),选一个后「继续刮削」;可打开「同时加入媒体库」,不打开也能刮削(直接用 TMDB 字段)。覆盖方式缺省按设置,对话框里可临时选「只补缺失 / 全部覆盖」。多选时逐项识别。
- 作品详情「重新刮削」「覆盖重写」与入库后自动刮削改为对作品目录刮削,和文件管理同一实现。
- 刮削记录简化为每次一条(目录、识别结果、写了哪些文件、跳过原因),作品详情显示该作品最近一条。去掉"只有已入库作品才能刮削"的限制;「刷新元数据」不再要求有已入库文件。
- 与 MP 的文件布局差分:用 MP `ScrapingChain.scrape_metadata` 的真实决策代码跑同一组目录对照 7 个场景,只有 3 处登记偏离(不写 backdrop.jpg 副本、剧照 `-thumb.jpg`、根下电影图片带视频名)。见 `docs/runbook/scrape.md`「与 MP 的差分」。
- 接口:`POST /files/scrape` 改为 `{items[{root,path,mediaEntityId?,external?}], mode?, addToLibrary?}` → `{records, needsChoice, skipped, pending, entities}`;新增 `GET /files/scrape-records?ids=`;`POST /files/scrape-batch` 去掉 `write`(只刷新元数据);`POST /library/{id}/scrape` 返回 `recordIds`;`GET /library/{id}` 的 `scrape` 改为刮削记录(带 `root` / `path` / `match`)。任务 `scrapeMedia` 升到 v2(输入 `{recordId}`)。迁移 `0058_scrape_record`(替代 `media_scrape`)。
## [0.3.6] - 2026-09-30
### 新增(SEARCH-4 资源筛选对齐 MP)
@@ -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)} />
+1 -1
View File
@@ -25,7 +25,7 @@
| AcquisitionPlan(下载计划,DL-3 起界面不单独命名) | 下载;还没提交到下载器的按在等什么写状态:「等你批准」「等磁盘空间」「等开启自动化」「准备提交」;没提交就结束的写「没提交成功 / 已取消 / 已过期」+ 原因;来由写「为什么下这个」;智能体动作计数写"准备下载 N 个资源" | 下载计划、草稿、已预留(界面) |
| DownloadTask | 下载任务 | 下载单 |
| Organization record | 整理记录;动作叫"整理",结果状态叫"已入库" | 转移、入库记录 |
| 往媒体库目录写 NFO 与图片(scrapeMedia,SCR-1) | 刮削;按钮「刮削」「重新刮削」「覆盖重写」;覆盖方式叫「只补缺失 / 全部覆盖」;只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
| 在目录里就地写 NFO 与图片(scrapeMedia,SCR-1 / SCR-2) | 刮削;文件管理菜单「刮削…」,作品详情「重新刮削」「覆盖重写」;覆盖方式叫「只补缺失 / 全部覆盖」;认不准作品时"选一个再刮削";只更新本工具字段的叫「刷新元数据」 | 投影、NFO 投影、写元数据(界面) |
| 整理方式 | 复制 / 移动 / 硬链接 | Copy / Move |
| Library root | 媒体库目录 | 库根、媒体库根、根目录 |
| 文件管理可浏览的目录(库根 + 下载器路径映射) | 登记目录 | 登记根、根 |
+4 -4
View File
@@ -34,7 +34,7 @@
| 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记;有成功项时写 `organization_record(action=restore)`,返回 `recordId`);`purge` 仅管理员会话,缺省只清过期项。"还原到…"的目标必须是可写的 managed 库根(否则 403 `not_writable`);原位还原时缺失的父目录在根内逐级重建(已存在的必须是真目录,符号链接拒绝 `path_escape`);跨卷还原受 `FILES_TRASH_CROSS_MAX_BYTES` 上限(超出该项 `too_large`),复制经 fsproxy。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 |
| identify | 只读:`parseReleasePath`(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ `catalog.lookup` 能力取远端候选(经进程共享的 TMDB 信号量与 15 s 超时;单次请求去重后最多 20 个远端查询,超出的项只本地召回并给 warning;失败只给 warning)→ `rankCandidates` 前 5;目录展开视频上限 200;不建作品、不进待识别池 |
| probe | 需要 `organize.execute`;≤20 项;已登记资产写回 `media_asset.media_info.probe`;未登记缓存 24 h(`DATA_DIR/cache/probe`) |
| scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo` / `images` 另排 `scrapeMedia` 写 NFO 与图片(返回 `queued` + `scrapeTaskId`,见 `scrape.md`)。界面「刷新元数据」只刷字段,「刮削」走 `scrape-batch` `write=true` |
| scrape | SCR-2 照 MP:任意视频文件或目录就地刮削(不要求已登记):已登记目录用其作品,否则按路径识别(本地 + TMDB),认不准返回 `needsChoice` 候选,选定后带 `mediaEntityId` / `external` 再提交;`addToLibrary` 顺手建作品;每项一条刮削记录,排 `scrapeMedia`,请求内最多等 `FILES_TRANSFER_WAIT_SEC`;`GET /files/scrape-records?ids=` 回查。只登记库根跳过。见 `scrape.md`。界面「刷新元数据」只刷字段(`scrape-batch`),「刮削…」走 `/files/scrape` |
| download | `GET /files/download?root=&path=&inline=`:仅管理员会话(API Key 含 admin scope 也 403);只允许 NFO/字幕/图片(视频 403),≤ `FILES_DOWNLOAD_MAX_BYTES`(默认 20 MiB,超出 413 `too_large:`);路径守卫同其它接口,打开前再 realpath 复核、按文件句柄 stat;流式返回(非 JSON 信封),`inline=true` 仅对图片生效(SVG 一律附件),带 `nosniff` + `CSP sandbox`;写审计 `files.download` |
## 文件管理 v2 后端缺口(W9-H,`docs/design/file-manager-v2.md` §15)
@@ -45,7 +45,7 @@
| G2 | organize `minSizeBytes`;identify `minSizeBytes` | 缺省取 `settings.organize.minSizeBytes`(缺省 50 MiB,W10-pre2 起进契约);设置读不到时 organize 退回端口 `minVideoBytes`,identify 不过滤。请求 / 向导给的值优先,给 0 表示不过滤。自动入库的 `planImport` 同样按"请求 > 设置 > 端口"取值。设置页还没有这个字段(前端后续在「媒体库与目录 → 整理方式」加),目前只能 `PATCH /settings` `{"organize":{"minSizeBytes":<字节>}}` 修改。organize:低于阈值的视频进组 `excluded[]`,`reason=below_min_size`(sample/no_episode 等照原因名);identify:该项 `skipped="below_min_size"`、不查候选,结果 `excludedBySize` 计数;每项带 `size` |
| G3 | organize `onExisting: skip\|conflict\|replace_with_verify`(缺省 conflict) | 透传 `planImport.onExisting`;选 replace 即视为人工授权(`grantReplace`),执行走现有 replace 分支(新文件核验通过后旧文件进回收站)。预览逐文件 `action`(transfer/replace/skip/hold)+ `existing`(same 同一文件已在库 / skip / replace / conflict / null)+ `skipReason`;conflict 时该组 `error.code=plan_blocked`。策略进请求指纹,换策略须重新预览 |
| G4 | organize `naming: template\|keep`(缺省 template) | keep = 保留源文件名(目录仍按模板/绑定/targetDir;改名后撞名 → hold + duplicate_target)。`planImport` 新参数 `namingMode` / `namingSource`,冻结计划 `organization_record.plan.naming = {mode, templateSource: entity\|root\|global\|override, templateVersion}`(模板来源按 作品 > 库根 > 全局 回落;keep = override)。组响应同名 `naming` + `targetSegments[{text, role: root\|season\|dir, bound}]`(bound = 沿用已有路径绑定) |
| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch`(SCR-1 起 `write=true` 改排 `scrapeMedia`) | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch:`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;无 active 资产 `not_registered`、不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 |
| G5 | identify 项 `records{count,lastRecordId,lastStatus}`;`GET /files/record-sources?recordId=`;organize `refreshMetadata`;`POST /files/scrape-batch`(只刷新元数据;刮削见 `/files/scrape`) | 整理记录按 `organization_file_result.source_path` 回查;record-sources 把记录主视频源(多个且同在源目录下时给目录)按登记目录 hostPath 换算成 FileRef,不在登记目录/已不存在的进 `unresolved`。`refreshMetadata=true`:执行成功的组按作品去重各排一次 `refreshMetadata`(组里 `refreshTaskId`)。scrape-batch(刷新元数据):`items`(文件/目录 → 其下已登记资产的作品)∪ `mediaEntityIds` 去重,≤200 个作品;不存在 `not_found`、文件不属于任何作品 `no_entity`;带幂等键 |
| G6 | `GET /files/roots` 每项 `pendingCount` / `pendingPartial` | 仅下载目录:递归数待识别视频(与列表 `unidentified` 同口径:视频、非临时名、不在纳管下载任务内容内),上限 `FILES_LIST_MAX_ENTRIES` 项,超出 `pendingPartial=true`(计数为下限)。按根缓存 5 分钟,该根的 `files.changed` 事件使缓存失效;库根与离线根为 `null` |
| G7 | 列表项 `parsedHint` | 待识别视频行:`parseReleasePath`(文件名 + 父/祖父目录)的轻量结果 `标题 (年) · S01E02 · 1080p`(剧集不带年);不查库,解析不出标题或非待识别行为 `null`。前端副行直接显示,不再自己解析 |
| G8(海报) | identify 候选 `posterUrl` | TMDB `poster_path` 拼 `https://image.tmdb.org/t/p/w154…`(TMDB 适配器候选带 `posterPath`,`catalog.lookup` 远端项透传);本地作品取 `metadata.posterPath`,没有时用同一请求里其已关联 TMDB 候选的海报;都没有为 `null`。前端按 `no-referrer` 懒加载 |
@@ -57,7 +57,7 @@
| 操作 | 管理员会话 / DEV_ADMIN_KEY | API Key |
|---|---|---|
| roots / list / stat / identify / trash 列表 / records / tree / record-sources / suggest-name | ✓ | `organize.read` |
| roots / list / stat / identify / trash 列表 / records / tree / record-sources / suggest-name / scrape-records | ✓ | `organize.read` |
| mkdir / rename / copy / organize(copy) / restore / scrape / scrape-batch / probe / entity-from-candidate | ✓ | `organize.execute` |
| move / organize(move) | ✓ | `organize.execute` + **每个源**所属作品(声明作品 ∪ 源下已登记资产的作品)都被持续授权覆盖,或整请求一次性审批;任一不覆盖 → 整体 403 `approval_required` 并列出未授权项;无法归属作品的源只认审批 |
| delete(回收站) | ✓ | `organize.execute` + 每次一次性审批(dryRun 的 planId) |
@@ -76,7 +76,7 @@
## 已知缺口
- 硬链接、蓝光原盘整目录操作仍未做(刮削也跳过蓝光原盘目录)。NFO / 图片投影已由 SCR-1 实现,见 `scrape.md`。
- 硬链接、蓝光原盘整目录操作仍未做。刮削(NFO / 图片,含蓝光原盘目录的 `<目录名>.nfo`)见 `scrape.md`。
- 搬运 move 在终态提交前删源侧空目录;若进程恰在两者之间退出,重跑 finalize 幂等,但极端情况下可能留下空目录(无数据风险)。
- 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。
+99 -42
View File
@@ -1,44 +1,60 @@
# 刮削运行手册(SCR-1:NFO 与图片写进媒体库目录)
# 刮削运行手册(SCR-1 写 NFO 与图片;SCR-2 照 MP 简化为就地刮削)
「刮削」= 把作品的最终字段写成 NFO,并下载海报、背景图、季海报、剧照,放进媒体库目录,Emby / Jellyfin / Kodi 直接读取。
「刷新元数据」只更新本工具库里的字段,不写媒体库目录;两者分开。
「刮削」= 在视频所在目录就地写 NFO,并下载海报、背景图、季海报、剧照,Emby / Jellyfin / Kodi 直接读取。
「刷新元数据」只更新本工具库里的字段,不写目录里的文件;两者分开。
SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接在对应目录刮削,那就直接在对应目录生成")起,
刮削对象从"作品"改成"目录 / 视频文件",照 MP 文件管理的刮削:**不要求先登记**,按路径识别作品后在该目录里写文件。
已登记作品的「重新刮削」、入库后自动刮削走同一实现(本质是对作品目录刮削)。
## 实现位置
| 位置 | 内容 |
| --- | --- |
| `packages/domain/src/scrape/nfo.ts` | NFO 生成(电影 / 剧集 / 季 / 集),纯函数 |
| `packages/domain/src/scrape/layout.ts` | 目标文件清单与命名 `planScrapeTargets`、作品目录推断 `inferWorkDir` |
| `apps/server/src/modules/acquisition-organize/services/scrape-input.ts` | 最终字段(`media_entity.metadata` / `content_unit.metadata`)→ NFO 输入 |
| `apps/server/src/modules/acquisition-organize/services/scrape.ts` | 排任务 `enqueueScrape`、执行 `runScrape`、状态 `loadScrapeState`、入库后钩子 `scrapeAfterImport` |
| `handlers.ts` 任务 `scrapeMedia` | 文件道;不重试(失败的文件在作品详情里重新刮削) |
| 迁移 `0057_media_scrape` | 每部作品最近一次刮削:状态、覆盖方式、逐文件结果 |
| 端口 `AoPorts.scrapeImage` | TMDB 图片路径 → 字节:`ImageCache.bytes("original", path)`,缓存目录 `DATA_DIR/cache/images`,出口按环境变量代理(`externalFetch`) |
| `packages/domain/src/scrape/layout.ts` | 目标文件清单与命名 `planScrapeTargets`(`parts` 控制作品级 / 季级)、`inferWorkDir`、`isSeasonDirName` / `seasonOfDirName` |
| `apps/server/src/modules/acquisition-organize/services/scrape-input.ts` | 最终字段 → NFO 输入;TMDB 富详情 / 季详情 → NFO 输入(`tmdbEntity` / `tmdbSeasonUnits`,映射同刷新元数据) |
| `apps/server/src/modules/acquisition-organize/services/scrape.ts` | 刮削记录 `queueScrape`、执行 `runScrapeRecord`(目录布局 → 字段 → 写文件)、作品目录 `entityScrapeLocations` / `scrapeEntity`、入库后钩子 `scrapeAfterImport` |
| `apps/server/src/modules/file-manager/scrape.ts` | 文件管理「刮削」:按路径识别(复用 `identifyCore` + `resolveIdentity`)、候选、排任务、请求内等结果 |
| `handlers.ts` 任务 `scrapeMedia`(v2,输入 `{recordId}`) | 文件道;不重试(失败的重新刮削) |
| 迁移 `0058_scrape_record` | 每次刮削一条记录;替代 0057 的 `media_scrape`(一部作品一行) |
| 端口 `AoPorts.scrapeImage` / `scrapeTmdbDetail` / `scrapeTmdbSeason` | 图片字节(图片缓存 `DATA_DIR/cache/images` + 出口代理);未登记目录直接取 TMDB 富详情 / 季详情(元数据语言链,经 TMDB 缓存) |
## 触发
| 触发 | 做法 |
| --- | --- |
| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport` 打开时排 `scrapeMedia`,幂等键 `scrapeMedia:record:<整理记录 id>`(同一记录只排一次;季包一次整理只刮一次) |
| 作品详情「重新刮削」「覆盖重写」 | `POST /library/{id}/scrape {mode?}`(`organize.execute`,202);没有已入库文件 412 |
| 文件管理「刮削」(单项菜单、多选、详情按钮) | `POST /files/scrape-batch {write: true}`,按作品去重各排一次 |
| `POST /files/scrape {writeNfo / images}` | 在原有 `refreshMetadata` 之外再排一次 `scrapeMedia`,返回 `queued` 与 `scrapeTaskId` |
| 文件管理「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情按钮) | `POST /files/scrape {items[{root,path,mediaEntityId?,external?}], mode?, addToLibrary?}`;任意视频文件或目录,每项一条记录,请求内最多等 `FILES_TRANSFER_WAIT_SEC`(缺省 20 s),没刮完的 `pending=true`,前端按 `GET /files/scrape-records?ids=` 轮询 |
| 作品详情「重新刮削」「覆盖重写」 | `POST /library/{id}/scrape {mode?}`(202):对作品目录刮削,每个目录一条记录;没有已入库文件 412(到文件管理里对目录刮削) |
| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport` 打开时对作品目录排刮削,幂等键 `scrapeMedia:record:<整理记录 id>:<序号>` |
同一作品已有排队中(未开始,30 分钟内)的刮削时直接复用(`deduplicated: true`)。
同一目录已有排队中(未开始,30 分钟内)、同覆盖方式、同作品的刮削时直接复用(`deduplicated`)。
## 设置(设置 → 媒体库与目录 →「刮削」)
### 识别(文件管理)
| 字段 | 缺省 | 说明 |
| --- | --- | --- |
| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) |
| `organize.scrapeMode` | `missing` | `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖(替换别的工具写的 NFO 和图片) |
| `organize.scrapeImages` | 开 | 海报、背景图、季海报 |
| `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 |
顺序:请求指定 > 目录里已登记文件所属的唯一作品(`source=library`)> 按路径识别(`source=auto`)。
## 写哪些文件
- 按路径识别复用 `/files/identify` 的核心(文件名 + 父 / 祖父目录解析 → 本地别名召回 + TMDB 远端候选,≤15 s),每个视频按
`resolveIdentity` 判断能否自动确认;**目录里所有视频都能自动确认且是同一作品**时直接用,否则该项进 `needsChoice`(合并各视频候选,前 5)。
- 选定后带 `mediaEntityId`(本地作品)或 `external {provider:"tmdb", externalId, kind}` 再提交一次(`source=chosen`)。
- 候选是 TMDB 条目:有已接受该 TMDB id 的本地作品就用它;没有也能刮削(直接取 TMDB 字段);`addToLibrary=true` 时顺手建作品
(同「整理」的按候选建作品,幂等)。**登记不是刮削的前提**。
文件名跟随已入库的视频(视频名本身由命名模板生成);作品目录优先取作品的路径绑定,没有时从视频路径推(剧集父目录像
`Season 1` / `S01` / `Specials` 时取上一级)。
### 写在哪里(对照 MP `ScrapingChain.scrape_metadata`)
| 选中的 | 写的文件 |
| --- | --- |
| 剧集目录 | `tvshow.nfo`、`poster.jpg`、`fanart.jpg`、`seasonNN-poster.jpg`;每个季目录(`Season 1` / `S01` / `Specials` / `SPs` / `第 1 季`)`season.nfo` + `poster.jpg`;每集 `<视频名>.nfo` + `<视频名>-thumb.jpg`。只进季目录一层,其他子目录不管 |
| 季目录 | `season.nfo` + 季目录 `poster.jpg` + 上级 `seasonNN-poster.jpg`;每集 NFO 与剧照。不写 `tvshow.nfo` |
| 单集文件 | 该集 `<视频名>.nfo` + 剧照 |
| 电影目录 | 目录里每个视频 `<视频名>.nfo`(不进子目录)+ 目录 `poster.jpg` / `fanart.jpg`;蓝光原盘目录(含 `BDMV` / `VIDEO_TS`)写 `<目录名>.nfo` |
| 电影文件 | `<视频名>.nfo` + 同目录 `poster.jpg` / `fanart.jpg`;文件直接在登记目录根时图片改为 `<视频名>-poster.jpg` / `-fanart.jpg` |
- 集号:已登记视频按覆盖的集单元;其余按路径解析(季号缺失时取季目录名,再缺按第 1 季;同 MP 对每集重新 `MetaInfoPath`)。
认不出集号的视频记 `scrape_no_episode` 跳过。多集文件按首集。
- 字段:本地作品有元数据时用最终字段(字段锁、人工覆盖优先,见 `metadata.md`);否则按 TMDB id 直接取 TMDB(与刷新元数据同一映射)。
- 登记目录:库根只写 `managed`(只登记模式跳过 `scrape_root_read_only`),库根离线 / 标记文件不对 `scrape_root_offline`;
下载根也能刮削(同 MP)。目标路径必须在登记目录内;已存在的目标是目录 / 符号链接时跳过。
```
人生切割术 (2022)/
@@ -49,9 +65,12 @@
└── Season 1/
├── season.nfo
├── poster.jpg # 季海报(与作品目录那张同一张)
├── 人生切割术 - S01E01 - 第 1 集.mkv
├── 人生切割术 - S01E01 - 第 1 集.nfo
└── 人生切割术 - S01E01 - 第 1 集-thumb.jpg
├── 人生切割术 - S01E01 - 有关地狱的好消息.mkv
├── 人生切割术 - S01E01 - 有关地狱的好消息.nfo
├── 人生切割术 - S01E01 - 有关地狱的好消息-thumb.jpg
├── 人生切割术 - S01E02 - 半循环.mkv
├── 人生切割术 - S01E02 - 半循环.nfo
└── 人生切割术 - S01E02 - 半循环-thumb.jpg
星际穿越 (2014)/
├── 星际穿越 (2014) - 1080p.mkv
├── 星际穿越 (2014) - 1080p.nfo
@@ -59,11 +78,41 @@
└── fanart.jpg
```
- 电影视频直接放在库根(没有作品目录)时,图片改为 `<视频名>-poster.jpg` / `<视频名>-fanart.jpg`,不往库根放 poster.jpg;
剧集视频直接在库根时只写每集文件,不写 tvshow / 季文件(结果里记 `scrape_no_work_dir`)。
- 多集文件(S01E01-E02)按首集写 NFO 与剧照(同 MP `begin_episode`)。蓝光原盘目录不刮削。
- 只写 managed 库根;只登记(`register_only`)库根整体跳过(`scrape_root_read_only`)。库根离线整体失败(`library_root_offline`)。
- 目标路径必须仍在库根内;已存在的目标是目录 / 符号链接时跳过。
(上面是 `apps/server/test/file-manager/scrape.test.ts` 第一个用例对未登记目录刮削后的实际文件树。)
## 与 MP 的差分
文件布局用 MP 上游 `ScrapingChain.scrape_metadata` 的真实决策代码跑同一组目录(`apps/server/test/scrape/gen-scrape-layout-mp.py` →
`scrape-layout-mp.json`),`scrape-layout-mp.test.ts` 对照 7 个场景(剧集目录、季目录、单集文件、电影目录、电影文件、根下电影文件、蓝光原盘目录),
只允许下表登记的偏离:
| 同一目录结构 | MP 写 | 我们写 | 理由 |
| --- | --- | --- | --- |
| 作品 / 电影目录背景图 | `backdrop.jpg` + `fanart.jpg` | `fanart.jpg` | 三家都认 fanart.jpg,副本没用 |
| 每集剧照 | `<视频名>.jpg` | `<视频名>-thumb.jpg` | Kodi 约定,Jellyfin / Emby 同样识别;不和同名封面混 |
| 视频直接在登记目录根 | 根里写 `poster.jpg` / `backdrop.jpg` / `fanart.jpg` | `<视频名>-poster.jpg` / `<视频名>-fanart.jpg` | 根里放一张 poster.jpg 会被当成整个库的封面 |
| 其余(tvshow / season / 集 NFO、季海报两处、电影 NFO、蓝光 `<目录名>.nfo`、季目录只写季级、单集只写该集) | — | 同 MP | — |
行为上的差异:
| 项 | MP | 我们 | 理由 |
| --- | --- | --- | --- |
| 已有文件 | 手动刮削一律覆盖(`overwrite=True`),自动刮削按每类文件的策略 | 缺省按设置「只补缺失 / 全部覆盖」,刮削对话框里可临时改 | 用户要求覆盖方式沿用设置、菜单里可临时选;缺省不动其他工具写的文件 |
| 识别 | 按路径识别,认不出直接失败;对话框可手填 TMDB / 豆瓣 id | 按路径识别,认不准时返回前 5 候选让用户选(也可搜索);已登记的目录直接用作品 | 选一次比手填 id 省事;已登记的不必再识别 |
| 执行 | 请求内同步 | 后台任务 `scrapeMedia`,请求内最多等 20 s | 大季包图片多;超时不丢 |
| 记录 | 只有日志 | 每次刮削一条记录(目录、识别结果、逐文件结果、原因),作品详情显示最近一条 | 可回看、可排查 |
| 图片种类 | poster / backdrop / logo / banner / disc / clearart / thumb(按图片源) | poster / fanart / 季海报 / 剧照 | 本地只有 TMDB 海报、背景图、剧照;其余要 fanart.tv 源 |
| 批量 | 多选共用一个识别条件 | 多选逐项各自识别,各自选候选 | 多选里常是不同作品 |
| 音乐 | 支持 | 不做 | 范围外 |
## 设置(设置 → 媒体库与目录 →「刮削」)
| 字段 | 缺省 | 说明 |
| --- | --- | --- |
| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) |
| `organize.scrapeMode` | `missing` | `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖;手动刮削时对话框可临时改 |
| `organize.scrapeImages` | 开 | 海报、背景图、季海报 |
| `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 |
## NFO 内容
@@ -86,27 +135,35 @@
| 电影不写 `studio` | 同上 |
| 集 NFO 不写 `uniqueid` / `tmdbid` | 本地不存 TMDB 集 id;媒体服务器按季号集号匹配 |
| 简介含 `]]>` 时拆成两段 CDATA | MP 的 minidom 直接抛错 |
| 不写 `backdrop.jpg` 副本,只写 `fanart.jpg` | 三家都认 fanart.jpg |
| 剧照 `<视频名>-thumb.jpg`(MP `<视频名>.jpg`) | Kodi 约定,Jellyfin / Emby 同样识别 |
重新生成 MP 输出(改了样例或跟进上游后):
```sh
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-mp.py
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/apps/server/test/scrape/gen-scrape-layout-mp.py
cd /opt/uhd_media_tool && deno run -A npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json
```
## 结果与排查
- 作品详情「刮削」卡片:状态(排队中 / 刮削中 / 已完成 / 部分完成 / 失败)、写入 / 跳过 / 失败计数、逐文件结果;排队或进行中时每 3 秒刷新。
- `GET /library/{id}` 的 `scrape`:`status`、`trigger`(auto / manual)、`mode`、`written` / `skipped` / `failed`、`reason`、`files[{path, kind, role, action, reason}]`、`recordId`、`taskId`。
- 审计 `organize.scrape`(目标作品;载荷含写入与失败的文件,最多 200 条);写入后发 `files.changed`,文件管理自动刷新。
- 原因码(`packages/contracts/src/reasons.ts`):`scrape_no_files`、`scrape_no_metadata`、`scrape_root_read_only`、`scrape_exists`、
`scrape_write_failed`、`scrape_image_failed`、`scrape_no_work_dir`、`library_root_offline`、`entity_not_found`。
- 文件管理刮削对话框:每项的状态、识别结果(作品、按路径识别 / 手动指定 / 已入库的作品、是否在媒体库)、写入 / 跳过 / 失败计数、逐文件结果;
认不准的项列候选,选定后「继续刮削」,可打开「同时加入媒体库」。
- 作品详情「刮削」卡片:该作品最近一条刮削记录;排队或进行中时每 3 秒刷新。
- `GET /files/scrape-records?ids=`、`GET /library/{id}` 的 `scrape`:`id`、`root`、`path`、`status`、`trigger`、`mode`、
`match{mediaEntityId, provider, externalId, kind, title, year, source}`、`written` / `skipped` / `failed`、`reason`、
`files[{path, kind, role, action, reason}]`(path 为登记目录内相对路径)、`organizationRecordId`、`taskId`。
- 审计 `organize.scrape`(目标 `scrape_record`;载荷含目录、识别结果、写入与失败的文件,最多 200 条);写入后发 `files.changed`,文件管理自动刷新。
- 原因码(`packages/contracts/src/reasons.ts`):`scrape_no_metadata`、`scrape_root_read_only`、`scrape_root_offline`、`scrape_not_video`、
`scrape_no_videos`、`scrape_path_missing`、`scrape_tmdb_unavailable`、`scrape_no_episode`、`scrape_exists`、`scrape_write_failed`、
`scrape_image_failed`、`path_escape`、`task_enqueue_failed`。
| 现象 | 看哪里 |
| --- | --- |
| 入库后没有 NFO | 设置里「入库后自动刮削」是否打开;作品详情「刮削」是否有记录;引擎运行 `scrapeMedia` |
| 全部「已存在」 | 覆盖方式是只补缺失;要替换用「覆盖重写」 |
| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后「重新刮削」只补缺失的图片 |
| 「作品还没有元数据」 | 作品没有 TMDB 字段,先刷新元数据或指定 TMDB 条目 |
| 刮削对话框一直让选作品 | 文件名解析不出标题,或 TMDB 有多个同名候选;选一次即可,打开「同时加入媒体库」后下次按已入库作品识别 |
| 全部「跳过」 | 覆盖方式是只补缺失;要替换选「全部覆盖」或作品详情「覆盖重写」 |
| 图片失败、NFO 正常 | TMDB 图片访问(出口代理);稍后重新刮削,只补缺失的图片 |
| 「没有可用的元数据」 | 作品没有 TMDB 字段也没有 TMDB id;在文件管理里刮削并指定 TMDB 条目 |
| 「TMDB 不可用」 | 设置里 TMDB API Key;未登记的目录要直接取 TMDB |
+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";
@@ -1567,10 +1568,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",
@@ -2260,8 +2270,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。
@@ -456,81 +457,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"]);
});
});