feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)

- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue,
  cut, release group suffix fallback, dir+file merge, safe episode offset)
- identity: title normalization (NFKC, trad->simp map, season suffix) and
  evidence-level candidate ranking (year tolerance ±1, no probabilities)
- slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine,
  movie/unit completion (season pack must be covered by actual file list)
- acquisition: new_title/catch_up/upgrade modes, priority order, budget,
  cache volume hard threshold with resume watermark (no double counting)
- path: immutable path bindings, restricted naming templates, sanitize,
  conflict detection, downloader<->host path mapping
- organize: planOrganize with sidecar pairing, sample exclusion, existing
  target policy, crossDevice marking, H&R -> conflict for move
- download-state: qB state mapping, completion, no-progress evidence

230 tests, ~97% statement coverage.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:29 +08:00
1 parent a6d049db81
commit 7ba712ea55
40 files changed
+5885 -2

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
export interface AcquisitionBudget {
maxNewTitlesPerDay?: number;
maxBytesPerDay?: number;
maxConcurrent?: number;
}
export interface BudgetUsage {
newTitlesToday: number;
bytesToday: number;
/** 已预留但尚未计入 bytesToday 的计划字节 */
reservedBytesToday?: number;
activeDownloads: number;
}
export interface BudgetRequest {
countsAsNewTitle: boolean;
bytes?: number;
}
export interface BudgetViolation {
kind: "new_titles" | "bytes" | "concurrency";
limit: number;
current: number;
requested: number;
}
export interface BudgetDecision {
decision: "allow" | "deny" | "unknown";
violations: BudgetViolation[];
/** 有字节预算但候选体积未知 */
unknown: "bytes"[];
}
/** 预算检查(每日新增作品数、下载字节、并发)。已知超限 → deny;体积未知 → unknown(不放行) */
export function checkBudget(budget: AcquisitionBudget, usage: BudgetUsage, req: BudgetRequest): BudgetDecision {
const violations: BudgetViolation[] = [];
const unknown: "bytes"[] = [];
if (
req.countsAsNewTitle &&
budget.maxNewTitlesPerDay !== undefined &&
usage.newTitlesToday + 1 > budget.maxNewTitlesPerDay
)
violations.push({
kind: "new_titles",
limit: budget.maxNewTitlesPerDay,
current: usage.newTitlesToday,
requested: 1,
});
if (budget.maxBytesPerDay !== undefined) {
const current = usage.bytesToday + (usage.reservedBytesToday ?? 0);
if (req.bytes === undefined) unknown.push("bytes");
else if (current + req.bytes > budget.maxBytesPerDay)
violations.push({ kind: "bytes", limit: budget.maxBytesPerDay, current, requested: req.bytes });
}
if (budget.maxConcurrent !== undefined && usage.activeDownloads + 1 > budget.maxConcurrent)
violations.push({ kind: "concurrency", limit: budget.maxConcurrent, current: usage.activeDownloads, requested: 1 });
const decision = violations.length > 0 ? "deny" : unknown.length > 0 ? "unknown" : "allow";
return { decision, violations, unknown };
}
+128
View File
@@ -0,0 +1,128 @@
export interface VolumeSnapshot {
volumeId: string;
totalBytes: number;
usedBytes: number;
}
export interface InflightTask {
taskId: string;
planId?: string;
volumeId: string;
/** 种子选中文件总大小;未知(磁力未取到元数据)时为 undefined */
sizeBytes?: number;
/** 已占用磁盘的字节(已下载或已预分配,取二者较大者由调用方给出) */
onDiskBytes: number;
/** 是否在管理标签范围内 */
managed: boolean;
status: "queued" | "downloading" | "paused" | "completed" | "other";
}
export interface Reservation {
planId: string;
volumeId: string;
bytes: number;
}
export interface ThresholdConfig {
/** 硬阈值使用率(%),例如 90 */
hardPct: number;
/** 恢复水位(%),例如 85,必须低于 hardPct */
resumePct: number;
}
export interface CacheVolumeInput {
/** 读不到磁盘状态时传 null */
volume: VolumeSnapshot | null;
config: ThresholdConfig;
inflight: readonly InflightTask[];
reservations: readonly Reservation[];
/** 待提交的新下载;不传表示只做巡检 */
request?: { bytes?: number };
/** 当前是否处于"因本阈值暂停"状态 */
pausedByThreshold: boolean;
}
export interface CacheVolumeDecision {
/** allow = 可提交;block = 阻止新增;pause_managed = 阻止新增并暂停纳管的下载中任务 */
decision: "allow" | "block" | "pause_managed";
/** 降到恢复水位:恢复"因本阈值暂停"的任务(不含用户手动暂停) */
resumeThresholdPaused: boolean;
pauseTaskIds: string[];
reasons: string[];
usedPct?: number;
projectedPct?: number;
pendingBytes: number;
reservedBytes: number;
unknownSizeTaskIds: string[];
/** 未纳管任务仍在增长:不能声称暂停后磁盘不再增长 */
unmanagedGrowthRisk: boolean;
}
/**
* 缓存卷硬阈值判定(REQ §8.4.1)。
* 预测占用 = 当前已用 + 在途任务剩余需求(size - 已落盘,已预分配不重复扣) + 未提交计划预留 + 本次请求;
* 已提交的计划只按在途任务计算一次,不再算预留。多个 qB 共用卷时由调用方按 volumeId 汇总传入。
*/
export function evaluateCacheVolume(input: CacheVolumeInput): CacheVolumeDecision {
const { config } = input;
if (!(config.resumePct < config.hardPct)) throw new Error("resumePct 必须低于 hardPct");
const base = {
pauseTaskIds: [] as string[],
pendingBytes: 0,
reservedBytes: 0,
unknownSizeTaskIds: [] as string[],
unmanagedGrowthRisk: false,
};
const v = input.volume;
if (!v || !(v.totalBytes > 0)) {
return { ...base, decision: "block", resumeThresholdPaused: false, reasons: ["volume_state_unknown"] };
}
const tasks = input.inflight.filter((t) => t.volumeId === v.volumeId);
let pending = 0;
const unknownSize: string[] = [];
let unmanagedGrowth = false;
for (const t of tasks) {
if (t.status === "completed") continue;
if (t.sizeBytes === undefined) {
unknownSize.push(t.taskId);
continue;
}
const rest = Math.max(0, t.sizeBytes - t.onDiskBytes);
pending += rest;
if (!t.managed && rest > 0) unmanagedGrowth = true;
}
const submittedPlans = new Set(tasks.map((t) => t.planId).filter((p): p is string => p !== undefined));
const reserved = input.reservations
.filter((r) => r.volumeId === v.volumeId && !submittedPlans.has(r.planId))
.reduce((s, r) => s + r.bytes, 0);
const reqBytes = input.request?.bytes ?? 0;
const usedPct = (v.usedBytes / v.totalBytes) * 100;
const projectedPct = ((v.usedBytes + pending + reserved + reqBytes) / v.totalBytes) * 100;
const out = {
...base,
pendingBytes: pending,
reservedBytes: reserved,
unknownSizeTaskIds: unknownSize,
unmanagedGrowthRisk: unmanagedGrowth,
usedPct,
projectedPct,
};
const reasons: string[] = [];
if (usedPct >= config.hardPct) {
return {
...out,
decision: "pause_managed",
resumeThresholdPaused: false,
pauseTaskIds: tasks.filter((t) => t.managed && t.status === "downloading").map((t) => t.taskId),
reasons: ["hard_threshold_reached"],
};
}
const resume = input.pausedByThreshold && usedPct <= config.resumePct;
if (input.pausedByThreshold && !resume) reasons.push("awaiting_resume_watermark");
if (input.request && input.request.bytes === undefined) reasons.push("request_size_unknown");
if (input.request && unknownSize.length > 0) reasons.push("inflight_size_unknown");
if (projectedPct >= config.hardPct) reasons.push("projected_over_threshold");
if (resume) reasons.push("below_resume_watermark");
const blocked = reasons.some((r) => r !== "below_resume_watermark");
return { ...out, decision: blocked ? "block" : "allow", resumeThresholdPaused: resume, reasons };
}
+26
View File
@@ -0,0 +1,26 @@
export {
type AcquisitionBudget,
type BudgetDecision,
type BudgetRequest,
type BudgetUsage,
type BudgetViolation,
checkBudget,
} from "./budget";
export {
type CacheVolumeDecision,
type CacheVolumeInput,
evaluateCacheVolume,
type InflightTask,
type Reservation,
type ThresholdConfig,
type VolumeSnapshot,
} from "./disk";
export { type AcquisitionMode, determineMode, type ModeDecision, type ModeInput } from "./mode";
export {
DEFAULT_PRIORITY_ORDER,
type PriorityClass,
priorityOf,
type QueueItem,
sortByPriority,
validatePriorityOrder,
} from "./priority";
+60
View File
@@ -0,0 +1,60 @@
/** "每日新资源"拆成的三种模式(REQ §6.3) */
export type AcquisitionMode = "new_title" | "catch_up" | "upgrade";
export interface ModeInput {
/** 本地实体;null = 库里没有这部作品 */
entity: { tracked: boolean; kind: "movie" | "tv" } | null;
/** 候选覆盖的 content_unit(剧集/综艺);电影留空 */
releaseUnits: readonly string[];
/** Slot 中已满足的 content_unit */
satisfiedUnits: readonly string[];
/** 电影:该 Slot 是否已满足 */
slotSatisfied: boolean;
/** 作品发行/首播日期 YYYY-MM-DD(不是站点上传日期) */
firstReleaseDate?: string;
/** 当前时间(ISO),由调用方注入 */
now: string;
/** 新作品窗口(天) */
newTitleWindowDays: number;
/** evaluateCandidate 对当前资产给出了 upgrade */
upgradeCandidate?: boolean;
}
export type ModeDecision = { mode: AcquisitionMode; reason: string } | { mode: null; reason: string };
const DAY_MS = 86_400_000;
/**
* 判定一条候选属于哪种收录模式。
* - 库里没有:只有发行/首播在窗口内才是新作品;站点今天上传旧片不算新片
* - 已有剧集:候选覆盖未满足的单元 → 追更补缺;都已满足且可升级 → 升级
* - 已有电影:Slot 缺口或更优版本 → 升级(独立于上映日期)
* - 只登记(未追踪)的作品不因新规则隐式变成自动下载
*/
export function determineMode(input: ModeInput): ModeDecision {
const { entity } = input;
if (!entity) {
if (!input.firstReleaseDate) return { mode: null, reason: "release_date_unknown" };
const released = Date.parse(`${input.firstReleaseDate}T00:00:00Z`);
const now = Date.parse(input.now);
if (Number.isNaN(released) || Number.isNaN(now)) return { mode: null, reason: "release_date_unknown" };
const ageDays = (now - released) / DAY_MS;
if (ageDays < -1) return { mode: null, reason: "not_yet_released" };
if (ageDays <= input.newTitleWindowDays) return { mode: "new_title", reason: "released_within_window" };
return { mode: null, reason: "not_new_release" };
}
if (!entity.tracked) return { mode: null, reason: "not_tracked" };
if (entity.kind === "tv") {
const satisfied = new Set(input.satisfiedUnits);
const gaps = input.releaseUnits.filter((u) => !satisfied.has(u));
if (gaps.length > 0) return { mode: "catch_up", reason: `covers_missing_units:${gaps.length}` };
if (input.releaseUnits.length === 0) return { mode: null, reason: "no_units_resolved" };
return input.upgradeCandidate
? { mode: "upgrade", reason: "better_version" }
: { mode: null, reason: "already_satisfied" };
}
if (!input.slotSatisfied) return { mode: "upgrade", reason: "slot_gap" };
return input.upgradeCandidate
? { mode: "upgrade", reason: "better_version" }
: { mode: null, reason: "already_satisfied" };
}
@@ -0,0 +1,37 @@
import type { AcquisitionMode } from "./mode";
/** 默认:用户明确立即处理 > 追更补缺 > 新作品 > 已有版本升级(REQ §6.8) */
export type PriorityClass = "immediate" | "catch_up" | "new_title" | "upgrade";
export const DEFAULT_PRIORITY_ORDER: readonly PriorityClass[] = ["immediate", "catch_up", "new_title", "upgrade"];
export interface QueueItem {
id: string;
priorityClass: PriorityClass;
/** 入队时间 ISO;同优先级先进先出,避免饥饿 */
enqueuedAt: string;
}
export function priorityOf(mode: AcquisitionMode, immediate = false): PriorityClass {
return immediate ? "immediate" : mode;
}
export function validatePriorityOrder(order: readonly PriorityClass[]): void {
const all = new Set<PriorityClass>(DEFAULT_PRIORITY_ORDER);
if (order.length !== all.size || new Set(order).size !== all.size || !order.every((o) => all.has(o)))
throw new Error(`优先级配置必须恰好包含 ${[...all].join("/")} 各一次`);
}
/** 按优先级排序(稳定、纯函数)。优先级只影响资源分配,不越过阈值/HR/权限 */
export function sortByPriority<T extends QueueItem>(
items: readonly T[],
order: readonly PriorityClass[] = DEFAULT_PRIORITY_ORDER,
): T[] {
validatePriorityOrder(order);
const rank = new Map(order.map((c, i) => [c, i]));
return [...items].sort(
(a, b) =>
rank.get(a.priorityClass)! - rank.get(b.priorityClass)! ||
a.enqueuedAt.localeCompare(b.enqueuedAt) ||
a.id.localeCompare(b.id),
);
}