fix(organize): 不是视频的文件移入回收站并开待处理,不留在媒体库
- 收尾判定 NOT_VIDEO 后,已拷 / 搬进库的新文件和它的附属文件移入回收站(登记 trash_item),开待处理; - 附属文件结果改为跳过 main_failed(不再写成功却不登记); - 已结束记录重放时补做,可重入;原地入库不动原文件,洗版替换照旧回滚。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
1c8c5d2fb7
commit
b40716d74f
5 files changed
+219
-2
No files matched your search
@@ -21,7 +21,12 @@ import { type AoDeps, upsertDecision } from "./common.ts";
|
||||
import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts";
|
||||
import { emitRecord } from "./organize-plan.ts";
|
||||
import type { FrozenPlan } from "./organize-types.ts";
|
||||
import { cleanupReplacement, settleSamePathReplacement, stagedReplacementBackup } from "./replacement.ts";
|
||||
import {
|
||||
cleanupReplacement,
|
||||
quarantineNotVideo,
|
||||
settleSamePathReplacement,
|
||||
stagedReplacementBackup,
|
||||
} from "./replacement.ts";
|
||||
import { scrapeAfterImport } from "./scrape.ts";
|
||||
|
||||
// ── 执行 ──
|
||||
@@ -81,6 +86,7 @@ export async function executeRecord(
|
||||
if (rec.status !== "running" && rec.status !== "queued") {
|
||||
await settleSamePathReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
|
||||
await cleanupReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
|
||||
await quarantineNotVideo(d, rec.plan as unknown as FrozenPlan, recordId);
|
||||
const frozen = rec.plan as unknown as FrozenPlan;
|
||||
// 与 finalizeRecord 同一口径(按设置 / 本次选项);排队按整理记录幂等,重放不会重复刮
|
||||
if (["succeeded", "partial"].includes(rec.status)) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { ExecuteResult } from "./organize-exec.ts";
|
||||
import { emitRecord } from "./organize-plan.ts";
|
||||
import { finalizeTransfer } from "./organize-transfer.ts";
|
||||
import type { FrozenPlan } from "./organize-types.ts";
|
||||
import { cleanupReplacement, settleSamePathReplacement } from "./replacement.ts";
|
||||
import { cleanupReplacement, quarantineNotVideo, settleSamePathReplacement } from "./replacement.ts";
|
||||
import { scrapeAfterImport } from "./scrape.ts";
|
||||
import { slotComplete as sharedSlotComplete } from "./subscriptions.ts";
|
||||
import { downloadTasksUnder } from "./takeover.ts";
|
||||
@@ -82,6 +82,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
.execute();
|
||||
|
||||
// 探测结果明确"不是视频" → 该文件核验失败
|
||||
const notVideo = new Set<string>();
|
||||
for (const f of files) {
|
||||
const p = probes.get(f.id) as { status?: string; video?: unknown[] } | undefined;
|
||||
if (p?.status === "ok" && Array.isArray(p.video) && p.video.length === 0) {
|
||||
@@ -97,6 +98,21 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
.where("id", "=", f.id)
|
||||
.execute();
|
||||
f.status = "failed";
|
||||
notVideo.add(f.source_path);
|
||||
}
|
||||
}
|
||||
// 主文件不是视频:附属文件不登记(结果改为跳过 main_failed),新文件随后移入回收站(quarantineNotVideo)
|
||||
if (notVideo.size) {
|
||||
const mainOf = new Map(frozen.organize.files.map((pf) => [pf.src, pf.mainSrc]));
|
||||
for (const f of files) {
|
||||
if (f.status !== "succeeded" || f.role === "main" || !notVideo.has(mainOf.get(f.source_path) ?? "")) continue;
|
||||
await trx
|
||||
.updateTable("organization_file_result")
|
||||
.set({ status: "skipped", skip_reason: "main_failed" })
|
||||
.where("id", "=", f.id)
|
||||
.execute();
|
||||
f.status = "skipped";
|
||||
f.skip_reason = "main_failed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +514,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
|
||||
await settleSamePathReplacement(d, frozenPre, recordId);
|
||||
await cleanupReplacement(d, frozenPre, recordId);
|
||||
await quarantineNotVideo(d, frozenPre, recordId);
|
||||
|
||||
// SCR-1:有资产入库时按设置自动刮削(写 NFO 与图片;排任务,不在这里等)
|
||||
if (result.status === "succeeded" || result.status === "partial") {
|
||||
|
||||
@@ -500,3 +500,107 @@ export async function stagedReplacementBackup(d: AoDeps, file: ReplacementFile):
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收尾判定「不是视频」(ffprobe 没有视频流)的文件:已拷 / 搬进库的新文件和它的附属文件移入回收站(Move 时源已删,
|
||||
* 回收站里是唯一一份),并开待处理。原地入库不动用户原有的文件;洗版替换由 settleSamePathReplacement 回滚。
|
||||
* 可重入:目标已不在、或已登记成资产、或大小对不上(不是本次写入的)就不动。
|
||||
*/
|
||||
export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId: string): Promise<void> {
|
||||
const db = d.ctx.db;
|
||||
const rec = await db
|
||||
.selectFrom("organization_record")
|
||||
.select(["action", "download_task_id"])
|
||||
.where("id", "=", recordId)
|
||||
.executeTakeFirst();
|
||||
if (!rec || rec.action === "in_place" || frozen.transfer) return;
|
||||
const results = await db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["id", "source_path", "target_path", "status", "skip_reason", "error", "source_size_bytes", "role"])
|
||||
.where("organization_record_id", "=", recordId)
|
||||
.orderBy("ordinal")
|
||||
.execute();
|
||||
const planned = new Map(frozen.organize.files.map((f) => [f.src, f]));
|
||||
const rejectedMains = new Set(
|
||||
results
|
||||
.filter((f) => f.status === "failed" && (f.error as { code?: string } | null)?.code === "NOT_VIDEO")
|
||||
.map((f) => f.source_path),
|
||||
);
|
||||
if (!rejectedMains.size) return;
|
||||
const targets = results.filter(
|
||||
(f) =>
|
||||
f.target_path &&
|
||||
(rejectedMains.has(f.source_path) ||
|
||||
(f.status === "skipped" &&
|
||||
f.skip_reason === "main_failed" &&
|
||||
rejectedMains.has(planned.get(f.source_path)?.mainSrc ?? ""))),
|
||||
);
|
||||
const fsr = fsRuntime(d);
|
||||
const moved: { path: string; trash: string }[] = [];
|
||||
const left: { path: string; error: string }[] = [];
|
||||
for (const f of targets) {
|
||||
const pf = planned.get(f.source_path);
|
||||
if (frozen.replacement && pf?.action === "replace") continue;
|
||||
const rel = f.target_path!;
|
||||
try {
|
||||
if ((await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel)).kind !== "online") throw new Error("库根离线");
|
||||
const rootReal = await realpath(frozen.rootPath);
|
||||
const abs = join(rootReal, rel);
|
||||
const st = await fsr.proxy.statSafe(abs, { root: rootReal, sentinel: frozen.sentinel });
|
||||
if (st.kind === "missing") continue;
|
||||
if (st.kind !== "ok" || !st.facts.isFile || st.facts.isSymbolicLink) throw new Error("目标不是普通文件");
|
||||
const registered = await db
|
||||
.selectFrom("asset_location")
|
||||
.select("id")
|
||||
.where("library_root_id", "=", frozen.libraryRootId)
|
||||
.where("relative_path", "=", rel)
|
||||
.where("status", "<>", "removed")
|
||||
.executeTakeFirst();
|
||||
if (registered) continue;
|
||||
const size = Number(f.source_size_bytes ?? pf?.size ?? -1);
|
||||
if (size >= 0 && st.facts.size !== size) throw new Error("目标大小与本次写入的不同,保留待核对");
|
||||
const id = randomUUID();
|
||||
const now = d.ports.now();
|
||||
const trash = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel);
|
||||
await mkdir(dirname(trash), { recursive: true });
|
||||
const m = await fsr.proxy.moveSafe(abs, trash);
|
||||
if (m.status !== "moved") throw new Error(`移入回收站失败:${m.status}`);
|
||||
const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
|
||||
await db
|
||||
.insertInto("trash_item")
|
||||
.values({
|
||||
id,
|
||||
root_id: `lib:${frozen.libraryRootId}`,
|
||||
library_root_id: frozen.libraryRootId,
|
||||
root_host_path: rootReal,
|
||||
original_path: rel,
|
||||
trash_path: trash,
|
||||
is_dir: false,
|
||||
size_bytes: st.facts.size,
|
||||
actor: "system:organize",
|
||||
deleted_at: now,
|
||||
expires_at: new Date(now.getTime() + days * 86_400_000),
|
||||
asset_snapshot: jsonb({ source: "organize_not_video", recordId, sourcePath: f.source_path }),
|
||||
})
|
||||
.execute();
|
||||
moved.push({ path: rel, trash });
|
||||
} catch (error) {
|
||||
left.push({ path: rel, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
if (!moved.length && !left.length) return;
|
||||
const names = [...rejectedMains].map((p) => basename(p));
|
||||
await upsertDecision(d, db, {
|
||||
kind: "other",
|
||||
title: `${rejectedMains.size} 个文件不是视频,没有入库`,
|
||||
summary: `${names.slice(0, 5).join("、")}${names.length > 5 ? " 等" : ""} 没有视频流(伪装或损坏的文件)。${
|
||||
moved.length ? "已拷进媒体库的文件移入回收站" : ""
|
||||
}${left.length ? `${moved.length ? "," : ""}${left.length} 个文件留在媒体库里待核对` : ""}${
|
||||
rec.action === "move" ? ";源文件已随移动删除,回收站里是唯一一份" : ""
|
||||
}`,
|
||||
subjectType: "organization_record",
|
||||
subjectId: recordId,
|
||||
dedupeKey: `organize_not_video:${recordId}`,
|
||||
evidence: { downloadTaskId: rec.download_task_id, moved, left },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 收尾时 ffprobe 判定「不是视频」:已拷 / 搬进库的新文件与它的附属文件移入回收站(不留未登记文件),
|
||||
// 附属文件结果改为跳过(main_failed),开待处理;重放不重复处理。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const FAKE = "Fake.2020.1080p.BluRay.x264-GRP.mkv";
|
||||
const SUB = "Fake.2020.1080p.BluRay.x264-GRP.zh.srt";
|
||||
|
||||
describe.skipIf(!hasPg)("整理:不是视频的文件不留在媒体库", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-nvlib-" });
|
||||
src = await createTempTree(
|
||||
{ [FAKE]: 200_000, [SUB]: "1\n00:00:01,000 --> 00:00:02,000\n字幕\n" },
|
||||
{ prefix: "umt-nvsrc-" },
|
||||
);
|
||||
h = await createHarness("not_video", {
|
||||
probe: async (p: string) =>
|
||||
p.endsWith(".mkv")
|
||||
? ({ status: "ok", video: [], audio: [], subtitle: [] } as never)
|
||||
: ({ status: "unknown" } as never),
|
||||
});
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
it("Move:新文件与字幕移入回收站,不登记资产,开待处理;重放不重复", async () => {
|
||||
const entity = await seedEntity(h.db, { kind: "movie", title: "伪装片", year: 2020 });
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(FAKE)],
|
||||
mediaEntityId: entity,
|
||||
action: "move",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
const ex = await executeRecord(h.d, plan.recordId!);
|
||||
expect(ex.status).toBe("failed");
|
||||
|
||||
const files = await h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["role", "status", "skip_reason", "target_path", "error", "media_asset_id"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.orderBy("ordinal")
|
||||
.execute();
|
||||
const main = files.find((f) => f.role === "main")!;
|
||||
const sub = files.find((f) => f.role === "subtitle")!;
|
||||
expect(main).toMatchObject({ status: "failed", media_asset_id: null });
|
||||
expect((main.error as { code: string }).code).toBe("NOT_VIDEO");
|
||||
expect(sub).toMatchObject({ status: "skipped", skip_reason: "main_failed", media_asset_id: null });
|
||||
|
||||
// 库里不留文件,回收站里各一份(Move 时源已删,回收站里是唯一一份)
|
||||
expect(existsSync(join(lib.root, main.target_path!))).toBe(false);
|
||||
expect(existsSync(join(lib.root, sub.target_path!))).toBe(false);
|
||||
expect(existsSync(src.path(FAKE))).toBe(false);
|
||||
const trash = await h.db.selectFrom("trash_item").select(["original_path", "trash_path"]).execute();
|
||||
expect(trash.map((t) => t.original_path).sort()).toEqual([main.target_path, sub.target_path].sort());
|
||||
for (const t of trash) expect(existsSync(t.trash_path)).toBe(true);
|
||||
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entity).execute()).toEqual(
|
||||
[],
|
||||
);
|
||||
const decisions = await h.db
|
||||
.selectFrom("decision")
|
||||
.select(["title", "status"])
|
||||
.where("dedupe_key", "=", `organize_not_video:${plan.recordId}`)
|
||||
.execute();
|
||||
expect(decisions).toEqual([{ title: "1 个文件不是视频,没有入库", status: "open" }]);
|
||||
|
||||
// 重放:已结束记录不重复移动
|
||||
await executeRecord(h.d, plan.recordId!);
|
||||
expect(await h.db.selectFrom("trash_item").select("id").execute()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,11 @@
|
||||
|
||||
洗版在本次冻结计划中保存选中的旧文件。新文件核验登记成功后才清理所选旧版本,剧集只处理成功对应的季集与 Part;覆盖不完整的合并集保留。相同目标路径会先暂存旧文件,失败后恢复。旧版的私有字幕等随之清理,共享 NFO/图片、未选版本及下载缓存保留;按现有文件替换设置移入回收站或删除。
|
||||
|
||||
## 收尾核验与资产登记
|
||||
|
||||
- 目标路径已有位置时:本次是「替换」(向导 / 待处理选替换,或洗版选中的旧版本),或旧位置属于别的作品,退役旧位置(没有别的位置时旧资产退役)并新建资产,大小、校验、版本信息都是新文件的;否则沿用同一资产。沿用的位置之前被对账判为缺失 / 不可用时恢复可用,资产事实换成这次入库的文件。
|
||||
- ffprobe 判定「不是视频」(没有视频流,伪装或损坏)的文件:核验失败,已拷 / 搬进库的新文件和它的字幕等附属文件移入回收站(Move 时源已删,回收站里是唯一一份),附属文件结果记跳过 `main_failed`,开待处理「N 个文件不是视频,没有入库」。原地入库不动原文件;洗版替换照旧回滚旧文件。
|
||||
|
||||
关联迁移 `0060_manual_acquisition`,已于 2026-10-01 随 v0.3.10 发布执行。业务依据与验收见 [MP 对照 §5.6](../porting/moviepilot-v3-comparison.md#56-本轮实现与验收已部署-v0310)。
|
||||
|
||||
## 与 MP 的对照
|
||||
|
||||
Reference in new issue
Block a user