Files
UHD_Media_Tool/packages/domain/src/path/mapping.ts
T
rootandClaude Fable 5.1 7ba712ea55 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]>
2026-09-27 18:11:29 +08:00

69 lines
2.5 KiB
TypeScript

/** 下载器实例看到的路径 ↔ 本服务宿主看到的路径(前缀替换,支持多实例) */
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;
}