Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
import type { NamingEntityInput } from "../naming/variables.ts";
|
|
import { DEFAULT_TEMPLATES, type NamingTemplateSet, renderMediaFileName } from "../path/binding.ts";
|
|
import { buildNamingContext } from "../path/template.ts";
|
|
import { type ParseOptions, parseReleasePath } from "../release-parser/index.ts";
|
|
import { fileKindOf } from "../release-parser/parse.ts";
|
|
import type { ParsedRelease } from "../release-parser/types.ts";
|
|
import {
|
|
basename,
|
|
type DefaultSubtitleLang,
|
|
dirname,
|
|
sidecarLanguageSuffix,
|
|
sidecarStem,
|
|
splitExt,
|
|
} from "./sidecars.ts";
|
|
|
|
export type OrganizeOperation = "copy" | "move" | "rename" | "in_place";
|
|
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[];
|
|
/** 本地实体的命名信息(不用发布标题命名;可带英文名/外部 ID/季集元数据等命名变量来源) */
|
|
entity: NamingEntityInput;
|
|
/** 目标目录(宿主绝对路径,来自 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 };
|
|
/** Explicit one-time replacement grants, restricted to the selected old library paths. */
|
|
replacePaths?: readonly string[];
|
|
parse?: ParseOptions;
|
|
/** 随行字幕里此语言加 .default 标记(对照 MP DEFAULT_SUB);不设不加 */
|
|
defaultSubtitleLang?: DefaultSubtitleLang;
|
|
/**
|
|
* 逐文件季/集覆写(人工确认,优先于文件名解析):键为源文件路径或其所在目录(最长前缀生效)。
|
|
* 只影响剧集的季目录选择与命名;电影忽略。
|
|
*/
|
|
overrides?: Readonly<Record<string, FileOverride>>;
|
|
}
|
|
|
|
export interface FileOverride {
|
|
season?: number;
|
|
episodes?: { start: number; end: number };
|
|
/**
|
|
* 不入库(调用方按作品集数映射判定:集数待确认 episode_unresolved、映射规则标为特别篇 / 花絮 / 忽略 special_extra);
|
|
* 只按精确路径生效,目录前缀覆写不带它
|
|
*/
|
|
exclude?: { reason: "episode_unresolved" | "special_extra"; detail?: string };
|
|
}
|
|
|
|
/** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */
|
|
export function overrideFor<T extends FileOverride>(
|
|
overrides: Readonly<Record<string, T>> | undefined,
|
|
path: string,
|
|
): T | undefined {
|
|
if (!overrides) return undefined;
|
|
const exact = overrides[path];
|
|
if (exact) return exact;
|
|
let best: { len: number; o: T } | undefined;
|
|
for (const [k, o] of Object.entries(overrides)) {
|
|
const dir = k.replace(/\/+$/, "");
|
|
if (path.startsWith(`${dir}/`) && (!best || dir.length > best.len)) best = { len: dir.length, o };
|
|
}
|
|
return best?.o;
|
|
}
|
|
|
|
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"
|
|
| "episode_unresolved"
|
|
| "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 inPlace = input.operation === "in_place";
|
|
|
|
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 ?? {});
|
|
const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined;
|
|
const ex = input.mediaType === "tv" ? input.overrides?.[f.path]?.exclude : undefined;
|
|
if (ex) {
|
|
excluded.push({ src: f.path, kind, reason: ex.reason, ...(ex.detail ? { detail: ex.detail } : {}) });
|
|
continue;
|
|
}
|
|
if (ov?.season !== undefined) parsed.season = ov.season;
|
|
if (ov?.episodes) {
|
|
parsed.episodes = ov.episodes;
|
|
delete parsed.airDate;
|
|
}
|
|
if (parsed.contentType === "extra" && parsed.evidence.includes("dir:extras")) {
|
|
excluded.push({ src: f.path, kind, reason: "extra_content" });
|
|
continue;
|
|
}
|
|
if (inPlace) {
|
|
if (input.mediaType === "tv" && !parsed.episodes && !parsed.airDate) {
|
|
excluded.push({ src: f.path, kind, reason: "no_episode" });
|
|
continue;
|
|
}
|
|
if (input.mediaType === "tv" && parsed.season === undefined) parsed.season = 1;
|
|
videos.push({ f, parsed, dst: f.path });
|
|
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",
|
|
...(!inPlace && 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, input.defaultSubtitleLang);
|
|
let dst = inPlace ? s.path : `${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",
|
|
...(!inPlace && 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 (inPlace || 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" && (!input.replacePaths || input.replacePaths.includes(p.dst))) {
|
|
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,
|
|
});
|
|
}
|
|
if (!inPlace)
|
|
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),
|
|
};
|
|
}
|