feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)
- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue, cut, release group suffix fallback, dir+file merge, safe episode offset) - identity: title normalization (NFKC, trad->simp map, season suffix) and evidence-level candidate ranking (year tolerance ±1, no probabilities) - slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine, movie/unit completion (season pack must be covered by actual file list) - acquisition: new_title/catch_up/upgrade modes, priority order, budget, cache volume hard threshold with resume watermark (no double counting) - path: immutable path bindings, restricted naming templates, sanitize, conflict detection, downloader<->host path mapping - organize: planOrganize with sidecar pairing, sample exclusion, existing target policy, crossDevice marking, H&R -> conflict for move - download-state: qB state mapping, completion, no-progress evidence 230 tests, ~97% statement coverage. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import type { Cut } from "../release-parser/types";
|
||||
import type { SlotGroupSpec } from "./types";
|
||||
|
||||
export interface MovieAssetFact {
|
||||
assetId: string;
|
||||
cut?: Cut;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
/** 电影完成条件:按内容版本核对,每个要求的剪辑版都有已验证资产 */
|
||||
export function movieCompletion(
|
||||
requiredCuts: readonly (Cut | "default")[] | undefined,
|
||||
assets: readonly MovieAssetFact[],
|
||||
): { complete: boolean; missingCuts: (Cut | "default")[]; satisfiedBy: Record<string, string> } {
|
||||
const required = requiredCuts?.length ? requiredCuts : (["default"] as const);
|
||||
const satisfiedBy: Record<string, string> = {};
|
||||
const missingCuts: (Cut | "default")[] = [];
|
||||
for (const cut of required) {
|
||||
const a = assets.find((x) => x.verified && (x.cut ?? "default") === cut);
|
||||
if (a) satisfiedBy[cut] = a.assetId;
|
||||
else missingCuts.push(cut);
|
||||
}
|
||||
return { complete: missingCuts.length === 0, missingCuts, satisfiedBy };
|
||||
}
|
||||
|
||||
export interface UnitAssetFact {
|
||||
assetId: string;
|
||||
verified: boolean;
|
||||
/** 由实际文件清单得出的已覆盖 content_unit */
|
||||
fileUnits: readonly string[];
|
||||
/** 发布标题声称的覆盖(如 season pack),仅作证据,不计入完成 */
|
||||
claimedUnits?: readonly string[];
|
||||
}
|
||||
|
||||
export interface UnitCompletion {
|
||||
complete: boolean;
|
||||
covered: string[];
|
||||
missing: string[];
|
||||
/** 标题声称覆盖但文件清单未证实的单元 */
|
||||
unverifiedClaims: string[];
|
||||
/** 总集数未知或未完成时继续追更 */
|
||||
keepTracking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 剧集/综艺完成条件:按 content_unit 覆盖集合计算。
|
||||
* season pack 必须由实际文件清单覆盖,不能因下载了整季包就声称完整(REQ §6.2)。
|
||||
*/
|
||||
export function unitCompletion(
|
||||
requiredUnits: readonly string[],
|
||||
assets: readonly UnitAssetFact[],
|
||||
opts: { totalKnown: boolean },
|
||||
): UnitCompletion {
|
||||
const coveredSet = new Set<string>();
|
||||
for (const a of assets) if (a.verified) for (const u of a.fileUnits) coveredSet.add(u);
|
||||
const covered = requiredUnits.filter((u) => coveredSet.has(u));
|
||||
const missing = requiredUnits.filter((u) => !coveredSet.has(u));
|
||||
const unverified = new Set<string>();
|
||||
for (const a of assets) for (const u of a.claimedUnits ?? []) if (!coveredSet.has(u)) unverified.add(u);
|
||||
const complete = requiredUnits.length > 0 && missing.length === 0;
|
||||
return {
|
||||
complete,
|
||||
covered,
|
||||
missing,
|
||||
unverifiedClaims: [...unverified],
|
||||
keepTracking: !opts.totalKnown || !complete,
|
||||
};
|
||||
}
|
||||
|
||||
/** require_distinct_asset:同一物理资产不能同时满足组内两个 Slot(M2 完整实现,v0.1 先给校验) */
|
||||
export function checkDistinctAssets(
|
||||
group: SlotGroupSpec,
|
||||
assignments: readonly { slotId: string; assetId: string }[],
|
||||
): { ok: boolean; sharedAssets: string[] } {
|
||||
if (!group.requireDistinctAsset) return { ok: true, sharedAssets: [] };
|
||||
const seen = new Map<string, string>();
|
||||
const shared = new Set<string>();
|
||||
for (const a of assignments) {
|
||||
const prev = seen.get(a.assetId);
|
||||
if (prev !== undefined && prev !== a.slotId) shared.add(a.assetId);
|
||||
seen.set(a.assetId, a.slotId);
|
||||
}
|
||||
return { ok: shared.size === 0, sharedAssets: [...shared] };
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types";
|
||||
import type { HardField, QualityFacts, QualityTier, SlotEvaluation, SlotSpec, UnknownPolicy, Violation } from "./types";
|
||||
|
||||
export const DEFAULT_SOURCE_TIERS: Readonly<Record<ReleaseSource, number>> = {
|
||||
REMUX: 3,
|
||||
"BluRay-Disc": 3,
|
||||
BluRay: 3,
|
||||
"WEB-DL": 2,
|
||||
WEBRip: 1,
|
||||
HDTV: 1,
|
||||
HDRip: 1,
|
||||
DVDRip: 0,
|
||||
DVD: 0,
|
||||
};
|
||||
|
||||
export const RESOLUTION_RANK: Readonly<Record<Resolution, number>> = {
|
||||
"4320p": 7,
|
||||
"2160p": 6,
|
||||
"1440p": 5,
|
||||
"1080p": 4,
|
||||
"1080i": 3,
|
||||
"720p": 2,
|
||||
"576p": 1,
|
||||
"480p": 0,
|
||||
};
|
||||
|
||||
export function qualityTier(f: QualityFacts, spec?: SlotSpec): QualityTier {
|
||||
const tiers = { ...DEFAULT_SOURCE_TIERS, ...spec?.upgrade?.sourceTiers };
|
||||
return {
|
||||
source: f.source ? tiers[f.source] : -1,
|
||||
resolution: f.resolution ? RESOLUTION_RANK[f.resolution] : -1,
|
||||
};
|
||||
}
|
||||
|
||||
function policyFor(spec: SlotSpec, field: HardField): UnknownPolicy {
|
||||
const p = spec.unknownPolicy;
|
||||
if (p === undefined) return "hold";
|
||||
if (typeof p === "string") return p;
|
||||
return p[field] ?? "hold";
|
||||
}
|
||||
|
||||
const lc = (s: string) => s.toLowerCase();
|
||||
|
||||
function subtitleSatisfied(have: string[], need: string): boolean {
|
||||
return have.some((h) => h === need || (need === "zh" && h.startsWith("zh")));
|
||||
}
|
||||
|
||||
/** 硬条件检查:区分"缺字段"(missing)和"字段不满足"(violations) */
|
||||
export function checkHardConditions(
|
||||
spec: SlotSpec,
|
||||
r: ParsedRelease,
|
||||
): { violations: Violation[]; missing: HardField[]; accepted: HardField[] } {
|
||||
const h = spec.hard;
|
||||
const violations: Violation[] = [];
|
||||
const missing: HardField[] = [];
|
||||
const accepted: HardField[] = [];
|
||||
const unknown = (f: HardField) => {
|
||||
const p = policyFor(spec, f);
|
||||
if (p === "hold") missing.push(f);
|
||||
else if (p === "reject") violations.push({ field: f, code: "unknown_rejected_by_policy" });
|
||||
else accepted.push(f);
|
||||
};
|
||||
|
||||
if (h.resolutions?.length) {
|
||||
if (!r.resolution) unknown("resolution");
|
||||
else if (!h.resolutions.includes(r.resolution))
|
||||
violations.push({ field: "resolution", code: "not_allowed", expected: h.resolutions, actual: r.resolution });
|
||||
}
|
||||
if (h.sources?.length || h.requireEncode) {
|
||||
if (!r.source) unknown("source");
|
||||
else {
|
||||
if (h.sources?.length && !h.sources.includes(r.source))
|
||||
violations.push({ field: "source", code: "not_allowed", expected: h.sources, actual: r.source });
|
||||
if (h.requireEncode && !r.isEncode)
|
||||
violations.push({ field: "source", code: "not_encode", expected: "BluRay encode", actual: r.source });
|
||||
}
|
||||
}
|
||||
if (h.maxSizeBytes !== undefined || h.minSizeBytes !== undefined) {
|
||||
if (r.sizeBytes === undefined) unknown("size");
|
||||
else {
|
||||
if (h.maxSizeBytes !== undefined && r.sizeBytes > h.maxSizeBytes)
|
||||
violations.push({ field: "size", code: "too_large", expected: h.maxSizeBytes, actual: r.sizeBytes });
|
||||
if (h.minSizeBytes !== undefined && r.sizeBytes < h.minSizeBytes)
|
||||
violations.push({ field: "size", code: "too_small", expected: h.minSizeBytes, actual: r.sizeBytes });
|
||||
}
|
||||
}
|
||||
if (h.requiredSubtitles?.length) {
|
||||
if (r.subtitles === undefined) unknown("subtitles");
|
||||
else {
|
||||
const lack = h.requiredSubtitles.filter((s) => !subtitleSatisfied(r.subtitles!, s));
|
||||
if (lack.length > 0)
|
||||
violations.push({ field: "subtitles", code: "missing_language", expected: lack, actual: r.subtitles });
|
||||
}
|
||||
}
|
||||
if (h.cuts?.length) {
|
||||
const cut = r.cut ?? "default";
|
||||
if (!h.cuts.includes(cut)) violations.push({ field: "cut", code: "not_allowed", expected: h.cuts, actual: cut });
|
||||
}
|
||||
if (h.excludeGroups?.length && r.releaseGroup) {
|
||||
const g = lc(r.releaseGroup.name);
|
||||
if (h.excludeGroups.some((x) => g.split("@").includes(lc(x))))
|
||||
violations.push({ field: "group", code: "excluded", actual: r.releaseGroup.name });
|
||||
}
|
||||
if (h.contentTypes?.length) {
|
||||
if (r.contentType === "unknown" && !h.contentTypes.includes("unknown")) unknown("contentType");
|
||||
else if (!h.contentTypes.includes(r.contentType))
|
||||
violations.push({ field: "contentType", code: "not_allowed", expected: h.contentTypes, actual: r.contentType });
|
||||
}
|
||||
return { violations, missing, accepted };
|
||||
}
|
||||
|
||||
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;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 软偏好排序键(越大越好,按字典序比较):HDR、发布组、来源偏好、编码、体积区间 */
|
||||
export function softRank(spec: SlotSpec, r: ParsedRelease): number[] {
|
||||
const s = spec.soft ?? {};
|
||||
const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : [];
|
||||
const size = s.preferredSizeBytes;
|
||||
let sizeScore = 0;
|
||||
if (size && r.sizeBytes !== undefined) {
|
||||
const okMin = size.min === undefined || r.sizeBytes >= size.min;
|
||||
const okMax = size.max === undefined || r.sizeBytes <= size.max;
|
||||
sizeScore = okMin && okMax ? 1 : -1;
|
||||
}
|
||||
return [
|
||||
prefIndexScore(s.hdr, r.hdr),
|
||||
prefIndexScore(s.groups?.map(lc), groups),
|
||||
prefIndexScore(s.sources, r.source ? [r.source] : []),
|
||||
prefIndexScore(s.codecs, r.videoCodec ? [r.videoCodec] : []),
|
||||
sizeScore,
|
||||
];
|
||||
}
|
||||
|
||||
export function compareRank(a: readonly number[], b: readonly number[]): number {
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
||||
if (d !== 0) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选对某个 Slot 的确定性评估。
|
||||
* - 已知不满足硬条件 → reject(列出全部违规)
|
||||
* - 硬条件字段缺失且策略为 hold → unknown_field(区分缺字段与不满足)
|
||||
* - 无当前资产 → accept(附软偏好排序键)
|
||||
* - 有当前资产 → 仅在达到最小改进阈值时 upgrade,否则 reject(同等级不反复更换)
|
||||
*/
|
||||
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 };
|
||||
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 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"] };
|
||||
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 }],
|
||||
};
|
||||
const minSteps = up.minTierSteps ?? 1;
|
||||
const dSource = tier.source - from.source;
|
||||
const dRes = tier.resolution - from.resolution;
|
||||
const bySource = dSource >= minSteps && dRes >= 0;
|
||||
const byRes = !!up.allowResolutionUpgrade && dRes > 0 && dSource >= 0;
|
||||
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 }],
|
||||
};
|
||||
}
|
||||
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 {
|
||||
decision: "upgrade",
|
||||
tier,
|
||||
fromTier: from,
|
||||
softRank: rank,
|
||||
reasons: [...reasons, bySource ? "source_tier_improved" : "resolution_improved"],
|
||||
};
|
||||
}
|
||||
|
||||
export interface RankedEvaluation<T extends ParsedRelease = ParsedRelease> {
|
||||
candidate: T;
|
||||
evaluation: SlotEvaluation;
|
||||
}
|
||||
|
||||
/** 对一批候选评估并排序:可用(accept/upgrade)在前,按质量等级再按软偏好;其余保持原顺序 */
|
||||
export function rankForSlot<T extends ParsedRelease>(
|
||||
spec: SlotSpec,
|
||||
candidates: readonly T[],
|
||||
current?: QualityFacts | null,
|
||||
): RankedEvaluation<T>[] {
|
||||
const evals = candidates.map((c, i) => ({ candidate: c, evaluation: evaluateCandidate(spec, c, current), i }));
|
||||
const usable = (e: SlotEvaluation) => e.decision === "accept" || e.decision === "upgrade";
|
||||
return evals
|
||||
.sort((a, b) => {
|
||||
const ua = usable(a.evaluation);
|
||||
const ub = usable(b.evaluation);
|
||||
if (ua !== ub) return ua ? -1 : 1;
|
||||
if (ua && ub) {
|
||||
const ea = a.evaluation as Extract<SlotEvaluation, { tier: QualityTier }>;
|
||||
const eb = b.evaluation as Extract<SlotEvaluation, { tier: QualityTier }>;
|
||||
return (
|
||||
eb.tier.source - ea.tier.source ||
|
||||
eb.tier.resolution - ea.tier.resolution ||
|
||||
compareRank(eb.softRank, ea.softRank) ||
|
||||
a.i - b.i
|
||||
);
|
||||
}
|
||||
return a.i - b.i;
|
||||
})
|
||||
.map(({ candidate, evaluation }) => ({ candidate, evaluation }));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
checkDistinctAssets,
|
||||
type MovieAssetFact,
|
||||
movieCompletion,
|
||||
type UnitAssetFact,
|
||||
type UnitCompletion,
|
||||
unitCompletion,
|
||||
} from "./completion";
|
||||
export {
|
||||
checkHardConditions,
|
||||
compareRank,
|
||||
DEFAULT_SOURCE_TIERS,
|
||||
evaluateCandidate,
|
||||
qualityTier,
|
||||
type RankedEvaluation,
|
||||
RESOLUTION_RANK,
|
||||
rankForSlot,
|
||||
softRank,
|
||||
} from "./evaluate";
|
||||
export {
|
||||
canTransition,
|
||||
type SlotEvent,
|
||||
type SlotState,
|
||||
type SlotStatus,
|
||||
SlotTransitionError,
|
||||
transition,
|
||||
} from "./state";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,97 @@
|
||||
/** Slot 状态(REQ §6.1) */
|
||||
export type SlotStatus =
|
||||
| "empty"
|
||||
| "reserved"
|
||||
| "downloading"
|
||||
| "verifying"
|
||||
| "satisfied"
|
||||
| "attention"
|
||||
| "upgrade_pending";
|
||||
|
||||
export interface SlotState {
|
||||
status: SlotStatus;
|
||||
/** 是否已有可用的当前资产(升级流程中旧资产仍在,部分覆盖的剧集也可能有资产) */
|
||||
hasAsset: boolean;
|
||||
}
|
||||
|
||||
export type SlotEvent =
|
||||
| { type: "reserve" }
|
||||
| { type: "release" }
|
||||
| { type: "download_started" }
|
||||
| { type: "download_completed" }
|
||||
| { type: "download_failed"; reason?: string }
|
||||
| { type: "verify_passed"; complete: boolean }
|
||||
| { type: "verify_failed"; reason?: string }
|
||||
| { type: "upgrade_found" }
|
||||
| { type: "upgrade_cancelled" }
|
||||
| { type: "attention_resolved"; hasAsset: boolean }
|
||||
| { type: "asset_missing_confirmed" };
|
||||
|
||||
export class SlotTransitionError extends Error {
|
||||
override name = "SlotTransitionError";
|
||||
constructor(
|
||||
readonly from: SlotStatus,
|
||||
readonly event: SlotEvent["type"],
|
||||
) {
|
||||
super(`非法的 Slot 状态迁移: ${from} --${event}-->`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slot 状态机(纯函数)。非法迁移抛 SlotTransitionError。
|
||||
* 升级流程:satisfied → upgrade_pending → reserved → downloading → verifying → satisfied;
|
||||
* 新文件验证通过前旧资产一直保留(hasAsset=true)。
|
||||
* 剧集部分覆盖:verify_passed(complete=false) 回到 empty 但 hasAsset=true,继续追更。
|
||||
*/
|
||||
export function transition(state: SlotState, event: SlotEvent): SlotState {
|
||||
const { status, hasAsset } = state;
|
||||
const bad = (): never => {
|
||||
throw new SlotTransitionError(status, event.type);
|
||||
};
|
||||
switch (event.type) {
|
||||
case "reserve":
|
||||
if (status === "empty" || status === "attention" || status === "upgrade_pending")
|
||||
return { status: "reserved", hasAsset };
|
||||
return bad();
|
||||
case "release":
|
||||
if (status === "reserved") return { status: hasAsset ? "satisfied" : "empty", hasAsset };
|
||||
return bad();
|
||||
case "download_started":
|
||||
if (status === "reserved") return { status: "downloading", hasAsset };
|
||||
return bad();
|
||||
case "download_completed":
|
||||
if (status === "downloading") return { status: "verifying", hasAsset };
|
||||
return bad();
|
||||
case "download_failed":
|
||||
if (status === "downloading" || status === "reserved") return { status: "attention", hasAsset };
|
||||
return bad();
|
||||
case "verify_passed":
|
||||
if (status === "verifying") return { status: event.complete ? "satisfied" : "empty", hasAsset: true };
|
||||
return bad();
|
||||
case "verify_failed":
|
||||
if (status === "verifying") return { status: "attention", hasAsset };
|
||||
return bad();
|
||||
case "upgrade_found":
|
||||
if (status === "satisfied") return { status: "upgrade_pending", hasAsset };
|
||||
return bad();
|
||||
case "upgrade_cancelled":
|
||||
if (status === "upgrade_pending") return { status: "satisfied", hasAsset };
|
||||
return bad();
|
||||
case "attention_resolved":
|
||||
if (status === "attention") return { status: event.hasAsset ? "satisfied" : "empty", hasAsset: event.hasAsset };
|
||||
return bad();
|
||||
case "asset_missing_confirmed":
|
||||
// 只接受"确认缺失"(对账多次观察后);单次观察失败不得触发(失败 ≠ 不存在)
|
||||
if (status === "satisfied" || status === "upgrade_pending") return { status: "attention", hasAsset: false };
|
||||
return bad();
|
||||
}
|
||||
}
|
||||
|
||||
export function canTransition(state: SlotState, event: SlotEvent): boolean {
|
||||
try {
|
||||
transition(state, event);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
ContentType,
|
||||
Cut,
|
||||
HdrFormat,
|
||||
ParsedRelease,
|
||||
ReleaseSource,
|
||||
Resolution,
|
||||
VideoCodec,
|
||||
} from "../release-parser/types";
|
||||
|
||||
/** 可判定的硬条件字段 */
|
||||
export type HardField = "resolution" | "source" | "size" | "subtitles" | "cut" | "group" | "contentType";
|
||||
|
||||
/** 字段缺失时的处理:hold = 返回 unknown_field 等待补充;reject = 拒绝;accept = 放行 */
|
||||
export type UnknownPolicy = "hold" | "reject" | "accept";
|
||||
|
||||
export interface SlotHardConditions {
|
||||
resolutions?: Resolution[];
|
||||
sources?: ReleaseSource[];
|
||||
/** 只接受蓝光二次压制(不含原盘/Remux) */
|
||||
requireEncode?: boolean;
|
||||
maxSizeBytes?: number;
|
||||
minSizeBytes?: number;
|
||||
/** 需要的字幕语言;"zh" 可被 zh/zh-Hans/zh-Hant 满足 */
|
||||
requiredSubtitles?: string[];
|
||||
/** 内容版本;"default" = 标题无剪辑标记 */
|
||||
cuts?: (Cut | "default")[];
|
||||
excludeGroups?: string[];
|
||||
contentTypes?: ContentType[];
|
||||
}
|
||||
|
||||
export interface SlotSoftPreferences {
|
||||
/** 按偏好顺序,越靠前越好 */
|
||||
hdr?: HdrFormat[];
|
||||
groups?: string[];
|
||||
sources?: ReleaseSource[];
|
||||
codecs?: VideoCodec[];
|
||||
preferredSizeBytes?: { min?: number; max?: number };
|
||||
}
|
||||
|
||||
export interface SlotUpgradePolicy {
|
||||
enabled: boolean;
|
||||
/** 最小改进:来源等级至少提升几级,默认 1(同等级不反复更换) */
|
||||
minTierSteps?: number;
|
||||
/** 来源等级表;默认 REMUX/原盘/蓝光二压=3,WEB-DL=2,WEBRip/HDTV/HDRip=1,DVD=0 */
|
||||
sourceTiers?: Partial<Record<ReleaseSource, number>>;
|
||||
/** 允许仅因分辨率提升而升级(同一 Slot 允许多个分辨率时) */
|
||||
allowResolutionUpgrade?: boolean;
|
||||
/** 合格蓝光压制组名单;设置后名单外的压制不自动升级(低劣压制不因 BluRay 标签自动替换) */
|
||||
encodeGroupAllowlist?: string[];
|
||||
/** 当前资产来源等级达到此值后不再升级 */
|
||||
ceilingTier?: number;
|
||||
}
|
||||
|
||||
export type SlotCompletionSpec = { kind: "movie"; cuts?: (Cut | "default")[] } | { kind: "units" };
|
||||
|
||||
export interface SlotSpec {
|
||||
id?: string;
|
||||
name: string;
|
||||
hard: SlotHardConditions;
|
||||
unknownPolicy?: UnknownPolicy | Partial<Record<HardField, UnknownPolicy>>;
|
||||
soft?: SlotSoftPreferences;
|
||||
upgrade?: SlotUpgradePolicy;
|
||||
completion?: SlotCompletionSpec;
|
||||
}
|
||||
|
||||
/** 多目标组合(M2 才做校验;数据结构先定):require_distinct_asset 表示必须是不同物理资产 */
|
||||
export interface SlotGroupSpec {
|
||||
slots: SlotSpec[];
|
||||
requireDistinctAsset?: boolean;
|
||||
}
|
||||
|
||||
/** 当前 Slot 资产的交付特征(来自入库时的解析/探测) */
|
||||
export type QualityFacts = Pick<
|
||||
Partial<ParsedRelease>,
|
||||
"resolution" | "source" | "hdr" | "releaseGroup" | "sizeBytes" | "isEncode" | "cut"
|
||||
>;
|
||||
|
||||
export interface QualityTier {
|
||||
source: number;
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
field: HardField | "upgrade";
|
||||
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"] };
|
||||
Reference in new issue
Block a user