Merge branch 'deai2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	apps/server/src/modules/acquisition-organize/services/organize.ts
#	apps/web/app/routes/console/sites.$siteId.tsx
This commit is contained in:
root committed 2026-09-29 21:26:16 +08:00
commit 12bc4664b6
247 files changed
+2658 -2148

No files matched your search

@@ -72,7 +72,7 @@ describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务"
for (const dryRun of [true, false]) {
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("不在已登记的库根或下载器路径映射之下");
expect(r.body.error.message).toContain("不在已登记的媒体库目录或下载器路径映射之下");
}
// `..` 即使规范化后仍在根内也拒绝
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
@@ -77,7 +77,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
return { items } as never;
});
// AG-3:订阅摘要(分组计数 + 条目),版本目标只给总数 / 已满足数,细节用 inventory.query
// AG-3:订阅摘要(分组计数 + 条目),收录版本只给总数 / 已满足数,细节用 inventory.query
registry.register(capabilitySpec("subscription.list"), async (input) => {
const db = deps().ctx.db;
let q = db
@@ -253,7 +253,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
const d = deps();
const db = d.ctx.db;
const slot = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.slotId).executeTakeFirst();
if (!slot) throw new CapabilityError("not_found", "版本目标不存在");
if (!slot) throw new CapabilityError("not_found", "收录版本不存在");
const rel = await db
.selectFrom("source_release")
.select(["id", "media_entity_id"])
@@ -285,7 +285,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
for (const r of c.rejected ?? []) rejected.push({ code: r.code, message: r.message });
for (const r of c.reasons ?? []) rejected.push({ code: r.split(":")[0] ?? r, message: r });
if (rejected.length === 0)
rejected.push({ code: "not_planned", message: "未生成计划:版本目标正忙或条件不满足" });
rejected.push({ code: "not_planned", message: "未生成计划:收录版本正忙或条件不满足" });
}
const fresh = await db
.selectFrom("acquisition_slot")
@@ -324,8 +324,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
.where("id", "=", plan.slot_id)
.forUpdate()
.executeTakeFirstOrThrow();
if (slot.revision + 1 !== plan.expected_revision) return "版本目标已变化,重新生成计划";
if (BUSY.includes(slot.status)) return `版本目标正忙(${slot.status})`;
if (slot.revision + 1 !== plan.expected_revision) return "收录版本已变化,重新生成计划";
if (BUSY.includes(slot.status)) return `收录版本正忙(${slot.status})`;
let st = { status: slot.status, hasAsset: slot.current_asset_id !== null };
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
st = transition(st, { type: "reserve" });
@@ -348,7 +348,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
return null;
})
.catch((err) => {
if ((err as { code?: string }).code === "23505") return "版本目标已被其他计划预留";
if ((err as { code?: string }).code === "23505") return "收录版本已被其他计划预留";
throw err;
});
if (ok) throw new CapabilityError("conflict", ok);
@@ -110,7 +110,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.sync",
defaults: {
name: "qB 同步",
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复",
group: "download",
schedule: every(syncMs),
},
@@ -129,8 +129,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerScheduledJob(ctx, {
key: "cache.threshold",
defaults: {
name: "缓存阈值检查",
description: "下载缓存超过硬阈值时入队清理",
name: "下载盘检查",
description: "下载盘超过暂停线时清理已入库的下载",
group: "maintain",
schedule: every(planMs),
},
@@ -145,7 +145,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.orchestrate",
defaults: {
name: "补提交与补入库",
description: "补投已预留未提交的下载、已完成未入库的任务",
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
group: "download",
schedule: every(planMs),
},
@@ -159,7 +159,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "slots.reevaluate",
defaults: {
name: "到期重评",
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
description: "重新检查等待中的资源",
group: "download",
schedule: every(planMs),
},
@@ -169,7 +169,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "subscriptions.search",
defaults: {
name: "订阅周期搜索",
description: "到期的订阅主动搜索站点",
description: "到期的订阅去站点搜索",
group: "collect",
schedule: every(planMs),
},
@@ -190,8 +190,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerScheduledJob(ctx, {
key: "library.reconcile",
defaults: {
name: "资产对账",
description: "核对媒体库文件与资产记录",
name: "文件核对",
description: "检查媒体库里的文件是否还在",
group: "organize",
schedule: every(reconcileMs),
},
@@ -30,19 +30,19 @@ const READ_ONLY: ReadOnly[] = [
{
...Api.mediaAssets,
table: "media_asset",
what: "资产",
what: "文件",
filters: { mediaEntityId: "media_entity_id", status: "status" },
},
{
...Api.assetUnitCoverage,
table: "asset_unit_coverage",
what: "资产覆盖",
what: "文件覆盖",
filters: { mediaAssetId: "media_asset_id", contentUnitId: "content_unit_id" },
},
{
...Api.assetLocations,
table: "asset_location",
what: "资产位置",
what: "文件位置",
filters: { mediaAssetId: "media_asset_id", status: "status" },
},
{
@@ -143,7 +143,7 @@ export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
const notes: string[] = [];
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已暂停新增下载" : "实例未配置保存路径,无法统计");
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
if (dv.unmanagedGrowthRisk) notes.push("有未纳管任务仍在增长,暂停纳管任务后磁盘仍会增长");
if (dv.unmanagedGrowthRisk) notes.push("有外部任务仍在增长,暂停本工具的任务后磁盘仍会增长");
const pct = usedPercent(v);
volumes.push({
volumeKey: v.volumeKey,
@@ -110,7 +110,7 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
method: "post",
path: "/library/reconcile",
scope: "admin",
summary: "入队一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
summary: "入队一轮资产对账(媒体库目录哨兵 + 增量 stat;不删记录、不补下载);返回任务 id,进度见任务页",
body: z.object({ libraryRootId: z.uuid().optional() }),
response: accepted,
},
@@ -49,7 +49,7 @@ export function registerNamingRoutes(app: ModuleApp, d: AoDeps) {
};
const rootRow = async (id: string) => {
const r = await db().selectFrom("library_root").selectAll().where("id", "=", id).executeTakeFirst();
if (!r) throw notFound("库根");
if (!r) throw notFound("媒体库目录");
return r;
};
const audit = (
@@ -209,7 +209,7 @@ async function loadSample(d: AoDeps, input: NamingPreviewInput): Promise<SampleS
.where("a.id", "=", s.assetId)
.orderBy("l.created_at", "desc")
.executeTakeFirst();
if (!asset) throw notFound("资产");
if (!asset) throw notFound("文件");
const fileName = (asset.relative_path ?? "").split("/").pop() || "asset.mkv";
const e = asset.media_entity_id
? await db.selectFrom("media_entity").selectAll().where("id", "=", asset.media_entity_id).executeTakeFirst()
@@ -244,7 +244,7 @@ async function preview(
let root: LibraryRootRow | null = null;
if (rootId) {
root = (await db.selectFrom("library_root").selectAll().where("id", "=", rootId).executeTakeFirst()) ?? null;
if (!root && input.libraryRootId) throw notFound("库根");
if (!root && input.libraryRootId) throw notFound("媒体库目录");
}
const eff = effectiveNaming(await namingSettings(), root, entity);
const template = input.template ?? eff.templates[kind];
@@ -91,11 +91,11 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
.where("active", "=", true)
.executeTakeFirst();
if (!sup) return;
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,Agent 不能自动加回");
if (source === "agent") conflict("用户已停用或删除过同一语义的规则,智能体不能自动加回");
await liftSuppressions(trx, fingerprint, by);
}
/** 被版本目标 / 订阅显式引用的次数 */
/** 被收录版本 / 订阅显式引用的次数 */
async function referenceCount(trx: Trx, id: string): Promise<number> {
const r = await sql<{ n: number }>`
select (select count(*) from acquisition_slot where rule_group_ids @> ${JSON.stringify([id])}::jsonb)
@@ -117,7 +117,7 @@ async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly stri
if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
}
/** 版本目标 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
/** 收录版本 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
export const assertRuleGroupIds = checkIdsExist;
const TRACKED_FIELDS = [
@@ -528,7 +528,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
.executeTakeFirst();
if (!cur) notFound("规则组");
const refs = await referenceCount(trx, cur!.id);
if (refs > 0) conflict(`规则组被 ${refs} 个版本目标或订阅引用,先解除引用`);
if (refs > 0) conflict(`规则组被 ${refs} 个收录版本或订阅引用,先解除引用`);
const row = await trx
.updateTable("rule_group")
.set({
@@ -597,7 +597,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
);
}
/** 规则组改动后重评引用它的版本目标(显式引用;默认链改动由周期重评覆盖) */
/** 规则组改动后重评引用它的收录版本(显式引用;默认链改动由周期重评覆盖) */
async function kickReevaluate(d: AoDeps, groupId: string) {
try {
const slots = await d.ctx.db
@@ -136,7 +136,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
Api.acquisitionSlots.get,
async (_c, { params }) => {
const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<AcquisitionSlot>(r ?? notFound("版本目标"));
return toApi<AcquisitionSlot>(r ?? notFound("收录版本"));
},
{ tag: TAG },
);
@@ -171,7 +171,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.onConflict((oc) => oc.doNothing())
.returningAll()
.executeTakeFirst();
if (!r) conflict("该作品或内容单元下已有同名版本目标");
if (!r) conflict("该作品或内容单元下已有同名收录版本");
await kickEvaluate(d, { acquisitionSlotId: r!.id });
return toApi<AcquisitionSlot>(r!);
},
@@ -208,7 +208,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.select("revision")
.where("id", "=", params.id)
.executeTakeFirst();
if (!exists) notFound("版本目标");
if (!exists) notFound("收录版本");
conflict(`revision 不匹配(当前 ${exists!.revision})`);
}
await kickEvaluate(d, { acquisitionSlotId: params.id });
@@ -49,7 +49,7 @@ import { actorOf, conflict, errMessage, jsonb, notFound, toApi, toApiList } from
import { mount, paginate } from "./helpers.ts";
// W9-E 订阅 API(docs/design/subscriptions.md §1–§3、§5):总览(分组/排序/筛选)、覆盖矩阵、表单字段规范化、
// 暂停/恢复/重新订阅、批量、手动补全某版本、版本目标排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
// 暂停/恢复/重新订阅、批量、手动补全某版本、收录版本排序;主动搜索、进度、总集数、完成历史沿用 W5-C。
const TAG = "subscriptions";
const OVERVIEW_MAX = 2000;
@@ -106,12 +106,12 @@ async function assertNoDuplicate(
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
}
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个收录版本(预设随后写入) */
const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
/**
* 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→
* 该订阅自己的版本目标;MP total_episode → 季总集数;best_version → 版本目标升级策略。
* 该订阅自己的收录版本;MP total_episode → 季总集数;best_version → 收录版本升级策略。
*/
async function applyDerived(
exec: Exec,
@@ -616,7 +616,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG },
);
// ── 版本目标:手动补全 / 排序 ──
// ── 收录版本:手动补全 / 排序 ──
mount(
app,
Api.subscriptions.fillSlot,
@@ -632,7 +632,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.execute()
: [];
const slot = slotsOfSub(slots, sub).find((s) => s.id === params.slotId);
if (!slot) notFound("版本目标");
if (!slot) notFound("收录版本");
invalidateCoverage(sub.id);
try {
return await d.ctx.engine.enqueue(
@@ -668,7 +668,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
new Set(want).size !== want.length ||
!want.every((id) => mine.some((s) => s.id === id))
)
throw new ApiException("bad_request", "slotIds 必须恰好是该订阅的全部版本目标");
throw new ApiException("bad_request", "slotIds 必须恰好是该订阅的全部收录版本");
for (const [i, id] of want.entries())
await trx.updateTable("acquisition_slot").set({ position: i }).where("id", "=", id).execute();
return (await trx.selectFrom("acquisition_slot").selectAll().where("id", "in", want).execute()).sort(
@@ -200,7 +200,7 @@ export async function createSlotsFromTemplates(
await exec
.insertInto("acquisition_slot")
.values({
// 同一事务建的版本目标 created_at 相同:按模板顺序给 position,第一个为主版本
// 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
position: i,
media_entity_id: input.entityId,
subscription_id: input.subscriptionId,
@@ -556,7 +556,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
}
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
const ctx = contextOf(rel, now);
// 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
// 规则组:收录版本 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
c.rules ??= loadRuleCatalog(db);
const chain = resolveRuleChain(await c.rules, {
kind: "acquisition",
@@ -568,7 +568,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
siteId: rel.site_id,
subscriptionId: sub?.id,
});
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入版本目标硬条件
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入收录版本硬条件
spec.hard = mergeHard(spec.hard, chain.hard);
if (chain.groups.length) {
spec.rules = { groups: chain.groups, atoms: chain.atoms };
@@ -811,7 +811,7 @@ async function unitQuality(
exec: Exec,
slot: AcquisitionSlotRow,
keys: readonly string[],
/** 版本目标引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
/** 收录版本引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
priOf?: (assetId: string) => Promise<number | undefined>,
): Promise<Record<string, QualityFacts | undefined>> {
const rows = await exec
@@ -25,9 +25,9 @@ import { defaultChainIds, loadRuleCatalog, type RuleCatalog, resolveRuleChain }
import { INFLIGHT_PLAN_STATUSES, rangeOf, subParams } from "./subscriptions.ts";
// W9-E 覆盖矩阵与订阅总览(docs/design/subscriptions.md §1、§2.1、§5):
// 一批订阅一次取齐 版本目标 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
// 在内存里按版本目标算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
// 版本目标覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个版本目标时,作品下全部资产都算)。
// 一批订阅一次取齐 收录版本 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
// 在内存里按收录版本算每格状态(domain coverageRow)。覆盖口径与 slotCoveredUnits 相同:
// 收录版本覆盖 = 该目标计划产出的资产 + 当前资产 + 分集账本资产(作品只有一个收录版本时,作品下全部资产都算)。
const MAX_EPISODES = 2000;
const COVERAGE_TTL_MS = 30_000;
@@ -111,11 +111,11 @@ export function subscriptionAutoFill(
};
}
/** 版本目标排序:position → created_at → id;第一个为主版本 */
/** 收录版本排序:position → created_at → id;第一个为主版本 */
export const slotOrder = (a: AcquisitionSlotRow, b: AcquisitionSlotRow) =>
a.position - b.position || a.created_at.getTime() - b.created_at.getTime() || a.id.localeCompare(b.id);
/** 订阅关联的版本目标:同作品、属于该订阅或不属于任何订阅 */
/** 订阅关联的收录版本:同作品、属于该订阅或不属于任何订阅 */
export const slotsOfSub = (all: readonly AcquisitionSlotRow[], sub: Pick<SubscriptionRow, "id" | "media_entity_id">) =>
all
.filter(
@@ -287,7 +287,7 @@ export async function buildSubscriptionModels(
if (p?.episode !== undefined || /^d\d{8}$/.test(k)) put(k, { assetId: id, quality: q, pri });
}
}
// 分集账本优先(质量与 pri 按版本目标记)
// 分集账本优先(质量与 pri 按收录版本记)
for (const r of ledger) {
const f = { assetId: r.media_asset_id, quality: qualityOf(r.quality), pri: r.pri };
if (r.kind === "episode" && r.episode_number !== null)
@@ -542,7 +542,7 @@ export async function buildSubscriptionModels(
upgrade: m.upgrade,
});
if (isMovie) {
// 电影:该版本目标不要求的内容版本不算缺
// 电影:该收录版本不要求的内容版本不算缺
for (const c of r.cells)
if (c.state === "missing" && !m.requiredEditions.includes(c.unitKey.slice("edition:".length))) {
c.state = "skipped";
@@ -106,7 +106,7 @@ export async function namingByVersion(exec: Exec, version: number): Promise<Nami
return { movie: t.movie, tv: t.tv, variety: t.variety, version: row.version };
}
/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 版本目标名 */
/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 收录版本名 */
export async function loadNamingEntity(
exec: Exec,
entity: MediaEntityRow,
@@ -67,7 +67,7 @@ export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
throw new PathGuardError(`源路径不存在或不可访问:${p}`, p);
}
if (!roots.some((root) => within(r, root)))
throw new PathGuardError(`源路径不在已登记的库根或下载器路径映射之下:${p}`, p);
throw new PathGuardError(`源路径不在已登记的媒体库目录或下载器路径映射之下:${p}`, p);
return r;
},
};
@@ -15,6 +15,7 @@ import {
statSafe,
} from "@umt/adapters";
import type { ActorType, OrganizationRecord, OrganizationTrigger, PathBinding } from "@umt/contracts";
import { reasonSentence, reasonText } from "@umt/contracts";
import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecordRow } from "@umt/db";
import { sql } from "@umt/db";
import {
@@ -415,7 +416,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const rid = await fail("identity_unknown", takeover ? { outcome: takeover.result.outcome } : {});
await decide_({
kind: "identity_ambiguous",
title: `无法确定作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
title: `没识别出作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`,
summary: takeoverSummary(takeover),
subjectType: "organization_record",
subjectId: rid.recordId,
@@ -500,8 +501,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
await decide_({
kind: "other",
severity: "critical",
title: `库根离线:${root.name}`,
summary: `哨兵检查失败(${health.reason}),整理已暂停。文件不会因此被判为缺失`,
title: `媒体库目录离线:${root.name}`,
summary: "媒体库目录不可访问或未挂载,整理已暂停。文件不会因此被判为缺失",
subjectType: "library_root",
subjectId: root.id,
dedupeKey: `library_root_offline:${root.id}`,
@@ -759,9 +760,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const existsConflict = blocking.some((c) => c.kind === "target_exists");
await upsertDecision(d, trx, {
kind: hnrConflict ? "approval_required" : "path_conflict",
title: hnrConflict ? `做种义务未满足,已阻止移动:${entity.title}` : `整理冲突:${entity.title}`,
title: hnrConflict
? `做种义务未满足,已阻止移动:${entity.title}`
: `${reasonText(blocking[0]?.kind)?.text ?? "整理冲突"}:${entity.title}`,
summary: blocking
.map((c) => c.detail ?? c.kind)
.map((c) => c.detail ?? reasonText(c.kind)?.text ?? c.kind)
.join(";")
.slice(0, 500),
subjectType: "organization_record",
@@ -911,7 +914,7 @@ export async function executeRecord(
const fsr = fsRuntime(d);
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
if (health.kind === "offline") throw new Error(`库根离线(${health.reason}),稍后重试`);
if (health.kind === "offline") throw new Error(`媒体库目录离线(${health.reason}),稍后重试`);
for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
if (fsr.partialMaxAgeMs > 0)
@@ -1730,7 +1733,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
}
/** 关闭下载任务的"无法确定作品 / 整理冲突 / 做种义务阻止移动"事项(resolution.key=manual_organize,记整理记录 id) */
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动 / 没识别出作品"事项(resolution.key=manual_organize,记整理记录 id) */
async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
const rows = await trx
@@ -1857,7 +1860,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
.join(";");
const reason = (code: string | null, err: unknown) =>
code
? { code: code.split(":")[0]!, message: conflictText(err) || code }
? { code: code.split(":")[0]!, message: conflictText(err) || reasonSentence(code, code) }
: err
? {
code: (err as { code?: string }).code ?? "error",
@@ -35,7 +35,7 @@ import { ApiException } from "../../../http/errors.ts";
import { hashJson, toApi } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts";
// 规则组(W9-D):目录加载、引用链解析(版本目标 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
// 规则组(W9-D):目录加载、引用链解析(收录版本 → 订阅 → 默认链,开升级叠加洗版默认)、指纹、输入规范化、试算。
export interface RuleCatalog {
/** 未删除的全部规则组(含停用,解析链时再过滤) */
@@ -132,17 +132,17 @@ export function defaultChainIds(cat: RuleCatalog, kind: RuleGroupKind): string[]
}
export interface ResolveInput extends RuleTarget {
/** 版本目标 rule_group_ids(非空时优先) */
/** 收录版本 rule_group_ids(非空时优先) */
slotRuleGroupIds?: readonly string[] | undefined;
/** 订阅 params.ruleGroupIds */
subRuleGroupIds?: readonly string[] | undefined;
kind: "acquisition" | "search";
/** 版本目标开了升级:叠加洗版默认链 */
/** 收录版本开了升级:叠加洗版默认链 */
upgrade?: boolean | undefined;
}
/**
* 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
* 引用链:订阅私有组 → (收录版本 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。
*/
export function resolveRuleChain(
@@ -190,7 +190,7 @@ export function chainHard(rows: readonly Pick<RuleGroupRow, "hard">[]): SlotHard
return { ...(inc.length ? { include: inc } : {}), ...(exclude.length ? { exclude } : {}) };
}
/** 版本目标硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
/** 收录版本硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
export function mergeHard(base: SlotHardConditions, extra: SlotHardConditions): SlotHardConditions {
if (!extra.include?.length && !extra.exclude?.length) return base;
const out: SlotHardConditions = { ...base };
@@ -11,7 +11,7 @@ import { TRACKING_SUB_STATUSES } from "./subscriptions.ts";
// 订阅日历(docs/design/subscriptions.md §4):事件 = 追踪中订阅范围内集的播出日期(集内容单元 air_date,
// 由 refreshMetadata 按 TMDB 季集数据写入),同日同季聚合;电影取上映日期(元数据 releaseDate)。
// 状态取主版本那一行的格(没有版本目标时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
// 状态取主版本那一行的格(没有收录版本时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
// 播出日期在今天之后 upcoming。范围内没有播出日期的集列进 unknown(界面显示"播出日期未知")。
// 与覆盖矩阵同一套模型(buildSubscriptionModels),口径一致。
@@ -8,7 +8,7 @@ import { INFLIGHT_PLAN_STATUSES } from "./subscriptions.ts";
// 订阅重置(差距 #24,对照 MP GET /subscribe/reset:清 note / episode_priority,lack = total,state = R)。
// 我们的"note"是在途计划:已预留未提交的、以及下载任务已不在下载器里的已提交计划都释放(计划 cancelled,
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉版本目标的延后记录,标记立即重评。
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉收录版本的延后记录,标记立即重评。
// 已入库资产、分集账本、总集数(含锁定)不动。
/** 仍算"活着"的下载任务状态:任务在下载器里,重置不能释放它的计划 */
@@ -19,10 +19,10 @@ import { allOfAny } from "./rules.ts";
// total_episode (+ manual_total_episode) → 保存后写季总集数(manual 锁定)
// include / exclude(字符串或数组)→ include[] / exclude[](正则白名单校验)
// keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds
// best_version / best_version_full → 订阅版本目标的升级策略(按集 / 整季;电影 = 单片)
// best_version / best_version_full → 订阅收录版本的升级策略(按集 / 整季;电影 = 单片)
// autoFillVersions{enabled, budget{perDay, bytes}} / autoFill:boolean → autoFill + fillBudget{unitsPerDay, bytesPerDay}
// resolution / quality(resource_type) / effect(MP 标签或正则)→ resolution / source / effect 质量预设
// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的版本目标。
// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的收录版本。
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -32,7 +32,7 @@ export interface NormalizedParams {
params: SubscriptionParams;
/** MP total_episode:保存后写入订阅季的总集数 */
episodeTotal?: { total: number; locked: boolean };
/** MP best_version:写到订阅版本目标的升级策略 */
/** MP best_version:写到订阅收录版本的升级策略 */
upgrade?: UpgradeStrategy;
/** 被改写的兼容字段(调试/审计用) */
mapped: string[];
@@ -141,7 +141,7 @@ export function parseMpEffect(v: string): SubscriptionEffect | null {
return null;
}
/** 质量预设 → 版本目标硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
/** 质量预设 → 收录版本硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
export interface PresetConditions {
hard: Record<string, unknown>;
soft: Record<string, unknown>;
@@ -214,8 +214,8 @@ export function presetConditions(p: Pick<SubscriptionParams, "resolution" | "sou
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
/**
* 质量预设写到该订阅自己的版本目标:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
* 只有当版本目标上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
* 质量预设写到该订阅自己的收录版本:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
* 只有当收录版本上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
*/
export async function applySlotPreset(
exec: Exec,
@@ -461,7 +461,7 @@ export async function normalizeSubscriptionParams(
// ── 库根存在性与正则白名单 ──
if (p.libraryRootId) {
const r = await exec.selectFrom("library_root").select("id").where("id", "=", p.libraryRootId).executeTakeFirst();
if (!r) bad(`库根不存在:${p.libraryRootId}`);
if (!r) bad(`媒体库目录不存在:${p.libraryRootId}`);
}
for (const k of ["include", "exclude"] as const)
for (const pat of p[k] ?? []) {
@@ -481,7 +481,7 @@ export async function normalizeSubscriptionParams(
return out;
}
/** best_version 等 → 订阅版本目标的升级策略(保留其余升级参数;revision+1) */
/** best_version 等 → 订阅收录版本的升级策略(保留其余升级参数;revision+1) */
export async function applySlotUpgrade(
exec: Exec,
input: { subscriptionId: string; entityId: string; strategy: UpgradeStrategy },
@@ -709,7 +709,7 @@ export async function sweepMetadataRefresh(d: AoDeps): Promise<number> {
}
/**
* 订阅指定的库根(params.libraryRootId):下载计划所属版本目标的订阅优先,否则取该作品最近的未取消订阅。
* 订阅指定的库根(params.libraryRootId):下载计划所属收录版本的订阅优先,否则取该作品最近的未取消订阅。
* 整理时只在作品没有路径绑定时使用(首次建绑定)。
*/
export async function subscriptionLibraryRoot(