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

+198
View File
@@ -0,0 +1,198 @@
import { sanitizeFileName, sanitizeSegment } from "./sanitize";
import { compileTemplate, type NamingContext, renderTemplate, type TemplateVar } from "./template";
export interface LibraryRoot {
id: string;
/** 宿主机上的根路径(由 settings/挂载映射解析) */
hostPath: string;
}
export interface PathBinding {
id: string;
entityId: string;
scope: "entity" | "season";
season?: number;
libraryRootId: string;
/** 相对库根的路径,POSIX 风格 */
relativePath: string;
templateVersion: number;
active?: boolean;
}
export interface NamingTemplateSet {
version: number;
movieDir: string;
tvDir: string;
seasonDir: string;
movieFile: string;
episodeFile: string;
/** 综艺按日期命名 */
dailyFile: string;
}
export const DEFAULT_TEMPLATES: NamingTemplateSet = {
version: 1,
movieDir: "{title}[[ ({year})]]",
tvDir: "{title}[[ ({year})]]",
seasonDir: "Season {season}",
movieFile: "{title}[[ ({year})]][[ - {cut}]][[ - {part}]][[ - {resolution}]]",
episodeFile: "{title} - {seasonEpisode}[[ - {part}]]",
dailyFile: "{title} - {airDate:dash}[[ - 第{issue}期]]",
};
/** 校验模板集合(白名单变量、括号成对);发布模板版本前调用 */
export function validateTemplateSet(t: NamingTemplateSet): void {
for (const k of ["movieDir", "tvDir", "seasonDir", "movieFile", "episodeFile", "dailyFile"] as const) {
compileTemplate(t[k]);
}
}
export interface NewBinding {
scope: "entity" | "season";
season?: number;
libraryRootId: string;
relativePath: string;
templateVersion: number;
}
export type TargetDirectoryDecision =
| { kind: "existing"; libraryRootId: string; relativePath: string; bindingIds: string[] }
| {
kind: "new";
libraryRootId: string;
relativePath: string;
newBindings: NewBinding[];
reusedBindingIds: string[];
}
| { kind: "conflict"; reason: "multiple_entity_bindings" | "multiple_season_bindings"; bindings: PathBinding[] }
| { kind: "error"; reason: "season_required" | "template_missing_vars"; missing?: TemplateVar[] };
export interface ResolveTargetInput {
entity: { id: string; kind: "movie" | "tv" };
/** 命名上下文(首次绑定才会用到) */
naming: NamingContext;
season?: number;
bindings: readonly PathBinding[];
/** 首次绑定时使用的库根 */
libraryRoot: LibraryRoot;
templates: NamingTemplateSet;
}
const joinRel = (...parts: string[]) => parts.filter((p) => p.length > 0).join("/");
function distinct(bs: PathBinding[]): PathBinding[] {
const seen = new Map<string, PathBinding>();
for (const b of bs) seen.set(`${b.libraryRootId}\u0000${b.relativePath}`, b);
return [...seen.values()];
}
/**
* 目标目录决策(REQ §7.1):已有绑定优先且不可变,不因译名/模板变化重新渲染;
* 只有首次建立作品/季目录时才按当前模板渲染,并记录模板版本。
* 同一作品存在多个不同的历史目录 → conflict,不自动合并。
*/
export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirectoryDecision {
const mine = input.bindings.filter((b) => b.entityId === input.entity.id && b.active !== false);
const entityBindings = mine.filter((b) => b.scope === "entity");
if (distinct(entityBindings).length > 1)
return { kind: "conflict", reason: "multiple_entity_bindings", bindings: entityBindings };
const entityBinding = entityBindings[0];
const t = input.templates;
if (input.entity.kind === "movie") {
if (entityBinding)
return {
kind: "existing",
libraryRootId: entityBinding.libraryRootId,
relativePath: entityBinding.relativePath,
bindingIds: [entityBinding.id],
};
const dir = renderTemplate(t.movieDir, input.naming);
if (!dir.ok) return { kind: "error", reason: "template_missing_vars", missing: dir.missing };
const rel = sanitizeSegment(dir.value);
return {
kind: "new",
libraryRootId: input.libraryRoot.id,
relativePath: rel,
newBindings: [
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: rel, templateVersion: t.version },
],
reusedBindingIds: [],
};
}
if (input.season === undefined) return { kind: "error", reason: "season_required" };
const seasonBindings = mine.filter((b) => b.scope === "season" && b.season === input.season);
if (distinct(seasonBindings).length > 1)
return { kind: "conflict", reason: "multiple_season_bindings", bindings: seasonBindings };
const seasonBinding = seasonBindings[0];
if (seasonBinding)
return {
kind: "existing",
libraryRootId: seasonBinding.libraryRootId,
relativePath: seasonBinding.relativePath,
bindingIds: [seasonBinding.id],
};
const seasonDir = renderTemplate(t.seasonDir, { ...input.naming, season: input.season });
if (!seasonDir.ok) return { kind: "error", reason: "template_missing_vars", missing: seasonDir.missing };
const seasonSeg = sanitizeSegment(seasonDir.value);
if (entityBinding) {
const rel = joinRel(entityBinding.relativePath, seasonSeg);
return {
kind: "new",
libraryRootId: entityBinding.libraryRootId,
relativePath: rel,
newBindings: [
{
scope: "season",
season: input.season,
libraryRootId: entityBinding.libraryRootId,
relativePath: rel,
templateVersion: t.version,
},
],
reusedBindingIds: [entityBinding.id],
};
}
const showDir = renderTemplate(t.tvDir, input.naming);
if (!showDir.ok) return { kind: "error", reason: "template_missing_vars", missing: showDir.missing };
const showRel = sanitizeSegment(showDir.value);
const rel = joinRel(showRel, seasonSeg);
return {
kind: "new",
libraryRootId: input.libraryRoot.id,
relativePath: rel,
newBindings: [
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: showRel, templateVersion: t.version },
{
scope: "season",
season: input.season,
libraryRootId: input.libraryRoot.id,
relativePath: rel,
templateVersion: t.version,
},
],
reusedBindingIds: [],
};
}
export type FileNameResult =
| { ok: true; fileName: string }
| { ok: false; missing: TemplateVar[] | ["episode_or_air_date"] };
/** 渲染视频文件名:电影用 movieFile;剧集有集号用 episodeFile;只有播出日期用 dailyFile */
export function renderMediaFileName(
kind: "movie" | "tv",
templates: NamingTemplateSet,
ctx: NamingContext,
ext: string,
): FileNameResult {
let tpl: string;
if (kind === "movie") tpl = templates.movieFile;
else if (ctx.seasonEpisode !== undefined) tpl = templates.episodeFile;
else if (ctx.airDate !== undefined) tpl = templates.dailyFile;
else return { ok: false, missing: ["episode_or_air_date"] };
const r = renderTemplate(tpl, ctx);
if (!r.ok) return r;
return { ok: true, fileName: sanitizeFileName(r.value, ext) };
}