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:
rootandClaude Fable 5.1 committed 2026-09-27 18:11:29 +08:00
1 parent a6d049db81
commit 7ba712ea55
40 files changed
+5885 -2

No files matched your search

+239
View File
@@ -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 }));
}