merge: fix/h-visibility
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
385933968f
20 files changed
+1045
-100
No files matched your search
@@ -15,7 +15,7 @@ import { registerNamingRoutes } from "./routes/naming.ts";
|
|||||||
import { registerOrganizationRoutes } from "./routes/organization.ts";
|
import { registerOrganizationRoutes } from "./routes/organization.ts";
|
||||||
import { registerRuleRoutes } from "./routes/rules.ts";
|
import { registerRuleRoutes } from "./routes/rules.ts";
|
||||||
import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot } from "./services/common.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 { recoverStalledRecords } from "./services/organize-records.ts";
|
||||||
import { sweepDueSlots, sweepMetadataRefresh, sweepSubscriptionSearch } from "./services/subscriptions.ts";
|
import { sweepDueSlots, sweepMetadataRefresh, sweepSubscriptionSearch } from "./services/subscriptions.ts";
|
||||||
import { organizeDecisionHook } from "./services/takeover.ts";
|
import { organizeDecisionHook } from "./services/takeover.ts";
|
||||||
@@ -139,11 +139,17 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
},
|
},
|
||||||
run: guarded(d, async (d) => {
|
run: guarded(d, async (d) => {
|
||||||
const cache = await d.ctx.settings.get("cache");
|
const cache = await d.ctx.settings.get("cache");
|
||||||
const over = (await loadVolumes(d)).some(
|
const vols = await loadVolumes(d);
|
||||||
(v) => (usedPercent(v) ?? 0) >= volumeThresholds(v, cache).hardThresholdPercent,
|
// 读不到容量的卷不当 0%:开待处理(同步每轮也会判定),摘要里写明
|
||||||
);
|
await reportVolumeStates(d, vols);
|
||||||
if (!over) return "未超阈值";
|
const unknown = vols.filter((v) => usedPercent(v) === null).length;
|
||||||
return (await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中";
|
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, {
|
registerScheduledJob(ctx, {
|
||||||
|
|||||||
@@ -117,12 +117,42 @@ export const DEFAULT_OPTIONS: AoOptions = {
|
|||||||
|
|
||||||
const str = (v: unknown) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
const str = (v: unknown) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
||||||
|
|
||||||
|
/** qB 客户端按 ServerContext 共享缓存(实例配置变了重建):下载同步、面板、Agent 的 download.recheck 共用同一登录会话 */
|
||||||
|
const sharedClients = new WeakMap<ServerContext, Map<string, { stamp: string; client: DownloaderClient }>>();
|
||||||
|
|
||||||
|
/** 按实例取 qB 客户端(凭证现场经 secrets 解出,解析失败按无凭证处理并记警告) */
|
||||||
|
export async function downloaderClientFor(ctx: ServerContext, inst: DownloaderInstanceRow): Promise<DownloaderClient> {
|
||||||
|
let clients = sharedClients.get(ctx);
|
||||||
|
if (!clients) {
|
||||||
|
clients = new Map();
|
||||||
|
sharedClients.set(ctx, clients);
|
||||||
|
}
|
||||||
|
const stamp = `${inst.updated_at.toISOString()}|${inst.base_url}`;
|
||||||
|
const hit = clients.get(inst.id);
|
||||||
|
if (hit && hit.stamp === stamp) return hit.client;
|
||||||
|
const settings = (inst.settings ?? {}) as Record<string, unknown>;
|
||||||
|
let secret: string | undefined;
|
||||||
|
if (inst.secret_ref) {
|
||||||
|
try {
|
||||||
|
secret = await ctx.secrets.resolve(inst.secret_ref);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn({ err: { message: (err as Error).message } }, "凭证解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const client = createQbClient({
|
||||||
|
instanceId: inst.id,
|
||||||
|
baseUrl: inst.base_url,
|
||||||
|
...qbAuthConfig({ authKind: settings.authKind, username: inst.username, secret: secret ?? null }),
|
||||||
|
});
|
||||||
|
clients.set(inst.id, { stamp, client });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
/** 用 ctx 组装默认端口;overrides 逐项覆盖(测试) */
|
/** 用 ctx 组装默认端口;overrides 逐项覆盖(测试) */
|
||||||
export function createPorts(
|
export function createPorts(
|
||||||
ctx: ServerContext,
|
ctx: ServerContext,
|
||||||
overrides: Partial<Omit<AoPorts, "options">> & { options?: Partial<AoOptions> } = {},
|
overrides: Partial<Omit<AoPorts, "options">> & { options?: Partial<AoOptions> } = {},
|
||||||
): AoPorts {
|
): AoPorts {
|
||||||
const clients = new Map<string, { stamp: string; client: DownloaderClient }>();
|
|
||||||
const resolveSecret = async (ref: string | null): Promise<string | undefined> => {
|
const resolveSecret = async (ref: string | null): Promise<string | undefined> => {
|
||||||
if (!ref) return undefined;
|
if (!ref) return undefined;
|
||||||
try {
|
try {
|
||||||
@@ -159,20 +189,7 @@ export function createPorts(
|
|||||||
if (r.created) await enqueueNewEntityRefresh(ctx, r.mediaEntityId);
|
if (r.created) await enqueueNewEntityRefresh(ctx, r.mediaEntityId);
|
||||||
return r.mediaEntityId;
|
return r.mediaEntityId;
|
||||||
},
|
},
|
||||||
async downloader(inst) {
|
downloader: (inst) => downloaderClientFor(ctx, inst),
|
||||||
const stamp = `${inst.updated_at.toISOString()}|${inst.base_url}`;
|
|
||||||
const hit = clients.get(inst.id);
|
|
||||||
if (hit && hit.stamp === stamp) return hit.client;
|
|
||||||
const settings = (inst.settings ?? {}) as Record<string, unknown>;
|
|
||||||
const secret = await resolveSecret(inst.secret_ref);
|
|
||||||
const client = createQbClient({
|
|
||||||
instanceId: inst.id,
|
|
||||||
baseUrl: inst.base_url,
|
|
||||||
...qbAuthConfig({ authKind: settings.authKind, username: inst.username, secret: secret ?? null }),
|
|
||||||
});
|
|
||||||
clients.set(inst.id, { stamp, client });
|
|
||||||
return client;
|
|
||||||
},
|
|
||||||
async torrentSource({ release, site }) {
|
async torrentSource({ release, site }) {
|
||||||
// 永久性条件用 SiteError:submitDownload 据此把计划判失败并释放 Slot,而不是反复重试、Slot 一直 reserved
|
// 永久性条件用 SiteError:submitDownload 据此把计划判失败并释放 Slot,而不是反复重试、Slot 一直 reserved
|
||||||
if (!site) throw new SiteError("", "not_found", "站点不存在,无法获取种子");
|
if (!site) throw new SiteError("", "not_found", "站点不存在,无法获取种子");
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function instanceLive(d: AoDeps, inst: DownloaderInstanceRow): Promise<In
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 还没提交到下载器、在等条件的计划(DL-3):草案且有待批准的审批(等批准);已预留但还没有下载任务
|
* 还没提交到下载器、在等条件的计划(DL-3):草案且有待批准的审批(等批准);已预留但还没有下载任务
|
||||||
* (等下载盘空间 / 自动化开启 / 提交任务执行)。没有审批的草案(智能体只生成没提交)不算在等。
|
* (等下载盘空间 / 下载目录读不到容量 / 自动化开启 / 提交任务执行)。没有审批的草案(智能体只生成没提交)不算在等。
|
||||||
*/
|
*/
|
||||||
export function waitingPlansQuery(db: Exec, instanceId?: string) {
|
export function waitingPlansQuery(db: Exec, instanceId?: string) {
|
||||||
return db
|
return db
|
||||||
@@ -161,7 +161,9 @@ export function waitingPlansQuery(db: Exec, instanceId?: string) {
|
|||||||
export function planWaitOf(status: string, deferred: string | null): PlanWait {
|
export function planWaitOf(status: string, deferred: string | null): PlanWait {
|
||||||
if (status === "draft") return "approval";
|
if (status === "draft") return "approval";
|
||||||
if (!deferred) return "queued";
|
if (!deferred) return "queued";
|
||||||
if (deferred.startsWith("cache:") || deferred === "volume_state_unknown") return "space";
|
// 下载目录读不到容量是配置问题(路径映射 / 挂载),不是空间不够
|
||||||
|
if (deferred === "volume_state_unknown" || deferred === "cache:volume_state_unknown") return "volume_unknown";
|
||||||
|
if (deferred.startsWith("cache:")) return "space";
|
||||||
if (deferred === "automation_disabled" || deferred === "read_only_reconcile") return "automation_off";
|
if (deferred === "automation_disabled" || deferred === "read_only_reconcile") return "automation_off";
|
||||||
return "queued";
|
return "queued";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
assertSafeToDeleteData,
|
assertSafeToDeleteData,
|
||||||
type DownloaderClient,
|
type DownloaderClient,
|
||||||
DownloaderError,
|
DownloaderError,
|
||||||
|
type QbFile,
|
||||||
type QbTorrent,
|
type QbTorrent,
|
||||||
SiteError,
|
SiteError,
|
||||||
type TorrentSource,
|
type TorrentSource,
|
||||||
@@ -268,6 +269,8 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
|
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
|
||||||
const client = await d.ports.downloader(topo.inst);
|
const client = await d.ports.downloader(topo.inst);
|
||||||
const tag = topo.inst.managed_tag;
|
const tag = topo.inst.managed_tag;
|
||||||
|
// 季包 / 多集包要按缺集选文件:先不让它开始下载(对照 MP QK1),选完文件再恢复
|
||||||
|
const pack = needsPackSelection(plan, rel);
|
||||||
// 评估时定下的保存路径(订阅 downloadSavePath 经路径映射后的下载器侧路径),没有用实例默认
|
// 评估时定下的保存路径(订阅 downloadSavePath 经路径映射后的下载器侧路径),没有用实例默认
|
||||||
const savePath = ev.savePath || topo.savePath;
|
const savePath = ev.savePath || topo.savePath;
|
||||||
|
|
||||||
@@ -280,6 +283,10 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
});
|
});
|
||||||
let dtId: string | undefined;
|
let dtId: string | undefined;
|
||||||
let inUseBy: string | undefined;
|
let inUseBy: string | undefined;
|
||||||
|
/** commit 时计划已不是 reserved(提交期间被取消等):不落任务行,事后撤销 qB 里的任务 */
|
||||||
|
let lostPlan: string | undefined;
|
||||||
|
/** 已存在的任务原本没有管理标签、是本次补上的 */
|
||||||
|
let tagAdded = false;
|
||||||
const result = await withAttemptToken<SubmitResult>(
|
const result = await withAttemptToken<SubmitResult>(
|
||||||
db,
|
db,
|
||||||
`submit:${plan.id}`,
|
`submit:${plan.id}`,
|
||||||
@@ -304,6 +311,8 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
savePath,
|
savePath,
|
||||||
tags: [tag],
|
tags: [tag],
|
||||||
...(topo.category ? { category: topo.category } : {}),
|
...(topo.category ? { category: topo.category } : {}),
|
||||||
|
// 磁力暂停添加时 qB 不取元数据:改用「拿到元数据后停止」;.torrent / URL 直接暂停添加
|
||||||
|
...(pack ? (source.kind === "magnet" ? { stopCondition: "MetadataReceived" as const } : { paused: true }) : {}),
|
||||||
});
|
});
|
||||||
switch (out.kind) {
|
switch (out.kind) {
|
||||||
case "added": {
|
case "added": {
|
||||||
@@ -314,7 +323,10 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
}
|
}
|
||||||
case "already_exists": {
|
case "already_exists": {
|
||||||
// 原本已存在:接管,只补管理标签,不改保存路径/其它标签/文件选择
|
// 原本已存在:接管,只补管理标签,不改保存路径/其它标签/文件选择
|
||||||
if (!out.existing.tags.includes(tag)) await client.addTags([out.hash], [tag]);
|
if (!out.existing.tags.includes(tag)) {
|
||||||
|
await client.addTags([out.hash], [tag]);
|
||||||
|
tagAdded = true;
|
||||||
|
}
|
||||||
return { kind: "already_exists", ...view(out.existing), tags: [...new Set([...out.existing.tags, tag])] };
|
return { kind: "already_exists", ...view(out.existing), tags: [...new Set([...out.existing.tags, tag])] };
|
||||||
}
|
}
|
||||||
case "accepted_unconfirmed":
|
case "accepted_unconfirmed":
|
||||||
@@ -336,6 +348,17 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
{
|
{
|
||||||
...(opts.runId ? { runId: opts.runId } : {}),
|
...(opts.runId ? { runId: opts.runId } : {}),
|
||||||
commit: async (trx, res) => {
|
commit: async (trx, res) => {
|
||||||
|
// 复核计划仍是 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") {
|
if (res.kind === "rejected" || res.kind === "source_failed") {
|
||||||
const r = res;
|
const r = res;
|
||||||
await failPlanTx(d, trx, plan.id, plan.slot_id, `${r.kind}: ${r.reason}`);
|
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,
|
tags: r.tags,
|
||||||
managed: true,
|
managed: true,
|
||||||
origin: r.kind === "already_exists" ? "adopted" : "submitted",
|
origin: r.kind === "already_exists" ? "adopted" : "submitted",
|
||||||
status: r.kind === "accepted_unconfirmed" ? "submitting" : "queued",
|
status:
|
||||||
pause_reason: null,
|
r.kind === "accepted_unconfirmed" ? "submitting" : pack && r.kind === "added" ? "paused" : "queued",
|
||||||
|
pause_reason: pack && r.kind === "added" ? "policy" : null,
|
||||||
removed_at: null,
|
removed_at: null,
|
||||||
completed_at: null,
|
completed_at: null,
|
||||||
no_progress_since: 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 }>`
|
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,
|
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,
|
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},
|
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),
|
${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.name}, ${r.savePath}, ${r.contentPath}, ${topo.category},
|
||||||
${r.tags}, true, ${r.kind === "already_exists" ? "adopted" : "submitted"},
|
${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
|
on conflict (instance_id, torrent_hash) do update
|
||||||
set acquisition_plan_id = coalesce(download_task.acquisition_plan_id, excluded.acquisition_plan_id),
|
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),
|
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);
|
returning id, (xmax = 0) as inserted`.execute(trx);
|
||||||
dtId = ins.rows[0]!.id;
|
dtId = ins.rows[0]!.id;
|
||||||
}
|
}
|
||||||
|
// 暂停添加的季包:选文件挂起,选完(或元数据超时整包)由提交后续步骤 / 同步恢复下载
|
||||||
|
const pendingSel = pack && r.kind !== "already_exists";
|
||||||
await trx
|
await trx
|
||||||
.updateTable("acquisition_plan")
|
.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)
|
.where("id", "=", plan.id)
|
||||||
.execute();
|
.execute();
|
||||||
await applySlotEvent(
|
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 (result.kind === "rejected" || result.kind === "source_failed") return { status: "failed", reason: result.reason };
|
||||||
if (inUseBy) return { status: "skipped", reason: "torrent_in_use_by_plan" };
|
if (inUseBy) return { status: "skipped", reason: "torrent_in_use_by_plan" };
|
||||||
const hash = (result as Extract<SubmitResult, { hash: string }>).hash;
|
const hash = (result as Extract<SubmitResult, { hash: string }>).hash;
|
||||||
// 季包/多集包:按缺集选文件(接管的已有任务不改文件选择);失败不影响提交结果
|
// 季包/多集包:按缺集选文件再恢复下载(接管的已有任务不改文件选择);文件清单还拿不到(磁力元数据未就绪)
|
||||||
if (result.kind === "added" && plan.unit_keys.length > 0 && plan.file_selection === null) {
|
// 时留给同步重试,失败不影响提交结果
|
||||||
await selectPackFiles(d, client, hash, plan, rel).catch((err) =>
|
if (pack && result.kind !== "already_exists") {
|
||||||
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
|
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 (result.kind !== "accepted_unconfirmed") await afterDownloadAccepted(d, plan.id);
|
||||||
if (!dtId) {
|
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;
|
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<AcquisitionPlanRow, "file_selection">) =>
|
||||||
|
(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<boolean> {
|
||||||
|
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<string, unknown>, 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<string, unknown>; 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<string>) {
|
||||||
|
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<string>`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(
|
async function selectPackFiles(
|
||||||
d: AoDeps,
|
d: AoDeps,
|
||||||
@@ -507,20 +683,12 @@ async function selectPackFiles(
|
|||||||
hash: string,
|
hash: string,
|
||||||
plan: AcquisitionPlanRow,
|
plan: AcquisitionPlanRow,
|
||||||
rel: SourceReleaseRow,
|
rel: SourceReleaseRow,
|
||||||
): Promise<void> {
|
files: QbFile[],
|
||||||
if (!plan.slot_id) return;
|
): Promise<{ selection: Record<string, unknown>; unitKeys?: string[] }> {
|
||||||
const parsed = parsedOf(rel);
|
const parsed = parsedOf(rel);
|
||||||
const isPack = !parsed.episodes && !parsed.airDate;
|
const whole = (selection: Record<string, unknown>) => ({ selection: { mode: "whole", ...selection } });
|
||||||
const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start;
|
const slotId = plan.slot_id;
|
||||||
if (!isPack && !multi) return;
|
if (!slotId) return whole({ reason: "no_slot" });
|
||||||
const record = (sel: Record<string, unknown>, 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 wildSeasons = new Set(
|
const wildSeasons = new Set(
|
||||||
@@ -535,7 +703,7 @@ async function selectPackFiles(
|
|||||||
const slot = await d.ctx.db
|
const slot = await d.ctx.db
|
||||||
.selectFrom("acquisition_slot")
|
.selectFrom("acquisition_slot")
|
||||||
.select(["id", "current_asset_id", "media_entity_id"])
|
.select(["id", "current_asset_id", "media_entity_id"])
|
||||||
.where("id", "=", plan.slot_id)
|
.where("id", "=", slotId)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
const slots = await d.ctx.db
|
const slots = await d.ctx.db
|
||||||
.selectFrom("acquisition_slot")
|
.selectFrom("acquisition_slot")
|
||||||
@@ -543,7 +711,7 @@ async function selectPackFiles(
|
|||||||
.where("media_entity_id", "=", slot.media_entity_id)
|
.where("media_entity_id", "=", slot.media_entity_id)
|
||||||
.execute();
|
.execute();
|
||||||
const covered = await slotCoveredUnits(d.ctx.db, slot, { entityWide: slots.length <= 1 });
|
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)]);
|
blocked = new Set([...covered.keys, ...others.flatMap((o) => o.unitKeys)]);
|
||||||
}
|
}
|
||||||
const wantedKey = (k: string) => {
|
const wantedKey = (k: string) => {
|
||||||
@@ -588,13 +756,12 @@ async function selectPackFiles(
|
|||||||
const videos = rows.filter((r) => r.kind === "video");
|
const videos = rows.filter((r) => r.kind === "video");
|
||||||
const unmapped = videos.filter((r) => r.keys.length === 0 && !r.special);
|
const unmapped = videos.filter((r) => r.keys.length === 0 && !r.special);
|
||||||
if (videos.length === 0 || unmapped.length > 0)
|
if (videos.length === 0 || unmapped.length > 0)
|
||||||
return void (await record({
|
return whole({
|
||||||
mode: "whole",
|
|
||||||
reason: videos.length === 0 ? "no_video_files" : "unmapped_video_file",
|
reason: videos.length === 0 ? "no_video_files" : "unmapped_video_file",
|
||||||
files: unmapped.slice(0, 20).map((r) => r.name),
|
files: unmapped.slice(0, 20).map((r) => r.name),
|
||||||
}));
|
});
|
||||||
const selected = [...new Set(videos.filter((r) => r.wanted).flatMap((r) => r.keys.filter(wantedKey)))];
|
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));
|
const skip = rows.filter((r) => (r.kind === "video" ? !r.wanted : r.keys.length > 0 && !r.wanted));
|
||||||
if (skip.length > 0)
|
if (skip.length > 0)
|
||||||
@@ -603,15 +770,15 @@ async function selectPackFiles(
|
|||||||
skip.map((r) => r.index),
|
skip.map((r) => r.index),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await record(
|
return {
|
||||||
{
|
selection: {
|
||||||
mode: skip.length > 0 ? "selected" : "all_needed",
|
mode: skip.length > 0 ? "selected" : "all_needed",
|
||||||
selectedUnits: selected,
|
selectedUnits: selected,
|
||||||
skippedFiles: skip.slice(0, 200).map((r) => r.name),
|
skippedFiles: skip.slice(0, 200).map((r) => r.name),
|
||||||
totalFiles: files.length,
|
totalFiles: files.length,
|
||||||
},
|
},
|
||||||
selected,
|
unitKeys: selected,
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string | null, reason: string) {
|
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string | null, reason: string) {
|
||||||
@@ -716,8 +883,16 @@ export interface SyncStats {
|
|||||||
removed: number;
|
removed: number;
|
||||||
thresholdPaused: number;
|
thresholdPaused: number;
|
||||||
thresholdResumed: 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 同口径 */
|
/** 连续多少轮同步(列表 + 按 hash 查)都查不到才判 removed;与资产对账的 missingConfirmAfter 同口径 */
|
||||||
export const MISSING_CONFIRM_ROUNDS = 3;
|
export const MISSING_CONFIRM_ROUNDS = 3;
|
||||||
|
|
||||||
@@ -731,23 +906,44 @@ export async function syncDownloads(d: AoDeps): Promise<SyncStats> {
|
|||||||
removed: 0,
|
removed: 0,
|
||||||
thresholdPaused: 0,
|
thresholdPaused: 0,
|
||||||
thresholdResumed: 0,
|
thresholdResumed: 0,
|
||||||
|
confirmedPlans: [],
|
||||||
};
|
};
|
||||||
const insts = await loadInstances(d.ctx.db);
|
const insts = await loadInstances(d.ctx.db);
|
||||||
for (const topo of insts) {
|
for (const topo of insts) {
|
||||||
stats.instances++;
|
stats.instances++;
|
||||||
await syncInstance(d, topo, stats);
|
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);
|
await enforceThresholds(d, insts, stats);
|
||||||
return 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 db = d.ctx.db;
|
||||||
const now = d.ports.now();
|
const now = d.ports.now();
|
||||||
const client = await d.ports.downloader(topo.inst);
|
|
||||||
const sourceTags = await qbSourceTags(d, topo);
|
|
||||||
let list: QbTorrent[];
|
let list: QbTorrent[];
|
||||||
|
let client: DownloaderClient;
|
||||||
try {
|
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 tags = [...new Set([topo.inst.managed_tag, ...sourceTags.map((s) => s.config.tag!)])];
|
||||||
const batches = await Promise.all(tags.map((tag) => client.list({ tag })));
|
const batches = await Promise.all(tags.map((tag) => client.list({ tag })));
|
||||||
list = [...new Map(batches.flat().map((t) => [t.hash, t])).values()].filter(
|
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) {
|
} catch (err) {
|
||||||
// 下载器失联:不改任何任务状态(失败 ≠ 不存在)
|
// 下载器失联:不改任何任务状态(失败 ≠ 不存在)
|
||||||
stats.unreachable.push(topo.inst.id);
|
|
||||||
await db
|
await db
|
||||||
.updateTable("downloader_instance")
|
.updateTable("downloader_instance")
|
||||||
.set({ health_status: "offline" })
|
.set({ health_status: "offline" })
|
||||||
.where("id", "=", topo.inst.id)
|
.where("id", "=", topo.inst.id)
|
||||||
.execute();
|
.execute();
|
||||||
d.ctx.logger.warn({ instanceId: topo.inst.id, err: { message: errMessage(err) } }, "qB 同步失败");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
@@ -769,6 +967,7 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
|
|||||||
.set({ health_status: "healthy", last_seen_at: now })
|
.set({ health_status: "healthy", last_seen_at: now })
|
||||||
.where("id", "=", topo.inst.id)
|
.where("id", "=", topo.inst.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
await closeRecoveredDecision(d, db, `downloader_offline:${topo.inst.id}`, "下载器已恢复连接");
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.selectFrom("download_task")
|
.selectFrom("download_task")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -783,10 +982,11 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
|
|||||||
const row = byHash.get(t.hash);
|
const row = byHash.get(t.hash);
|
||||||
if (row) {
|
if (row) {
|
||||||
if (await observe(d, row, t, now, stats)) stats.updated++;
|
if (await observe(d, row, t, now, stats)) stats.updated++;
|
||||||
} else {
|
} else if (!opts.observeOnly) {
|
||||||
await adopt(d, topo, t, now, stats);
|
await adopt(d, topo, t, now, stats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await settlePendingPacks(d, client, topo, seen);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (seen.has(row.torrent_hash) || !row.managed) continue;
|
if (seen.has(row.torrent_hash) || !row.managed) continue;
|
||||||
let still: QbTorrent | null;
|
let still: QbTorrent | null;
|
||||||
@@ -920,9 +1120,21 @@ async function observeLocked(
|
|||||||
);
|
);
|
||||||
// 卡住 / 异常后恢复正常(进度增长或状态恢复):关闭同一任务未关闭的无进展 / 异常事项
|
// 卡住 / 异常后恢复正常(进度增长或状态恢复):关闭同一任务未关闭的无进展 / 异常事项
|
||||||
if (changed && RECOVERED.has(merged.status)) {
|
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")
|
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) {
|
if (becameComplete) {
|
||||||
stats.completed.push(row.id);
|
stats.completed.push(row.id);
|
||||||
@@ -974,7 +1186,7 @@ const RECOVERED = new Set<DownloadStatus>(["queued", "downloading", "completed",
|
|||||||
* 卡住 / 异常事项自动关闭:只关 open / awaiting_user;Agent 正在调查(investigating)的由它自己核实后关闭。
|
* 卡住 / 异常事项自动关闭:只关 open / awaiting_user;Agent 正在调查(investigating)的由它自己核实后关闭。
|
||||||
* resolution 不带处理选项,executeResolution 不会再执行任何动作。
|
* 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 by = "system:download-sync";
|
||||||
const rows = await trx
|
const rows = await trx
|
||||||
.updateTable("decision")
|
.updateTable("decision")
|
||||||
@@ -982,7 +1194,7 @@ async function closeRecoveredDecision(d: AoDeps, trx: Exec, dedupeKey: string, s
|
|||||||
status: "resolved",
|
status: "resolved",
|
||||||
resolved_by: by,
|
resolved_by: by,
|
||||||
resolved_at: d.ports.now(),
|
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("dedupe_key", "=", dedupeKey)
|
||||||
.where("status", "in", ["open", "awaiting_user"])
|
.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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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) {
|
async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, stats: SyncStats) {
|
||||||
const db = d.ctx.db;
|
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) {
|
async function enforceThresholds(d: AoDeps, insts: InstanceTopology[], stats: SyncStats) {
|
||||||
if (insts.length === 0) return;
|
if (insts.length === 0) return;
|
||||||
|
const vols = await loadVolumes(d, d.ctx.db, insts);
|
||||||
|
await reportVolumeStates(d, vols);
|
||||||
// 只读对账模式禁止自动写入(含对 qB 的暂停/恢复)
|
// 只读对账模式禁止自动写入(含对 qB 的暂停/恢复)
|
||||||
if ((await d.ctx.settings.get("automation")).readOnlyReconcile) return;
|
if ((await d.ctx.settings.get("automation")).readOnlyReconcile) return;
|
||||||
const cache = await d.ctx.settings.get("cache");
|
const cache = await d.ctx.settings.get("cache");
|
||||||
const vols = await loadVolumes(d, d.ctx.db, insts);
|
|
||||||
for (const v of vols) {
|
for (const v of vols) {
|
||||||
const dv = decideVolume(v, cache);
|
const dv = decideVolume(v, cache);
|
||||||
if (dv.decision === "pause_managed" && dv.pauseTaskIds.length > 0) {
|
// 排队中(queuedDL)的纳管任务一并暂停:否则其它任务被暂停后 qB 的队列会把它拉起来
|
||||||
const n = await pauseForThreshold(d, v, dv.pauseTaskIds);
|
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;
|
stats.thresholdPaused += n;
|
||||||
await d.ctx.outbox.emit(d.ctx.db, "download.threshold", {
|
await d.ctx.outbox.emit(d.ctx.db, "download.threshold", {
|
||||||
volumeKey: v.volumeKey,
|
volumeKey: v.volumeKey,
|
||||||
state: "tripped",
|
state: "tripped",
|
||||||
usedPercent: dv.usedPct === undefined ? null : Math.min(100, dv.usedPct),
|
usedPercent: dv.usedPct === undefined ? null : Math.min(100, dv.usedPct),
|
||||||
pausedTaskIds: dv.pauseTaskIds,
|
pausedTaskIds: pauseIds,
|
||||||
});
|
});
|
||||||
} else if (dv.resumeThresholdPaused) {
|
} else if (dv.resumeThresholdPaused) {
|
||||||
const ids = await resumeThresholdPaused(d, v);
|
const ids = await resumeThresholdPaused(d, v);
|
||||||
@@ -1272,8 +1571,9 @@ export async function cleanupCandidates(d: AoDeps, v: VolumeState): Promise<Clea
|
|||||||
const site = rel
|
const site = rel
|
||||||
? await db.selectFrom("site").select("config").where("id", "=", rel.site_id).executeTakeFirst()
|
? await db.selectFrom("site").select("config").where("id", "=", rel.site_id).executeTakeFirst()
|
||||||
: null;
|
: null;
|
||||||
const days = instancePolicy(topo.inst).seedRetentionDays ?? retention.seedRetentionDays;
|
const policy = instancePolicy(topo.inst);
|
||||||
const lock = await lockReason(d, row, rel ?? null, site ?? null, days, now);
|
const days = policy.seedRetentionDays ?? retention.seedRetentionDays;
|
||||||
|
const lock = await lockReason(d, row, rel ?? null, site ?? null, days, now, policy.hnrUnknownCleanable === true);
|
||||||
out.push({ row, topo, bytes: row.size_bytes ?? 0, cleanable: lock === null, lockReason: lock });
|
out.push({ row, topo, bytes: row.size_bytes ?? 0, cleanable: lock === null, lockReason: lock });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1287,10 +1587,13 @@ async function lockReason(
|
|||||||
site: { config: unknown } | null,
|
site: { config: unknown } | null,
|
||||||
days: number,
|
days: number,
|
||||||
now: Date,
|
now: Date,
|
||||||
|
/** 下载器设置「H&R 未知也可清理」:保种天数到期后,H&R 要求未知按已满足处理 */
|
||||||
|
hnrUnknownCleanable = false,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!retentionElapsed(row, days, now)) return "retention_days";
|
if (!retentionElapsed(row, days, now)) return "retention_days";
|
||||||
const hnr = hnrSatisfied(row, rel as never, site as never);
|
const hnr = hnrSatisfied(row, rel as never, site as never);
|
||||||
if (hnr !== true) return hnr === false ? "hnr_unsatisfied" : "hnr_unknown";
|
if (hnr === false) return "hnr_unsatisfied";
|
||||||
|
if (hnr === "unknown" && !hnrUnknownCleanable) return "hnr_unknown";
|
||||||
// 已验证入库:最近一次整理记录成功,且没有失败/排队的文件(部分入库不能整种清理)
|
// 已验证入库:最近一次整理记录成功,且没有失败/排队的文件(部分入库不能整种清理)
|
||||||
const rec = await d.ctx.db
|
const rec = await d.ctx.db
|
||||||
.selectFrom("organization_record")
|
.selectFrom("organization_record")
|
||||||
@@ -1385,11 +1688,16 @@ export async function cleanupDownloadCache(
|
|||||||
if (pct > th.cleanupTargetPercent && pct >= th.hardThresholdPercent) {
|
if (pct > th.cleanupTargetPercent && pct >= th.hardThresholdPercent) {
|
||||||
entry.action = entry.removed.length > 0 ? "cleaned" : "insufficient";
|
entry.action = entry.removed.length > 0 ? "cleaned" : "insufficient";
|
||||||
// 没有可清理对象:待处理 + 阈值继续阻止新下载(不突破保留要求)
|
// 没有可清理对象:待处理 + 阈值继续阻止新下载(不突破保留要求)
|
||||||
|
const hnrUnknown = entry.skipped.filter((x) => x.reason === "hnr_unknown").length;
|
||||||
await upsertDecision(d, db, {
|
await upsertDecision(d, db, {
|
||||||
kind: "space_insufficient",
|
kind: "space_insufficient",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
title: `下载缓存卷 ${v.volumeKey} 空间不足`,
|
title: `下载缓存卷 ${v.volumeKey} 空间不足`,
|
||||||
summary: `使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停`,
|
summary:
|
||||||
|
`使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停` +
|
||||||
|
(hnrUnknown
|
||||||
|
? `。另有 ${hnrUnknown} 个任务因为 H&R 要求未知没有清理,确认没有 H&R 要求的话,可在下载器设置里打开「H&R 未知也可清理」`
|
||||||
|
: ""),
|
||||||
subjectType: "cache_volume",
|
subjectType: "cache_volume",
|
||||||
dedupeKey: `space_insufficient:${v.volumeKey}`,
|
dedupeKey: `space_insufficient:${v.volumeKey}`,
|
||||||
evidence: { usedPercent: pct, locked: entry.skipped },
|
evidence: { usedPercent: pct, locked: entry.skipped },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createQbClient, type DownloaderClient, qbAuthConfig } from "@umt/adapters";
|
|
||||||
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
||||||
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
|
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
|
||||||
import type { DownloaderInstanceRow } from "@umt/db";
|
import { downloaderClientFor } from "../acquisition-organize/ports.ts";
|
||||||
import type { ServerContext } from "../types.ts";
|
import type { ServerContext } from "../types.ts";
|
||||||
import { planBinding } from "./authorize.ts";
|
import { planBinding } from "./authorize.ts";
|
||||||
import type { AgentModuleOptions } from "./runner.ts";
|
import type { AgentModuleOptions } from "./runner.ts";
|
||||||
@@ -15,16 +14,6 @@ const RECHECK_FROM: DownloadStatus[] = ["missing", "failed", "stalled", "paused"
|
|||||||
/** resume 允许的起点 */
|
/** resume 允许的起点 */
|
||||||
const RESUME_FROM: DownloadStatus[] = ["paused", "stalled", "failed"];
|
const RESUME_FROM: DownloadStatus[] = ["paused", "stalled", "failed"];
|
||||||
|
|
||||||
async function defaultClient(ctx: ServerContext, inst: DownloaderInstanceRow): Promise<DownloaderClient> {
|
|
||||||
const settings = (inst.settings ?? {}) as Record<string, unknown>;
|
|
||||||
const secret = inst.secret_ref ? await ctx.secrets.resolve(inst.secret_ref) : undefined;
|
|
||||||
return createQbClient({
|
|
||||||
instanceId: inst.id,
|
|
||||||
baseUrl: inst.base_url,
|
|
||||||
...qbAuthConfig({ authKind: settings.authKind, username: inst.username, secret: secret ?? null }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 持续授权的对象范围:按下载任务 → 计划 → Slot → 作品核对(authorize 对非计划类能力拿不到目标作品) */
|
/** 持续授权的对象范围:按下载任务 → 计划 → Slot → 作品核对(authorize 对非计划类能力拿不到目标作品) */
|
||||||
async function checkGrantScope(ctx: ServerContext, grantId: string, planId: string | null) {
|
async function checkGrantScope(ctx: ServerContext, grantId: string, planId: string | null) {
|
||||||
const g = await ctx.db.selectFrom("automation_grant").select("scope").where("id", "=", grantId).executeTakeFirst();
|
const g = await ctx.db.selectFrom("automation_grant").select("scope").where("id", "=", grantId).executeTakeFirst();
|
||||||
@@ -75,7 +64,8 @@ export function registerDownloadRecoveryCapabilities(
|
|||||||
.where("id", "=", dt.instance_id)
|
.where("id", "=", dt.instance_id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!inst) throw new CapabilityError("not_found", "下载器实例不存在");
|
if (!inst) throw new CapabilityError("not_found", "下载器实例不存在");
|
||||||
const client = opts.downloader ? await opts.downloader(inst) : await defaultClient(ctx, inst);
|
// 与下载同步共用按实例缓存的客户端(同一登录会话与凭证处理),不另建
|
||||||
|
const client = opts.downloader ? await opts.downloader(inst) : await downloaderClientFor(ctx, inst);
|
||||||
try {
|
try {
|
||||||
if (input.action === "recheck") await client.recheck([dt.torrent_hash]);
|
if (input.action === "recheck") await client.recheck([dt.torrent_hash]);
|
||||||
else await client.resume([dt.torrent_hash]);
|
else await client.resume([dt.torrent_hash]);
|
||||||
|
|||||||
@@ -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<string, { kind: "file"; bytes: Uint8Array } | { kind: "magnet"; uri: string }>();
|
||||||
|
/** releaseId → 取种子时执行的钩子(模拟提交过程中发生的事) */
|
||||||
|
const onSource = new Map<string, () => Promise<void>>();
|
||||||
|
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,7 +64,7 @@ describe("humanizeReport(汇报兜底)", () => {
|
|||||||
names,
|
names,
|
||||||
);
|
);
|
||||||
expect(r.text).toBe(
|
expect(r.text).toBe(
|
||||||
"检测站点 被拒;查看下载任务 无法调用(返回 参数不对);准备下载 返回 plan=null,拒绝原因 下载盘用量未知",
|
"检测站点 被拒;查看下载任务 无法调用(返回 参数不对);准备下载 返回 plan=null,拒绝原因 下载目录读不到容量",
|
||||||
);
|
);
|
||||||
expect(r.text).not.toMatch(/site\.health_check|invalid_input|volume_state_unknown/);
|
expect(r.text).not.toMatch(/site\.health_check|invalid_input|volume_state_unknown/);
|
||||||
expect(humanizeReport("被规则拒绝(unauthorized),未重试", names).text).toBe("被规则拒绝(需要你批准),未重试");
|
expect(humanizeReport("被规则拒绝(unauthorized),未重试", names).text).toBe("被规则拒绝(需要你批准),未重试");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const PURPOSE_LABEL: Record<PlanPurpose, string> = { fill: "补缺", upgr
|
|||||||
export const PLAN_WAIT: Record<PlanWait, { label: string; tone: "warning" | "neutral" }> = {
|
export const PLAN_WAIT: Record<PlanWait, { label: string; tone: "warning" | "neutral" }> = {
|
||||||
approval: { label: "等你批准", tone: "warning" },
|
approval: { label: "等你批准", tone: "warning" },
|
||||||
space: { label: "等磁盘空间", tone: "warning" },
|
space: { label: "等磁盘空间", tone: "warning" },
|
||||||
|
volume_unknown: { label: "下载目录读不到", tone: "warning" },
|
||||||
automation_off: { label: "等开启自动化", tone: "warning" },
|
automation_off: { label: "等开启自动化", tone: "warning" },
|
||||||
queued: { label: "准备提交", tone: "neutral" },
|
queued: { label: "准备提交", tone: "neutral" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
|
|||||||
approval_required: "需要审批",
|
approval_required: "需要审批",
|
||||||
metadata_conflict: "元数据冲突",
|
metadata_conflict: "元数据冲突",
|
||||||
source_unreachable: "来源不可达",
|
source_unreachable: "来源不可达",
|
||||||
|
downloader_offline: "下载器连不上",
|
||||||
|
cache_volume_unknown: "下载目录读不到容量",
|
||||||
other: "其他",
|
other: "其他",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -97,16 +97,20 @@ const POLICY_FIELDS = [
|
|||||||
["targetPct", "cleanupTargetPercent"],
|
["targetPct", "cleanupTargetPercent"],
|
||||||
["seedDays", "seedRetentionDays"],
|
["seedDays", "seedRetentionDays"],
|
||||||
] as const;
|
] as const;
|
||||||
function policyForm(r: DownloaderInstance): Record<string, string> {
|
function policyForm(r: DownloaderInstance): Record<string, string | boolean> {
|
||||||
const p = ((r.settings as { policy?: Record<string, unknown> }).policy ?? {}) as Record<string, unknown>;
|
const p = ((r.settings as { policy?: Record<string, unknown> }).policy ?? {}) as Record<string, unknown>;
|
||||||
return Object.fromEntries(POLICY_FIELDS.map(([f, k]) => [f, typeof p[k] === "number" ? String(p[k]) : ""]));
|
return {
|
||||||
|
...Object.fromEntries(POLICY_FIELDS.map(([f, k]) => [f, typeof p[k] === "number" ? String(p[k]) : ""])),
|
||||||
|
hnrUnknownCleanable: p.hnrUnknownCleanable === true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function policyBody(v: FormValues): Record<string, number> {
|
function policyBody(v: FormValues): Record<string, number | boolean> {
|
||||||
const out: Record<string, number> = {};
|
const out: Record<string, number | boolean> = {};
|
||||||
for (const [f, k] of POLICY_FIELDS) {
|
for (const [f, k] of POLICY_FIELDS) {
|
||||||
const raw = String(v[f] ?? "").trim();
|
const raw = String(v[f] ?? "").trim();
|
||||||
if (raw !== "" && Number.isFinite(Number(raw))) out[k] = Number(raw);
|
if (raw !== "" && Number.isFinite(Number(raw))) out[k] = Number(raw);
|
||||||
}
|
}
|
||||||
|
if (v.hnrUnknownCleanable === true) out.hnrUnknownCleanable = true;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +206,12 @@ function DownloadersTable() {
|
|||||||
placeholder: "留空用全局",
|
placeholder: "留空用全局",
|
||||||
hint: "入库后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
|
hint: "入库后至少保留几天再允许清理;公网 BT 可填 0(站点 H&R 要求仍然遵守)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "hnrUnknownCleanable",
|
||||||
|
label: "H&R 未知也可清理",
|
||||||
|
kind: "switch",
|
||||||
|
hint: "保种天数到期后,没给出 H&R 要求的任务(如 M-Team、接管的任务、站点没配 H&R)也允许清理;默认不清理",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "isDefault",
|
name: "isDefault",
|
||||||
label: "设为默认下载器",
|
label: "设为默认下载器",
|
||||||
@@ -223,6 +233,7 @@ function DownloadersTable() {
|
|||||||
resumePct: "",
|
resumePct: "",
|
||||||
targetPct: "",
|
targetPct: "",
|
||||||
seedDays: "",
|
seedDays: "",
|
||||||
|
hnrUnknownCleanable: false,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const DECISION_KIND: Record<string, string> = {
|
|||||||
approval_required: "待审批",
|
approval_required: "待审批",
|
||||||
metadata_conflict: "元数据冲突",
|
metadata_conflict: "元数据冲突",
|
||||||
source_unreachable: "来源不可达",
|
source_unreachable: "来源不可达",
|
||||||
|
downloader_offline: "下载器连不上",
|
||||||
|
cache_volume_unknown: "下载目录读不到容量",
|
||||||
other: "其它",
|
other: "其它",
|
||||||
};
|
};
|
||||||
export const decisionKindLabel = (v: string) => DECISION_KIND[v] ?? v;
|
export const decisionKindLabel = (v: string) => DECISION_KIND[v] ?? v;
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
| `sites.snapshot` | 站点快照 | 采集 | 30 秒 | 扫描到周期的站点,入队 `snapshotSite` |
|
| `sites.snapshot` | 站点快照 | 采集 | 30 秒 | 扫描到周期的站点,入队 `snapshotSite` |
|
||||||
| `identity.review` | 待识别复查 | 采集 | 30 秒 | 对 `next_review_at` 已到的条目入队 `resolveObservation` |
|
| `identity.review` | 待识别复查 | 采集 | 30 秒 | 对 `next_review_at` 已到的条目入队 `resolveObservation` |
|
||||||
| `subscriptions.search` | 订阅周期搜索 | 采集 | 同 qB 同步 | `sweepSubscriptionSearch` |
|
| `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` |
|
| `downloads.orchestrate` | 补提交与补入库 | 下载 | 同上 | `sweepReservedPlans` 和补入队 `verifyAndImport` |
|
||||||
| `slots.reevaluate` | 到期重评 | 下载 | 同上 | 处理 `next_evaluate_at` 已到的收录位 |
|
| `slots.reevaluate` | 到期重评 | 下载 | 同上 | 处理 `next_evaluate_at` 已到的收录位 |
|
||||||
| `subscriptions.metadata` | 总集数刷新 | 整理 | 同上 | `sweepMetadataRefresh` |
|
| `subscriptions.metadata` | 总集数刷新 | 整理 | 同上 | `sweepMetadataRefresh` |
|
||||||
| `library.reconcile` | 资产对账 | 整理 | 6 小时 | 入队 `reconcileLibrary`,幂等键是 `auto:<时间槽>` |
|
| `library.reconcile` | 资产对账 | 整理 | 6 小时 | 入队 `reconcileLibrary`,幂等键是 `auto:<时间槽>` |
|
||||||
| `cache.threshold` | 缓存阈值检查 | 维护 | 同 qB 同步 | 超过硬阈值时入队 `cleanupDownloadCache` |
|
| `cache.threshold` | 缓存阈值检查 | 维护 | 同 qB 同步 | 超过硬阈值时入队 `cleanupDownloadCache`;读不到容量的下载目录不当 0%,摘要里写明并开待处理。H&R 要求未知的任务缺省不清理,下载器设置打开「H&R 未知也可清理」后保种天数到期即可清理 |
|
||||||
| `trash.purge` | 回收站清理 | 维护 | 24 小时 | 入队 `purgeTrash` |
|
| `trash.purge` | 回收站清理 | 维护 | 24 小时 | 入队 `purgeTrash` |
|
||||||
| `pi.token_refresh` | 上游登录刷新 | 维护 | 10 分钟 | 入队 `refreshPiTokens`,幂等键 `refreshPiTokens:<时间槽>`;对 30 分钟内过期的订阅登录上游刷新 token |
|
| `pi.token_refresh` | 上游登录刷新 | 维护 | 10 分钟 | 入队 `refreshPiTokens`,幂等键 `refreshPiTokens:<时间槽>`;对 30 分钟内过期的订阅登录上游刷新 token |
|
||||||
| `auth.cleanup` | 登录会话清理 | 维护 | 1 小时 | 直接执行 `cleanupAuthTables`:删 7 天前的登录尝试、30 天前过期或吊销的会话 |
|
| `auth.cleanup` | 登录会话清理 | 维护 | 1 小时 | 直接执行 `cleanupAuthTables`:删 7 天前的登录尝试、30 天前过期或吊销的会话 |
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export interface AddTorrentRequest {
|
|||||||
category?: string;
|
category?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
|
/**
|
||||||
|
* qB ≥ 4.5 的停止条件:MetadataReceived = 磁力拿到元数据后自动暂停(暂停添加的磁力不会去取元数据);
|
||||||
|
* 旧版本忽略该参数、照常开始下载
|
||||||
|
*/
|
||||||
|
stopCondition?: "MetadataReceived" | "FilesChecked";
|
||||||
rename?: string;
|
rename?: string;
|
||||||
contentLayout?: "Original" | "Subfolder" | "NoSubfolder";
|
contentLayout?: "Original" | "Subfolder" | "NoSubfolder";
|
||||||
sequential?: boolean;
|
sequential?: boolean;
|
||||||
@@ -516,6 +521,7 @@ export class QbClient implements DownloaderClient {
|
|||||||
const paused = req.paused ? "true" : "false";
|
const paused = req.paused ? "true" : "false";
|
||||||
form.append("paused", paused); // <= 4.x
|
form.append("paused", paused); // <= 4.x
|
||||||
form.append("stopped", paused); // >= 5.x
|
form.append("stopped", paused); // >= 5.x
|
||||||
|
if (req.stopCondition) form.append("stopCondition", req.stopCondition);
|
||||||
if (req.rename) form.append("rename", req.rename);
|
if (req.rename) form.append("rename", req.rename);
|
||||||
form.append("contentLayout", req.contentLayout ?? "Original");
|
form.append("contentLayout", req.contentLayout ?? "Original");
|
||||||
form.append("autoTMM", "false");
|
form.append("autoTMM", "false");
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ export const DecisionKind = z.enum([
|
|||||||
"identity_unresolved",
|
"identity_unresolved",
|
||||||
/** 资源已绑定作品但集数待确认(无法换算 / 映射规则失效):按作品 + 来源季 + 发布组合并,附建议规则 */
|
/** 资源已绑定作品但集数待确认(无法换算 / 映射规则失效):按作品 + 来源季 + 发布组合并,附建议规则 */
|
||||||
"episode_mapping",
|
"episode_mapping",
|
||||||
|
/** 下载器连续不可达超过一段时间(按实例去重,恢复后自动关闭) */
|
||||||
|
"downloader_offline",
|
||||||
|
/** 下载目录读不到容量(路径映射 / 挂载问题,按卷去重,恢复后自动关闭) */
|
||||||
|
"cache_volume_unknown",
|
||||||
]);
|
]);
|
||||||
export type DecisionKind = z.infer<typeof DecisionKind>;
|
export type DecisionKind = z.infer<typeof DecisionKind>;
|
||||||
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
|
export const DecisionStatus = z.enum(["open", "investigating", "awaiting_user", "resolved", "dismissed", "expired"]);
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ export type DownloadBoardItem = z.infer<typeof DownloadBoardItem>;
|
|||||||
* automation_off = 自动化总开关关闭 / 只读对账;queued = 条件已满足、等提交任务执行。
|
* 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<typeof PlanWait>;
|
export type PlanWait = z.infer<typeof PlanWait>;
|
||||||
|
|
||||||
/** 还没提交到下载器的计划,作为「进行中」的一行(id 是计划 id) */
|
/** 还没提交到下载器的计划,作为「进行中」的一行(id 是计划 id) */
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export const DownloaderPolicy = z
|
|||||||
resumePercent: z.number().min(1).max(100).optional(),
|
resumePercent: z.number().min(1).max(100).optional(),
|
||||||
cleanupTargetPercent: z.number().min(1).max(100).optional(),
|
cleanupTargetPercent: z.number().min(1).max(100).optional(),
|
||||||
seedRetentionDays: z.number().int().min(0).max(365).optional(),
|
seedRetentionDays: z.number().int().min(0).max(365).optional(),
|
||||||
|
/** 保种天数到期、但站点 H&R 要求未知(M-Team、接管任务、站点没配 H&R)时也允许清理;缺省不允许 */
|
||||||
|
hnrUnknownCleanable: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
export type DownloaderPolicy = z.infer<typeof DownloaderPolicy>;
|
export type DownloaderPolicy = z.infer<typeof DownloaderPolicy>;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const REASONS: Record<string, ReasonText> = {
|
|||||||
not_found: { text: "没找到" },
|
not_found: { text: "没找到" },
|
||||||
precondition: { text: "前置条件不满足" },
|
precondition: { text: "前置条件不满足" },
|
||||||
internal: { text: "内部错误" },
|
internal: { text: "内部错误" },
|
||||||
volume_state_unknown: { text: "下载盘用量未知", action: "在下载面板检查下载盘" },
|
volume_state_unknown: { text: "下载目录读不到容量", action: "检查下载器的路径映射和容器挂载" },
|
||||||
hard_threshold_reached: { text: "下载盘已到暂停线" },
|
hard_threshold_reached: { text: "下载盘已到暂停线" },
|
||||||
projected_over_threshold: { text: "下完会超过下载盘暂停线" },
|
projected_over_threshold: { text: "下完会超过下载盘暂停线" },
|
||||||
awaiting_resume_watermark: { text: "等下载盘降到恢复线" },
|
awaiting_resume_watermark: { text: "等下载盘降到恢复线" },
|
||||||
@@ -85,6 +85,8 @@ const REASONS: Record<string, ReasonText> = {
|
|||||||
download_failed: { text: "下载失败" },
|
download_failed: { text: "下载失败" },
|
||||||
download_stalled: { text: "下载无进展" },
|
download_stalled: { text: "下载无进展" },
|
||||||
space_insufficient: { text: "空间不足" },
|
space_insufficient: { text: "空间不足" },
|
||||||
|
downloader_offline: { text: "下载器连不上" },
|
||||||
|
cache_volume_unknown: { text: "下载目录读不到容量" },
|
||||||
unidentifiable: { text: "没识别出作品" },
|
unidentifiable: { text: "没识别出作品" },
|
||||||
user_deleted: { text: "已删除" },
|
user_deleted: { text: "已删除" },
|
||||||
// DL-3:还没提交就结束或在等的下载(计划的 statusReason、提交任务的延后原因)
|
// DL-3:还没提交就结束或在等的下载(计划的 statusReason、提交任务的延后原因)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe("reasonText(DEAI-2 原因码人话化)", () => {
|
|||||||
|
|
||||||
it("智能体汇报里的码(FIX-7):能力错误、带分类前缀的空间判定、站点登录", () => {
|
it("智能体汇报里的码(FIX-7):能力错误、带分类前缀的空间判定、站点登录", () => {
|
||||||
expect(reasonText("invalid_input")?.text).toBe("参数不对");
|
expect(reasonText("invalid_input")?.text).toBe("参数不对");
|
||||||
expect(reasonText("cache:volume_state_unknown")?.text).toBe("下载盘用量未知");
|
expect(reasonText("cache:volume_state_unknown")?.text).toBe("下载目录读不到容量");
|
||||||
expect(reasonText("deferred:no_downloader_instance")?.text).toBe("没有可用的下载器");
|
expect(reasonText("deferred:no_downloader_instance")?.text).toBe("没有可用的下载器");
|
||||||
expect(reasonText("auth_missing")?.text).toContain("Cookie");
|
expect(reasonText("auth_missing")?.text).toContain("Cookie");
|
||||||
expect(reasonText("unidentifiable")?.text).toBe("没识别出作品");
|
expect(reasonText("unidentifiable")?.text).toBe("没识别出作品");
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Kysely } from "kysely";
|
||||||
|
import { execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||||
|
|
||||||
|
// 下载侧可见性:decision.kind 增加
|
||||||
|
// - downloader_offline:下载器连续不可达超过一段时间(按实例去重,恢复后自动关闭)
|
||||||
|
// - cache_volume_unknown:下载目录读不到容量(路径映射 / 挂载问题,按卷去重,恢复后自动关闭)
|
||||||
|
|
||||||
|
const OLD_DECISION_KINDS = [
|
||||||
|
"identity_ambiguous",
|
||||||
|
"download_stalled",
|
||||||
|
"download_failed",
|
||||||
|
"path_conflict",
|
||||||
|
"space_insufficient",
|
||||||
|
"approval_required",
|
||||||
|
"metadata_conflict",
|
||||||
|
"source_unreachable",
|
||||||
|
"other",
|
||||||
|
"identity_unresolved",
|
||||||
|
"episode_mapping",
|
||||||
|
];
|
||||||
|
const NEW_DECISION_KINDS = [...OLD_DECISION_KINDS, "downloader_offline", "cache_volume_unknown"];
|
||||||
|
|
||||||
|
const swapDecisionKinds = (values: readonly string[]) => `
|
||||||
|
alter table decision drop constraint decision_kind_check;
|
||||||
|
alter table decision add constraint decision_kind_check ${oneOf("kind", values)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await execDdl(db, swapDecisionKinds(NEW_DECISION_KINDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await execDdl(
|
||||||
|
db,
|
||||||
|
`update decision set kind = 'other' where kind in ('downloader_offline', 'cache_volume_unknown');
|
||||||
|
${swapDecisionKinds(OLD_DECISION_KINDS)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,7 +112,9 @@ export type DecisionKind =
|
|||||||
| "source_unreachable"
|
| "source_unreachable"
|
||||||
| "other"
|
| "other"
|
||||||
| "identity_unresolved"
|
| "identity_unresolved"
|
||||||
| "episode_mapping";
|
| "episode_mapping"
|
||||||
|
| "downloader_offline"
|
||||||
|
| "cache_volume_unknown";
|
||||||
export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired";
|
export type DecisionStatus = "open" | "investigating" | "awaiting_user" | "resolved" | "dismissed" | "expired";
|
||||||
export type DecisionSeverity = "info" | "warning" | "critical";
|
export type DecisionSeverity = "info" | "warning" | "critical";
|
||||||
export type DownloadTaskOrigin = "submitted" | "adopted";
|
export type DownloadTaskOrigin = "submitted" | "adopted";
|
||||||
|
|||||||
Reference in new issue
Block a user