From e77fdc65492f31bbeef22556783fcb8f18d2ee1c Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 15:54:29 +0800 Subject: [PATCH] feat(domain): subscription grouping, coverage row states, auto-fill resolution and fill budget Co-Authored-By: Claude Opus 5.5 --- packages/domain/src/acquisition/fill.ts | 70 +++++++++++ packages/domain/src/acquisition/index.ts | 9 ++ packages/domain/src/slot/index.ts | 13 ++ packages/domain/src/slot/overview.ts | 148 +++++++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 packages/domain/src/acquisition/fill.ts create mode 100644 packages/domain/src/slot/overview.ts diff --git a/packages/domain/src/acquisition/fill.ts b/packages/domain/src/acquisition/fill.ts new file mode 100644 index 00000000..f7522185 --- /dev/null +++ b/packages/domain/src/acquisition/fill.ts @@ -0,0 +1,70 @@ +// 多版本自动补全(docs/design/subscriptions.md §2.2):非主版本目标在主版本已满足的单元上补下其他版本。 +// 开关按 订阅 → 规则组(链上后者优先)→ 系统缺省(开)取值;补全预算按订阅、按自然日计,超出记延后。 + +export type AutoFillModeValue = "inherit" | "on" | "off"; + +export interface AutoFillResolution { + enabled: boolean; + mode: AutoFillModeValue; + source: "subscription" | "rule_group" | "default"; + /** source = rule_group 时,决定取值的规则组 */ + ruleGroupId?: string; +} + +/** groupFlags:订阅生效规则链上各组的 auto_fill(null = 未设),按链顺序 */ +export function resolveAutoFill( + mode: AutoFillModeValue | undefined, + groupFlags: readonly { id: string; autoFill: boolean | null }[] = [], + systemDefault = true, +): AutoFillResolution { + if (mode === "on" || mode === "off") return { enabled: mode === "on", mode, source: "subscription" }; + for (let k = groupFlags.length - 1; k >= 0; k--) { + const g = groupFlags[k]!; + if (g.autoFill !== null) return { enabled: g.autoFill, mode: "inherit", source: "rule_group", ruleGroupId: g.id }; + } + return { enabled: systemDefault, mode: "inherit", source: "default" }; +} + +export interface FillBudgetLimits { + bytesPerDay?: number | undefined; + unitsPerDay?: number | undefined; +} + +export interface FillBudgetUsage { + bytesToday: number; + unitsToday: number; +} + +export interface FillBudgetDecision { + allow: boolean; + violations: { kind: "bytes" | "units"; limit: number; current: number; requested: number }[]; +} + +/** 补全预算:未设的维度不限;请求体积未知时只看单元数(体积由全局预算的 unknown 规则兜底) */ +export function checkFillBudget( + limits: FillBudgetLimits | undefined, + usage: FillBudgetUsage, + request: { bytes?: number | null | undefined; units: number }, +): FillBudgetDecision { + const violations: FillBudgetDecision["violations"] = []; + if (limits?.unitsPerDay !== undefined && usage.unitsToday + request.units > limits.unitsPerDay) + violations.push({ + kind: "units", + limit: limits.unitsPerDay, + current: usage.unitsToday, + requested: request.units, + }); + if ( + limits?.bytesPerDay !== undefined && + request.bytes !== null && + request.bytes !== undefined && + usage.bytesToday + request.bytes > limits.bytesPerDay + ) + violations.push({ + kind: "bytes", + limit: limits.bytesPerDay, + current: usage.bytesToday, + requested: request.bytes, + }); + return { allow: violations.length === 0, violations }; +} diff --git a/packages/domain/src/acquisition/index.ts b/packages/domain/src/acquisition/index.ts index 68f61ed3..49ac29a3 100644 --- a/packages/domain/src/acquisition/index.ts +++ b/packages/domain/src/acquisition/index.ts @@ -15,6 +15,15 @@ export { type ThresholdConfig, type VolumeSnapshot, } from "./disk.ts"; +export { + type AutoFillModeValue, + type AutoFillResolution, + checkFillBudget, + type FillBudgetDecision, + type FillBudgetLimits, + type FillBudgetUsage, + resolveAutoFill, +} from "./fill.ts"; export { type AcquisitionMode, determineMode, type ModeDecision, type ModeInput } from "./mode.ts"; export { DEFAULT_PRIORITY_ORDER, diff --git a/packages/domain/src/slot/index.ts b/packages/domain/src/slot/index.ts index 901bbd13..368fbfc7 100644 --- a/packages/domain/src/slot/index.ts +++ b/packages/domain/src/slot/index.ts @@ -46,6 +46,19 @@ export { unitsOverlap, } from "./gaps.ts"; export { busyStatus, type PlanStage, stageAfter, transitionWithInflight } from "./inflight.ts"; +export { + type CoverageColumnInput, + type CoverageRowCell, + type CoverageRowInput, + type CoverageRowResult, + type CoverageState, + type CoveredFact, + coverageRow, + type SubscriptionGroupInput, + type SubscriptionGroupName, + type SubscriptionGroupResult, + subscriptionGroup, +} from "./overview.ts"; export { canTransition, type SlotEvent, diff --git a/packages/domain/src/slot/overview.ts b/packages/domain/src/slot/overview.ts new file mode 100644 index 00000000..0699acde --- /dev/null +++ b/packages/domain/src/slot/overview.ts @@ -0,0 +1,148 @@ +import { inRange, parseUnitKey, type UnitRange, unitsOverlap } from "./gaps.ts"; + +// 订阅总览与覆盖矩阵的纯函数(docs/design/subscriptions.md §1.1、§2.1):分组判定、矩阵一行的格状态。 +// 输入全部由服务端批量查询后给出,这里不做 IO。 + +export type SubscriptionGroupName = "following" | "waiting" | "completed" | "paused" | "pending"; + +export interface SubscriptionGroupInput { + status: "active" | "paused" | "pending" | "completed" | "cancelled"; + mediaKind: "movie" | "series" | "variety"; + mode: "once" | "continuous"; + /** 范围内各季总集数是否都已知(电影恒为 true) */ + totalsKnown: boolean; + /** 任一版本目标有已入库或在途单元 */ + anyProgress: boolean; + /** 所有版本目标在范围内全覆盖(且总集数已知) */ + allSlotsComplete: boolean; +} + +export interface SubscriptionGroupResult { + group: SubscriptionGroupName | null; + /** 已完成分组里仍在追下一季(continuous 剧集) */ + awaitingNewSeason: boolean; +} + +/** 分组按顺序判定:暂停 → 待定 → 已完成 → 追更中 → 待搜索;cancelled 不进总览 */ +export function subscriptionGroup(i: SubscriptionGroupInput): SubscriptionGroupResult { + if (i.status === "cancelled") return { group: null, awaitingNewSeason: false }; + if (i.status === "paused") return { group: "paused", awaitingNewSeason: false }; + if (i.status === "pending" || (i.mediaKind !== "movie" && !i.totalsKnown)) + return { group: "pending", awaitingNewSeason: false }; + if (i.status === "completed") return { group: "completed", awaitingNewSeason: false }; + if (i.allSlotsComplete) + return { group: "completed", awaitingNewSeason: i.mode === "continuous" && i.mediaKind !== "movie" }; + return { group: i.anyProgress ? "following" : "waiting", awaitingNewSeason: false }; +} + +export type CoverageState = "have" | "inflight" | "missing" | "skipped"; + +export interface CoverageColumnInput { + unitKey: string; + /** 用于集范围裁剪;综艺日期单元 / 电影版本为 undefined */ + episode?: number | undefined; + /** YYYY-MM-DD;晚于 today 且未入库/未在途 → skipped(not_aired) */ + airDate?: string | null | undefined; + /** 所在季总集数是否已知;未知时 missing 标 suspected */ + totalKnown?: boolean | undefined; +} + +export interface CoveredFact { + assetId: string | null; + quality?: { resolution: string | null; source: string | null; label: string | null } | null; + pri?: number | null; +} + +export interface CoverageRowInput { + columns: readonly CoverageColumnInput[]; + covered: ReadonlyMap; + inflight: readonly { id: string; stage: string; unitKeys: readonly string[] }[]; + deferredKeys?: readonly string[]; + range?: UnitRange | undefined; + /** YYYY-MM-DD */ + today: string; + /** 开了升级时:已入库 pri 低于 targetPri 标 upgradable */ + upgrade?: { enabled: boolean; targetPri: number } | undefined; +} + +export interface CoverageRowCell { + unitKey: string; + state: CoverageState; + assetId: string | null; + quality: { resolution: string | null; source: string | null; label: string | null } | null; + pri: number | null; + planId: string | null; + planStage: string | null; + reason: string | null; + upgradable: boolean; + deferred: boolean; +} + +export interface CoverageRowResult { + cells: CoverageRowCell[]; + summary: { have: number; inflight: number; missing: number; skipped: number }; + /** 已入库格的最低 pri(没有 pri 的格不计) */ + minPri: number | null; +} + +/** + * 一行(一个版本目标或"库内任一版本")的格状态:已入库 > 在途(含整季通配预留)> 范围外/未播出 > 缺失。 + * 在途按计划单元键与列键求交(unitsOverlap:s{季}* 通配与同季各集相交)。 + */ +export function coverageRow(i: CoverageRowInput): CoverageRowResult { + const deferred = new Set(i.deferredKeys ?? []); + const deferredWild = new Set( + (i.deferredKeys ?? []) + .map(parseUnitKey) + .filter((x) => x?.wildcard) + .map((x) => x!.season), + ); + const summary = { have: 0, inflight: 0, missing: 0, skipped: 0 }; + let minPri: number | null = null; + const cells = i.columns.map((col): CoverageRowCell => { + const base: CoverageRowCell = { + unitKey: col.unitKey, + state: "missing", + assetId: null, + quality: null, + pri: null, + planId: null, + planStage: null, + reason: null, + upgradable: false, + deferred: false, + }; + const pk = parseUnitKey(col.unitKey); + base.deferred = deferred.has(col.unitKey) || (pk?.episode !== undefined && deferredWild.has(pk.season)); + const cov = i.covered.get(col.unitKey); + if (cov) { + summary.have++; + const pri = cov.pri ?? null; + if (pri !== null) minPri = minPri === null ? pri : Math.min(minPri, pri); + return { + ...base, + state: "have", + assetId: cov.assetId, + quality: cov.quality ?? null, + pri, + upgradable: !!i.upgrade?.enabled && pri !== null && pri < i.upgrade.targetPri, + }; + } + const plan = i.inflight.find((p) => unitsOverlap([col.unitKey], p.unitKeys).length > 0); + if (plan) { + summary.inflight++; + return { ...base, state: "inflight", planId: plan.id, planStage: plan.stage }; + } + if (col.episode !== undefined && !inRange(col.episode, i.range)) { + summary.skipped++; + return { ...base, state: "skipped", reason: "out_of_range" }; + } + if (col.airDate && col.airDate > i.today) { + summary.skipped++; + return { ...base, state: "skipped", reason: "not_aired" }; + } + summary.missing++; + return { ...base, reason: col.totalKnown === false ? "suspected" : null }; + }); + return { cells, summary, minPri }; +}