feat(domain): subscription grouping, coverage row states, auto-fill resolution and fill budget
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
99a3e17210
commit
e77fdc6549
4 files changed
+240
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, CoveredFact>;
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user