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:
rootandClaude Opus 5.5 committed 2026-10-03 23:25:41 +08:00
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();