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,68 @@
|
||||
/** 下载器实例看到的路径 ↔ 本服务宿主看到的路径(前缀替换,支持多实例) */
|
||||
export interface PathMapping {
|
||||
instanceId: string;
|
||||
downloaderPath: string;
|
||||
hostPath: string;
|
||||
}
|
||||
|
||||
export type MapResult =
|
||||
| { ok: true; path: string; mapping: PathMapping }
|
||||
| { ok: false; reason: "no_mapping" | "ambiguous" };
|
||||
|
||||
const norm = (p: string) => {
|
||||
const s = p.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||
return s.length > 1 ? s.replace(/\/+$/, "") : s;
|
||||
};
|
||||
|
||||
function mapPrefix(
|
||||
path: string,
|
||||
instanceId: string,
|
||||
mappings: readonly PathMapping[],
|
||||
from: "downloaderPath" | "hostPath",
|
||||
to: "downloaderPath" | "hostPath",
|
||||
): MapResult {
|
||||
const p = norm(path);
|
||||
let best: PathMapping[] = [];
|
||||
let bestLen = -1;
|
||||
for (const m of mappings) {
|
||||
if (m.instanceId !== instanceId) continue;
|
||||
const prefix = norm(m[from]);
|
||||
const hit = p === prefix || p.startsWith(prefix === "/" ? "/" : `${prefix}/`);
|
||||
if (!hit) continue;
|
||||
if (prefix.length > bestLen) {
|
||||
best = [m];
|
||||
bestLen = prefix.length;
|
||||
} else if (prefix.length === bestLen) best.push(m);
|
||||
}
|
||||
if (best.length === 0) return { ok: false, reason: "no_mapping" };
|
||||
const targets = new Set(best.map((m) => norm(m[to])));
|
||||
if (targets.size > 1) return { ok: false, reason: "ambiguous" };
|
||||
const m = best[0]!;
|
||||
const rest = p.slice(bestLen).replace(/^\//, "");
|
||||
const base = norm(m[to]);
|
||||
const out = rest ? (base === "/" ? `/${rest}` : `${base}/${rest}`) : base;
|
||||
return { ok: true, path: out, mapping: m };
|
||||
}
|
||||
|
||||
/** 最长前缀匹配、按路径段边界(/downloads 不匹配 /downloads2);找不到映射不猜测 */
|
||||
export function toHostPath(path: string, instanceId: string, mappings: readonly PathMapping[]): MapResult {
|
||||
return mapPrefix(path, instanceId, mappings, "downloaderPath", "hostPath");
|
||||
}
|
||||
|
||||
export function toDownloaderPath(path: string, instanceId: string, mappings: readonly PathMapping[]): MapResult {
|
||||
return mapPrefix(path, instanceId, mappings, "hostPath", "downloaderPath");
|
||||
}
|
||||
|
||||
/** 同一实例同一前缀指向不同目标 → 配置错误 */
|
||||
export function validatePathMappings(mappings: readonly PathMapping[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const m of mappings) {
|
||||
const k = `${m.instanceId}\u0000${norm(m.downloaderPath)}`;
|
||||
const prev = seen.get(k);
|
||||
if (prev !== undefined && prev !== norm(m.hostPath))
|
||||
errors.push(`实例 ${m.instanceId} 的 ${m.downloaderPath} 映射到多个宿主路径`);
|
||||
seen.set(k, norm(m.hostPath));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
Reference in new issue
Block a user