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

+377
View File
@@ -0,0 +1,377 @@
import { DEFAULT_TEMPLATES, type NamingTemplateSet, renderMediaFileName } from "../path/binding";
import { buildNamingContext } from "../path/template";
import { type ParseOptions, parseReleasePath } from "../release-parser";
import { fileKindOf } from "../release-parser/parse";
import type { ParsedRelease } from "../release-parser/types";
import { basename, dirname, sidecarLanguageSuffix, sidecarStem, splitExt } from "./sidecars";
export type OrganizeOperation = "copy" | "move" | "rename";
export type OrganizeFileKind = "video" | "subtitle" | "audio" | "nfo" | "other";
export type ExistingPolicy = "skip" | "replace_with_verify" | "conflict";
export interface SourceFile {
path: string;
size: number;
/** 设备号(stat.dev),由调用方提供;未知则不判断跨卷 */
device?: string | number;
/** qB 文件清单下标 */
index?: number;
sha256?: string;
}
export interface ExistingTarget {
path: string;
size: number;
sha256?: string;
}
export interface OrganizeInput {
operation: OrganizeOperation;
mediaType: "movie" | "tv";
/** 种子内容根(整季包目录);只在它之内取父目录名参与解析 */
sourceRoot?: string;
files: readonly SourceFile[];
/** 本地实体的命名信息(不用发布标题命名) */
entity: { title: string; originalTitle?: string; year?: number };
/** 目标目录(宿主绝对路径,来自 path.resolveTargetDirectory + 库根) */
target: { movieDir?: string; seasonDirs?: Record<number, string>; device?: string | number };
templates?: NamingTemplateSet;
existingTargets?: readonly ExistingTarget[];
/** 目标已存在时的策略,默认 conflict(待处理) */
onExisting?: ExistingPolicy;
/** H&R/做种保留是否已满足;move 必需 */
retention?: { hnrSatisfied: boolean | "unknown" };
/** 主视频最小体积,默认 50 MiB;低于此值视为样例/垃圾 */
minVideoBytes?: number;
grants?: { replace?: boolean };
parse?: ParseOptions;
}
export type PlannedAction = "transfer" | "replace" | "skip" | "hold";
export interface PlannedFile {
src: string;
dst: string;
size: number;
kind: OrganizeFileKind;
role: "main" | "sidecar";
mainSrc?: string;
/** 跨卷:执行层必须走 copy → verify → delete source,EXDEV 不得静默降级 */
crossDevice?: boolean;
action: PlannedAction;
skipReason?: "already_in_library" | "target_exists" | "main_not_transferred";
season?: number;
episodes?: { start: number; end: number };
airDate?: string;
}
export interface ExcludedFile {
src: string;
kind: OrganizeFileKind;
reason:
| "sample"
| "too_small"
| "not_imported_kind"
| "orphan_sidecar"
| "no_episode"
| "special_extra"
| "extra_content"
| "no_target_dir"
| "naming_failed";
detail?: string;
}
export interface OrganizeConflict {
kind:
| "target_exists"
| "duplicate_target"
| "replace_requires_grant"
| "hnr_unsatisfied"
| "hnr_unknown"
| "orphan_sidecar"
| "no_main_video";
/** 阻塞 = 需要审批/授权后才能执行 */
blocking: boolean;
src?: string;
dst?: string;
detail?: string;
}
export type Precondition =
| { kind: "free_space"; device?: string | number; bytes: number }
| { kind: "target_absent"; paths: string[] }
| { kind: "source_unchanged"; files: { path: string; size: number }[] }
| { kind: "verify_after_copy"; paths: string[] }
| { kind: "hnr_satisfied" }
| { kind: "quarantine_replaced"; paths: string[] };
export interface OrganizePlan {
operation: OrganizeOperation;
crossDevice: boolean;
files: PlannedFile[];
excluded: ExcludedFile[];
preconditions: Precondition[];
conflicts: OrganizeConflict[];
requiresApproval: boolean;
}
const SAMPLE_RE = /(?:^|[.\-_ [(])sample(?:[.\-_ \])]|$)/i;
const SPECIAL_RE =
/(?:^|[.\-_ [])(?:NC)?(?:OP|ED|PV|CM|MENU|TRAILER|TEASER|SP|OVA|OAD|NCOP|NCED)\d{0,2}(?:[.\-_ \]]|$)/i;
function kindOf(path: string): OrganizeFileKind {
const k = fileKindOf(path);
if (k === "video" || k === "subtitle" || k === "audio" || k === "nfo") return k;
return "other";
}
function segmentsFor(
path: string,
sourceRoot?: string,
): { file: string; parent?: string; grandParent?: string } | string {
if (!sourceRoot) return path;
const root = sourceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const p = path.replace(/\\/g, "/");
if (p !== root && !p.startsWith(`${root}/`)) return path;
const within = [basename(root), ...p.slice(root.length + 1).split("/")].filter(Boolean);
const file = within.pop() ?? basename(p);
if (file === basename(root) && within.length === 0) return { file };
const parent = within.pop();
const grandParent = within.pop();
return {
file,
...(parent !== undefined ? { parent } : {}),
...(grandParent !== undefined ? { grandParent } : {}),
};
}
const sameDevice = (a?: string | number, b?: string | number) =>
a === undefined || b === undefined ? undefined : String(a) !== String(b);
/**
* 整理计划(纯逻辑,不做 IO):选择主视频与随行附属文件、排除样例、计算目标、
* 目标存在策略、跨卷标记、H&R 前置条件。结果交给执行层冻结并按步骤执行。
*/
export function planOrganize(input: OrganizeInput): OrganizePlan {
const templates = input.templates ?? DEFAULT_TEMPLATES;
const minVideo = input.minVideoBytes ?? 50 * 1024 * 1024;
const onExisting = input.onExisting ?? "conflict";
const excluded: ExcludedFile[] = [];
const conflicts: OrganizeConflict[] = [];
const planned: PlannedFile[] = [];
const existing = new Map((input.existingTargets ?? []).map((e) => [e.path, e]));
const targetDevice = input.target.device;
const isRename = input.operation === "rename";
const videos: { f: SourceFile; parsed: ParsedRelease; dst: string }[] = [];
const sidecars: SourceFile[] = [];
for (const f of input.files) {
const kind = kindOf(f.path);
if (kind === "subtitle" || kind === "audio") {
sidecars.push(f);
continue;
}
if (kind !== "video") {
excluded.push({ src: f.path, kind, reason: "not_imported_kind" });
continue;
}
const segs = f.path.replace(/\\/g, "/").split("/");
if (SAMPLE_RE.test(basename(f.path)) || segs.some((s) => /^samples?$/i.test(s))) {
excluded.push({ src: f.path, kind, reason: "sample" });
continue;
}
if (f.size < minVideo) {
excluded.push({ src: f.path, kind, reason: "too_small", detail: String(f.size) });
continue;
}
const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {});
if (parsed.contentType === "extra" && parsed.evidence.includes("dir:extras")) {
excluded.push({ src: f.path, kind, reason: "extra_content" });
continue;
}
const [, ext] = splitExt(basename(f.path));
let dir: string | undefined;
let named: ReturnType<typeof renderMediaFileName>;
if (input.mediaType === "movie") {
dir = input.target.movieDir;
named = renderMediaFileName("movie", templates, buildNamingContext(input.entity, parsed), ext);
} else {
if (!parsed.episodes && !parsed.airDate) {
const special = SPECIAL_RE.test(splitExt(basename(f.path))[0]);
excluded.push({ src: f.path, kind, reason: special ? "special_extra" : "no_episode" });
continue;
}
const seasonDirs = input.target.seasonDirs ?? {};
let season = parsed.season;
const keys = Object.keys(seasonDirs).map(Number);
if (season === undefined && keys.length === 1) season = keys[0];
dir = season !== undefined ? seasonDirs[season] : undefined;
const ctx = buildNamingContext(input.entity, { ...parsed, ...(season !== undefined ? { season } : {}) });
named = renderMediaFileName("tv", templates, ctx, ext);
parsed.season = season;
}
if (!dir) {
excluded.push({ src: f.path, kind, reason: "no_target_dir", detail: `season=${parsed.season ?? "?"}` });
continue;
}
if (!named.ok) {
excluded.push({ src: f.path, kind, reason: "naming_failed", detail: named.missing.join(",") });
continue;
}
videos.push({ f, parsed, dst: `${dir.replace(/\/+$/, "")}/${named.fileName}` });
}
if (videos.length === 0 && input.files.length > 0)
conflicts.push({ kind: "no_main_video", blocking: true, detail: "没有可入库的主视频" });
// 主视频
for (const v of videos) {
planned.push({
src: v.f.path,
dst: v.dst,
size: v.f.size,
kind: "video",
role: "main",
action: "transfer",
...(sameDevice(v.f.device, targetDevice) !== undefined
? { crossDevice: sameDevice(v.f.device, targetDevice) }
: {}),
...(v.parsed.season !== undefined ? { season: v.parsed.season } : {}),
...(v.parsed.episodes ? { episodes: v.parsed.episodes } : {}),
...(v.parsed.airDate ? { airDate: v.parsed.airDate } : {}),
});
}
// 附属文件:同目录、主干名唯一匹配才随行;否则孤儿,不搬不删
const mainByDirStem = new Map<string, { f: SourceFile; dst: string }[]>();
for (const v of videos) {
const key = `${dirname(v.f.path)}\u0000${splitExt(basename(v.f.path))[0].toLowerCase()}`;
mainByDirStem.set(key, [...(mainByDirStem.get(key) ?? []), { f: v.f, dst: v.dst }]);
}
const usedSidecarDst = new Set<string>();
for (const s of sidecars) {
const kind = kindOf(s.path);
const { stem, tags } = sidecarStem(s.path);
const mains = mainByDirStem.get(`${dirname(s.path)}\u0000${stem}`) ?? [];
if (mains.length !== 1) {
excluded.push({
src: s.path,
kind,
reason: "orphan_sidecar",
detail: mains.length > 1 ? "ambiguous" : "no_match",
});
conflicts.push({ kind: "orphan_sidecar", blocking: false, src: s.path });
continue;
}
const main = mains[0]!;
const [mainStem] = splitExt(main.dst);
const [, ext] = splitExt(basename(s.path));
const suffix = sidecarLanguageSuffix(tags);
let dst = `${mainStem}${suffix}.${ext.toLowerCase()}`;
for (let n = 1; usedSidecarDst.has(dst); n++) dst = `${mainStem}${suffix}.${n}.${ext.toLowerCase()}`;
usedSidecarDst.add(dst);
planned.push({
src: s.path,
dst,
size: s.size,
kind,
role: "sidecar",
mainSrc: main.f.path,
action: "transfer",
...(sameDevice(s.device, targetDevice) !== undefined ? { crossDevice: sameDevice(s.device, targetDevice) } : {}),
});
}
// 计划内目标重复
const byDst = new Map<string, PlannedFile[]>();
for (const p of planned) byDst.set(p.dst, [...(byDst.get(p.dst) ?? []), p]);
for (const [dst, ps] of byDst) {
if (ps.length < 2) continue;
for (const p of ps) p.action = "hold";
conflicts.push({ kind: "duplicate_target", blocking: true, dst, detail: ps.map((p) => p.src).join(" | ") });
}
// 目标已存在
const replaced: string[] = [];
for (const p of planned) {
if (p.action !== "transfer") continue;
const e = existing.get(p.dst);
if (!e) continue;
if (e.size === p.size && e.sha256 !== undefined && e.sha256 === input.files.find((f) => f.path === p.src)?.sha256) {
p.action = "skip";
p.skipReason = "already_in_library";
continue;
}
if (onExisting === "skip") {
p.action = "skip";
p.skipReason = "target_exists";
conflicts.push({ kind: "target_exists", blocking: false, src: p.src, dst: p.dst, detail: "skipped" });
} else if (onExisting === "replace_with_verify") {
p.action = "replace";
replaced.push(p.dst);
if (!input.grants?.replace)
conflicts.push({ kind: "replace_requires_grant", blocking: true, src: p.src, dst: p.dst });
} else {
p.action = "hold";
conflicts.push({ kind: "target_exists", blocking: true, src: p.src, dst: p.dst });
}
}
// 主视频不搬时附属文件也不单独搬
const mainAction = new Map(planned.filter((p) => p.role === "main").map((p) => [p.src, p.action]));
for (const p of planned) {
if (p.role !== "sidecar" || !p.mainSrc) continue;
const ma = mainAction.get(p.mainSrc);
if ((ma === "skip" || ma === "hold") && p.action === "transfer") {
p.action = ma;
if (ma === "skip") p.skipReason = "main_not_transferred";
}
}
// Move 的 H&R 前置条件:未满足 → 冲突待处理,不静默改 Copy
const preconditions: Precondition[] = [];
if (input.operation === "move") {
const h = input.retention?.hnrSatisfied;
if (h === true) preconditions.push({ kind: "hnr_satisfied" });
else
conflicts.push({
kind: h === false ? "hnr_unsatisfied" : "hnr_unknown",
blocking: true,
detail: "等待做种义务满足,或经授权改用 copy",
});
}
const active = planned.filter((p) => p.action === "transfer" || p.action === "replace");
const crossDevice = active.some((p) => p.crossDevice === true);
const needsCopy = (p: PlannedFile) =>
input.operation === "copy" || (input.operation === "move" && p.crossDevice !== false) || p.action === "replace";
if (active.length > 0) {
if (!isRename) {
const bytes = active.filter(needsCopy).reduce((s, p) => s + p.size, 0);
if (bytes > 0)
preconditions.push({
kind: "free_space",
...(targetDevice !== undefined ? { device: targetDevice } : {}),
bytes,
});
}
preconditions.push({
kind: "target_absent",
paths: active.filter((p) => p.action === "transfer").map((p) => p.dst),
});
preconditions.push({ kind: "source_unchanged", files: active.map((p) => ({ path: p.src, size: p.size })) });
const verify = active.filter((p) => !isRename && needsCopy(p)).map((p) => p.dst);
if (verify.length > 0) preconditions.push({ kind: "verify_after_copy", paths: verify });
if (replaced.length > 0) preconditions.push({ kind: "quarantine_replaced", paths: replaced });
}
return {
operation: input.operation,
crossDevice,
files: planned,
excluded,
preconditions,
conflicts,
requiresApproval: conflicts.some((c) => c.blocking),
};
}