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 },
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user