merge: fix/b-download
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
15d7f2ec2f
10 files changed
+566
-18
No files matched your search
@@ -198,13 +198,17 @@ async function emitStatus(
|
||||
from: DownloadStatus | null,
|
||||
to: DownloadStatus,
|
||||
pauseReason: PauseReason | null,
|
||||
/** 任务行的代数:removed 行复活后 +1,完成事件换一把幂等键,复活后的完成才能再次触发整理 */
|
||||
generation = 0,
|
||||
) {
|
||||
await d.ctx.outbox.emit(
|
||||
exec,
|
||||
"download.status_changed",
|
||||
{ downloadTaskId: id, from, to, pauseReason },
|
||||
// 完成事件幂等:只触发一次入库;其它状态变化不去重
|
||||
to === "completed" ? { idempotencyKey: `download.completed:${id}` } : {},
|
||||
// 完成事件幂等:同一代只触发一次入库;其它状态变化不去重
|
||||
to === "completed"
|
||||
? { idempotencyKey: generation > 0 ? `download.completed:${id}:g${generation}` : `download.completed:${id}` }
|
||||
: {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,6 +279,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
||||
tags: t.tags,
|
||||
});
|
||||
let dtId: string | undefined;
|
||||
let inUseBy: string | undefined;
|
||||
const result = await withAttemptToken<SubmitResult>(
|
||||
db,
|
||||
`submit:${plan.id}`,
|
||||
@@ -337,7 +342,82 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
||||
return;
|
||||
}
|
||||
const r = res as Extract<SubmitResult, { hash: string }>;
|
||||
const ins = await sql<{ id: string; inserted: boolean }>`
|
||||
const existing = await trx
|
||||
.selectFrom("download_task")
|
||||
.select(["id", "acquisition_plan_id", "status", "removed_at"])
|
||||
.where("instance_id", "=", topo.inst.id)
|
||||
.where("torrent_hash", "=", r.hash)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
const removed = !!existing && (existing.removed_at !== null || existing.status === "removed");
|
||||
if (
|
||||
!ev.manual &&
|
||||
existing &&
|
||||
!removed &&
|
||||
existing.acquisition_plan_id &&
|
||||
existing.acquisition_plan_id !== plan.id
|
||||
) {
|
||||
// 同一种子的任务行已挂着另一个计划(手动先下 / 另一 Slot 跨轮次选中同一资源):任务行只推进它挂着的计划,
|
||||
// 本计划挂不上去,取消并释放 Slot 预留,不留没有任务的 submitted 孤儿(该资源对本 Slot 不再计划)。
|
||||
// 手动计划不受影响:它经 import_plan_id 挂到已有任务,由整理收尾结束(organize-finalize 的 importPlanId)
|
||||
const owner = existing.acquisition_plan_id;
|
||||
inUseBy = owner;
|
||||
await trx
|
||||
.updateTable("acquisition_plan")
|
||||
.set({ status: "cancelled", status_reason: `torrent_in_use_by_plan:${owner}` })
|
||||
.where("id", "=", plan.id)
|
||||
.execute();
|
||||
await applySlotEvent(
|
||||
d,
|
||||
trx,
|
||||
plan.slot_id,
|
||||
{ type: "release" },
|
||||
{ reason: "torrent_in_use_by_plan", planId: plan.id },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing && removed) {
|
||||
// 同一种子的旧任务行已 removed(用户删过 / 清理删过 / Move 后删过):复活并改挂本计划,
|
||||
// 换代让完成事件能再次触发整理
|
||||
const slotEntity = plan.slot_id
|
||||
? (
|
||||
await trx
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("media_entity_id")
|
||||
.where("id", "=", plan.slot_id)
|
||||
.executeTakeFirst()
|
||||
)?.media_entity_id
|
||||
: null;
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set((eb) => ({
|
||||
acquisition_plan_id: plan.id,
|
||||
import_plan_id: ev.manual ? plan.id : null,
|
||||
source_release_id: rel.id,
|
||||
media_entity_id: slotEntity ?? ev.mediaEntityId ?? rel.media_entity_id,
|
||||
name: r.name,
|
||||
save_path: r.savePath,
|
||||
content_path: r.contentPath,
|
||||
category: topo.category,
|
||||
tags: r.tags,
|
||||
managed: true,
|
||||
origin: r.kind === "already_exists" ? "adopted" : "submitted",
|
||||
status: r.kind === "accepted_unconfirmed" ? "submitting" : "queued",
|
||||
pause_reason: null,
|
||||
removed_at: null,
|
||||
completed_at: null,
|
||||
no_progress_since: null,
|
||||
last_progress_at: null,
|
||||
missing_since: null,
|
||||
missing_count: 0,
|
||||
generation: eb("generation", "+", 1),
|
||||
next_check_at: d.ports.now(),
|
||||
}))
|
||||
.where("id", "=", existing.id)
|
||||
.execute();
|
||||
dtId = existing.id;
|
||||
} else {
|
||||
const ins = await sql<{ id: string; inserted: boolean }>`
|
||||
insert into download_task (instance_id, torrent_hash, infohash_v1, infohash_v2, acquisition_plan_id, import_plan_id, source_release_id,
|
||||
media_entity_id, name,
|
||||
save_path, content_path, category, tags, managed, origin, status, size_bytes, next_check_at)
|
||||
@@ -353,7 +433,8 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
||||
media_entity_id = coalesce(download_task.media_entity_id, excluded.media_entity_id),
|
||||
managed = true, tags = excluded.tags, updated_at = now()
|
||||
returning id, (xmax = 0) as inserted`.execute(trx);
|
||||
dtId = ins.rows[0]!.id;
|
||||
dtId = ins.rows[0]!.id;
|
||||
}
|
||||
await trx
|
||||
.updateTable("acquisition_plan")
|
||||
.set({ status: "submitted", status_reason: r.kind })
|
||||
@@ -369,13 +450,14 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
||||
await d.ctx.outbox.emit(
|
||||
trx,
|
||||
"download.submitted",
|
||||
{ downloadTaskId: dtId, instanceId: topo.inst.id, torrentHash: r.hash, planId: plan.id, name: r.name },
|
||||
{ downloadTaskId: dtId!, instanceId: topo.inst.id, torrentHash: r.hash, planId: plan.id, name: r.name },
|
||||
{ idempotencyKey: `download.submitted:${plan.id}` },
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.kind === "rejected" || result.kind === "source_failed") return { status: "failed", reason: result.reason };
|
||||
if (inUseBy) return { status: "skipped", reason: "torrent_in_use_by_plan" };
|
||||
const hash = (result as Extract<SubmitResult, { hash: string }>).hash;
|
||||
// 季包/多集包:按缺集选文件(接管的已有任务不改文件选择);失败不影响提交结果
|
||||
if (result.kind === "added" && plan.unit_keys.length > 0 && plan.file_selection === null) {
|
||||
@@ -636,6 +718,9 @@ export interface SyncStats {
|
||||
thresholdResumed: number;
|
||||
}
|
||||
|
||||
/** 连续多少轮同步(列表 + 按 hash 查)都查不到才判 removed;与资产对账的 missingConfirmAfter 同口径 */
|
||||
export const MISSING_CONFIRM_ROUNDS = 3;
|
||||
|
||||
export async function syncDownloads(d: AoDeps): Promise<SyncStats> {
|
||||
const stats: SyncStats = {
|
||||
instances: 0,
|
||||
@@ -715,13 +800,20 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
|
||||
// 管理标签被移除:停止后续自动动作,保留历史与资产
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set({ managed: false, tags: still.tags, last_synced_at: now })
|
||||
.set({ managed: false, tags: still.tags, last_synced_at: now, missing_since: null, missing_count: 0 })
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
} else if (row.missing_count + 1 < MISSING_CONFIRM_ROUNDS) {
|
||||
// 列表与按 hash 都查不到:先记缺席(qB 刚重启、任务还没加载完时会短暂返回空 / 部分列表)
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set({ missing_since: row.missing_since ?? now, missing_count: row.missing_count + 1, last_synced_at: now })
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
} else {
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set({ status: "removed", removed_at: now, last_synced_at: now })
|
||||
.set({ status: "removed", removed_at: now, last_synced_at: now, missing_count: row.missing_count + 1 })
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
await emitStatus(d, trx, row.id, row.status, "removed", null);
|
||||
@@ -771,7 +863,8 @@ async function observeLocked(
|
||||
let noProgressSince = row.no_progress_since;
|
||||
let lastProgressAt = row.last_progress_at;
|
||||
let stalledNow = false;
|
||||
if (obs.status === "downloading") {
|
||||
// importing / imported 期间(qB 重新校验 / 移动等中间态)不做无进展判定
|
||||
if (obs.status === "downloading" && (merged.status === "downloading" || merged.status === "stalled")) {
|
||||
if (downloaded > row.downloaded_bytes || !lastProgressAt) {
|
||||
lastProgressAt = now;
|
||||
noProgressSince = null;
|
||||
@@ -809,12 +902,28 @@ async function observeLocked(
|
||||
last_progress_at: lastProgressAt,
|
||||
no_progress_since: noProgressSince,
|
||||
last_synced_at: now,
|
||||
missing_since: null,
|
||||
missing_count: 0,
|
||||
...(becameComplete ? { completed_at: now } : {}),
|
||||
})
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
if (changed)
|
||||
await emitStatus(d, trx, row.id, row.status, merged.status, merged.status === "paused" ? merged.pauseReason : null);
|
||||
await emitStatus(
|
||||
d,
|
||||
trx,
|
||||
row.id,
|
||||
row.status,
|
||||
merged.status,
|
||||
merged.status === "paused" ? merged.pauseReason : null,
|
||||
row.generation,
|
||||
);
|
||||
// 卡住 / 异常后恢复正常(进度增长或状态恢复):关闭同一任务未关闭的无进展 / 异常事项
|
||||
if (changed && RECOVERED.has(merged.status)) {
|
||||
if (row.status === "stalled") await closeRecoveredDecision(d, trx, `download_stalled:${row.id}`, merged.status);
|
||||
if (row.status === "failed" || row.status === "missing")
|
||||
await closeRecoveredDecision(d, trx, `download_failed:${row.id}`, merged.status);
|
||||
}
|
||||
if (becameComplete) {
|
||||
stats.completed.push(row.id);
|
||||
const plan = row.acquisition_plan_id
|
||||
@@ -858,6 +967,37 @@ async function observeLocked(
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** 视为"恢复正常"的状态(用户暂停不算恢复,事项留给人看) */
|
||||
const RECOVERED = new Set<DownloadStatus>(["queued", "downloading", "completed", "importing", "imported"]);
|
||||
|
||||
/**
|
||||
* 卡住 / 异常事项自动关闭:只关 open / awaiting_user;Agent 正在调查(investigating)的由它自己核实后关闭。
|
||||
* resolution 不带处理选项,executeResolution 不会再执行任何动作。
|
||||
*/
|
||||
async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, status: DownloadStatus) {
|
||||
const by = "system:download-sync";
|
||||
const rows = await trx
|
||||
.updateTable("decision")
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolved_by: by,
|
||||
resolved_at: d.ports.now(),
|
||||
resolution: jsonb({ key: "download_recovered", optionKey: null, by, note: `下载已恢复(${status})` }),
|
||||
})
|
||||
.where("dedupe_key", "=", dedupeKey)
|
||||
.where("status", "in", ["open", "awaiting_user"])
|
||||
.returning("id")
|
||||
.execute();
|
||||
for (const r of rows) {
|
||||
await d.ctx.outbox.emit(
|
||||
trx,
|
||||
"decision.updated",
|
||||
{ decisionId: r.id, status: "resolved" },
|
||||
{ idempotencyKey: `decision.updated:${r.id}:resolved` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户给已有任务贴了管理标签:登记接管,不改保存路径与其它标签 */
|
||||
async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, stats: SyncStats) {
|
||||
const db = d.ctx.db;
|
||||
@@ -901,12 +1041,49 @@ async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date,
|
||||
last_progress_at: now,
|
||||
...(st.status === "completed" ? { completed_at: now } : {}),
|
||||
})
|
||||
.onConflict((oc) => oc.columns(["instance_id", "torrent_hash"]).doNothing())
|
||||
.returning("id")
|
||||
// 同一种子的旧任务行已 removed:复活为接管任务(原计划已在移除时结束,不再挂回);未移除的冲突不动
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(["instance_id", "torrent_hash"])
|
||||
.doUpdateSet((eb) => ({
|
||||
acquisition_plan_id: null,
|
||||
import_plan_id: null,
|
||||
source_release_id: eb.fn.coalesce("download_task.source_release_id", "excluded.source_release_id"),
|
||||
media_entity_id: eb.fn.coalesce("download_task.media_entity_id", "excluded.media_entity_id"),
|
||||
name: eb.ref("excluded.name"),
|
||||
save_path: eb.ref("excluded.save_path"),
|
||||
content_path: eb.ref("excluded.content_path"),
|
||||
category: eb.ref("excluded.category"),
|
||||
tags: eb.ref("excluded.tags"),
|
||||
managed: true,
|
||||
origin: "adopted",
|
||||
status: eb.ref("excluded.status"),
|
||||
pause_reason: eb.ref("excluded.pause_reason"),
|
||||
raw_state: eb.ref("excluded.raw_state"),
|
||||
progress: eb.ref("excluded.progress"),
|
||||
size_bytes: eb.ref("excluded.size_bytes"),
|
||||
downloaded_bytes: eb.ref("excluded.downloaded_bytes"),
|
||||
remaining_bytes: eb.ref("excluded.remaining_bytes"),
|
||||
ratio: eb.ref("excluded.ratio"),
|
||||
seeding_time_sec: eb.ref("excluded.seeding_time_sec"),
|
||||
last_synced_at: eb.ref("excluded.last_synced_at"),
|
||||
last_progress_at: eb.ref("excluded.last_progress_at"),
|
||||
completed_at: eb.ref("excluded.completed_at"),
|
||||
no_progress_since: null,
|
||||
removed_at: null,
|
||||
missing_since: null,
|
||||
missing_count: 0,
|
||||
generation: eb("download_task.generation", "+", 1),
|
||||
}))
|
||||
.where((eb) =>
|
||||
eb.or([eb("download_task.removed_at", "is not", null), eb("download_task.status", "=", "removed")]),
|
||||
),
|
||||
)
|
||||
.returning(["id", "generation"])
|
||||
.executeTakeFirst();
|
||||
if (!ins) return;
|
||||
stats.adopted++;
|
||||
await emitStatus(d, trx, ins.id, null, st.status, st.status === "paused" ? st.pauseReason : null);
|
||||
await emitStatus(d, trx, ins.id, null, st.status, st.status === "paused" ? st.pauseReason : null, ins.generation);
|
||||
if (st.status === "completed") stats.completed.push(ins.id);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user