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:
rootandClaude Opus 5.5 committed 2026-10-03 23:28:35 +08:00
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);
});
});
+5
View File
@@ -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 的对照