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; 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>; } 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( overrides: Readonly> | 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; 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(); 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(); 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(); 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), }; }