Files
UHD_Media_Tool/packages/domain/src/organize/plan.ts
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

435 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 };
}
/** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */
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"
| "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;
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),
};
}