- 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]>
69 lines
2.5 KiB
TypeScript
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;
|
|
}
|