fix(organize): 非洗版替换新建资产;复用缺失位置时更新状态与事实
- 目标路径已有位置且本次是「替换」(向导 / 待处理选替换),或旧位置属于别的作品时,退役旧位置(没有别的位置时资产退役) 并新建资产,size / hash / delivery / 版本信息都是新文件的(复用洗版分支的处理); - 复用之前判为缺失 / 不可用的位置时,位置恢复可用、资产恢复 active,大小 / 校验 / 版本信息换成这次入库的文件。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
b7f9eb82ad
commit
1c8c5d2fb7
2 files changed
+173
-43
No files matched your search
@@ -145,16 +145,22 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
const parent = pf?.mainSrc ? assetBySrc.get(pf.mainSrc) : undefined;
|
||||
if (!isMain && !parent) continue;
|
||||
let hit = await trx
|
||||
.selectFrom("asset_location")
|
||||
.select(["id", "media_asset_id"])
|
||||
.where("library_root_id", "=", frozen.libraryRootId)
|
||||
.where("relative_path", "=", f.target_path)
|
||||
.where("status", "!=", "removed")
|
||||
.selectFrom("asset_location as l")
|
||||
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
|
||||
.select(["l.id", "l.media_asset_id", "l.status", "a.media_entity_id"])
|
||||
.where("l.library_root_id", "=", frozen.libraryRootId)
|
||||
.where("l.relative_path", "=", f.target_path)
|
||||
.where("l.status", "!=", "removed")
|
||||
.executeTakeFirst();
|
||||
// 同一路径已有位置时,这些情况旧资产不能沿用,退役旧位置、新建资产(size / hash / 版本信息都是新文件的):
|
||||
// - 替换(向导 / 待处理选「替换」= replace_with_verify;洗版只退役选中的旧版本);
|
||||
// - 旧位置属于别的作品。
|
||||
if (
|
||||
hit &&
|
||||
pf?.action === "replace" &&
|
||||
frozen.replacement?.assets.some((a) => a.files.some((old) => old.locationId === hit!.id))
|
||||
((pf?.action === "replace" &&
|
||||
(!frozen.replacement ||
|
||||
frozen.replacement.assets.some((a) => a.files.some((old) => old.locationId === hit!.id)))) ||
|
||||
hit.media_entity_id !== frozen.entityId)
|
||||
) {
|
||||
await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "=", hit.id).execute();
|
||||
await sql`update media_asset set status='retired' where id=${hit.media_asset_id} and not exists(select 1 from asset_location where media_asset_id=${hit.media_asset_id} and status <> 'removed')`.execute(
|
||||
@@ -166,46 +172,80 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
let locationId: string;
|
||||
const st = await statSafe(join(frozen.rootPath, f.target_path));
|
||||
const factsOf = st.kind === "ok" ? st.facts : null;
|
||||
const fx = frozen.facts[f.source_path];
|
||||
const probe = probes.get(f.id);
|
||||
const facts = {
|
||||
size_bytes: factsOf?.size ?? f.source_size_bytes,
|
||||
sample_digest: f.checksum?.startsWith("sample:") ? f.checksum.slice(7) : null,
|
||||
content_hash: f.checksum?.startsWith("sha256:") ? f.checksum.slice(7) : null,
|
||||
hash_algorithm: f.checksum?.startsWith("sha256:") ? "sha256" : null,
|
||||
media_info: jsonb({
|
||||
...(probe ? { probe } : {}),
|
||||
_umt: {
|
||||
quality: fx?.quality ?? {},
|
||||
unitKeys: fx?.unitKeys ?? [],
|
||||
},
|
||||
}),
|
||||
// LIBV-1:库内文件名 + 探测 + 资源解析合成版本规格
|
||||
delivery: jsonb(
|
||||
isMain
|
||||
? deliveryForAsset(f.target_path, {
|
||||
...(probe ? { probe } : {}),
|
||||
_umt: { quality: fx?.quality ?? {} },
|
||||
})
|
||||
: fx
|
||||
? {
|
||||
...(fx.quality.resolution ? { resolution: fx.quality.resolution } : {}),
|
||||
...(fx.quality.source ? { sourceType: fx.quality.source } : {}),
|
||||
...(fx.quality.releaseGroup ? { releaseGroup: fx.quality.releaseGroup.name } : {}),
|
||||
...(fx.quality.hdr?.length ? { hdr: fx.quality.hdr } : {}),
|
||||
}
|
||||
: {},
|
||||
),
|
||||
content_edition: fx?.quality.cut ?? null,
|
||||
};
|
||||
const locFacts = {
|
||||
size_bytes: factsOf?.size ?? null,
|
||||
inode: factsOf?.ino ?? null,
|
||||
device: factsOf?.dev ?? null,
|
||||
mtime: factsOf ? new Date(factsOf.mtimeMs) : null,
|
||||
last_seen_at: now,
|
||||
last_checked_at: now,
|
||||
};
|
||||
if (hit) {
|
||||
assetId = hit.media_asset_id;
|
||||
locationId = hit.id;
|
||||
// 复用的位置之前被判缺失 / 不可用(同名文件重新入库):位置恢复可用,资产事实换成这次的文件
|
||||
if (hit.status !== "available") {
|
||||
await trx
|
||||
.updateTable("asset_location")
|
||||
.set({ status: "available", ...locFacts })
|
||||
.where("id", "=", hit.id)
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("media_asset")
|
||||
.set({
|
||||
...facts,
|
||||
verified_at: now,
|
||||
...(dt ? { source_release_id: dt.source_release_id, download_task_id: dt.id } : {}),
|
||||
})
|
||||
.where("id", "=", assetId)
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("media_asset")
|
||||
.set({ status: "active" })
|
||||
.where("id", "=", assetId)
|
||||
.where("status", "=", "missing")
|
||||
.execute();
|
||||
}
|
||||
} else {
|
||||
const fx = frozen.facts[f.source_path];
|
||||
const probe = probes.get(f.id);
|
||||
const asset = await trx
|
||||
.insertInto("media_asset")
|
||||
.values({
|
||||
media_entity_id: frozen.entityId,
|
||||
kind: isMain ? "video" : f.role === "audio" ? "audio" : f.role === "subtitle" ? "subtitle" : "other",
|
||||
parent_asset_id: parent?.assetId ?? null,
|
||||
size_bytes: factsOf?.size ?? f.source_size_bytes,
|
||||
sample_digest: f.checksum?.startsWith("sample:") ? f.checksum.slice(7) : null,
|
||||
content_hash: f.checksum?.startsWith("sha256:") ? f.checksum.slice(7) : null,
|
||||
hash_algorithm: f.checksum?.startsWith("sha256:") ? "sha256" : null,
|
||||
media_info: jsonb({
|
||||
...(probe ? { probe } : {}),
|
||||
_umt: {
|
||||
quality: fx?.quality ?? {},
|
||||
unitKeys: fx?.unitKeys ?? [],
|
||||
},
|
||||
}),
|
||||
// LIBV-1:库内文件名 + 探测 + 资源解析合成版本规格
|
||||
delivery: jsonb(
|
||||
isMain
|
||||
? deliveryForAsset(f.target_path, {
|
||||
...(probe ? { probe } : {}),
|
||||
_umt: { quality: fx?.quality ?? {} },
|
||||
})
|
||||
: fx
|
||||
? {
|
||||
...(fx.quality.resolution ? { resolution: fx.quality.resolution } : {}),
|
||||
...(fx.quality.source ? { sourceType: fx.quality.source } : {}),
|
||||
...(fx.quality.releaseGroup ? { releaseGroup: fx.quality.releaseGroup.name } : {}),
|
||||
...(fx.quality.hdr?.length ? { hdr: fx.quality.hdr } : {}),
|
||||
}
|
||||
: {},
|
||||
),
|
||||
content_edition: fx?.quality.cut ?? null,
|
||||
...facts,
|
||||
source_release_id: dt?.source_release_id ?? null,
|
||||
download_task_id: dt?.id ?? null,
|
||||
status: "active",
|
||||
@@ -223,13 +263,8 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
relative_path: f.target_path,
|
||||
status: "available",
|
||||
is_primary: true,
|
||||
size_bytes: factsOf?.size ?? null,
|
||||
inode: factsOf?.ino ?? null,
|
||||
device: factsOf?.dev ?? null,
|
||||
mtime: factsOf ? new Date(factsOf.mtimeMs) : null,
|
||||
...locFacts,
|
||||
sidecar_of_location_id: parent?.locationId ?? null,
|
||||
last_seen_at: now,
|
||||
last_checked_at: now,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
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";
|
||||
|
||||
// 整理登记资产时同一路径已有位置:
|
||||
// - 非洗版「替换」(向导 / 待处理选替换 = replace_with_verify)退役旧位置并新建资产,size / hash / 版本信息是新文件的;
|
||||
// - 复用之前判为缺失的位置(同名文件重新入库)时,位置恢复可用,资产事实换成这次的文件。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const OLD = "Film.2020.1080p.BluRay.x264-AAA.mkv";
|
||||
const NEW = "Film.2020.1080p.WEB-DL.x264-BBB.mkv";
|
||||
|
||||
describe.skipIf(!hasPg)("整理:替换与缺失位置复用时资产事实跟着新文件", () => {
|
||||
let h: Harness;
|
||||
let lib: TempTree;
|
||||
let src: TempTree;
|
||||
let entity = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-aflib-" });
|
||||
src = await createTempTree({ [OLD]: 200_000, [NEW]: 300_000 }, { prefix: "umt-afsrc-" });
|
||||
h = await createHarness("asset_facts");
|
||||
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
||||
await h.setSettings("organize", { scrapeAfterImport: false });
|
||||
entity = await seedEntity(h.db, { kind: "movie", title: "替换片", year: 2020 });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
for (const t of [lib, src]) await t?.cleanup();
|
||||
});
|
||||
|
||||
async function organize(file: string, extra: Record<string, unknown> = {}) {
|
||||
const plan = await planImport(h.d, {
|
||||
sourcePaths: [src.path(file)],
|
||||
mediaEntityId: entity,
|
||||
action: "copy",
|
||||
trigger: "manual",
|
||||
actorType: "user",
|
||||
...extra,
|
||||
});
|
||||
expect(plan.status).toBe("running");
|
||||
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
|
||||
return h.db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["media_asset_id", "target_path"])
|
||||
.where("organization_record_id", "=", plan.recordId!)
|
||||
.where("role", "=", "main")
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
const asset = (id: string) =>
|
||||
h.db.selectFrom("media_asset").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const locations = (id: string) =>
|
||||
h.db.selectFrom("asset_location").select(["status", "size_bytes"]).where("media_asset_id", "=", id).execute();
|
||||
|
||||
let newAsset = "";
|
||||
let target = "";
|
||||
|
||||
it("非洗版替换:退役旧位置与旧资产,新建资产记新文件的大小与版本", async () => {
|
||||
const first = await organize(OLD);
|
||||
const second = await organize(NEW, { onExisting: "replace_with_verify", grantReplace: true });
|
||||
expect(second.target_path).toBe(first.target_path);
|
||||
expect(second.media_asset_id).not.toBe(first.media_asset_id);
|
||||
newAsset = second.media_asset_id!;
|
||||
target = second.target_path!;
|
||||
|
||||
expect((await asset(first.media_asset_id!)).status).toBe("retired");
|
||||
expect(await locations(first.media_asset_id!)).toEqual([{ status: "removed", size_bytes: expect.anything() }]);
|
||||
const a = await asset(newAsset);
|
||||
expect(a).toMatchObject({ status: "active", media_entity_id: entity });
|
||||
expect(Number(a.size_bytes)).toBe(300_000);
|
||||
expect(JSON.stringify(a.delivery)).toContain("BBB");
|
||||
expect((await locations(newAsset)).map((l) => l.status)).toEqual(["available"]);
|
||||
});
|
||||
|
||||
it("复用判为缺失的位置:位置恢复可用,资产恢复 active 且事实换成新文件", async () => {
|
||||
await h.db
|
||||
.updateTable("asset_location")
|
||||
.set({ status: "confirmed_missing" })
|
||||
.where("media_asset_id", "=", newAsset)
|
||||
.execute();
|
||||
await h.db.updateTable("media_asset").set({ status: "missing" }).where("id", "=", newAsset).execute();
|
||||
await rm(lib.path(target));
|
||||
const again = await organize(OLD);
|
||||
expect(again.media_asset_id).toBe(newAsset);
|
||||
const a = await asset(newAsset);
|
||||
expect(a.status).toBe("active");
|
||||
expect(Number(a.size_bytes)).toBe(200_000);
|
||||
expect(JSON.stringify(a.delivery)).toContain("AAA");
|
||||
const [loc] = await locations(newAsset);
|
||||
expect(loc).toMatchObject({ status: "available" });
|
||||
expect(Number(loc!.size_bytes)).toBe(200_000);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user