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