feat(domain): W5-C subscription gaps, episode totals, multi-inflight slot, hard conditions and priority ladder
- 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 <[email protected]>
This commit is contained in:
1 parent
89ea68465e
commit
d162d464c4
9 files changed
+1023
-42
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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<ParsedRelease, "episodes" | "totalEpisodes">, 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</.test(p)) return "backreference";
|
||||
if (/\(\?<[=!]/.test(p)) return "lookbehind";
|
||||
if (/\([^()]*[+*][^()]*\)[+*{]/.test(p)) return "nested_quantifier";
|
||||
try {
|
||||
new RegExp(p, "i");
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const reCache = new Map<string, RegExp>();
|
||||
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<ParsedRelease, "raw">, 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<T>(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<Record<string, QualityFacts | null | undefined>>,
|
||||
ctx: EvaluationContext = {},
|
||||
): { units: string[]; perUnit: Record<string, SlotEvaluation["decision"]> } {
|
||||
const perUnit: Record<string, SlotEvaluation["decision"]> = {};
|
||||
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<T extends ParsedRelease = ParsedRelease> {
|
||||
candidate: T;
|
||||
evaluation: SlotEvaluation;
|
||||
}
|
||||
|
||||
/** 对一批候选评估并排序:可用(accept/upgrade)在前,按质量等级再按软偏好;其余保持原顺序 */
|
||||
/** 对一批候选评估并排序:可用(accept/upgrade)在前,按阶梯 pri、质量等级再按软偏好;其余保持原顺序 */
|
||||
export function rankForSlot<T extends ParsedRelease>(
|
||||
spec: SlotSpec,
|
||||
candidates: readonly T[],
|
||||
current?: QualityFacts | null,
|
||||
ctxOf?: (c: T) => EvaluationContext,
|
||||
): RankedEvaluation<T>[] {
|
||||
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<T extends ParsedRelease>(
|
||||
const ea = a.evaluation as Extract<SlotEvaluation, { tier: QualityTier }>;
|
||||
const eb = b.evaluation as Extract<SlotEvaluation, { tier: QualityTier }>;
|
||||
return (
|
||||
(eb.pri ?? 100) - (ea.pri ?? 100) ||
|
||||
eb.tier.source - ea.tier.source ||
|
||||
eb.tier.resolution - ea.tier.resolution ||
|
||||
compareRank(eb.softRank, ea.softRank) ||
|
||||
|
||||
@@ -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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PlanStage, number> = { reserved: 1, downloading: 2, verifying: 3 };
|
||||
const BUSY = new Set<SlotStatus>(["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);
|
||||
}
|
||||
}
|
||||
@@ -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<ParsedRelease>,
|
||||
"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[] };
|
||||
Reference in new issue
Block a user