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) };
}
+56
View File
@@ -0,0 +1,56 @@
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;
}
+27
View File
@@ -0,0 +1,27 @@
export {
DEFAULT_TEMPLATES,
type FileNameResult,
type LibraryRoot,
type NamingTemplateSet,
type NewBinding,
type PathBinding,
type ResolveTargetInput,
renderMediaFileName,
resolveTargetDirectory,
type TargetDirectoryDecision,
validateTemplateSet,
} from "./binding";
export { detectPathConflicts, type ExistingPath, type PathConflict, type PlannedPath } from "./conflicts";
export { type MapResult, type PathMapping, toDownloaderPath, toHostPath, validatePathMappings } from "./mapping";
export { sanitizeFileName, sanitizeSegment, utf8Length } from "./sanitize";
export {
buildNamingContext,
type CompiledTemplate,
compileTemplate,
type NamingContext,
type RenderResult,
renderTemplate,
TEMPLATE_VARS,
TemplateError,
type TemplateVar,
} from "./template";
+68
View File
@@ -0,0 +1,68 @@
/** 下载器实例看到的路径 ↔ 本服务宿主看到的路径(前缀替换,支持多实例) */
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;
}
+54
View File
@@ -0,0 +1,54 @@
const INVALID: Record<string, string> = {
"\\": "\",
"/": "/",
":": ":",
"*": "*",
"?": "?",
'"': """,
"<": "<",
">": ">",
"|": "|",
};
const RESERVED = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i;
const enc = new TextEncoder();
export function utf8Length(s: string): number {
return enc.encode(s).length;
}
function truncateBytes(s: string, maxBytes: number): string {
if (utf8Length(s) <= maxBytes) return s;
let out = "";
for (const c of s) {
if (utf8Length(out + c) > maxBytes) break;
out += c;
}
return out;
}
/**
* 路径段非法字符处理:`\/:*?"<>|` 转全角(对照 MP),去控制字符、首尾空白与点,
* Windows 保留名加下划线,按 UTF-8 字节截断(默认 255),空串变 `_`。
*/
export function sanitizeSegment(input: string, maxBytes = 255): string {
let s = "";
for (const c of input.normalize("NFC")) {
const code = c.codePointAt(0)!;
if (code < 0x20 || code === 0x7f) continue;
s += INVALID[c] ?? c;
}
s = s
.replace(/\s+/g, " ")
.trim()
.replace(/^[.\s]+/, "")
.replace(/[.\s]+$/, "");
if (RESERVED.test(s)) s = `${s}_`;
s = truncateBytes(s, maxBytes).replace(/[.\s]+$/, "");
return s.length > 0 ? s : "_";
}
/** 文件名:只处理主干,扩展名保持;总长不超过 maxBytes */
export function sanitizeFileName(stem: string, ext: string, maxBytes = 255): string {
const e = ext ? `.${ext.replace(/^\.+/, "").toLowerCase()}` : "";
return sanitizeSegment(stem, maxBytes - utf8Length(e)) + e;
}
+151
View File
@@ -0,0 +1,151 @@
import type { ParsedRelease } from "../release-parser/types";
import { sanitizeSegment } from "./sanitize";
/** 受限模板(白名单变量 + 可选片段),不用 Jinja/任意表达式 */
export const TEMPLATE_VARS = [
"title",
"originalTitle",
"year",
"season",
"episode",
"episodeEnd",
"seasonEpisode",
"airDate",
"issue",
"part",
"cut",
"resolution",
"source",
"videoCodec",
"hdr",
"releaseGroup",
] as const;
export type TemplateVar = (typeof TEMPLATE_VARS)[number];
export type NamingContext = Partial<Record<TemplateVar, string | number>>;
export class TemplateError extends Error {
override name = "TemplateError";
}
type Part = { kind: "text"; text: string } | { kind: "var"; name: TemplateVar; format?: string };
interface Piece {
optional: boolean;
parts: Part[];
}
export interface CompiledTemplate {
source: string;
pieces: Piece[];
vars: TemplateVar[];
}
function parseParts(src: string): Part[] {
const parts: Part[] = [];
const re = /\{([A-Za-z]+)(?::([A-Za-z0-9-]+))?\}/g;
let last = 0;
for (const m of src.matchAll(re)) {
if (m.index > last) parts.push({ kind: "text", text: src.slice(last, m.index) });
const name = m[1] as TemplateVar;
if (!TEMPLATE_VARS.includes(name)) throw new TemplateError(`未知模板变量: ${m[1]}`);
parts.push({ kind: "var", name, ...(m[2] ? { format: m[2] } : {}) });
last = m.index + m[0].length;
}
if (last < src.length) parts.push({ kind: "text", text: src.slice(last) });
for (const p of parts) if (p.kind === "text" && /[{}]/.test(p.text)) throw new TemplateError(`花括号不成对: ${src}`);
return parts;
}
export function compileTemplate(source: string): CompiledTemplate {
if (source.length > 512) throw new TemplateError("模板过长");
const pieces: Piece[] = [];
let i = 0;
while (i < source.length) {
const open = source.indexOf("[[", i);
const close = source.indexOf("]]", i);
if (close !== -1 && (open === -1 || close < open)) throw new TemplateError("可选片段 ]] 没有对应的 [[");
if (open === -1) {
pieces.push({ optional: false, parts: parseParts(source.slice(i)) });
break;
}
if (open > i) pieces.push({ optional: false, parts: parseParts(source.slice(i, open)) });
const end = source.indexOf("]]", open + 2);
if (end === -1) throw new TemplateError("可选片段缺少 ]]");
const inner = source.slice(open + 2, end);
if (inner.includes("[[")) throw new TemplateError("可选片段不能嵌套");
pieces.push({ optional: true, parts: parseParts(inner) });
i = end + 2;
}
const vars = [
...new Set(pieces.flatMap((p) => p.parts.flatMap((x) => (x.kind === "var" ? [x.name] : [])))),
] as TemplateVar[];
return { source, pieces, vars };
}
function formatValue(v: string | number, format?: string): string {
if (!format) return String(v);
const pad = /^0(\d)$/.exec(format);
if (pad && typeof v === "number") return String(v).padStart(Number(pad[1]), "0");
if (format === "dash" && typeof v === "string" && /^\d{8}$/.test(v))
return `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6)}`;
if (format === "dot" && typeof v === "string" && /^\d{8}$/.test(v))
return `${v.slice(0, 4)}.${v.slice(4, 6)}.${v.slice(6)}`;
return String(v);
}
const present = (v: unknown) => v !== undefined && v !== null && v !== "";
export type RenderResult = { ok: true; value: string } | { ok: false; missing: TemplateVar[] };
/** 渲染:必选部分缺变量 → missing;可选片段缺任一变量则整段省略。变量值内的路径分隔符等被转全角 */
export function renderTemplate(tpl: CompiledTemplate | string, ctx: NamingContext): RenderResult {
const c = typeof tpl === "string" ? compileTemplate(tpl) : tpl;
const missing: TemplateVar[] = [];
let out = "";
for (const piece of c.pieces) {
const lack = piece.parts.filter((p) => p.kind === "var" && !present(ctx[p.name])) as Extract<
Part,
{ kind: "var" }
>[];
if (lack.length > 0) {
if (!piece.optional) for (const l of lack) if (!missing.includes(l.name)) missing.push(l.name);
continue;
}
for (const p of piece.parts) {
out += p.kind === "text" ? p.text : sanitizeSegment(formatValue(ctx[p.name]!, p.format));
}
}
if (missing.length > 0) return { ok: false, missing };
return { ok: true, value: out.replace(/\s+/g, " ").trim() };
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 由作品信息 + 解析结果构造命名上下文(标题用本地实体的绑定名,不用发布标题) */
export function buildNamingContext(
entity: { title: string; originalTitle?: string; year?: number },
parsed?: Partial<ParsedRelease>,
): NamingContext {
const ctx: NamingContext = { title: entity.title };
if (entity.originalTitle) ctx.originalTitle = entity.originalTitle;
if (entity.year !== undefined) ctx.year = entity.year;
if (!parsed) return ctx;
if (parsed.season !== undefined) ctx.season = parsed.season;
if (parsed.episodes) {
ctx.episode = parsed.episodes.start;
if (parsed.episodes.end > parsed.episodes.start) ctx.episodeEnd = parsed.episodes.end;
if (parsed.season !== undefined) {
const { start, end } = parsed.episodes;
// 多集文件保留区间(偏离 MP 的 >2 集截断)
ctx.seasonEpisode = `S${pad2(parsed.season)}E${pad2(start)}${end > start ? `-E${pad2(end)}` : ""}`;
}
}
if (parsed.airDate) ctx.airDate = parsed.airDate;
if (parsed.issue !== undefined) ctx.issue = parsed.issue;
if (parsed.part) ctx.part = parsed.part;
if (parsed.cut) ctx.cut = parsed.cut;
if (parsed.resolution) ctx.resolution = parsed.resolution;
if (parsed.source) ctx.source = parsed.source;
if (parsed.videoCodec) ctx.videoCodec = parsed.videoCodec;
if (parsed.hdr?.length) ctx.hdr = parsed.hdr.join(" ");
if (parsed.releaseGroup) ctx.releaseGroup = parsed.releaseGroup.name;
return ctx;
}