Merge branch 'scr2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # CHANGELOG.md # apps/server/src/modules/file-manager/routes.ts
This commit is contained in:
commit
44f7c88be6
41 files changed
+2810
-826
No files matched your search
@@ -3,11 +3,11 @@ import { join, relative } from "node:path";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, planImport } from "../services/organize.ts";
|
||||
import { runScrape, SCRAPE_TASK } from "../services/scrape.ts";
|
||||
import { runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../services/scrape.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// SCR-1 刮削:入库成功后自动排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;图片失败不阻塞 NFO;
|
||||
// 只登记库根不写;作品详情「重新刮削」接口与刮削状态。
|
||||
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
|
||||
// 图片失败不阻塞 NFO;只登记库根不写;作品详情「重新刮削」接口与刮削状态(最近一条刮削记录)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 200_000;
|
||||
@@ -75,13 +75,26 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
return plan.recordId!;
|
||||
}
|
||||
|
||||
/** 按引擎记录的入队项跑刮削(假引擎不执行) */
|
||||
/** 按引擎记录的入队项跑刮削(假引擎不执行):该作品最近一条刮削记录 */
|
||||
async function runQueued(entity: string) {
|
||||
const q = h.engine.enqueued.filter(
|
||||
(x) => x.task === SCRAPE_TASK && (x.input as { mediaEntityId: string }).mediaEntityId === entity,
|
||||
);
|
||||
expect(q.length).toBeGreaterThan(0);
|
||||
return runScrape(h.d, q[q.length - 1]!.input as Parameters<typeof runScrape>[1]);
|
||||
const recs = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.select(["id"])
|
||||
.where("media_entity_id", "=", entity)
|
||||
.where("status", "=", "queued")
|
||||
.orderBy("requested_at", "desc")
|
||||
.execute();
|
||||
expect(recs.length).toBeGreaterThan(0);
|
||||
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
|
||||
expect(q.some((x) => (x.input as { recordId: string }).recordId === recs[0]!.id)).toBe(true);
|
||||
return runScrapeRecord(h.d, recs[0]!.id);
|
||||
}
|
||||
|
||||
/** 作品详情「重新刮削」的服务端做法:对作品目录排刮削并执行 */
|
||||
async function rescrape(entity: string, mode?: "missing" | "overwrite") {
|
||||
const q = await scrapeEntity(h.d, entity, { trigger: "manual", mode });
|
||||
expect(q.length).toBe(1);
|
||||
return runScrapeRecord(h.d, q[0]!.recordId);
|
||||
}
|
||||
|
||||
let series = "";
|
||||
@@ -165,8 +178,18 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
const rec = await organize(series, "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv");
|
||||
await organize(series, "Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv");
|
||||
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
|
||||
expect(q[0]!.key).toBe(`${SCRAPE_TASK}:record:${rec}`);
|
||||
expect(q[0]!.input).toMatchObject({ mediaEntityId: series, trigger: "auto", mode: "missing", recordId: rec });
|
||||
expect(q[0]!.key).toBe(`${SCRAPE_TASK}:record:${rec}:0`);
|
||||
const first = await h.db
|
||||
.selectFrom("scrape_record")
|
||||
.selectAll()
|
||||
.where("id", "=", (q[0]!.input as { recordId: string }).recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(first).toMatchObject({
|
||||
media_entity_id: series,
|
||||
trigger: "auto",
|
||||
mode: "missing",
|
||||
organization_record_id: rec,
|
||||
});
|
||||
|
||||
const r = await runQueued(series);
|
||||
expect(r.status).toBe("partial"); // 背景图下载失败
|
||||
@@ -213,23 +236,23 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
});
|
||||
const audit = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["action", "target_id"])
|
||||
.select(["action", "target_id", "target_type"])
|
||||
.where("action", "=", "organize.scrape")
|
||||
.execute();
|
||||
expect(audit.some((a) => a.target_id === series)).toBe(true);
|
||||
expect(audit.some((a) => a.target_type === "scrape_record" && a.target_id === st.body.data.scrape.id)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it("只补缺失:已有文件(包括别人写的)不动;全部覆盖:改写", async () => {
|
||||
const files = await tree(lib.root);
|
||||
const show = join(lib.root, files.find((p) => p.endsWith("tvshow.nfo"))!);
|
||||
await writeFile(show, "<tvshow>别的工具写的</tvshow>");
|
||||
const again = await runScrape(h.d, { mediaEntityId: series, trigger: "manual", mode: "missing" });
|
||||
const again = await rescrape(series, "missing");
|
||||
expect(await readFile(show, "utf8")).toBe("<tvshow>别的工具写的</tvshow>");
|
||||
expect(again.files.find((x) => x.role === "tvshow_nfo")).toMatchObject({
|
||||
action: "skipped",
|
||||
reason: "scrape_exists",
|
||||
});
|
||||
const over = await runScrape(h.d, { mediaEntityId: series, trigger: "manual", mode: "overwrite" });
|
||||
const over = await rescrape(series, "overwrite");
|
||||
expect(over.written).toBeGreaterThan(0);
|
||||
expect(await readFile(show, "utf8")).toContain("<title>人生切割术</title>");
|
||||
}, 60_000);
|
||||
@@ -276,24 +299,23 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
||||
expect(nfo).toContain("<runtime>169</runtime>");
|
||||
}, 60_000);
|
||||
|
||||
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 409", async () => {
|
||||
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", async () => {
|
||||
const files = await tree(lib.root);
|
||||
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
|
||||
const dirAbs = join(lib.root, video.slice(0, video.lastIndexOf("/")));
|
||||
if (process.getuid?.() !== 0) {
|
||||
await chmod(dirAbs, 0o555);
|
||||
const r = await runScrape(h.d, { mediaEntityId: movie, trigger: "manual", mode: "overwrite" });
|
||||
const r = await rescrape(movie, "overwrite");
|
||||
await chmod(dirAbs, 0o755);
|
||||
expect(r.files.some((x) => x.reason === "scrape_write_failed")).toBe(true);
|
||||
}
|
||||
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).execute();
|
||||
const ro = await runScrape(h.d, { mediaEntityId: movie, trigger: "manual", mode: "overwrite" });
|
||||
const ro = await rescrape(movie, "overwrite");
|
||||
expect(ro).toMatchObject({ status: "failed", reason: "scrape_root_read_only", written: 0 });
|
||||
await h.db.updateTable("library_root").set({ management_mode: "managed" }).execute();
|
||||
|
||||
const empty = await seedEntity(h.db, { kind: "movie", title: "没入库", year: 2020 });
|
||||
expect((await h.api("POST", `/library/${empty}/scrape`, {})).status).toBe(412);
|
||||
const none = await runScrape(h.d, { mediaEntityId: empty, trigger: "manual" });
|
||||
expect(none).toMatchObject({ status: "failed", reason: "scrape_no_metadata" });
|
||||
expect(await scrapeEntity(h.d, empty, { trigger: "manual" })).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { type AoDeps, automationBlock, ensureTaskRow, markTask } from "./service
|
||||
import { cleanupDownloadCache, submitDownload } from "./services/downloads.ts";
|
||||
import { executeRecord, planImport } from "./services/organize.ts";
|
||||
import { reconcileLibrary } from "./services/reconcile.ts";
|
||||
import { runScrape, SCRAPE_TASK } from "./services/scrape.ts";
|
||||
import { runScrapeRecord, SCRAPE_TASK } from "./services/scrape.ts";
|
||||
import { errMessage } from "./util.ts";
|
||||
|
||||
// 持久任务处理器。输入只放业务 id;业务状态写业务表;副作用经 withAttemptToken(在 services 里)。
|
||||
@@ -188,22 +188,16 @@ export function registerAoTasks(registry: TaskRegistry, d: AoDeps) {
|
||||
);
|
||||
},
|
||||
}),
|
||||
// SCR-1 刮削:写 NFO 与图片到媒体库目录(文件道);结果写 media_scrape,不重试(失败的文件在作品详情里重新刮削)
|
||||
// 刮削(SCR-1 / SCR-2):按刮削记录在目录里就地写 NFO 与图片(文件道);结果写回 scrape_record,不重试(失败的重新刮削)
|
||||
defineTask({
|
||||
name: SCRAPE_TASK,
|
||||
version: 1,
|
||||
version: 2,
|
||||
lane: "file",
|
||||
inputSchema: z.object({
|
||||
mediaEntityId: id,
|
||||
trigger: z.enum(["auto", "manual"]),
|
||||
mode: z.enum(["missing", "overwrite"]).optional(),
|
||||
recordId: id.optional(),
|
||||
actor: z.object({ type: z.string(), id: z.string().nullable() }).optional(),
|
||||
}),
|
||||
inputSchema: z.object({ recordId: id }),
|
||||
retryPolicy: { maximumAttempts: 1 },
|
||||
handler: async (t) =>
|
||||
t.step.run("scrape", async () => {
|
||||
const r = await runScrape(d, { ...t.input, runId: t.runId }, t.abortSignal);
|
||||
const r = await runScrapeRecord(d, t.input.recordId, t.abortSignal);
|
||||
return { status: r.status, written: r.written, skipped: r.skipped, failed: r.failed, reason: r.reason };
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type DownloaderClient,
|
||||
diskUsage,
|
||||
type EmbyClient,
|
||||
fetchRichDetail,
|
||||
fetchRichSeason,
|
||||
ffprobe,
|
||||
getSiteAdapter,
|
||||
getSiteDefinition,
|
||||
@@ -13,6 +15,8 @@ import {
|
||||
qbAuthConfig,
|
||||
type SecretResolver,
|
||||
SiteError,
|
||||
type TmdbRichDetail,
|
||||
type TmdbRichSeasonDetail,
|
||||
type TorrentSource,
|
||||
type VerifyMethod,
|
||||
} from "@umt/adapters";
|
||||
@@ -88,6 +92,12 @@ export interface AoPorts {
|
||||
* 失败抛错(由刮削记为该图片失败,不影响 NFO)。
|
||||
*/
|
||||
scrapeImage(tmdbPath: string): Promise<{ content: Uint8Array; mime: string }>;
|
||||
/**
|
||||
* SCR-2 刮削未登记的目录:直接取 TMDB 富详情 / 季详情(按设置的元数据语言链,经 TMDB 缓存)。
|
||||
* TMDB 没配置返回 null;条目不存在或上游失败抛错。
|
||||
*/
|
||||
scrapeTmdbDetail(type: "movie" | "tv", id: string): Promise<TmdbRichDetail | null>;
|
||||
scrapeTmdbSeason(tvId: string, season: number): Promise<TmdbRichSeasonDetail | null>;
|
||||
options: AoOptions;
|
||||
}
|
||||
|
||||
@@ -197,6 +207,16 @@ export function createPorts(
|
||||
});
|
||||
return images.bytes("original", tmdbPath);
|
||||
},
|
||||
async scrapeTmdbDetail(type, id) {
|
||||
const client = await rt().tmdb();
|
||||
if (!client) return null;
|
||||
return fetchRichDetail(client, type, id, await metadataLanguages(ctx), await metadataTextOptions(ctx));
|
||||
},
|
||||
async scrapeTmdbSeason(tvId, season) {
|
||||
const client = await rt().tmdb();
|
||||
if (!client) return null;
|
||||
return fetchRichSeason(client, tvId, season, await metadataLanguages(ctx), await metadataTextOptions(ctx));
|
||||
},
|
||||
diskUsage: (p) => diskUsage(p),
|
||||
probe: (p) => ffprobe(p),
|
||||
async mediaServer(inst) {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { sql } from "@umt/db";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { type AoDeps, CANDIDATE_ARTIFACT_TITLE } from "../services/common.ts";
|
||||
import { enqueueScrape, loadScrapeState } from "../services/scrape.ts";
|
||||
import { latestEntityScrape, scrapeEntity } from "../services/scrape.ts";
|
||||
import { notFound, toApi, toApiList } from "../util.ts";
|
||||
import { mount, paginate } from "./helpers.ts";
|
||||
import { registerLibraryVersionRoutes } from "./library-versions.ts";
|
||||
@@ -407,21 +407,21 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
createdAt: new Date(r.created_at).toISOString(),
|
||||
finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null,
|
||||
})),
|
||||
scrape: await loadScrapeState(d, id),
|
||||
scrape: await latestEntityScrape(d, id),
|
||||
};
|
||||
return detail;
|
||||
},
|
||||
{ tag: TAG },
|
||||
);
|
||||
|
||||
// SCR-1 刮削:作品详情「重新刮削」;与入库后自动刮削、文件管理「刮削」同一任务 scrapeMedia
|
||||
// 作品详情「重新刮削」「覆盖重写」:对作品的目录刮削(SCR-2:与文件管理「刮削」、入库后自动刮削同一实现)
|
||||
mount(
|
||||
app,
|
||||
{
|
||||
method: "post",
|
||||
path: "/library/{id}/scrape",
|
||||
scope: "organize.execute",
|
||||
summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)",
|
||||
summary: "刮削:对作品目录就地写 NFO 与图片(后台任务 scrapeMedia;覆盖方式缺省按设置)",
|
||||
params: IdParam,
|
||||
body: LibraryScrapeRequest,
|
||||
response: LibraryScrapeResult,
|
||||
@@ -429,22 +429,20 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
async (c, { params, body }) => {
|
||||
const e = await db().selectFrom("media_entity").select(["id"]).where("id", "=", params.id).executeTakeFirst();
|
||||
if (!e) notFound("作品");
|
||||
const has = await db()
|
||||
.selectFrom("media_asset")
|
||||
.select(["id"])
|
||||
.where("media_entity_id", "=", params.id)
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirst();
|
||||
if (!has) throw new ApiException("precondition_failed", "作品没有已入库文件。先整理入库,再刮削");
|
||||
const actor = c.get("actor");
|
||||
const req = (body ?? {}) as LibraryScrapeRequest;
|
||||
const r = await enqueueScrape(d, {
|
||||
mediaEntityId: params.id,
|
||||
const recs = await scrapeEntity(d, params.id, {
|
||||
trigger: "manual",
|
||||
mode: req.mode,
|
||||
actor: { type: actor?.type === "api_key" ? "api_key" : "user", id: actor?.id ?? null },
|
||||
});
|
||||
return { mediaEntityId: params.id, ...r };
|
||||
if (!recs.length) throw new ApiException("precondition_failed", "作品没有已入库文件。到文件管理里对目录刮削");
|
||||
return {
|
||||
mediaEntityId: params.id,
|
||||
taskId: recs[0]?.taskId ?? null,
|
||||
deduplicated: recs.every((r) => r.deduplicated),
|
||||
recordIds: recs.map((r) => r.recordId),
|
||||
};
|
||||
},
|
||||
{ tag: TAG, status: 202 },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { TmdbRichDetail, TmdbRichSeasonDetail } from "@umt/adapters";
|
||||
import type { NfoEpisode, NfoIds, NfoPerson, NfoSeason, NfoWork } from "@umt/domain";
|
||||
import { richEntityFields } from "../../sources-identity/service/metadata-rich.ts";
|
||||
|
||||
// SCR-1:本地作品 / 季 / 集的最终字段(media_entity.metadata、content_unit.metadata,字段级 {value, …})→ NFO 输入。
|
||||
// 纯函数,不查库;差分测试(apps/server/test/scrape/nfo-mp-diff.test.ts)直接调用。
|
||||
@@ -134,3 +136,58 @@ export function imagePathsOf(entityMeta: unknown) {
|
||||
}
|
||||
export const unitPosterPath = (m: unknown) => str(val(m, "posterPath"));
|
||||
export const unitStillPath = (m: unknown) => str(val(m, "stillPath"));
|
||||
|
||||
// ── SCR-2:未登记目录直接用 TMDB 字段(与刷新元数据同一套映射,形状同 media_entity.metadata / content_unit.metadata) ──
|
||||
|
||||
/** TMDB 富详情 → 作品(NFO 输入);字段映射同刷新元数据(richEntityFields) */
|
||||
export function tmdbEntity(r: TmdbRichDetail): { entity: EntityForNfo; ids: IdentityMap } {
|
||||
return {
|
||||
entity: {
|
||||
kind: r.entityType === "movie" ? "movie" : "series",
|
||||
title: r.title,
|
||||
original_title: r.originalTitle,
|
||||
year: r.year,
|
||||
metadata: richEntityFields(r),
|
||||
},
|
||||
ids: {
|
||||
tmdb: r.externalId,
|
||||
...(r.externalIds.imdb ? { imdb: r.externalIds.imdb } : {}),
|
||||
...(r.externalIds.tvdb ? { tvdb: r.externalIds.tvdb } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fv = (value: unknown) => ({ value });
|
||||
|
||||
/** TMDB 季详情 → 季与各集(NFO 输入) */
|
||||
export function tmdbSeasonUnits(sd: TmdbRichSeasonDetail): { season: UnitForNfo; episodes: Map<number, UnitForNfo> } {
|
||||
const season: UnitForNfo = {
|
||||
season_number: sd.seasonNumber,
|
||||
episode_number: null,
|
||||
title: sd.name,
|
||||
air_date: sd.airDate,
|
||||
metadata: {
|
||||
name: fv(sd.name),
|
||||
overview: fv(sd.overview),
|
||||
airDate: fv(sd.airDate),
|
||||
posterPath: fv(sd.posterPath),
|
||||
},
|
||||
};
|
||||
const episodes = new Map<number, UnitForNfo>();
|
||||
for (const e of sd.episodes)
|
||||
episodes.set(e.episodeNumber, {
|
||||
season_number: e.seasonNumber,
|
||||
episode_number: e.episodeNumber,
|
||||
title: e.name,
|
||||
air_date: e.airDate,
|
||||
metadata: {
|
||||
name: fv(e.name),
|
||||
overview: fv(e.overview),
|
||||
airDate: fv(e.airDate?.slice(0, 10) ?? null),
|
||||
runtime: fv(e.runtime),
|
||||
stillPath: fv(e.stillPath),
|
||||
voteAverage: fv(e.voteAverage),
|
||||
},
|
||||
});
|
||||
return { season, episodes };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user