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:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user