- 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]>
57 lines
1.8 KiB
TypeScript
57 lines
1.8 KiB
TypeScript
export interface PlannedPath {
|
|
src: string;
|
|
dst: string;
|
|
}
|
|
|
|
export interface ExistingPath {
|
|
path: string;
|
|
size?: number;
|
|
sha256?: string;
|
|
}
|
|
|
|
export interface PathConflict {
|
|
dst: string;
|
|
kind: "duplicate_in_plan" | "exists" | "case_collision";
|
|
srcs: string[];
|
|
existing?: ExistingPath;
|
|
}
|
|
|
|
/**
|
|
* 冲突检测:计划内多个源写同一目标、目标已存在、(大小写不敏感文件系统上)仅大小写不同。
|
|
* 只输出结构,是否覆盖由整理策略与授权决定。
|
|
*/
|
|
export function detectPathConflicts(
|
|
planned: readonly PlannedPath[],
|
|
existing: readonly ExistingPath[],
|
|
opts: { caseInsensitive?: boolean } = {},
|
|
): PathConflict[] {
|
|
const out: PathConflict[] = [];
|
|
const byDst = new Map<string, string[]>();
|
|
for (const p of planned) byDst.set(p.dst, [...(byDst.get(p.dst) ?? []), p.src]);
|
|
for (const [dst, srcs] of byDst) if (srcs.length > 1) out.push({ dst, kind: "duplicate_in_plan", srcs });
|
|
const exact = new Map(existing.map((e) => [e.path, e]));
|
|
const folded = new Map(existing.map((e) => [e.path.toLowerCase(), e]));
|
|
for (const [dst, srcs] of byDst) {
|
|
const hit = exact.get(dst);
|
|
if (hit) {
|
|
out.push({ dst, kind: "exists", srcs, existing: hit });
|
|
continue;
|
|
}
|
|
if (opts.caseInsensitive) {
|
|
const f = folded.get(dst.toLowerCase());
|
|
if (f) out.push({ dst, kind: "case_collision", srcs, existing: f });
|
|
}
|
|
}
|
|
if (opts.caseInsensitive) {
|
|
const seen = new Map<string, string>();
|
|
for (const dst of byDst.keys()) {
|
|
const k = dst.toLowerCase();
|
|
const prev = seen.get(k);
|
|
if (prev !== undefined && prev !== dst)
|
|
out.push({ dst, kind: "case_collision", srcs: [...(byDst.get(prev) ?? []), ...(byDst.get(dst) ?? [])] });
|
|
seen.set(k, dst);
|
|
}
|
|
}
|
|
return out;
|
|
}
|