From 19f7bd0330083125b90b24eda939d6d4f9940ed1 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:20:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20=E4=B8=8B=E8=BD=BD=E5=99=A8?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E4=B8=8E=E4=B8=8B=E8=BD=BD=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E8=AF=BB=E4=B8=8D=E5=88=B0=E5=AE=B9=E9=87=8F=E5=BC=80=E5=BE=85?= =?UTF-8?q?=E5=A4=84=E7=90=86=EF=BC=8C=E5=AD=A3=E5=8C=85=E6=9A=82=E5=81=9C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=90=8E=E9=80=89=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M5:单个下载器连续 10 分钟连不上开 downloader_offline(按实例去重),恢复连接后自动关闭 - M3:下载目录读不到容量按卷开 critical 的 cache_volume_unknown,读到后关闭;面板单独显示 「下载目录读不到」不再归为等空间;cache.threshold 不再把未知当 0% - M4:季包 / 多集包 .torrent 暂停添加、磁力用「拿到元数据后停止」,选完文件再恢复;文件清单 拿不到时留给同步重试,超过 10 分钟整包下载并记 file_list_timeout - L1:提交 commit 时锁计划复核仍是 reserved,被取消的不写回 submitted,qB 里刚加的任务暂停、 去掉管理标签并打 <管理标签>-cancelled - L3:已收单未确认的提交在同步首次看到任务时补调 afterDownloadAccepted - L4:排队中(queuedDL)的纳管任务一并受阈值暂停 - L6:停用的下载器上还有在下的任务时仍同步这些任务(不接管新任务、不开离线事项) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../src/modules/acquisition-organize/index.ts | 18 +- .../services/download-board.ts | 6 +- .../services/downloads.ts | 397 +++++++++++-- .../download-visibility.test.ts | 549 ++++++++++++++++++ .../app/components/downloads/plan-format.ts | 1 + docs/runbook/schedules.md | 4 +- packages/adapters/src/qbittorrent/client.ts | 6 + packages/contracts/src/download-board.ts | 3 +- 8 files changed, 924 insertions(+), 60 deletions(-) create mode 100644 apps/server/test/acquisition-organize/download-visibility.test.ts diff --git a/apps/server/src/modules/acquisition-organize/index.ts b/apps/server/src/modules/acquisition-organize/index.ts index 507f2183..5f482143 100644 --- a/apps/server/src/modules/acquisition-organize/index.ts +++ b/apps/server/src/modules/acquisition-organize/index.ts @@ -15,7 +15,7 @@ import { registerNamingRoutes } from "./routes/naming.ts"; import { registerOrganizationRoutes } from "./routes/organization.ts"; import { registerRuleRoutes } from "./routes/rules.ts"; import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot } from "./services/common.ts"; -import { SWEEP_SLOT_MS, sweepReservedPlans, syncDownloads } from "./services/downloads.ts"; +import { reportVolumeStates, SWEEP_SLOT_MS, sweepReservedPlans, syncDownloads } from "./services/downloads.ts"; import { recoverStalledRecords } from "./services/organize-records.ts"; import { sweepDueSlots, sweepMetadataRefresh, sweepSubscriptionSearch } from "./services/subscriptions.ts"; import { organizeDecisionHook } from "./services/takeover.ts"; @@ -139,11 +139,17 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se }, run: guarded(d, async (d) => { const cache = await d.ctx.settings.get("cache"); - const over = (await loadVolumes(d)).some( - (v) => (usedPercent(v) ?? 0) >= volumeThresholds(v, cache).hardThresholdPercent, - ); - if (!over) return "未超阈值"; - return (await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中"; + const vols = await loadVolumes(d); + // 读不到容量的卷不当 0%:开待处理(同步每轮也会判定),摘要里写明 + await reportVolumeStates(d, vols); + const unknown = vols.filter((v) => usedPercent(v) === null).length; + const note = unknown ? `,${unknown} 个下载目录读不到容量` : ""; + const over = vols.some((v) => { + const pct = usedPercent(v); + return pct !== null && pct >= volumeThresholds(v, cache).hardThresholdPercent; + }); + if (!over) return `未超阈值${note}`; + return ((await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中") + note; }), }); registerScheduledJob(ctx, { diff --git a/apps/server/src/modules/acquisition-organize/services/download-board.ts b/apps/server/src/modules/acquisition-organize/services/download-board.ts index c18bbbdc..ef690d9a 100644 --- a/apps/server/src/modules/acquisition-organize/services/download-board.ts +++ b/apps/server/src/modules/acquisition-organize/services/download-board.ts @@ -124,7 +124,7 @@ export function instanceLive(d: AoDeps, inst: DownloaderInstanceRow): Promise { + // 复核计划仍是 reserved(取消接口同样锁计划行):qB 回查期间被取消的,不把它写回 submitted + const cur = await trx + .selectFrom("acquisition_plan") + .select("status") + .where("id", "=", plan.id) + .forUpdate() + .executeTakeFirst(); + if (cur?.status !== "reserved") { + lostPlan = cur?.status ?? "missing"; + return; + } if (res.kind === "rejected" || res.kind === "source_failed") { const r = res; await failPlanTx(d, trx, plan.id, plan.slot_id, `${r.kind}: ${r.reason}`); @@ -402,8 +425,9 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: tags: r.tags, managed: true, origin: r.kind === "already_exists" ? "adopted" : "submitted", - status: r.kind === "accepted_unconfirmed" ? "submitting" : "queued", - pause_reason: null, + status: + r.kind === "accepted_unconfirmed" ? "submitting" : pack && r.kind === "added" ? "paused" : "queued", + pause_reason: pack && r.kind === "added" ? "policy" : null, removed_at: null, completed_at: null, no_progress_since: null, @@ -420,12 +444,13 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: 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) + save_path, content_path, category, tags, managed, origin, status, pause_reason, size_bytes, next_check_at) values (${topo.inst.id}, ${r.hash}, ${r.hash.length === 40 ? r.hash : null}, ${r.hash.length === 64 ? r.hash : null}, ${plan.id}, ${ev.manual ? plan.id : null}, ${rel.id}, coalesce((select media_entity_id from acquisition_slot where id = ${plan.slot_id}), ${ev.mediaEntityId ?? rel.media_entity_id}::uuid), ${r.name}, ${r.savePath}, ${r.contentPath}, ${topo.category}, ${r.tags}, true, ${r.kind === "already_exists" ? "adopted" : "submitted"}, - ${r.kind === "accepted_unconfirmed" ? "submitting" : "queued"}, ${rel.size_bytes}, now()) + ${r.kind === "accepted_unconfirmed" ? "submitting" : pack && r.kind === "added" ? "paused" : "queued"}, + ${pack && r.kind === "added" ? "policy" : null}, ${rel.size_bytes}, now()) on conflict (instance_id, torrent_hash) do update set acquisition_plan_id = coalesce(download_task.acquisition_plan_id, excluded.acquisition_plan_id), import_plan_id = coalesce(excluded.import_plan_id, download_task.import_plan_id), @@ -435,9 +460,17 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: returning id, (xmax = 0) as inserted`.execute(trx); dtId = ins.rows[0]!.id; } + // 暂停添加的季包:选文件挂起,选完(或元数据超时整包)由提交后续步骤 / 同步恢复下载 + const pendingSel = pack && r.kind !== "already_exists"; await trx .updateTable("acquisition_plan") - .set({ status: "submitted", status_reason: r.kind }) + .set({ + status: "submitted", + status_reason: r.kind, + ...(pendingSel + ? { file_selection: jsonb({ mode: "pending", since: d.ports.now().toISOString(), attempts: 0 }) } + : {}), + }) .where("id", "=", plan.id) .execute(); await applySlotEvent( @@ -456,14 +489,18 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: }, }, ); + if (lostPlan) { + await undoLostSubmit(d, client, result, tag, tagAdded, plan.id, lostPlan); + return { status: "skipped", reason: `plan_${lostPlan}` }; + } 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).hash; - // 季包/多集包:按缺集选文件(接管的已有任务不改文件选择);失败不影响提交结果 - if (result.kind === "added" && plan.unit_keys.length > 0 && plan.file_selection === null) { - await selectPackFiles(d, client, hash, plan, rel).catch((err) => - d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"), - ); + // 季包/多集包:按缺集选文件再恢复下载(接管的已有任务不改文件选择);文件清单还拿不到(磁力元数据未就绪) + // 时留给同步重试,失败不影响提交结果 + if (pack && result.kind !== "already_exists") { + const fresh = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", plan.id).executeTakeFirst(); + if (fresh && isPendingSelection(fresh)) await settlePackFiles(d, client, hash, fresh, rel); } if (result.kind !== "accepted_unconfirmed") await afterDownloadAccepted(d, plan.id); if (!dtId) { @@ -495,11 +532,150 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: } const PACK_FILE_LIST = { retries: 3, intervalMs: 300 } as const; +/** 季包文件清单(磁力元数据)最多等多久;超时仍拿不到就整包下载并记录原因 */ +export const PACK_METADATA_TIMEOUT_MS = 10 * 60_000; + +/** 资源是季包(没有集号也没有日期)或多集包 */ +function isPackRelease(rel: SourceReleaseRow): boolean { + const parsed = parsedOf(rel); + const isPack = !parsed.episodes && !parsed.airDate; + const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start; + return isPack || multi; +} + +/** 订阅计划下季包 / 多集包、还没选过文件:提交时暂停添加,选完文件再恢复 */ +function needsPackSelection(plan: AcquisitionPlanRow, rel: SourceReleaseRow): boolean { + return !!plan.slot_id && plan.unit_keys.length > 0 && plan.file_selection === null && isPackRelease(rel); +} + +interface PendingSelection { + mode: "pending"; + since: string; + attempts: number; +} + +const isPendingSelection = (plan: Pick) => + (plan.file_selection as { mode?: string } | null)?.mode === "pending"; /** - * 季包按缺集选文件(对照 MP 从整季包按集选文件,仅 qB):提交后读文件清单,把不需要单元的文件优先级置 0; + * 暂停添加的季包:读文件清单 → 选文件 → 恢复下载 → 记录选择。 + * 文件清单还拿不到(磁力元数据未就绪)且没到 PACK_METADATA_TIMEOUT_MS:只累加重试次数,留给下一轮同步; + * 超时仍拿不到:整包下载并记 file_list_timeout。恢复下载失败时选择保持 pending,下一轮同步重做(选文件幂等)。 + * 返回 true 表示已收尾(不再 pending)。 + */ +async function settlePackFiles( + d: AoDeps, + client: DownloaderClient, + hash: string, + plan: AcquisitionPlanRow, + rel: SourceReleaseRow, +): Promise { + const pending = plan.file_selection as unknown as PendingSelection; + const now = d.ports.now(); + const waitedMs = now.getTime() - new Date(pending.since).getTime(); + const files = await client.files(hash, PACK_FILE_LIST).catch(() => [] as QbFile[]); + const record = (sel: Record, unitKeys?: string[]) => + d.ctx.db + .updateTable("acquisition_plan") + .set({ file_selection: jsonb(sel), ...(unitKeys ? { unit_keys: unitKeys } : {}) }) + .where("id", "=", plan.id) + .execute(); + if (files.length === 0 && waitedMs < PACK_METADATA_TIMEOUT_MS) { + await record({ ...pending, attempts: pending.attempts + 1 }); + return false; + } + let sel: { selection: Record; unitKeys?: string[] }; + if (files.length === 0) { + sel = { + selection: { mode: "whole", reason: "file_list_timeout", waitedSec: Math.round(waitedMs / 1000) }, + }; + } else { + try { + sel = await selectPackFiles(d, client, hash, plan, rel, files); + } catch (err) { + d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"); + sel = { selection: { mode: "whole", reason: "select_failed", error: errMessage(err).slice(0, 300) } }; + } + } + try { + await client.resume([hash]); + } catch (err) { + d.ctx.logger.warn( + { planId: plan.id, err: { message: errMessage(err) } }, + "季包选完文件后恢复下载失败,下一轮同步重试", + ); + return false; + } + await record(sel.selection, sel.unitKeys); + return true; +} + +/** 同步时收尾挂起的季包选文件(本轮在 qB 里看得到的任务) */ +async function settlePendingPacks(d: AoDeps, client: DownloaderClient, topo: InstanceTopology, seen: Set) { + const rows = await d.ctx.db + .selectFrom("acquisition_plan as p") + .innerJoin("download_task as dt", "dt.acquisition_plan_id", "p.id") + .selectAll("p") + .select("dt.torrent_hash") + .where("dt.instance_id", "=", topo.inst.id) + .where("dt.removed_at", "is", null) + .where("dt.status", "!=", "removed") + .where("p.status", "=", "submitted") + .where(sql`p.file_selection->>'mode'`, "=", "pending") + .execute(); + for (const { torrent_hash, ...plan } of rows) { + if (!seen.has(torrent_hash)) continue; + try { + const rel = await d.ctx.db + .selectFrom("source_release") + .selectAll() + .where("id", "=", plan.source_release_id) + .executeTakeFirstOrThrow(); + await settlePackFiles(d, client, torrent_hash, plan as AcquisitionPlanRow, rel); + } catch (err) { + d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件重试失败"); + } + } +} + +/** + * 提交期间计划被取消(commit 复核不是 reserved):任务行没落,qB 里刚加的任务暂停、去掉管理标签、 + * 打上 <管理标签>-cancelled 留给人删(不自动删数据);原本就在 qB 里的任务只撤掉本次补上的管理标签。 + */ +async function undoLostSubmit( + d: AoDeps, + client: DownloaderClient, + result: SubmitResult, + tag: string, + tagAdded: boolean, + planId: string, + planStatus: string, +) { + if (!("hash" in result)) return; + try { + if (result.kind === "already_exists") { + if (tagAdded) await client.removeTags([result.hash], [tag]); + } else { + await client.pause([result.hash]); + await client.removeTags([result.hash], [tag]); + await client.addTags([result.hash], [`${tag}-cancelled`]); + } + d.ctx.logger.warn( + { planId, planStatus, hash: result.hash, kind: result.kind }, + "提交期间计划已结束,已撤下 qB 里的任务(暂停并去掉管理标签)", + ); + } catch (err) { + d.ctx.logger.error( + { planId, planStatus, hash: result.hash, err: { message: errMessage(err) } }, + "提交期间计划已结束,撤下 qB 任务失败,需要到 qB 里手动处理", + ); + } +} + +/** + * 季包按缺集选文件(对照 MP 从整季包按集选文件,仅 qB):按文件清单把不需要单元的文件优先级置 0; * 需要单元 = 计划预留的集(季通配 = 该季未覆盖、且不在其它在途计划里的集)。 - * 文件清单拿不到 / 有视频文件认不出集号 → 整包下载并记录理由;计划的 unit_keys 收敛为实际选中的集。 + * 有视频文件认不出集号 → 整包下载并记录理由;计划的 unit_keys 收敛为实际选中的集。返回要记录的选择(由调用方写)。 */ async function selectPackFiles( d: AoDeps, @@ -507,20 +683,12 @@ async function selectPackFiles( hash: string, plan: AcquisitionPlanRow, rel: SourceReleaseRow, -): Promise { - if (!plan.slot_id) return; + files: QbFile[], +): Promise<{ selection: Record; unitKeys?: string[] }> { const parsed = parsedOf(rel); - const isPack = !parsed.episodes && !parsed.airDate; - const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start; - if (!isPack && !multi) return; - const record = (sel: Record, unitKeys?: string[]) => - d.ctx.db - .updateTable("acquisition_plan") - .set({ file_selection: jsonb(sel), ...(unitKeys ? { unit_keys: unitKeys } : {}) }) - .where("id", "=", plan.id) - .execute(); - const files = await client.files(hash, PACK_FILE_LIST).catch(() => []); - if (files.length === 0) return void (await record({ mode: "whole", reason: "file_list_unavailable" })); + const whole = (selection: Record) => ({ selection: { mode: "whole", ...selection } }); + const slotId = plan.slot_id; + if (!slotId) return whole({ reason: "no_slot" }); // 季通配:展开为"该季未覆盖且不在其它在途计划里" const wildSeasons = new Set( @@ -535,7 +703,7 @@ async function selectPackFiles( const slot = await d.ctx.db .selectFrom("acquisition_slot") .select(["id", "current_asset_id", "media_entity_id"]) - .where("id", "=", plan.slot_id) + .where("id", "=", slotId) .executeTakeFirstOrThrow(); const slots = await d.ctx.db .selectFrom("acquisition_slot") @@ -543,7 +711,7 @@ async function selectPackFiles( .where("media_entity_id", "=", slot.media_entity_id) .execute(); const covered = await slotCoveredUnits(d.ctx.db, slot, { entityWide: slots.length <= 1 }); - const others = await slotInflightPlans(d.ctx.db, plan.slot_id, plan.id); + const others = await slotInflightPlans(d.ctx.db, slotId, plan.id); blocked = new Set([...covered.keys, ...others.flatMap((o) => o.unitKeys)]); } const wantedKey = (k: string) => { @@ -588,13 +756,12 @@ async function selectPackFiles( const videos = rows.filter((r) => r.kind === "video"); const unmapped = videos.filter((r) => r.keys.length === 0 && !r.special); if (videos.length === 0 || unmapped.length > 0) - return void (await record({ - mode: "whole", + return whole({ reason: videos.length === 0 ? "no_video_files" : "unmapped_video_file", files: unmapped.slice(0, 20).map((r) => r.name), - })); + }); const selected = [...new Set(videos.filter((r) => r.wanted).flatMap((r) => r.keys.filter(wantedKey)))]; - if (selected.length === 0) return void (await record({ mode: "whole", reason: "no_missing_units_in_pack" })); + if (selected.length === 0) return whole({ reason: "no_missing_units_in_pack" }); // 视频不需要 → 不下;附属文件(字幕等)有集号且不需要 → 不下;无集号的附属文件保留 const skip = rows.filter((r) => (r.kind === "video" ? !r.wanted : r.keys.length > 0 && !r.wanted)); if (skip.length > 0) @@ -603,15 +770,15 @@ async function selectPackFiles( skip.map((r) => r.index), 0, ); - await record( - { + return { + selection: { mode: skip.length > 0 ? "selected" : "all_needed", selectedUnits: selected, skippedFiles: skip.slice(0, 200).map((r) => r.name), totalFiles: files.length, }, - selected, - ); + unitKeys: selected, + }; } async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string | null, reason: string) { @@ -716,8 +883,16 @@ export interface SyncStats { removed: number; thresholdPaused: number; thresholdResumed: number; + /** 本轮首次在 qB 里确认的「已收单未确认」提交的计划(同步后补调 afterDownloadAccepted) */ + confirmedPlans: string[]; } +/** 下载器连续不可达多久开 downloader_offline 待处理 */ +export const DOWNLOADER_OFFLINE_DECISION_MS = 10 * 60_000; + +/** 在途(还没下完)的任务状态:停用的实例上有这些任务时仍同步 */ +const INFLIGHT_STATUSES: DownloadStatus[] = ["queued", "submitting", "downloading", "stalled", "paused"]; + /** 连续多少轮同步(列表 + 按 hash 查)都查不到才判 removed;与资产对账的 missingConfirmAfter 同口径 */ export const MISSING_CONFIRM_ROUNDS = 3; @@ -731,23 +906,44 @@ export async function syncDownloads(d: AoDeps): Promise { removed: 0, thresholdPaused: 0, thresholdResumed: 0, + confirmedPlans: [], }; const insts = await loadInstances(d.ctx.db); for (const topo of insts) { stats.instances++; await syncInstance(d, topo, stats); } + // 停用的实例上还有在途任务:只同步这些任务的状态(不接管新任务、不开离线事项、不计入失联统计) + const enabled = new Set(insts.map((t) => t.inst.id)); + for (const topo of await loadInstances(d.ctx.db, { enabledOnly: false })) { + if (enabled.has(topo.inst.id)) continue; + const inflight = await d.ctx.db + .selectFrom("download_task") + .select("id") + .where("instance_id", "=", topo.inst.id) + .where("removed_at", "is", null) + .where("managed", "=", true) + .where("status", "in", INFLIGHT_STATUSES) + .executeTakeFirst(); + if (inflight) await syncInstance(d, topo, stats, { observeOnly: true }); + } + for (const planId of stats.confirmedPlans) { + await afterDownloadAccepted(d, planId).catch((err) => + d.ctx.logger.warn({ planId, err: { message: errMessage(err) } }, "补记订阅进度失败"), + ); + } await enforceThresholds(d, insts, stats); return stats; } -async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats) { +async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats, opts: { observeOnly?: boolean } = {}) { const db = d.ctx.db; const now = d.ports.now(); - const client = await d.ports.downloader(topo.inst); - const sourceTags = await qbSourceTags(d, topo); let list: QbTorrent[]; + let client: DownloaderClient; try { + client = await d.ports.downloader(topo.inst); + const sourceTags = await qbSourceTags(d, topo); const tags = [...new Set([topo.inst.managed_tag, ...sourceTags.map((s) => s.config.tag!)])]; const batches = await Promise.all(tags.map((tag) => client.list({ tag }))); list = [...new Map(batches.flat().map((t) => [t.hash, t])).values()].filter( @@ -755,13 +951,15 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats) ); } catch (err) { // 下载器失联:不改任何任务状态(失败 ≠ 不存在) - stats.unreachable.push(topo.inst.id); await db .updateTable("downloader_instance") .set({ health_status: "offline" }) .where("id", "=", topo.inst.id) .execute(); d.ctx.logger.warn({ instanceId: topo.inst.id, err: { message: errMessage(err) } }, "qB 同步失败"); + if (opts.observeOnly) return; + stats.unreachable.push(topo.inst.id); + await reportDownloaderOffline(d, topo, now, err); return; } await db @@ -769,6 +967,7 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats) .set({ health_status: "healthy", last_seen_at: now }) .where("id", "=", topo.inst.id) .execute(); + await closeRecoveredDecision(d, db, `downloader_offline:${topo.inst.id}`, "下载器已恢复连接"); const rows = await db .selectFrom("download_task") .selectAll() @@ -783,10 +982,11 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats) const row = byHash.get(t.hash); if (row) { if (await observe(d, row, t, now, stats)) stats.updated++; - } else { + } else if (!opts.observeOnly) { await adopt(d, topo, t, now, stats); } } + await settlePendingPacks(d, client, topo, seen); for (const row of rows) { if (seen.has(row.torrent_hash) || !row.managed) continue; let still: QbTorrent | null; @@ -920,9 +1120,21 @@ async function observeLocked( ); // 卡住 / 异常后恢复正常(进度增长或状态恢复):关闭同一任务未关闭的无进展 / 异常事项 if (changed && RECOVERED.has(merged.status)) { - if (row.status === "stalled") await closeRecoveredDecision(d, trx, `download_stalled:${row.id}`, merged.status); + const note = `下载已恢复(${merged.status})`; + if (row.status === "stalled") await closeRecoveredDecision(d, trx, `download_stalled:${row.id}`, note); if (row.status === "failed" || row.status === "missing") - await closeRecoveredDecision(d, trx, `download_failed:${row.id}`, merged.status); + await closeRecoveredDecision(d, trx, `download_failed:${row.id}`, note); + } + // 「已收单未确认」的提交第一次在 qB 里看到:计划改记已确认,同步后补调 afterDownloadAccepted(订阅进度 / 提前完成) + if (row.status === "submitting" && row.acquisition_plan_id) { + const confirmed = await trx + .updateTable("acquisition_plan") + .set({ status_reason: "accepted_confirmed" }) + .where("id", "=", row.acquisition_plan_id) + .where("status_reason", "=", "accepted_unconfirmed") + .returning("id") + .executeTakeFirst(); + if (confirmed) stats.confirmedPlans.push(confirmed.id); } if (becameComplete) { stats.completed.push(row.id); @@ -974,7 +1186,7 @@ const RECOVERED = new Set(["queued", "downloading", "completed", * 卡住 / 异常事项自动关闭:只关 open / awaiting_user;Agent 正在调查(investigating)的由它自己核实后关闭。 * resolution 不带处理选项,executeResolution 不会再执行任何动作。 */ -async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, status: DownloadStatus) { +async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, note: string) { const by = "system:download-sync"; const rows = await trx .updateTable("decision") @@ -982,7 +1194,7 @@ async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, s status: "resolved", resolved_by: by, resolved_at: d.ports.now(), - resolution: jsonb({ key: "download_recovered", optionKey: null, by, note: `下载已恢复(${status})` }), + resolution: jsonb({ key: "download_recovered", optionKey: null, by, note }), }) .where("dedupe_key", "=", dedupeKey) .where("status", "in", ["open", "awaiting_user"]) @@ -998,6 +1210,82 @@ async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, s } } +/** 同一 dedupeKey 已有未关闭的事项(巡检类事项每轮都会判定,已开着就不再刷新、不累加次数) */ +async function hasOpenDecision(exec: Exec, dedupeKey: string): Promise { + const r = await exec + .selectFrom("decision") + .select("id") + .where("dedupe_key", "=", dedupeKey) + .where("status", "in", ["open", "investigating", "awaiting_user"]) + .executeTakeFirst(); + return !!r; +} + +/** 下载器连续不可达超过 DOWNLOADER_OFFLINE_DECISION_MS:按实例开一条待处理,恢复连接后由同步关闭 */ +async function reportDownloaderOffline(d: AoDeps, topo: InstanceTopology, now: Date, err: unknown) { + const since = topo.inst.last_seen_at ?? topo.inst.created_at; + const offlineMs = now.getTime() - since.getTime(); + if (offlineMs < DOWNLOADER_OFFLINE_DECISION_MS) return; + const dedupeKey = `downloader_offline:${topo.inst.id}`; + if (await hasOpenDecision(d.ctx.db, dedupeKey)) return; + await upsertDecision(d, d.ctx.db, { + kind: "downloader_offline", + severity: "critical", + title: `下载器连不上:${topo.inst.name}`, + summary: + `${topo.inst.last_seen_at ? `已 ${Math.round(offlineMs / 60_000)} 分钟` : "添加后一直"}连不上 ${topo.inst.base_url},` + + "新下载提交不了,在下的任务状态也不更新。检查下载器是否在运行、地址和登录信息是否正确", + subjectType: "downloader_instance", + subjectId: topo.inst.id, + dedupeKey, + evidence: { + instanceId: topo.inst.id, + baseUrl: topo.inst.base_url, + lastSeenAt: topo.inst.last_seen_at?.toISOString() ?? null, + error: errMessage(err).slice(0, 500), + }, + }); +} + +/** + * 下载目录读不到容量(statfs 失败、保存路径没有路径映射):按卷开 critical 待处理,读得到后关闭。 + * 读不到时新下载一律延后(不能把"未知"当"有空间"),所以这是配置问题(路径映射 / 容器挂载),不是空间问题。 + */ +export async function reportVolumeStates(d: AoDeps, vols: VolumeState[]): Promise { + for (const v of vols) { + const dedupeKey = `cache_volume_unknown:${v.volumeKey}`; + if (v.snapshot) { + await closeRecoveredDecision(d, d.ctx.db, dedupeKey, "下载目录已能读到容量"); + continue; + } + if (await hasOpenDecision(d.ctx.db, dedupeKey)) continue; + const usage = v.usage && v.usage.status !== "ok" ? (v.usage as { code?: string; message?: string }) : null; + const savePaths = v.instances.map((t) => ({ + instance: t.inst.name, + savePath: t.savePath, + localPath: t.localSavePath, + })); + const why = v.path + ? `读不到本机路径 ${v.path} 的容量${usage?.code ? `(${usage.code})` : ""},检查容器是否挂载了下载目录` + : "下载器的保存路径没法换算成本机路径,检查下载器的保存路径和路径映射"; + await upsertDecision(d, d.ctx.db, { + kind: "cache_volume_unknown", + severity: "critical", + title: `下载目录读不到容量:${v.instances.map((t) => t.inst.name).join("、")}`, + summary: `${why}。读到之前新下载一律延后`, + subjectType: "cache_volume", + dedupeKey, + evidence: { + volumeKey: v.volumeKey, + path: v.path, + reason: v.path ? "statfs_failed" : "save_path_unmapped", + usage: usage ? { code: usage.code ?? null, message: usage.message ?? null } : null, + instances: savePaths, + }, + }); + } +} + /** 用户给已有任务贴了管理标签:登记接管,不改保存路径与其它标签 */ async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, stats: SyncStats) { const db = d.ctx.db; @@ -1092,20 +1380,31 @@ async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, async function enforceThresholds(d: AoDeps, insts: InstanceTopology[], stats: SyncStats) { if (insts.length === 0) return; + const vols = await loadVolumes(d, d.ctx.db, insts); + await reportVolumeStates(d, vols); // 只读对账模式禁止自动写入(含对 qB 的暂停/恢复) if ((await d.ctx.settings.get("automation")).readOnlyReconcile) return; const cache = await d.ctx.settings.get("cache"); - const vols = await loadVolumes(d, d.ctx.db, insts); for (const v of vols) { const dv = decideVolume(v, cache); - if (dv.decision === "pause_managed" && dv.pauseTaskIds.length > 0) { - const n = await pauseForThreshold(d, v, dv.pauseTaskIds); + // 排队中(queuedDL)的纳管任务一并暂停:否则其它任务被暂停后 qB 的队列会把它拉起来 + const pauseIds = + dv.decision === "pause_managed" + ? [ + ...new Set([ + ...dv.pauseTaskIds, + ...v.inflight.filter((t) => t.managed && t.status === "queued").map((t) => t.taskId), + ]), + ] + : []; + if (pauseIds.length > 0) { + const n = await pauseForThreshold(d, v, pauseIds); stats.thresholdPaused += n; await d.ctx.outbox.emit(d.ctx.db, "download.threshold", { volumeKey: v.volumeKey, state: "tripped", usedPercent: dv.usedPct === undefined ? null : Math.min(100, dv.usedPct), - pausedTaskIds: dv.pauseTaskIds, + pausedTaskIds: pauseIds, }); } else if (dv.resumeThresholdPaused) { const ids = await resumeThresholdPaused(d, v); diff --git a/apps/server/test/acquisition-organize/download-visibility.test.ts b/apps/server/test/acquisition-organize/download-visibility.test.ts new file mode 100644 index 00000000..b564485c --- /dev/null +++ b/apps/server/test/acquisition-organize/download-visibility.test.ts @@ -0,0 +1,549 @@ +import { type EpisodeResolution, parseRelease, resolveEpisodes } from "@umt/domain"; +import { toParsedColumns } from "@umt/mapping"; +import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; +import { + cleanupCandidates, + DOWNLOADER_OFFLINE_DECISION_MS, + PACK_METADATA_TIMEOUT_MS, + submitDownload, + syncDownloads, +} from "../../src/modules/acquisition-organize/services/downloads.ts"; +import { loadVolumes } from "../../src/modules/acquisition-organize/services/volumes.ts"; +import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { + createHarness, + fakeDisk, + type Harness, + pgAvailable, + seedBase, + seedEntity, + seedRelease, + seedSlot, +} from "./harness.ts"; + +// 下载侧可见性与剩余隐患:下载器离线 / 下载目录读不到容量开待处理并自动关闭、H&R 未知按下载器设置放行、 +// 季包暂停添加选完文件再恢复(磁力元数据延后 / 超时整包)、取消与提交并发、已收单未确认的提交补记订阅进度、 +// 排队中的任务受阈值暂停、停用实例上的在途任务仍同步。真实 PG + mock qB。 + +const hasPg = await pgAvailable(); +const SIZE = 300_000; +const TABLE = { seasons: [{ season: 1, total: 12 }], latestSeason: 1, ended: true }; +const ep = (n: number) => `Pack.Show.S01E${String(n).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`; + +describe.skipIf(!hasPg)("下载侧可见性(真实 PG + mock qB)", () => { + let h: Harness; + let qb: MockQb; + let lib: TempTree; + let cache: TempTree; + const disk = fakeDisk(10_000_000_000); + /** releaseId → 种子来源(.torrent 字节或磁力) */ + const sources = new Map(); + /** releaseId → 取种子时执行的钩子(模拟提交过程中发生的事) */ + const onSource = new Map Promise>(); + let base: Awaited>; + let n = 0; + + beforeAll(async () => { + qb = await createMockQb({ addVisibleAfterPolls: 0 }); + lib = await createTempTree({}, { sentinel: true, prefix: "umt-vislib-" }); + cache = await createTempTree({}, { prefix: "umt-viscache-" }); + disk.state.watch = [cache.root]; + disk.setPct(40); + h = await createHarness( + "dl_visibility", + { + diskUsage: disk.fn, + torrentSource: async ({ release }) => { + await onSource.get(release.id)?.(); + return sources.get(release.id)!; + }, + }, + { withSources: true }, + ); + base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); + }, 60_000); + + afterAll(async () => { + await h?.close(); + await qb?.close(); + await lib?.cleanup(); + await cache?.cleanup(); + }); + + const decision = (key: string) => + h.db + .selectFrom("decision") + .selectAll() + .where("dedupe_key", "=", key) + .orderBy("created_at", "desc") + .executeTakeFirst(); + const planRow = (id: string) => + h.db.selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + const dtByHash = (hash: string) => + h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirst(); + + async function movieRelease(title: string, hr: boolean | "unknown" = false) { + const entityId = await seedEntity(h.db, { kind: "movie", title, year: 2020 + (n++ % 5) }); + const slotId = await seedSlot(h.db, { entityId }); + const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) }); + const id = await seedRelease(h.db, { + siteId: base.siteId, + entityId, + title: `${title}.2021.1080p.BluRay.x264-GRP`, + size: SIZE, + hash: t.hash, + hr, + }); + sources.set(id, { kind: "file", bytes: t.bytes }); + return { entityId, slotId, releaseId: id, hash: t.hash }; + } + + async function planFor(releaseId: string) { + const ev = await evaluateAcquisition(h.d, { sourceReleaseId: releaseId }); + expect(ev.plans).toHaveLength(1); + return ev.plans[0]!; + } + + /** 季包:剧集 + 季表 + 订阅收录版本 + 只缺 S01E03 的计划 */ + async function seasonPack(opts: { magnet?: boolean } = {}) { + const title = `Pack Show ${n++}`; + const entityId = await seedEntity(h.db, { kind: "series", title, year: 2023 }); + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: entityId, + kind: "season", + season_number: 1, + episode_total: 12, + status: "confirmed", + source: "tmdb", + }) + .execute(); + const sub = await h.db + .insertInto("subscription") + .values({ media_entity_id: entityId, media_kind: "series", mode: "continuous", status: "active" }) + .returning("id") + .executeTakeFirstOrThrow(); + const slot = await h.db + .insertInto("acquisition_slot") + .values({ + media_entity_id: entityId, + subscription_id: sub.id, + name: "1080p", + hard_conditions: JSON.stringify({}), + upgrade_threshold: JSON.stringify({}), + }) + .returning("id") + .executeTakeFirstOrThrow(); + const relTitle = `Pack.Show.${n}.S01.1080p.WEB-DL.x264-GRP`; + const raw = parseRelease(relTitle); + const resolution: EpisodeResolution = resolveEpisodes({ parsed: raw, kind: "series", table: TABLE, rules: [] }); + const cols = toParsedColumns(canonicalParsed(raw, resolution)); + const files = [1, 2, 3, 4].map((e) => ({ path: ep(e), length: SIZE })); + const t = makeTorrent({ name: relTitle, files, pieceSeed: Math.floor(Math.random() * 1e6) }); + const rel = await h.db + .insertInto("source_release") + .values({ + site_id: base.siteId, + site_release_id: `${Math.floor(Math.random() * 1e9)}`, + title: relTitle, + size_bytes: SIZE * 4, + infohash_v1: t.hash, + media_entity_id: entityId, + resolution_status: "resolved", + parsed: JSON.stringify(cols.parsed), + parser_version: cols.parserVersion, + episode_resolution: JSON.stringify(resolution), + raw: JSON.stringify({ _site: { hr: false } }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + sources.set( + rel.id, + opts.magnet + ? { kind: "magnet", uri: `magnet:?xt=urn:btih:${t.hash}&dn=${relTitle}` } + : { kind: "file", bytes: t.bytes }, + ); + const plan = await h.db + .insertInto("acquisition_plan") + .values({ + slot_id: slot.id, + source_release_id: rel.id, + status: "reserved", + evaluation: JSON.stringify({ instanceId: base.instanceId, savePath: cache.root, mediaEntityId: entityId }), + payload_hash: `ph-${rel.id}`, + idempotency_key: `ik-${rel.id}`, + unit_keys: ["s1e3"], + }) + .returning("id") + .executeTakeFirstOrThrow(); + return { planId: plan.id, hash: t.hash, relTitle }; + } + + /** 磁力的 mock 种子:元数据到达后换成真实文件清单 */ + const giveMetadata = (hash: string) => { + const t = qb.get(hash)!; + t.files = [1, 2, 3, 4].map((e) => ({ name: ep(e), size: SIZE, progress: 0, priority: 1 })); + }; + const priorities = (hash: string) => + Object.fromEntries(qb.get(hash)!.files.map((f) => [f.name.split("/").pop()!, f.priority])); + const resumedHashes = () => + [...qb.callsTo("torrents/resume"), ...qb.callsTo("torrents/start")].flatMap((c) => + (c.form.hashes ?? "").split("|"), + ); + + it("下载器连不上超过 10 分钟开 downloader_offline 待处理(按实例去重),恢复连接后自动关闭", async () => { + const qb2 = await createMockQb(); + const inst = await h.db + .insertInto("downloader_instance") + .values({ + name: "离线的 qB", + base_url: "http://127.0.0.1:9", + username: "admin", + managed_tag: "umt-offline", + settings: JSON.stringify({ savePath: cache.root }), + last_seen_at: new Date(Date.now() - 2 * 60_000), + }) + .returning("id") + .executeTakeFirstOrThrow(); + const key = `downloader_offline:${inst.id}`; + try { + // 刚断 2 分钟:只标离线,不开事项 + let st = await syncDownloads(h.d); + expect(st.unreachable).toEqual([inst.id]); + expect(await decision(key)).toBeUndefined(); + + await h.db + .updateTable("downloader_instance") + .set({ last_seen_at: new Date(Date.now() - DOWNLOADER_OFFLINE_DECISION_MS - 60_000) }) + .where("id", "=", inst.id) + .execute(); + await syncDownloads(h.d); + await syncDownloads(h.d); + const open = await h.db.selectFrom("decision").selectAll().where("dedupe_key", "=", key).execute(); + expect(open).toHaveLength(1); + expect(open[0]).toMatchObject({ + kind: "downloader_offline", + severity: "critical", + status: "open", + subject_type: "downloader_instance", + subject_id: inst.id, + }); + expect(open[0]!.summary).toContain("分钟连不上"); + + // 恢复:地址改对 + await h.db.updateTable("downloader_instance").set({ base_url: qb2.url }).where("id", "=", inst.id).execute(); + st = await syncDownloads(h.d); + expect(st.unreachable).toEqual([]); + expect(await decision(key)).toMatchObject({ status: "resolved", resolved_by: "system:download-sync" }); + const health = await h.db + .selectFrom("downloader_instance") + .select(["health_status", "last_seen_at"]) + .where("id", "=", inst.id) + .executeTakeFirstOrThrow(); + expect(health.health_status).toBe("healthy"); + } finally { + await h.db.deleteFrom("downloader_instance").where("id", "=", inst.id).execute(); + await qb2.close(); + } + }); + + it("下载目录读不到容量:提交延后、面板显示「下载目录读不到」而不是等空间,按卷开 critical 事项,恢复后关闭", async () => { + const { releaseId } = await movieRelease("读不到容量片"); + const planId = await planFor(releaseId); + disk.state.fail = true; + try { + const s = await submitDownload(h.d, planId); + expect(s).toMatchObject({ status: "deferred", reason: "cache:volume_state_unknown" }); + // 评估时已建好提交任务行(假引擎不执行):按处理器的做法记下延后结果 + await h.db + .updateTable("task") + .set({ status: "succeeded", output: JSON.stringify(s) }) + .where("kind", "=", "submitDownload") + .where("idempotency_key", "=", `plan:${planId}`) + .execute(); + await syncDownloads(h.d); + const vol = (await loadVolumes(h.d))[0]!; + const key = `cache_volume_unknown:${vol.volumeKey}`; + const dec = await decision(key); + expect(dec).toMatchObject({ kind: "cache_volume_unknown", severity: "critical", status: "open" }); + expect(dec!.evidence).toMatchObject({ reason: "statfs_failed", path: cache.root, usage: { code: "EIO" } }); + // 再同步一轮不重复开 + await syncDownloads(h.d); + expect(await h.db.selectFrom("decision").select("id").where("dedupe_key", "=", key).execute()).toHaveLength(1); + + const board = (await h.api("GET", "/download-tasks/board")).body.data; + const row = board.items.find((x: { id: string }) => x.id === planId); + expect(row).toMatchObject({ kind: "plan", wait: "volume_unknown", waitReason: "cache:volume_state_unknown" }); + + disk.state.fail = false; + await syncDownloads(h.d); + expect(await decision(key)).toMatchObject({ status: "resolved" }); + } finally { + disk.state.fail = false; + await h.db + .updateTable("acquisition_plan") + .set({ status: "cancelled", status_reason: "test_cleanup" }) + .where("id", "=", planId) + .execute(); + } + }); + + it("H&R 未知:缺省锁定(hnr_unknown);下载器打开「H&R 未知也可清理」后保种到期即可清理", async () => { + const { releaseId, hash, entityId } = await movieRelease("H&R 未知片", "unknown"); + const dt = await h.db + .insertInto("download_task") + .values({ + instance_id: base.instanceId, + torrent_hash: hash, + source_release_id: releaseId, + media_entity_id: entityId, + name: "H&R 未知片", + save_path: cache.root, + content_path: `${cache.root}/H&R 未知片.mkv`, + tags: ["umt"], + managed: true, + status: "imported", + size_bytes: SIZE, + completed_at: new Date(Date.now() - 30 * 86_400_000), + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("organization_record") + .values({ + download_task_id: dt.id, + trigger: "auto", + actor_type: "system", + action: "copy", + status: "succeeded", + } as never) + .execute(); + const lockOf = async () => { + const vol = (await loadVolumes(h.d))[0]!; + return (await cleanupCandidates(h.d, vol)).find((c) => c.row.id === dt.id)!; + }; + expect(await lockOf()).toMatchObject({ cleanable: false, lockReason: "hnr_unknown" }); + + await h.db + .updateTable("downloader_instance") + .set({ settings: JSON.stringify({ savePath: cache.root, policy: { hnrUnknownCleanable: true } }) }) + .where("id", "=", base.instanceId) + .execute(); + try { + expect(await lockOf()).toMatchObject({ cleanable: true, lockReason: null }); + // H&R 明确未满足的不受这个开关影响 + await h.db + .updateTable("source_release") + .set({ raw: JSON.stringify({ _site: { hr: true } }) }) + .where("id", "=", releaseId) + .execute(); + expect(await lockOf()).toMatchObject({ cleanable: false, lockReason: "hnr_unsatisfied" }); + } finally { + await h.db + .updateTable("downloader_instance") + .set({ settings: JSON.stringify({ savePath: cache.root }) }) + .where("id", "=", base.instanceId) + .execute(); + await h.db.updateTable("download_task").set({ managed: false }).where("id", "=", dt.id).execute(); + } + }); + + it("季包(.torrent)暂停添加:选完文件再恢复下载", async () => { + const p = await seasonPack(); + const s = await submitDownload(h.d, p.planId); + expect(s, JSON.stringify(s)).toMatchObject({ status: "submitted", reason: "added" }); + const add = qb.callsTo("torrents/add").at(-1)!; + expect(add.status).toBe(200); + const plan = await planRow(p.planId); + expect(plan.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3"] }); + expect(priorities(p.hash)).toEqual({ [ep(1)]: 0, [ep(2)]: 0, [ep(3)]: 1, [ep(4)]: 0 }); + // 先设文件优先级再恢复 + const prioAt = qb.callsTo("torrents/filePrio").at(-1)!.at; + const resume = qb + .callsTo("torrents/resume") + .concat(qb.callsTo("torrents/start")) + .find((c) => (c.form.hashes ?? "").includes(p.hash)); + expect(resume).toBeDefined(); + expect(resume!.at).toBeGreaterThanOrEqual(prioAt); + expect(qb.get(p.hash)!.state).toBe("downloading"); + // 提交时任务行记为策略暂停,恢复后同步回到下载中 + await syncDownloads(h.d); + expect(await dtByHash(p.hash)).toMatchObject({ status: "downloading", pause_reason: null }); + }); + + it("磁力季包:不暂停添加而是「拿到元数据后停止」;元数据未就绪时延后选文件,同步里拿到后选完再恢复", async () => { + const p = await seasonPack({ magnet: true }); + // 磁力元数据还没到:文件清单为空 + qb.fault("torrents/files", { status: 200, body: "[]", times: 3 }); + const s = await submitDownload(h.d, p.planId); + expect(s).toMatchObject({ status: "submitted", reason: "added" }); + expect(await planRow(p.planId)).toMatchObject({ file_selection: { mode: "pending", attempts: 1 } }); + expect(resumedHashes()).not.toContain(p.hash); + qb.clearFaults(); + giveMetadata(p.hash); + await syncDownloads(h.d); + const plan = await planRow(p.planId); + expect(plan.file_selection).toMatchObject({ mode: "selected", selectedUnits: ["s1e3"] }); + expect(plan.unit_keys).toEqual(["s1e3"]); + expect(priorities(p.hash)[ep(3)]).toBe(1); + expect(priorities(p.hash)[ep(1)]).toBe(0); + expect(resumedHashes()).toContain(p.hash); + }); + + it("磁力季包元数据一直拿不到:超时后整包下载并记录原因", async () => { + const p = await seasonPack({ magnet: true }); + qb.fault("torrents/files", { status: 200, body: "[]", times: 1000 }); + const realNow = h.d.ports.now; + try { + await submitDownload(h.d, p.planId); + await syncDownloads(h.d); + expect(await planRow(p.planId)).toMatchObject({ file_selection: { mode: "pending", attempts: 2 } }); + h.d.ports.now = () => new Date(Date.now() + PACK_METADATA_TIMEOUT_MS + 60_000); + await syncDownloads(h.d); + } finally { + h.d.ports.now = realNow; + qb.clearFaults(); + } + const plan = await planRow(p.planId); + expect(plan.file_selection).toMatchObject({ mode: "whole", reason: "file_list_timeout" }); + expect(resumedHashes()).toContain(p.hash); + }); + + it("取消与提交并发:qB 已收单时计划被取消,commit 复核后不写回 submitted,qB 里的任务暂停并去掉管理标签", async () => { + const { releaseId, hash } = await movieRelease("并发取消片"); + const planId = await planFor(releaseId); + onSource.set(releaseId, async () => { + const r = await h.api("POST", `/acquisition-plans/${planId}/cancel`); + expect(r.status).toBe(200); + }); + const s = await submitDownload(h.d, planId); + expect(s).toMatchObject({ status: "skipped", reason: "plan_cancelled" }); + expect(await planRow(planId)).toMatchObject({ status: "cancelled", status_reason: "cancelled_by_user" }); + expect(await dtByHash(hash)).toBeUndefined(); + const t = qb.get(hash)!; + expect(t.tags).not.toContain("umt"); + expect(t.tags).toContain("umt-cancelled"); + expect(t.state).toMatch(/^(paused|stopped)/); + // 同步不接管它,计划也不被复活 + await syncDownloads(h.d); + expect(await dtByHash(hash)).toBeUndefined(); + expect(await planRow(planId)).toMatchObject({ status: "cancelled" }); + }); + + it("已收单未确认的提交:同步首次在 qB 里看到时补记订阅进度,计划改记已确认", async () => { + const { releaseId, hash, slotId } = await movieRelease("未确认片"); + const plan = await h.db + .insertInto("acquisition_plan") + .values({ + slot_id: slotId, + source_release_id: releaseId, + status: "submitted", + status_reason: "accepted_unconfirmed", + evaluation: JSON.stringify({ instanceId: base.instanceId }), + payload_hash: `ph-${releaseId}`, + idempotency_key: `ik-${releaseId}`, + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("download_task") + .values({ + instance_id: base.instanceId, + torrent_hash: hash, + acquisition_plan_id: plan.id, + source_release_id: releaseId, + name: "未确认片", + tags: ["umt"], + managed: true, + status: "submitting", + }) + .execute(); + const sub = () => + h.db + .selectFrom("subscription as s") + .innerJoin("acquisition_slot as sl", "sl.subscription_id", "s.id") + .select("s.last_progress_at") + .where("sl.id", "=", slotId) + .executeTakeFirstOrThrow(); + expect((await sub()).last_progress_at).toBeNull(); + // 还没出现在 qB 里:不补记 + await syncDownloads(h.d); + expect(await planRow(plan.id)).toMatchObject({ status_reason: "accepted_unconfirmed" }); + + qb.seed({ hash, name: "未确认片", tags: ["umt"], save_path: cache.root }); + const st = await syncDownloads(h.d); + expect(st.confirmedPlans).toEqual([plan.id]); + expect(await planRow(plan.id)).toMatchObject({ status: "submitted", status_reason: "accepted_confirmed" }); + expect((await sub()).last_progress_at).not.toBeNull(); + // 只补一次 + expect((await syncDownloads(h.d)).confirmedPlans).toEqual([]); + await h.db.updateTable("download_task").set({ managed: false }).where("torrent_hash", "=", hash).execute(); + }); + + it("排队中(queuedDL)的纳管任务也受阈值暂停,降到恢复线后恢复", async () => { + const { releaseId, hash } = await movieRelease("排队片"); + const planId = await planFor(releaseId); + expect(await submitDownload(h.d, planId)).toMatchObject({ status: "submitted" }); + qb.setState(hash, "queuedDL"); + await syncDownloads(h.d); + expect(await dtByHash(hash)).toMatchObject({ status: "queued" }); + disk.setPct(95); + try { + const st = await syncDownloads(h.d); + expect(st.thresholdPaused).toBeGreaterThan(0); + expect(await dtByHash(hash)).toMatchObject({ status: "paused", pause_reason: "threshold" }); + expect(qb.get(hash)!.state).toMatch(/^(paused|stopped)/); + } finally { + disk.setPct(40); + } + await syncDownloads(h.d); + expect(await dtByHash(hash)).toMatchObject({ pause_reason: null }); + expect((await dtByHash(hash))!.status).not.toBe("paused"); + }); + + it("停用的实例上还有在途任务:仍同步其状态,但不接管新任务", async () => { + const inst = await h.db + .insertInto("downloader_instance") + .values({ + name: "停用的 qB", + base_url: qb.url, + username: "admin", + managed_tag: "umt-old", + enabled: false, + settings: JSON.stringify({ savePath: cache.root }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + const hash = (900 + n++).toString(16).padStart(40, "e"); + const other = (901 + n++).toString(16).padStart(40, "e"); + qb.seed({ hash, name: "停用实例上的任务", tags: ["umt-old"], save_path: cache.root }); + qb.seed({ hash: other, name: "停用实例上的新任务", tags: ["umt-old"], save_path: cache.root }); + qb.setProgress(hash, 0.5); + const dt = await h.db + .insertInto("download_task") + .values({ + instance_id: inst.id, + torrent_hash: hash, + name: "停用实例上的任务", + tags: ["umt-old"], + managed: true, + status: "downloading", + progress: 0.1, + }) + .returning("id") + .executeTakeFirstOrThrow(); + try { + const st = await syncDownloads(h.d); + expect(st.unreachable).toEqual([]); + const row = await h.db.selectFrom("download_task").selectAll().where("id", "=", dt.id).executeTakeFirstOrThrow(); + expect(row.progress).toBeCloseTo(0.5); + expect(await dtByHash(other)).toBeUndefined(); + } finally { + await h.db.deleteFrom("download_task").where("instance_id", "=", inst.id).execute(); + await h.db.deleteFrom("downloader_instance").where("id", "=", inst.id).execute(); + } + }); +}); diff --git a/apps/web/app/components/downloads/plan-format.ts b/apps/web/app/components/downloads/plan-format.ts index 23b2d1e2..88687f99 100644 --- a/apps/web/app/components/downloads/plan-format.ts +++ b/apps/web/app/components/downloads/plan-format.ts @@ -10,6 +10,7 @@ export const PURPOSE_LABEL: Record = { fill: "补缺", upgr export const PLAN_WAIT: Record = { approval: { label: "等你批准", tone: "warning" }, space: { label: "等磁盘空间", tone: "warning" }, + volume_unknown: { label: "下载目录读不到", tone: "warning" }, automation_off: { label: "等开启自动化", tone: "warning" }, queued: { label: "准备提交", tone: "neutral" }, }; diff --git a/docs/runbook/schedules.md b/docs/runbook/schedules.md index 285351f5..0063384f 100644 --- a/docs/runbook/schedules.md +++ b/docs/runbook/schedules.md @@ -21,12 +21,12 @@ | `sites.snapshot` | 站点快照 | 采集 | 30 秒 | 扫描到周期的站点,入队 `snapshotSite` | | `identity.review` | 待识别复查 | 采集 | 30 秒 | 对 `next_review_at` 已到的条目入队 `resolveObservation` | | `subscriptions.search` | 订阅周期搜索 | 采集 | 同 qB 同步 | `sweepSubscriptionSearch` | -| `downloads.sync` | qB 同步 | 下载 | `cache.checkIntervalSec`(60 秒) | `syncDownloads`,包括按阈值暂停和恢复。所有下载器都连不上时记为失败 | +| `downloads.sync` | qB 同步 | 下载 | `cache.checkIntervalSec`(60 秒) | `syncDownloads`,包括按阈值暂停和恢复(排队中的任务一起暂停)、季包选文件的重试(磁力元数据未就绪时最多等 10 分钟,超时整包下载)。所有下载器都连不上时记为失败;单个下载器连续 10 分钟连不上开 `downloader_offline` 待处理,下载目录读不到容量开 `cache_volume_unknown` 待处理,恢复后都自动关闭。停用的下载器上还有在下的任务时仍同步这些任务,不接管新任务 | | `downloads.orchestrate` | 补提交与补入库 | 下载 | 同上 | `sweepReservedPlans` 和补入队 `verifyAndImport` | | `slots.reevaluate` | 到期重评 | 下载 | 同上 | 处理 `next_evaluate_at` 已到的收录位 | | `subscriptions.metadata` | 总集数刷新 | 整理 | 同上 | `sweepMetadataRefresh` | | `library.reconcile` | 资产对账 | 整理 | 6 小时 | 入队 `reconcileLibrary`,幂等键是 `auto:<时间槽>` | -| `cache.threshold` | 缓存阈值检查 | 维护 | 同 qB 同步 | 超过硬阈值时入队 `cleanupDownloadCache` | +| `cache.threshold` | 缓存阈值检查 | 维护 | 同 qB 同步 | 超过硬阈值时入队 `cleanupDownloadCache`;读不到容量的下载目录不当 0%,摘要里写明并开待处理。H&R 要求未知的任务缺省不清理,下载器设置打开「H&R 未知也可清理」后保种天数到期即可清理 | | `trash.purge` | 回收站清理 | 维护 | 24 小时 | 入队 `purgeTrash` | | `pi.token_refresh` | 上游登录刷新 | 维护 | 10 分钟 | 入队 `refreshPiTokens`,幂等键 `refreshPiTokens:<时间槽>`;对 30 分钟内过期的订阅登录上游刷新 token | | `auth.cleanup` | 登录会话清理 | 维护 | 1 小时 | 直接执行 `cleanupAuthTables`:删 7 天前的登录尝试、30 天前过期或吊销的会话 | diff --git a/packages/adapters/src/qbittorrent/client.ts b/packages/adapters/src/qbittorrent/client.ts index 15cda299..223b362b 100644 --- a/packages/adapters/src/qbittorrent/client.ts +++ b/packages/adapters/src/qbittorrent/client.ts @@ -120,6 +120,11 @@ export interface AddTorrentRequest { category?: string; tags?: string[]; paused?: boolean; + /** + * qB ≥ 4.5 的停止条件:MetadataReceived = 磁力拿到元数据后自动暂停(暂停添加的磁力不会去取元数据); + * 旧版本忽略该参数、照常开始下载 + */ + stopCondition?: "MetadataReceived" | "FilesChecked"; rename?: string; contentLayout?: "Original" | "Subfolder" | "NoSubfolder"; sequential?: boolean; @@ -516,6 +521,7 @@ export class QbClient implements DownloaderClient { const paused = req.paused ? "true" : "false"; form.append("paused", paused); // <= 4.x form.append("stopped", paused); // >= 5.x + if (req.stopCondition) form.append("stopCondition", req.stopCondition); if (req.rename) form.append("rename", req.rename); form.append("contentLayout", req.contentLayout ?? "Original"); form.append("autoTMM", "false"); diff --git a/packages/contracts/src/download-board.ts b/packages/contracts/src/download-board.ts index 567a0ec3..b8510d86 100644 --- a/packages/contracts/src/download-board.ts +++ b/packages/contracts/src/download-board.ts @@ -70,7 +70,8 @@ export type DownloadBoardItem = z.infer; * automation_off = 自动化总开关关闭 / 只读对账;queued = 条件已满足、等提交任务执行。 * 每日下载额度在生成计划时核对,超额不生成计划,所以没有"等额度"的计划。 */ -export const PlanWait = z.enum(["approval", "space", "automation_off", "queued"]); +/** volume_unknown:下载目录读不到容量(配置问题,不是空间不够) */ +export const PlanWait = z.enum(["approval", "space", "volume_unknown", "automation_off", "queued"]); export type PlanWait = z.infer; /** 还没提交到下载器的计划,作为「进行中」的一行(id 是计划 id) */