From d162d464c4f0e40aefcc3da315f84195e20306d5 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 23:33:46 +0800 Subject: [PATCH] feat(domain): W5-C subscription gaps, episode totals, multi-inflight slot, hard conditions and priority ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveEpisodeTotal: totals only grow; TMDB regression floored at highest covered episode; manual lock - computeUnitGaps / seasonProgress / unitsOverlap: missing = units ∩ range − covered − in-flight, season wildcard reservation, suspected-missing when total unknown - transitionWithInflight: slot status aggregates several in-flight plans (Slot × unit reservation) - hard conditions: excludeSources, hdr sets, codec sets, per-unit size, include/exclude regex (whitelist-validated), site scope, minSeeders, freeOnly (site facts via EvaluationContext) - priority ladder (MP rule-group tiers) → pri 0-100 with per-tier trace; priority-basis upgrades, targetPri ceiling, upgrade modes movie/per_unit/full_pack, improvableUnits - all evaluations carry readable trace via describeViolation Co-Authored-By: Claude Opus 5.5 --- packages/domain/README.md | 2 +- packages/domain/src/slot/episode-total.ts | 75 +++++ packages/domain/src/slot/evaluate.ts | 388 ++++++++++++++++++++-- packages/domain/src/slot/gaps.ts | 154 +++++++++ packages/domain/src/slot/index.ts | 27 ++ packages/domain/src/slot/inflight.ts | 68 ++++ packages/domain/src/slot/types.ts | 121 ++++++- packages/domain/test/slot.test.ts | 4 +- packages/domain/test/subscription.test.ts | 226 +++++++++++++ 9 files changed, 1023 insertions(+), 42 deletions(-) create mode 100644 packages/domain/src/slot/episode-total.ts create mode 100644 packages/domain/src/slot/gaps.ts create mode 100644 packages/domain/src/slot/inflight.ts create mode 100644 packages/domain/test/subscription.test.ts diff --git a/packages/domain/README.md b/packages/domain/README.md index f8356061..39ba8c3d 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -12,7 +12,7 @@ | --- | --- | --- | | `release-parser/` | `parseRelease(title, opts)`、`parseReleasePath(path \| segments, opts)`、`mergeParsed`、`scanCnMarkers`、`evalEpisodeOffset`、`cnToInt`、`fileKindOf`、`expandEpisodes`、`RELEASE_GROUP_SEED`、`PARSER_VERSION` | 标题/文件名/目录名 → `ParsedRelease`。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 | | `identity/` | `scoreCandidate`、`rankCandidates`、`resolveIdentity`、`normalizeTitle`、`stripSeasonSuffix`、`toSimplified` | 候选(TMDB 或本地别名召回,统一为 `IdentityCandidate`)按证据等级排序;年份容差 ±1;只给等级不给概率 | -| `slot/` | `evaluateCandidate(spec, parsed, current?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`transition`/`canTransition`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets` | 硬条件(区分缺字段与不满足)、软偏好排序、升级阈值、状态机、完成条件 | +| `slot/` | `evaluateCandidate(spec, parsed, current?, ctx?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`priorityLadder`/`currentPri`、`improvableUnits`、`describeViolation`、`validateKeywordPattern`、`transition`/`canTransition`、`transitionWithInflight`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets`、`resolveEpisodeTotal`、`computeUnitGaps`/`seasonProgress`/`unitsOverlap` | 硬条件(区分缺字段与不满足;含排除来源、HDR/编码集合、每集体积、关键词正则、站点/做种/免费,站点事实经 `EvaluationContext`)、软偏好排序、优先级阶梯(MP `>` 分层 → pri 0–100 + 逐层 trace)、升级阈值与洗版模式(movie/per_unit/full_pack)、状态机(含 Slot×单元多在途聚合)、完成条件、季总集数账本(只升、回退不低于已覆盖最高集)、缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 | | `acquisition/` | `determineMode`、`sortByPriority`/`priorityOf`、`checkBudget`、`evaluateCacheVolume` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 | | `path/` | `resolveTargetDirectory`、`renderMediaFileName`、`renderTemplate`/`compileTemplate`、`buildNamingContext`、`sanitizeSegment`/`sanitizeFileName`、`detectPathConflicts`、`toHostPath`/`toDownloaderPath`、`validatePathMappings` | 路径绑定(已有优先、不可变)、受限模板命名、非法字符、冲突结构、下载器↔宿主路径映射 | | `organize/` | `planOrganize(input) → OrganizePlan`、`sidecarStem`、`sidecarLanguageSuffix` | 主视频 + 随行字幕/音轨、样例排除、目标存在策略、跨卷标记、H&R 前置条件 | diff --git a/packages/domain/src/slot/episode-total.ts b/packages/domain/src/slot/episode-total.ts new file mode 100644 index 00000000..8fb1a541 --- /dev/null +++ b/packages/domain/src/slot/episode-total.ts @@ -0,0 +1,75 @@ +// 季总集数账本(对照 MP __resolve_effective_total_episode / __resolve_total_episode_decrease / +// SubscribeEpisodesRefresh 只升注入):纯函数,来源值由调用方给。 + +export type EpisodeTotalSource = "tmdb" | "injected" | "manual" | "covered_floor" | "previous"; + +export interface EpisodeTotalInput { + /** 账本里现有的总集数(null = 未知) */ + previous: number | null; + /** 元数据(TMDB)本次给出的集数;0 / null = 未公布 */ + fresh?: number | null; + /** 插件/Agent 注入值:只升不降 */ + injected?: number | null; + /** 已存在(入库覆盖)的最高集号:回退时不得低于它 */ + coveredMax?: number | null; + /** 人工锁定:锁定后元数据与注入都不再改写 */ + locked?: boolean; +} + +export interface EpisodeTotalResult { + total: number | null; + /** 总集数是否已知(未知时追更不结束) */ + known: boolean; + source: EpisodeTotalSource; + changed: boolean; + /** 元数据回退被已有最高集号兜住 */ + clampedByCovered: boolean; +} + +const pos = (v: number | null | undefined): number | null => + typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : null; + +/** + * 解析总集数: + * - 锁定 → 保持现值; + * - 元数据变大 → 采用(追更范围扩大);元数据变小 → 取 max(新值, 已存在最高集号)(防 TMDB 临时回退误完成); + * - 注入值只升;元数据未公布(0/null)→ 保持现值,现值也没有 → 未知。 + */ +export function resolveEpisodeTotal(input: EpisodeTotalInput): EpisodeTotalResult { + const prev = pos(input.previous); + const covered = pos(input.coveredMax); + const keep = (source: EpisodeTotalSource = "previous"): EpisodeTotalResult => ({ + total: prev, + known: prev !== null, + source, + changed: false, + clampedByCovered: false, + }); + if (input.locked) return keep("manual"); + const fresh = pos(input.fresh); + const injected = pos(input.injected); + let total = prev; + let source: EpisodeTotalSource = "previous"; + let clamped = false; + if (fresh !== null) { + if (prev === null || fresh >= prev) { + total = fresh; + source = "tmdb"; + } else { + const floor = Math.max(fresh, covered ?? 0); + total = floor; + source = floor === fresh ? "tmdb" : "covered_floor"; + clamped = floor !== fresh; + } + } + if (injected !== null && (total === null || injected > total)) { + total = injected; + source = "injected"; + clamped = false; + } + if (total === null && covered !== null) { + // 没有任何来源:已见过的最高集只说明"至少这么多",不当作已知总数 + return { total: null, known: false, source: "previous", changed: false, clampedByCovered: false }; + } + return { total, known: total !== null, source, changed: total !== prev, clampedByCovered: clamped }; +} diff --git a/packages/domain/src/slot/evaluate.ts b/packages/domain/src/slot/evaluate.ts index bc1e671f..2451c96f 100644 --- a/packages/domain/src/slot/evaluate.ts +++ b/packages/domain/src/slot/evaluate.ts @@ -1,10 +1,13 @@ import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types.ts"; import type { + EvaluationContext, HardField, + LadderResult, QualityFacts, QualityTier, SlotEvaluation, SlotSpec, + SlotTierRule, UnknownPolicy, Violation, } from "./types.ts"; @@ -57,6 +60,7 @@ function subtitleSatisfied(have: string[], need: string): boolean { export function checkHardConditions( spec: SlotSpec, r: ParsedRelease, + ctx: EvaluationContext = {}, ): { violations: Violation[]; missing: HardField[]; accepted: HardField[] } { const h = spec.hard; const violations: Violation[] = []; @@ -114,9 +118,258 @@ export function checkHardConditions( else if (!h.contentTypes.includes(r.contentType)) violations.push({ field: "contentType", code: "not_allowed", expected: h.contentTypes, actual: r.contentType }); } + if (h.excludeSources?.length) { + if (!r.source) unknown("source"); + else if (h.excludeSources.includes(r.source)) + violations.push({ field: "source", code: "excluded", expected: h.excludeSources, actual: r.source }); + } + if (h.hdr) { + const have = r.hdr ?? []; + const { anyOf, noneOf, requireSdr } = h.hdr; + if (anyOf?.length && !anyOf.some((x) => have.includes(x))) + violations.push({ field: "hdr", code: "missing_required", expected: anyOf, actual: have }); + const hit = (noneOf ?? []).filter((x) => have.includes(x)); + if (hit.length) violations.push({ field: "hdr", code: "excluded", expected: noneOf, actual: hit }); + if (requireSdr && have.length) violations.push({ field: "hdr", code: "not_sdr", actual: have }); + } + if (h.codecs?.length || h.excludeCodecs?.length) { + if (!r.videoCodec) unknown("codec"); + else { + if (h.codecs?.length && !h.codecs.includes(r.videoCodec)) + violations.push({ field: "codec", code: "not_allowed", expected: h.codecs, actual: r.videoCodec }); + if (h.excludeCodecs?.includes(r.videoCodec)) + violations.push({ field: "codec", code: "excluded", expected: h.excludeCodecs, actual: r.videoCodec }); + } + } + if (h.minUnitSizeBytes !== undefined || h.maxUnitSizeBytes !== undefined) { + const n = unitCountOf(r, ctx); + if (r.sizeBytes === undefined || n === undefined) unknown("size"); + else { + const per = Math.round(r.sizeBytes / n); + if (h.maxUnitSizeBytes !== undefined && per > h.maxUnitSizeBytes) + violations.push({ field: "size", code: "unit_too_large", expected: h.maxUnitSizeBytes, actual: per }); + if (h.minUnitSizeBytes !== undefined && per < h.minUnitSizeBytes) + violations.push({ field: "size", code: "unit_too_small", expected: h.minUnitSizeBytes, actual: per }); + } + } + if (h.include?.length || h.exclude?.length) { + const text = matchText(r, ctx); + const bad = [...(h.include ?? []), ...(h.exclude ?? [])].filter((p) => validateKeywordPattern(p) !== null); + if (bad.length) violations.push({ field: "keywords", code: "invalid_pattern", actual: bad }); + else { + if (h.include?.length && !h.include.some((p) => keywordRe(p).test(text))) + violations.push({ field: "keywords", code: "include_missed", expected: h.include }); + const hits = (h.exclude ?? []).filter((p) => keywordRe(p).test(text)); + if (hits.length) violations.push({ field: "keywords", code: "exclude_hit", actual: hits }); + } + } + if (h.sites?.length || h.excludeSites?.length) { + if (!ctx.siteId) unknown("site"); + else { + if (h.sites?.length && !h.sites.includes(ctx.siteId)) + violations.push({ field: "site", code: "not_in_scope", expected: h.sites, actual: ctx.siteId }); + if (h.excludeSites?.includes(ctx.siteId)) + violations.push({ field: "site", code: "excluded", actual: ctx.siteId }); + } + } + if (h.minSeeders !== undefined) { + if (ctx.seeders === undefined) unknown("seeders"); + else if (ctx.seeders < h.minSeeders) + violations.push({ field: "seeders", code: "too_few", expected: h.minSeeders, actual: ctx.seeders }); + } + if (h.freeOnly) { + if (ctx.downloadVolumeFactor === undefined) unknown("promotion"); + else if (ctx.downloadVolumeFactor > 0) + violations.push({ field: "promotion", code: "not_free", actual: ctx.downloadVolumeFactor }); + } return { violations, missing, accepted }; } +/** 候选覆盖的单元数:上下文给定 > 解析出的集范围 > 标题里的"全 N 集" */ +export function unitCountOf(r: Pick, ctx: EvaluationContext = {}) { + if (ctx.unitCount !== undefined && ctx.unitCount > 0) return ctx.unitCount; + if (r.episodes) return Math.max(1, r.episodes.end - r.episodes.start + 1); + if (r.totalEpisodes && r.totalEpisodes > 0) return r.totalEpisodes; + return undefined; +} + +const MAX_PATTERN = 200; + +/** + * 关键词正则白名单校验:长度上限、禁止反向引用/后行断言/嵌套量词(防灾难性回溯),必须能编译。 + * 返回 null 表示合法,否则为原因。 + */ +export function validateKeywordPattern(p: string): string | null { + if (typeof p !== "string" || p.length === 0) return "empty"; + if (p.length > MAX_PATTERN) return "too_long"; + if (/\\[1-9]|\\k(); +function keywordRe(p: string): RegExp { + let re = reCache.get(p); + if (!re) { + re = new RegExp(p, "i"); + if (reCache.size > 500) reCache.clear(); + reCache.set(p, re); + } + return re; +} + +function matchText(r: Pick, ctx: EvaluationContext): string { + return [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join("\n"); +} + +const GiB = 1024 ** 3; +const fmtBytes = (v: unknown) => + typeof v === "number" ? (v >= GiB ? `${(v / GiB).toFixed(2)} GiB` : `${Math.round(v / 1024 ** 2)} MiB`) : String(v); +const fmtList = (v: unknown) => (Array.isArray(v) ? `[${v.join(", ")}]` : String(v ?? "未知")); + +/** 违规 → 可读说明(对齐 MP trace 的可读性,但字段结构化保留在 violations) */ +export function describeViolation(v: Violation): string { + const e = v.expected; + const a = v.actual; + switch (`${v.field}.${v.code}`) { + case "resolution.not_allowed": + return `分辨率 ${a} 不在允许列表 ${fmtList(e)}`; + case "source.not_allowed": + return `来源 ${a} 不在允许列表 ${fmtList(e)}`; + case "source.excluded": + return `来源 ${a} 被排除`; + case "source.not_encode": + return `要求蓝光压制,实际 ${a}`; + case "size.too_large": + return `体积 ${fmtBytes(a)} 超过上限 ${fmtBytes(e)}`; + case "size.too_small": + return `体积 ${fmtBytes(a)} 低于下限 ${fmtBytes(e)}`; + case "size.unit_too_large": + return `每集体积 ${fmtBytes(a)} 超过上限 ${fmtBytes(e)}`; + case "size.unit_too_small": + return `每集体积 ${fmtBytes(a)} 低于下限 ${fmtBytes(e)}`; + case "subtitles.missing_language": + return `缺少字幕 ${fmtList(e)}(实际 ${fmtList(a)})`; + case "cut.not_allowed": + return `版本 ${a} 不在允许列表 ${fmtList(e)}`; + case "group.excluded": + return `发布组 ${a} 被排除`; + case "contentType.not_allowed": + return `内容类型 ${a} 不在允许列表 ${fmtList(e)}`; + case "hdr.missing_required": + return `HDR 需含 ${fmtList(e)} 之一(实际 ${fmtList(a)})`; + case "hdr.excluded": + return `HDR ${fmtList(a)} 被排除`; + case "hdr.not_sdr": + return `要求 SDR,实际带 ${fmtList(a)}`; + case "codec.not_allowed": + return `编码 ${a} 不在允许列表 ${fmtList(e)}`; + case "codec.excluded": + return `编码 ${a} 被排除`; + case "keywords.include_missed": + return `不包含任何关键词 ${fmtList(e)}`; + case "keywords.exclude_hit": + return `命中排除关键词 ${fmtList(a)}`; + case "keywords.invalid_pattern": + return `关键词正则不合法 ${fmtList(a)}`; + case "site.not_in_scope": + return "站点不在范围内"; + case "site.excluded": + return "站点被排除"; + case "seeders.too_few": + return `做种数 ${a} 小于 ${e}`; + case "promotion.not_free": + return `非免费(下载系数 ${a})`; + case "tier.no_tier_matched": + return "不命中优先级阶梯的任何一层"; + case "upgrade.upgrade_disabled": + return "已有版本且未开启升级"; + case "upgrade.at_ceiling": + return "当前版本已达顶档,不再升级"; + case "upgrade.same_tier": + return "与当前版本同档,不反复更换"; + case "upgrade.lower_tier": + return "低于当前版本"; + case "upgrade.below_min_improvement": + return "提升幅度不足最小改进阈值"; + case "upgrade.encode_not_qualified": + return `压制组 ${a ?? "未知"} 不在合格名单`; + case "upgrade.not_higher_priority": + return `阶梯 pri 未严格提升(当前 ${JSON.stringify(e)},候选 ${JSON.stringify(a)})`; + case "upgrade.not_full_pack": + return "全集洗版只接受整季包"; + case "upgrade.no_improvable_units": + return "候选所含单元都没有可提升的"; + default: + return e === undefined + ? `${v.field}: ${v.code}` + : `${v.field}: ${v.code}(期望 ${JSON.stringify(e)},实际 ${JSON.stringify(a ?? null)})`; + } +} + +/** 阶梯层匹配:返回未满足的条件说明(空 = 命中) */ +function tierFailures(rule: SlotTierRule, r: ParsedRelease, ctx: EvaluationContext): string[] { + const out: string[] = []; + if (rule.resolutions?.length && !(r.resolution && rule.resolutions.includes(r.resolution))) + out.push(`分辨率 ${r.resolution ?? "未知"} 不在 ${fmtList(rule.resolutions)}`); + if (rule.sources?.length && !(r.source && rule.sources.includes(r.source))) + out.push(`来源 ${r.source ?? "未知"} 不在 ${fmtList(rule.sources)}`); + if (rule.excludeSources?.length && (!r.source || rule.excludeSources.includes(r.source))) + out.push(`来源 ${r.source ?? "未知"} 被本层排除`); + if (rule.requireEncode && !r.isEncode) out.push("不是蓝光压制"); + if (rule.hdr?.length && !rule.hdr.some((x) => (r.hdr ?? []).includes(x))) + out.push(`HDR ${fmtList(r.hdr ?? [])} 不含 ${fmtList(rule.hdr)}`); + if (rule.codecs?.length && !(r.videoCodec && rule.codecs.includes(r.videoCodec))) + out.push(`编码 ${r.videoCodec ?? "未知"} 不在 ${fmtList(rule.codecs)}`); + if (rule.groups?.length) { + const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; + if (!rule.groups.some((g) => groups.includes(lc(g)))) out.push(`发布组 ${r.releaseGroup?.name ?? "未知"} 不在名单`); + } + if (rule.include?.length || rule.exclude?.length) { + const text = matchText(r, ctx); + const ok = (p: string) => validateKeywordPattern(p) === null; + if (rule.include?.length && !rule.include.some((p) => ok(p) && keywordRe(p).test(text))) + out.push(`不含关键词 ${fmtList(rule.include)}`); + const hit = (rule.exclude ?? []).filter((p) => ok(p) && keywordRe(p).test(text)); + if (hit.length) out.push(`命中排除词 ${fmtList(hit)}`); + } + return out; +} + +/** + * 优先级阶梯(对应 MP 规则组 `A > B > C`):逐层匹配,第 k 层(0 起)命中 → pri = 100 - k; + * 未配置阶梯 → pri = 100、level = null(不参与排序差异);配置了但都不命中 → pri = 0。 + */ +export function priorityLadder(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}): LadderResult { + const tiers = spec.soft?.tiers ?? []; + if (tiers.length === 0) return { pri: 100, level: null, levelName: null, trace: [] }; + const trace: string[] = []; + for (let k = 0; k < tiers.length; k++) { + const t = tiers[k]!; + const name = t.name ?? `第${k + 1}层`; + const fail = tierFailures(t, r, ctx); + if (fail.length === 0) { + trace.push(`${name}:命中,pri=${100 - k}`); + return { pri: 100 - k, level: k, levelName: name, trace }; + } + trace.push(`${name}:${fail.join(";")}`); + } + return { pri: 0, level: null, levelName: null, trace }; +} + +/** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */ +export function currentPri(spec: SlotSpec, current: QualityFacts): number { + if (typeof current.pri === "number") return current.pri; + const pseudo = { raw: "", hdr: [], isEncode: false, ...current } as unknown as ParsedRelease; + return priorityLadder(spec, pseudo).pri; +} + function prefIndexScore(pref: readonly T[] | undefined, have: readonly T[]): number { if (!pref?.length) return 0; for (let i = 0; i < pref.length; i++) if (have.includes(pref[i]!)) return pref.length - i; @@ -155,27 +408,78 @@ export function compareRank(a: readonly number[], b: readonly number[]): number * 候选对某个 Slot 的确定性评估。 * - 已知不满足硬条件 → reject(列出全部违规) * - 硬条件字段缺失且策略为 hold → unknown_field(区分缺字段与不满足) - * - 无当前资产 → accept(附软偏好排序键) - * - 有当前资产 → 仅在达到最小改进阈值时 upgrade,否则 reject(同等级不反复更换) + * - 配置了阶梯且 tiersRequired、一层都不命中 → reject(tier.no_tier_matched) + * - 无当前资产 → accept(附软偏好排序键、阶梯 pri) + * - 有当前资产 → 仅在达到最小改进阈值时 upgrade,否则 reject(同等级不反复更换); + * priority 口径下要求 pri 严格提升(对照 MP 洗版 pri_order > current_priority) + * 所有结果带 trace(可读说明)。 */ -export function evaluateCandidate(spec: SlotSpec, r: ParsedRelease, current?: QualityFacts | null): SlotEvaluation { - const { violations, missing, accepted } = checkHardConditions(spec, r); - if (violations.length > 0) return { decision: "reject", violations }; - if (missing.length > 0) return { decision: "unknown_field", missing }; +export function evaluateCandidate( + spec: SlotSpec, + r: ParsedRelease, + current?: QualityFacts | null, + ctx: EvaluationContext = {}, +): SlotEvaluation { + const { violations, missing, accepted } = checkHardConditions(spec, r, ctx); + const ladder = priorityLadder(spec, r, ctx); + if (violations.length > 0) + return { decision: "reject", violations, trace: [...violations.map(describeViolation), ...ladder.trace], ladder }; + if (missing.length > 0) + return { decision: "unknown_field", missing, trace: missing.map((f) => `字段未知,需要补充:${f}`) }; + if (spec.soft?.tiers?.length && spec.soft.tiersRequired && ladder.level === null) { + const v: Violation = { field: "tier", code: "no_tier_matched" }; + return { decision: "reject", violations: [v], trace: [describeViolation(v), ...ladder.trace], ladder }; + } const tier = qualityTier(r, spec); const rank = softRank(spec, r); const reasons = accepted.map((f) => `unknown_${f}_accepted_by_policy`); - if (!current) return { decision: "accept", tier, softRank: rank, reasons: [...reasons, "hard_conditions_met"] }; + const tiered = !!spec.soft?.tiers?.length; + const priReasons = tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : []; + if (!current) + return { + decision: "accept", + tier, + softRank: rank, + reasons: [...reasons, "hard_conditions_met", ...priReasons], + pri: ladder.pri, + ladder, + trace: ["硬条件全部满足", ...ladder.trace], + }; + const reject = (v: Violation): SlotEvaluation => ({ + decision: "reject", + violations: [v], + trace: [describeViolation(v), ...ladder.trace], + ladder, + }); const up = spec.upgrade; - if (!up?.enabled) return { decision: "reject", violations: [{ field: "upgrade", code: "upgrade_disabled" }] }; - if (!current.source) return { decision: "unknown_field", missing: ["current_asset"] }; + if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" }); + if (up.mode === "full_pack" && (r.episodes || r.airDate)) return reject({ field: "upgrade", code: "not_full_pack" }); + const basis = up.basis ?? (tiered ? "priority" : "tier"); + if (basis === "priority") { + const fromPri = currentPri(spec, current); + const target = up.targetPri ?? 100; + if (fromPri >= target) return reject({ field: "upgrade", code: "at_ceiling", expected: target, actual: fromPri }); + if (ladder.pri <= fromPri) + return reject({ field: "upgrade", code: "not_higher_priority", expected: fromPri, actual: ladder.pri }); + const from = qualityTier(current, spec); + return { + decision: "upgrade", + tier, + fromTier: from, + softRank: rank, + reasons: [...reasons, "priority_improved", ...priReasons], + pri: ladder.pri, + fromPri, + ladder, + trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace], + }; + } + if (!current.source) + return { decision: "unknown_field", missing: ["current_asset"], trace: ["当前资产交付特征未知"] }; const from = qualityTier(current, spec); if (up.ceilingTier !== undefined && from.source >= up.ceilingTier) - return { - decision: "reject", - violations: [{ field: "upgrade", code: "at_ceiling", expected: up.ceilingTier, actual: from.source }], - }; + return reject({ field: "upgrade", code: "at_ceiling", expected: up.ceilingTier, actual: from.source }); const minSteps = up.minTierSteps ?? 1; const dSource = tier.source - from.source; const dRes = tier.resolution - from.resolution; @@ -184,47 +488,68 @@ export function evaluateCandidate(spec: SlotSpec, r: ParsedRelease, current?: Qu if (!bySource && !byRes) { const code = dSource === 0 && dRes === 0 ? "same_tier" : dSource < 0 || dRes < 0 ? "lower_tier" : "below_min_improvement"; - return { - decision: "reject", - violations: [{ field: "upgrade", code, expected: { minTierSteps: minSteps, from }, actual: tier }], - }; + return reject({ field: "upgrade", code, expected: { minTierSteps: minSteps, from }, actual: tier }); } if (r.isEncode && up.encodeGroupAllowlist?.length) { const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g)))) - return { - decision: "reject", - violations: [ - { - field: "upgrade", - code: "encode_not_qualified", - expected: up.encodeGroupAllowlist, - actual: r.releaseGroup?.name, - }, - ], - }; + return reject({ + field: "upgrade", + code: "encode_not_qualified", + expected: up.encodeGroupAllowlist, + actual: r.releaseGroup?.name, + }); } return { decision: "upgrade", tier, fromTier: from, softRank: rank, - reasons: [...reasons, bySource ? "source_tier_improved" : "resolution_improved"], + reasons: [...reasons, bySource ? "source_tier_improved" : "resolution_improved", ...priReasons], + pri: ladder.pri, + ladder, + trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace], }; } +/** + * 分集洗版:候选所含单元里哪些值得替换(对照 MP get_best_version_interested_episodes)。 + * unitCurrent 缺某单元 = 该单元还没有版本(视为可填补)。返回可提升单元与逐单元结论。 + */ +export function improvableUnits( + spec: SlotSpec, + r: ParsedRelease, + units: readonly string[], + unitCurrent: Readonly>, + ctx: EvaluationContext = {}, +): { units: string[]; perUnit: Record } { + const perUnit: Record = {}; + const out: string[] = []; + for (const u of units) { + const ev = evaluateCandidate(spec, r, unitCurrent[u] ?? null, ctx); + perUnit[u] = ev.decision; + if (ev.decision === "accept" || ev.decision === "upgrade") out.push(u); + } + return { units: out, perUnit }; +} + export interface RankedEvaluation { candidate: T; evaluation: SlotEvaluation; } -/** 对一批候选评估并排序:可用(accept/upgrade)在前,按质量等级再按软偏好;其余保持原顺序 */ +/** 对一批候选评估并排序:可用(accept/upgrade)在前,按阶梯 pri、质量等级再按软偏好;其余保持原顺序 */ export function rankForSlot( spec: SlotSpec, candidates: readonly T[], current?: QualityFacts | null, + ctxOf?: (c: T) => EvaluationContext, ): RankedEvaluation[] { - const evals = candidates.map((c, i) => ({ candidate: c, evaluation: evaluateCandidate(spec, c, current), i })); + const evals = candidates.map((c, i) => ({ + candidate: c, + evaluation: evaluateCandidate(spec, c, current, ctxOf?.(c) ?? {}), + i, + })); const usable = (e: SlotEvaluation) => e.decision === "accept" || e.decision === "upgrade"; return evals .sort((a, b) => { @@ -235,6 +560,7 @@ export function rankForSlot( const ea = a.evaluation as Extract; const eb = b.evaluation as Extract; return ( + (eb.pri ?? 100) - (ea.pri ?? 100) || eb.tier.source - ea.tier.source || eb.tier.resolution - ea.tier.resolution || compareRank(eb.softRank, ea.softRank) || diff --git a/packages/domain/src/slot/gaps.ts b/packages/domain/src/slot/gaps.ts new file mode 100644 index 00000000..fd3ae549 --- /dev/null +++ b/packages/domain/src/slot/gaps.ts @@ -0,0 +1,154 @@ +// 缺集计算(对照 MP no_exists:媒体库已有 ∩ 订阅范围 − note): +// 单元用字符串键表示(content_unit id 或合成键 s{季}e{集});在途计划的单元等价 MP note(添加成功即记)。 +// 通配键 s{季}* 表示"整季、具体集未知"(季包提交前无法开种子核对时的预留)。 + +export interface UnitRange { + start?: number; + end?: number; +} + +/** 合成单元键 */ +export const episodeKey = (season: number, episode: number) => `s${season}e${episode}`; +export const seasonWildcard = (season: number) => `s${season}*`; + +const EP_RE = /^s(\d+)e(\d+)$/; +const WILD_RE = /^s(\d+)\*$/; + +/** 解析合成键:{season, episode} | {season, wildcard} | null(content_unit id 等) */ +export function parseUnitKey(k: string): { season: number; episode?: number; wildcard?: true } | null { + const e = EP_RE.exec(k); + if (e) return { season: Number(e[1]), episode: Number(e[2]) }; + const w = WILD_RE.exec(k); + if (w) return { season: Number(w[1]), wildcard: true }; + return null; +} + +/** 两组单元是否有交集(通配键与同季任何集键相交) */ +export function unitsOverlap(a: readonly string[], b: readonly string[]): string[] { + const out = new Set(); + const bs = new Set(b); + const bWild = new Set( + b + .map(parseUnitKey) + .filter((x) => x?.wildcard) + .map((x) => x!.season), + ); + const bSeasons = new Set( + b + .map(parseUnitKey) + .filter((x) => x && !x.wildcard) + .map((x) => x!.season), + ); + for (const k of a) { + if (bs.has(k)) { + out.add(k); + continue; + } + const p = parseUnitKey(k); + if (!p) continue; + if (p.wildcard ? bWild.has(p.season) || bSeasons.has(p.season) : bWild.has(p.season)) out.add(k); + } + return [...out]; +} + +export function inRange(episode: number | undefined, range?: UnitRange): boolean { + if (!range || episode === undefined) return true; + if (range.start !== undefined && episode < range.start) return false; + if (range.end !== undefined && episode > range.end) return false; + return true; +} + +export interface UnitGapInput { + /** 候选/目标涉及的单元键 */ + units: readonly string[]; + /** 已入库覆盖(文件清单证实) */ + covered: readonly string[]; + /** 在途计划预留 */ + inflight: readonly string[]; + /** 订阅集范围裁剪(只作用于合成集键) */ + range?: UnitRange; +} + +export interface UnitGaps { + /** 需要下载的单元 */ + missing: string[]; + /** 已覆盖 */ + covered: string[]; + /** 已在途(等价 MP note) */ + reserved: string[]; + /** 不在订阅范围内 */ + outOfRange: string[]; +} + +/** 缺集 = 单元 ∩ 范围 − 已覆盖 − 在途 */ +export function computeUnitGaps(input: UnitGapInput): UnitGaps { + const cov = new Set(input.covered); + const out: UnitGaps = { missing: [], covered: [], reserved: [], outOfRange: [] }; + const reserved = new Set(unitsOverlap(input.units, input.inflight)); + for (const u of input.units) { + const p = parseUnitKey(u); + if (p && !p.wildcard && !inRange(p.episode, input.range)) out.outOfRange.push(u); + else if (cov.has(u)) out.covered.push(u); + else if (reserved.has(u)) out.reserved.push(u); + else out.missing.push(u); + } + return out; +} + +/** 季内集号进度:已知总数时列出缺集;未知时只报告"疑似缺集"(已见最高集以下的空洞) */ +export interface SeasonProgressInput { + season: number; + total: number | null; + totalKnown: boolean; + covered: readonly number[]; + inflight: readonly number[]; + range?: UnitRange; +} + +export interface SeasonProgress { + season: number; + total: number | null; + totalKnown: boolean; + covered: number[]; + inflight: number[]; + /** 总集数已知时:范围内未覆盖且不在途的集 */ + missing: number[]; + /** 总集数未知时:已见最高集以下未覆盖的集(继续追踪并提示) */ + suspectedMissing: number[]; + complete: boolean; + keepTracking: boolean; +} + +export function seasonProgress(i: SeasonProgressInput): SeasonProgress { + const start = Math.max(1, i.range?.start ?? 1); + const cov = new Set(i.covered); + const inf = new Set(i.inflight); + const covered = [...cov].filter((e) => inRange(e, i.range)).sort((a, b) => a - b); + const inflight = [...inf].filter((e) => !cov.has(e) && inRange(e, i.range)).sort((a, b) => a - b); + const missing: number[] = []; + const suspected: number[] = []; + if (i.totalKnown && i.total !== null) { + const end = Math.min(i.total, i.range?.end ?? i.total); + for (let e = start; e <= end && missing.length < 2000; e++) if (!cov.has(e) && !inf.has(e)) missing.push(e); + } else { + const max = Math.max(0, ...covered, ...inflight); + for (let e = start; e < max && suspected.length < 2000; e++) if (!cov.has(e) && !inf.has(e)) suspected.push(e); + } + const end = i.totalKnown && i.total !== null ? Math.min(i.total, i.range?.end ?? i.total) : null; + const allCovered = + end !== null && + end >= start && + Array.from({ length: end - start + 1 }, (_, k) => start + k).every((e) => cov.has(e)); + const complete = i.totalKnown && allCovered; + return { + season: i.season, + total: i.total, + totalKnown: i.totalKnown, + covered, + inflight, + missing, + suspectedMissing: suspected, + complete, + keepTracking: !complete, + }; +} diff --git a/packages/domain/src/slot/index.ts b/packages/domain/src/slot/index.ts index a72c499f..aa086c69 100644 --- a/packages/domain/src/slot/index.ts +++ b/packages/domain/src/slot/index.ts @@ -6,17 +6,44 @@ export { type UnitCompletion, unitCompletion, } from "./completion.ts"; +export { + type EpisodeTotalInput, + type EpisodeTotalResult, + type EpisodeTotalSource, + resolveEpisodeTotal, +} from "./episode-total.ts"; export { checkHardConditions, compareRank, + currentPri, DEFAULT_SOURCE_TIERS, + describeViolation, evaluateCandidate, + improvableUnits, + priorityLadder, qualityTier, type RankedEvaluation, RESOLUTION_RANK, rankForSlot, softRank, + unitCountOf, + validateKeywordPattern, } from "./evaluate.ts"; +export { + computeUnitGaps, + episodeKey, + inRange, + parseUnitKey, + type SeasonProgress, + type SeasonProgressInput, + seasonProgress, + seasonWildcard, + type UnitGapInput, + type UnitGaps, + type UnitRange, + unitsOverlap, +} from "./gaps.ts"; +export { busyStatus, type PlanStage, stageAfter, transitionWithInflight } from "./inflight.ts"; export { canTransition, type SlotEvent, diff --git a/packages/domain/src/slot/inflight.ts b/packages/domain/src/slot/inflight.ts new file mode 100644 index 00000000..e4ddf2f1 --- /dev/null +++ b/packages/domain/src/slot/inflight.ts @@ -0,0 +1,68 @@ +import { type SlotEvent, type SlotState, type SlotStatus, transition } from "./state.ts"; + +// Slot × 单元多在途:同一 Slot 可有多个单元不相交的在途计划(剧集)。 +// Slot 状态 = 在途计划里最靠后的阶段;没有在途时按状态机落到静止态(empty/satisfied/attention)。 + +export type PlanStage = "reserved" | "downloading" | "verifying"; + +const STAGE_RANK: Record = { reserved: 1, downloading: 2, verifying: 3 }; +const BUSY = new Set(["reserved", "downloading", "verifying"]); + +export function busyStatus(stages: readonly PlanStage[]): PlanStage | null { + let best: PlanStage | null = null; + for (const s of stages) if (!best || STAGE_RANK[s] > STAGE_RANK[best]) best = s; + return best; +} + +/** 事件发生后,触发该事件的计划自身所处阶段(null = 已离开在途) */ +export function stageAfter(event: SlotEvent["type"]): PlanStage | null { + switch (event) { + case "reserve": + return "reserved"; + case "download_started": + return "downloading"; + case "download_completed": + return "verifying"; + default: + return null; + } +} + +/** 静止态:没有在途计划时的落点 */ +function restAfter(state: SlotState, event: SlotEvent): SlotState | null { + switch (event.type) { + case "verify_passed": + return { status: event.complete ? "satisfied" : "empty", hasAsset: true }; + case "release": + return { status: state.hasAsset ? "satisfied" : "empty", hasAsset: state.hasAsset }; + case "download_failed": + case "verify_failed": + return { status: "attention", hasAsset: state.hasAsset }; + default: + return null; + } +} + +/** + * 带在途计划的 Slot 迁移: + * - others = 同 Slot 其它在途计划的阶段;own = 本事件后本计划的阶段; + * - 仍有在途 → 状态取最靠后阶段(hasAsset 按事件更新); + * - 没有在途 → 先按严格状态机,非法时(多在途期间状态被聚合)落到静止态; + * Slot 已是静止态且严格迁移非法 → null(可能已被人工处理,不改写)。 + */ +export function transitionWithInflight( + state: SlotState, + event: SlotEvent, + others: readonly PlanStage[], +): SlotState | null { + const own = stageAfter(event.type); + const busy = busyStatus(own ? [...others, own] : others); + const hasAsset = event.type === "verify_passed" ? true : state.hasAsset; + if (busy) return { status: busy, hasAsset }; + try { + return transition(state, event); + } catch { + if (!BUSY.has(state.status)) return null; + return restAfter(state, event); + } +} diff --git a/packages/domain/src/slot/types.ts b/packages/domain/src/slot/types.ts index e31b8197..bbc9c333 100644 --- a/packages/domain/src/slot/types.ts +++ b/packages/domain/src/slot/types.ts @@ -9,7 +9,20 @@ import type { } from "../release-parser/types.ts"; /** 可判定的硬条件字段 */ -export type HardField = "resolution" | "source" | "size" | "subtitles" | "cut" | "group" | "contentType"; +export type HardField = + | "resolution" + | "source" + | "size" + | "subtitles" + | "cut" + | "group" + | "contentType" + | "hdr" + | "codec" + | "keywords" + | "site" + | "seeders" + | "promotion"; /** 字段缺失时的处理:hold = 返回 unknown_field 等待补充;reject = 拒绝;accept = 放行 */ export type UnknownPolicy = "hold" | "reject" | "accept"; @@ -27,6 +40,39 @@ export interface SlotHardConditions { cuts?: (Cut | "default")[]; excludeGroups?: string[]; contentTypes?: ContentType[]; + /** 排除的来源(如不要 REMUX/原盘);与 sources 允许列表可同时使用 */ + excludeSources?: ReleaseSource[]; + /** HDR 条件:anyOf 至少含其一;noneOf 一个都不能含;requireSdr = 不得带任何 HDR */ + hdr?: { anyOf?: HdrFormat[]; noneOf?: HdrFormat[]; requireSdr?: boolean }; + /** 允许的视频编码 */ + codecs?: VideoCodec[]; + excludeCodecs?: VideoCodec[]; + /** 按每个内容单元(每集)折算的体积上下限:体积 / 覆盖单元数(对照 MP __match_size) */ + minUnitSizeBytes?: number; + maxUnitSizeBytes?: number; + /** 关键词正则(不区分大小写,匹配 标题+副标题+标签):include 至少命中一条,exclude 一条都不能命中 */ + include?: string[]; + exclude?: string[]; + /** 站点范围:只接受这些站点;excludeSites 排除 */ + sites?: string[]; + excludeSites?: string[]; + minSeeders?: number; + /** 只要免费(下载量不计)促销 */ + freeOnly?: boolean; +} + +/** 优先级阶梯的一层(对应 MP 规则组 `>` 分层;层内条件同时满足才算命中) */ +export interface SlotTierRule { + name?: string; + resolutions?: Resolution[]; + sources?: ReleaseSource[]; + excludeSources?: ReleaseSource[]; + hdr?: HdrFormat[]; + codecs?: VideoCodec[]; + groups?: string[]; + include?: string[]; + exclude?: string[]; + requireEncode?: boolean; } export interface SlotSoftPreferences { @@ -36,6 +82,10 @@ export interface SlotSoftPreferences { sources?: ReleaseSource[]; codecs?: VideoCodec[]; preferredSizeBytes?: { min?: number; max?: number }; + /** 优先级阶梯:第 k 层(0 起)命中 → pri = 100 - k;都不命中 pri = 0 */ + tiers?: SlotTierRule[]; + /** 为真时一层都不命中即拒绝(MP 规则组语义:不命中任一层级即过滤) */ + tiersRequired?: boolean; } export interface SlotUpgradePolicy { @@ -50,6 +100,17 @@ export interface SlotUpgradePolicy { encodeGroupAllowlist?: string[]; /** 当前资产来源等级达到此值后不再升级 */ ceilingTier?: number; + /** + * 洗版模式(对照 MP 三模式):movie = 单片质量;per_unit = 分集洗版,只替换能提升的单元; + * full_pack = 全集洗版,只接受覆盖整季的包。缺省:电影 movie,剧集 per_unit。 + */ + mode?: "movie" | "per_unit" | "full_pack"; + /** + * 升级比较口径:tier = 来源/分辨率等级(缺省);priority = 阶梯 pri 严格提升(配置了 soft.tiers 时缺省)。 + */ + basis?: "tier" | "priority"; + /** priority 口径下达到该 pri 即视为顶档不再升级,缺省 100 */ + targetPri?: number; } export type SlotCompletionSpec = { kind: "movie"; cuts?: (Cut | "default")[] } | { kind: "units" }; @@ -73,8 +134,34 @@ export interface SlotGroupSpec { /** 当前 Slot 资产的交付特征(来自入库时的解析/探测) */ export type QualityFacts = Pick< Partial, - "resolution" | "source" | "hdr" | "releaseGroup" | "sizeBytes" | "isEncode" | "cut" ->; + "resolution" | "source" | "hdr" | "releaseGroup" | "sizeBytes" | "isEncode" | "cut" | "videoCodec" +> & { + /** 入库时记下的阶梯 pri(有则优先于按特征重算) */ + pri?: number; +}; + +/** 评估上下文:站点字段与折算口径(不在标题解析里的事实由调用方给) */ +export interface EvaluationContext { + siteId?: string; + /** 副标题/标签等参与关键词匹配的文本 */ + subtitle?: string; + labels?: string[]; + seeders?: number; + /** 下载量系数(0 = 免费);未知不传 */ + downloadVolumeFactor?: number; + /** 候选覆盖的内容单元数(每集体积折算);未给时按解析出的集范围/总集数推断 */ + unitCount?: number; +} + +/** 阶梯判定结果:pri 0–100 与逐层说明 */ +export interface LadderResult { + pri: number; + /** 命中的层(0 起);null = 未命中/未配置 */ + level: number | null; + levelName: string | null; + /** 逐层可读说明,如 "第1层 4K&REMUX:分辨率 1080p 不在 [2160p]" */ + trace: string[]; +} export interface QualityTier { source: number; @@ -82,14 +169,32 @@ export interface QualityTier { } export interface Violation { - field: HardField | "upgrade"; + field: HardField | "upgrade" | "tier"; code: string; expected?: unknown; actual?: unknown; } export type SlotEvaluation = - | { decision: "accept"; tier: QualityTier; softRank: number[]; reasons: string[] } - | { decision: "upgrade"; tier: QualityTier; fromTier: QualityTier; softRank: number[]; reasons: string[] } - | { decision: "reject"; violations: Violation[] } - | { decision: "unknown_field"; missing: HardField[] | ["current_asset"] }; + | { + decision: "accept"; + tier: QualityTier; + softRank: number[]; + reasons: string[]; + pri?: number; + ladder?: LadderResult; + trace?: string[]; + } + | { + decision: "upgrade"; + tier: QualityTier; + fromTier: QualityTier; + softRank: number[]; + reasons: string[]; + pri?: number; + fromPri?: number; + ladder?: LadderResult; + trace?: string[]; + } + | { decision: "reject"; violations: Violation[]; trace?: string[]; ladder?: LadderResult } + | { decision: "unknown_field"; missing: HardField[] | ["current_asset"]; trace?: string[] }; diff --git a/packages/domain/test/slot.test.ts b/packages/domain/test/slot.test.ts index 54f0ae28..d1815066 100644 --- a/packages/domain/test/slot.test.ts +++ b/packages/domain/test/slot.test.ts @@ -47,7 +47,7 @@ describe("evaluateCandidate 硬条件", () => { }); it("缺字段 → unknown_field(与不满足区分)", () => { const r = evaluateCandidate(archival4k, rel("Dune 2021 BluRay")); - expect(r).toEqual({ decision: "unknown_field", missing: ["resolution", "size"] }); + expect(r).toMatchObject({ decision: "unknown_field", missing: ["resolution", "size"] }); }); it("unknownPolicy 可按字段配置 reject / accept", () => { const spec: SlotSpec = { ...archival4k, unknownPolicy: { size: "accept", resolution: "reject" } }; @@ -111,7 +111,7 @@ describe("升级规则", () => { expect( evaluateCandidate({ ...archival4k, upgrade: undefined }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb), ).toMatchObject({ decision: "reject" }); - expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay 2GB"), {})).toEqual({ + expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay 2GB"), {})).toMatchObject({ decision: "unknown_field", missing: ["current_asset"], }); diff --git a/packages/domain/test/subscription.test.ts b/packages/domain/test/subscription.test.ts new file mode 100644 index 00000000..04c696dc --- /dev/null +++ b/packages/domain/test/subscription.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { + computeUnitGaps, + describeViolation, + evaluateCandidate, + improvableUnits, + type ParsedRelease, + parseRelease, + priorityLadder, + rankForSlot, + resolveEpisodeTotal, + type SlotSpec, + seasonProgress, + transitionWithInflight, + unitsOverlap, + validateKeywordPattern, +} from "../src/index.ts"; + +// W5-C 订阅增强:总集数账本、缺集、Slot×单元在途、硬条件扩展、优先级阶梯、洗版模式(对照 MP 账本 §13 样本) + +const GiB = 1024 ** 3; +const MiB = 1024 ** 2; +const rel = (t: string, over: Partial = {}): ParsedRelease => ({ ...parseRelease(t), ...over }); + +describe("resolveEpisodeTotal 总集数只升、回退不低于已存在最高集", () => { + it("TMDB 未公布(0)→ 未知,不当作已知总数", () => { + expect(resolveEpisodeTotal({ previous: null, fresh: 0 })).toMatchObject({ total: null, known: false }); + // 只见过前 3 集:仍未知(已见 ≠ 全集) + expect(resolveEpisodeTotal({ previous: null, fresh: null, coveredMax: 3 })).toMatchObject({ known: false }); + }); + it("变大采用;注入值只升(样本:插件 12 > TMDB 8 → 12)", () => { + expect(resolveEpisodeTotal({ previous: 8, fresh: 10 })).toMatchObject({ total: 10, source: "tmdb", changed: true }); + expect(resolveEpisodeTotal({ previous: null, fresh: 8, injected: 12 })).toMatchObject({ + total: 12, + source: "injected", + }); + expect(resolveEpisodeTotal({ previous: 12, fresh: 12, injected: 6 })).toMatchObject({ total: 12, changed: false }); + }); + it("TMDB 回退 12→10,库里已有 1–11 → 11(样本 9);回退但未覆盖更高集 → 接受新值", () => { + expect(resolveEpisodeTotal({ previous: 12, fresh: 10, coveredMax: 11 })).toMatchObject({ + total: 11, + source: "covered_floor", + clampedByCovered: true, + }); + expect(resolveEpisodeTotal({ previous: 12, fresh: 10, coveredMax: 4 })).toMatchObject({ total: 10 }); + }); + it("人工锁定后不被元数据改写", () => { + expect(resolveEpisodeTotal({ previous: 13, fresh: 20, locked: true })).toMatchObject({ + total: 13, + source: "manual", + changed: false, + }); + }); +}); + +describe("computeUnitGaps / seasonProgress(媒体库已有 ∩ 范围 − 在途)", () => { + it("样本 1:12 集,库 1–4,在途 5–6,start=3 → 缺 7–12", () => { + const units = Array.from({ length: 12 }, (_, i) => `s1e${i + 1}`); + const g = computeUnitGaps({ + units, + covered: ["s1e1", "s1e2", "s1e3", "s1e4"], + inflight: ["s1e5", "s1e6"], + range: { start: 3 }, + }); + expect(g.missing).toEqual(["s1e7", "s1e8", "s1e9", "s1e10", "s1e11", "s1e12"]); + expect(g.outOfRange).toEqual(["s1e1", "s1e2"]); + expect(g.reserved).toEqual(["s1e5", "s1e6"]); + }); + it("季通配预留与同季任何集相交;不同季不相交", () => { + expect(unitsOverlap(["s1e3"], ["s1*"])).toEqual(["s1e3"]); + expect(unitsOverlap(["s1*"], ["s1e9"])).toEqual(["s1*"]); + expect(unitsOverlap(["s2e1"], ["s1*"])).toEqual([]); + expect(unitsOverlap(["uuid-a", "s1e1"], ["uuid-a"])).toEqual(["uuid-a"]); + }); + it("总集数已知:缺集明确,全覆盖才完成;未知:只报疑似缺集并继续追踪", () => { + const known = seasonProgress({ season: 1, total: 4, totalKnown: true, covered: [1, 2, 4], inflight: [] }); + expect(known).toMatchObject({ missing: [3], complete: false, keepTracking: true }); + expect(seasonProgress({ season: 1, total: 3, totalKnown: true, covered: [1, 2, 3], inflight: [] }).complete).toBe( + true, + ); + const unknown = seasonProgress({ season: 1, total: null, totalKnown: false, covered: [1, 2, 5], inflight: [4] }); + expect(unknown).toMatchObject({ complete: false, keepTracking: true, missing: [], suspectedMissing: [3] }); + }); +}); + +describe("transitionWithInflight(Slot×单元多在途)", () => { + it("两个在途:一个核验通过,Slot 仍按另一个的阶段显示;最后一个通过才落到静止态", () => { + const s1 = transitionWithInflight( + { status: "verifying", hasAsset: false }, + { type: "verify_passed", complete: false }, + ["downloading"], + ); + expect(s1).toEqual({ status: "downloading", hasAsset: true }); + const s2 = transitionWithInflight(s1!, { type: "download_completed" }, []); + expect(s2).toEqual({ status: "verifying", hasAsset: true }); + expect(transitionWithInflight(s2!, { type: "verify_passed", complete: false }, [])).toEqual({ + status: "empty", + hasAsset: true, + }); + }); + it("第二个计划开始下载:Slot 已在下载中也不报非法;静止态上的非法事件不改写", () => { + expect( + transitionWithInflight({ status: "downloading", hasAsset: false }, { type: "download_started" }, ["downloading"]), + ).toEqual({ status: "downloading", hasAsset: false }); + expect(transitionWithInflight({ status: "satisfied", hasAsset: true }, { type: "download_completed" }, [])).toEqual( + { status: "verifying", hasAsset: true }, + ); + expect(transitionWithInflight({ status: "satisfied", hasAsset: true }, { type: "verify_failed" }, [])).toBeNull(); + }); +}); + +describe("硬条件扩展", () => { + const base: SlotSpec = { name: "x", hard: {} }; + it("排除来源、HDR 集合、编码集合", () => { + const spec: SlotSpec = { + ...base, + hard: { excludeSources: ["REMUX"], hdr: { noneOf: ["DV"] }, codecs: ["H.265"] }, + }; + const r = evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.REMUX.DV.HEVC-FGT")); + expect(r.decision).toBe("reject"); + if (r.decision !== "reject") return; + expect(r.violations.map((v) => `${v.field}.${v.code}`)).toEqual(["source.excluded", "hdr.excluded"]); + expect(r.trace?.[0]).toBe("来源 REMUX 被排除"); + const avc = evaluateCandidate(spec, rel("Dune.2021.1080p.WEB-DL.H264-GRP")); + expect(avc.decision === "reject" && avc.violations.map((v) => v.code)).toEqual(["not_allowed"]); + expect( + evaluateCandidate({ ...base, hard: { hdr: { anyOf: ["DV", "HDR10"] } } }, rel("X.2021.2160p.WEB-DL.HDR.H265-A")) + .decision, + ).toBe("accept"); + }); + it("样本 12:每集体积 500MB–3GB,10 集 20GB 季包 → 每集 2GB 通过;单集 4GB 拒绝", () => { + const spec: SlotSpec = { ...base, hard: { minUnitSizeBytes: 500 * MiB, maxUnitSizeBytes: 3 * GiB } }; + const pack = rel("Show.S01.1080p.WEB-DL.x264-GRP", { sizeBytes: 20 * GiB }); + expect(evaluateCandidate(spec, pack, null, { unitCount: 10 }).decision).toBe("accept"); + // 季包未知集数 → 字段未知(hold),不当成整包体积 + expect(evaluateCandidate(spec, pack).decision).toBe("unknown_field"); + const ep = evaluateCandidate(spec, rel("Show.S01E02.1080p.WEB-DL.x264-GRP", { sizeBytes: 4 * GiB })); + expect(ep.decision === "reject" && ep.violations[0]?.code).toBe("unit_too_large"); + }); + it("关键词包含/排除匹配 标题+副标题+标签;不安全正则拒绝", () => { + const spec: SlotSpec = { ...base, hard: { include: ["中字|简繁"], exclude: ["\\bHC\\b"] } }; + const r = rel("Movie.2024.1080p.WEB-DL.x264-GRP"); + expect(evaluateCandidate(spec, r).decision).toBe("reject"); + expect(evaluateCandidate(spec, r, null, { subtitle: "官方中字" }).decision).toBe("accept"); + expect(evaluateCandidate(spec, r, null, { subtitle: "中字", labels: ["HC"] }).decision).toBe("reject"); + expect(validateKeywordPattern("(a+)+$")).toBe("nested_quantifier"); + expect(validateKeywordPattern("(?<=x)y")).toBe("lookbehind"); + expect(validateKeywordPattern("(a)\\1")).toBe("backreference"); + const bad = evaluateCandidate({ ...base, hard: { include: ["(a+)+"] } }, r); + expect(bad.decision === "reject" && bad.violations[0]?.code).toBe("invalid_pattern"); + }); + it("站点范围 / 做种数 / 免费:缺上下文 → 字段未知", () => { + const spec: SlotSpec = { ...base, hard: { sites: ["site-a"], minSeeders: 5, freeOnly: true } }; + const r = rel("Movie.2024.1080p.WEB-DL.x264-GRP"); + expect(evaluateCandidate(spec, r).decision).toBe("unknown_field"); + const ev = evaluateCandidate(spec, r, null, { siteId: "site-b", seeders: 3, downloadVolumeFactor: 1 }); + expect(ev.decision === "reject" && ev.violations.map((v) => `${v.field}.${v.code}`)).toEqual([ + "site.not_in_scope", + "seeders.too_few", + "promotion.not_free", + ]); + expect(evaluateCandidate(spec, r, null, { siteId: "site-a", seeders: 9, downloadVolumeFactor: 0 }).decision).toBe( + "accept", + ); + }); +}); + +describe("优先级阶梯(MP 规则组 `>` 分层)", () => { + const ladder: SlotSpec = { + name: "阶梯", + hard: {}, + soft: { + tiers: [ + { name: "4K REMUX", resolutions: ["2160p"], sources: ["REMUX"] }, + { name: "1080p WEB", resolutions: ["1080p"], sources: ["WEB-DL"] }, + { name: "720p", resolutions: ["720p"] }, + ], + tiersRequired: true, + }, + upgrade: { enabled: true }, + }; + it("样本 11:A > B > C 命中 B → pri=99;都不命中 → 过滤;trace 逐层说明", () => { + const b = priorityLadder(ladder, rel("Show.S01E01.1080p.WEB-DL.x264-GRP")); + expect(b).toMatchObject({ pri: 99, level: 1, levelName: "1080p WEB" }); + expect(b.trace[0]).toContain("分辨率 1080p 不在 [2160p]"); + const none = evaluateCandidate(ladder, rel("Show.S01E01.1080p.HDTV.x264-GRP")); + expect(none.decision === "reject" && none.violations[0]?.code).toBe("no_tier_matched"); + expect(none.trace?.length).toBe(4); + }); + it("排序先按 pri:4K 总在 1080p 前,不看来源等级(单 Slot `4K > 1080P` 语义)", () => { + const spec: SlotSpec = { + name: "r", + hard: {}, + soft: { tiers: [{ resolutions: ["2160p"] }, { resolutions: ["1080p"] }] }, + }; + const ranked = rankForSlot(spec, [rel("M.2024.1080p.BluRay.x264-A"), rel("M.2024.2160p.WEB-DL.H265-B")]); + expect(ranked[0]!.candidate.resolution).toBe("2160p"); + expect(ranked[0]!.evaluation.decision === "accept" && ranked[0]!.evaluation.pri).toBe(100); + }); + it("洗版(priority 口径):pri 严格提升才升级;达到 targetPri 视为顶档(样本 6)", () => { + const cur = { resolution: "1080p", source: "WEB-DL" } as const; + const same = evaluateCandidate(ladder, rel("Show.S01E01.1080p.WEB-DL.x265-OTHER"), cur); + expect(same.decision === "reject" && same.violations[0]?.code).toBe("not_higher_priority"); + const better = evaluateCandidate(ladder, rel("Show.S01E01.2160p.BluRay.REMUX-FGT"), cur); + expect(better).toMatchObject({ decision: "upgrade", pri: 100, fromPri: 99 }); + const top = evaluateCandidate(ladder, rel("Show.S01E01.2160p.BluRay.REMUX-FGT"), { ...cur, pri: 100 }); + expect(top.decision === "reject" && top.violations[0]?.code).toBe("at_ceiling"); + }); + it("分集洗版:只取能提升的单元(样本 3);全集洗版只接受整季包", () => { + const spec: SlotSpec = { ...ladder, soft: { ...ladder.soft, tiersRequired: false } }; + const r = rel("Show.S01E01-E02.1080p.WEB-DL.x264-GRP"); + const out = improvableUnits(spec, r, ["s1e1", "s1e2"], { + s1e1: { resolution: "720p", source: "HDTV" }, + s1e2: { resolution: "2160p", source: "REMUX" }, + }); + expect(out.units).toEqual(["s1e1"]); + const full: SlotSpec = { ...spec, upgrade: { enabled: true, mode: "full_pack" } }; + const ep = evaluateCandidate(full, r, { resolution: "720p", source: "HDTV" }); + expect(ep.decision === "reject" && ep.violations[0]?.code).toBe("not_full_pack"); + }); + it("describeViolation 给中文可读说明", () => { + expect(describeViolation({ field: "size", code: "too_large", expected: 2 * GiB, actual: 3 * GiB })).toBe( + "体积 3.00 GiB 超过上限 2.00 GiB", + ); + }); +});