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:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user