diff --git a/packages/domain/README.md b/packages/domain/README.md index 20c2d262..920071fd 100644 --- a/packages/domain/README.md +++ b/packages/domain/README.md @@ -1,3 +1,56 @@ # @umt/domain -纯业务逻辑(无 IO):发布标题解析、身份匹配、Slot 满足度、路径绑定、整理计划与策略评估。禁止 import 任何网络/文件/数据库库,保证 100% 可单元测试。 +纯业务逻辑(无 IO):发布标题解析、身份匹配、Slot 满足度、收录模式/预算/磁盘阈值、路径绑定与命名、整理计划、下载状态映射。 + +- 禁止 import 任何网络/文件/数据库库,也不读全局配置与时钟;时间(`now`)、设备号、文件清单、已存在目标等事实一律由调用方传入。 +- 不依赖 `@umt/contracts`:这里的类型是领域类型,W2 在 server/tasks 层映射到 zod 合同。 +- 唯一入口 `src/index.ts`(`export *` 各模块)。测试:`pnpm --filter @umt/domain test`。 + +## 模块地图 + +| 目录 | 主要导出 | 说明 | +| --- | --- | --- | +| `release-parser/` | `parseRelease(title, opts)`、`parseReleasePath(path \| segments, opts)`、`mergeParsed`、`scanCnMarkers`、`evalEpisodeOffset`、`cnToInt`、`fileKindOf`、`expandEpisodes`、`RELEASE_GROUP_SEED`、`PARSER_VERSION` | 标题/文件名/目录名 → `ParsedRelease`。行为对照 MP MetaInfo/MetaInfoPath,偏离点见下 | +| `identity/` | `scoreCandidate`、`rankCandidates`、`resolveIdentity`、`normalizeTitle`、`stripSeasonSuffix`、`toSimplified` | 候选(TMDB 或本地别名召回,统一为 `IdentityCandidate`)按证据等级排序;年份容差 ±1;只给等级不给概率 | +| `slot/` | `evaluateCandidate(spec, parsed, current?)`、`rankForSlot`、`checkHardConditions`、`softRank`、`qualityTier`、`transition`/`canTransition`、`movieCompletion`、`unitCompletion`、`checkDistinctAssets` | 硬条件(区分缺字段与不满足)、软偏好排序、升级阈值、状态机、完成条件 | +| `acquisition/` | `determineMode`、`sortByPriority`/`priorityOf`、`checkBudget`、`evaluateCacheVolume` | 新作品/追更补缺/升级判定;即时>追更>新作品>升级(可配置);每日预算;缓存卷硬阈值与恢复水位 | +| `path/` | `resolveTargetDirectory`、`renderMediaFileName`、`renderTemplate`/`compileTemplate`、`buildNamingContext`、`sanitizeSegment`/`sanitizeFileName`、`detectPathConflicts`、`toHostPath`/`toDownloaderPath`、`validatePathMappings` | 路径绑定(已有优先、不可变)、受限模板命名、非法字符、冲突结构、下载器↔宿主路径映射 | +| `organize/` | `planOrganize(input) → OrganizePlan`、`sidecarStem`、`sidecarLanguageSuffix` | 主视频 + 随行字幕/音轨、样例排除、目标存在策略、跨卷标记、H&R 前置条件 | +| `download-state/` | `mapQbState`、`applyObservation`、`isDownloadComplete`、`detectNoProgress` | qB 状态 → download_task 状态机;完成判定;无进展只产出证据 | + +## 不变量(对应 REQ §2.1,改动这些行为必须先改需求) + +1. **身份不等于名字,路径不等于外部 ID。** `identity` 只输出候选与证据等级,不写入任何绑定;文件名里的 `[tmdbid=…]` 只提升证据一级,不直接视为已确认身份。命名一律用本地实体的标题(`buildNamingContext(entity, parsed)`),发布标题只提供季集/版本等事实。 +2. **已建立的路径绑定不随元数据重新计算。** `resolveTargetDirectory` 在存在有效绑定时直接返回 `existing`,模板与译名不参与;只有首次建立作品/季目录才渲染模板并带 `templateVersion`;同一作品多个历史目录 → `conflict`,不自动合并。 +3. **模型不是业务事实来源,也不产生概率。** 解析与匹配只给 `rule_confirmed / multi_evidence / needs_confirm`(及 `rejected`)等级和 `evidence[]` 规则名;Slot 评估与优先级都是确定性代码。 +4. **失败不等于不存在。** 字段缺失与字段不满足分开(`unknown_field` vs `reject`);磁盘状态读不到 → `block`;体积未知 → 预算 `unknown`;qB 返回未知状态 → 沿用上次并标记 `uncertain`;`mapQbState(null)`(=removed)只能在 qB 可达且确认列表中没有时调用;Slot 只在"确认缺失"事件下进 attention。 +5. **清理/覆盖按明确授权。** 整理计划默认 `onExisting: "conflict"`;替换需 `grants.replace` 且旧文件先隔离;move 在 H&R 未满足或未知时是阻塞冲突,不静默改 copy;跨卷只标记 `crossDevice`,由执行层走 copy→verify→delete source;孤儿字幕不搬不删;无进展只 `record_evidence`,从不换源。 + +## 与 MoviePilot 的偏离(测试里以 `[deviation: …]` 标注) + +| 场景 | MP | 本项目 | +| --- | --- | --- | +| 综艺日期 `2024.05.03` / `20240503` | 删除或忽略 | `airDate`(YYYYMMDD),有日期即单期,绝不判整季包 | +| `第N期` | 当 episode | `issue`,与 episode 分开 | +| `第八季 全12期` | 季号丢失 | season=8,totalEpisodes=12 | +| 未知发布组 | `None` | 末段 `-GROUP` 兜底,`releaseGroup.source = "suffix"`;动漫开头方括号 `bracket` | +| 剪辑版 Extended/Director's Cut/IMAX… | 当干扰词删除 | 独立 `cut` 字段(内容版本),与交付版本分开 | +| 文件无剧集信号、目录是剧集 | 不合并 | 合并名称/季号,证据 `parent_dir:type_inferred`,置信度 `needs_confirm` | +| 辅助词文件名(`简体.srt`) | 取到根目录名 | `auxiliaryOnly`,名称取父目录,交给附属文件配对 | +| 附加内容目录(Featurettes/花絮) | 识别成独立电影 | `contentType = "extra"`,归属父作品;整理计划中排除(M2) | +| 分盘 `CD1.mkv` | part 丢失 | `part = "CD1"` | +| 有集号无季号 | 季号为空 | 推定 1 并标 `seasonInferred` | +| 集号偏移 | `eval()` | 白名单解析器 `evalEpisodeOffset`,负结果拒绝 | +| 多集文件 E01-E03 | 截成单集命名 | 保留区间 `S02E01-E03` | +| overwrite / latest | 可静默覆盖、删同集其他版本 | 默认冲突待处理;跨 Slot 永不删除;替换需授权 + 隔离 | +| move 跨盘 | EXDEV 隐式降级复制,删源失败仍成功 | 只标记 `crossDevice`,执行层显式三步 | +| Move 删种 | 不看 H&R | H&R 未满足/未知 → 阻塞冲突 | + +## 给 W2 的接入提示 + +- 解析:下载完成后用 `parseReleasePath({ file, parent, grandParent })` 或 `planOrganize({ sourceRoot })`(只在种子根内取目录名);RSS/站点标题用 `parseRelease(title, { subtitle })`。`ParsedRelease.sizeBytes` 可用站点字段覆盖(`{ ...parsed, sizeBytes }`)后再交给 Slot 评估。 +- 身份:本地别名召回(pg_trgm)与 TMDB 结果都转成 `IdentityCandidate`(本地用 `provider: "local"` + `localEntityId` + `linkedExternalIds`);`resolveIdentity` 为 `auto` 才自动绑定,其余进待识别池。 +- 选片:`rankForSlot(spec, candidates, currentAssetFacts)`;`unknown_field` 表示需补查详情(如字幕/体积),不是拒绝。 +- 下载前:`determineMode` → `priorityOf` → `checkBudget` → `evaluateCacheVolume`(按卷汇总所有 qB 实例的在途任务,已提交计划的预留不要再传)。 +- 入库:`resolveTargetDirectory`(返回的 `newBindings` 与文件登记同事务写入)→ 库根 hostPath 拼成绝对目录 → `planOrganize` → 冻结计划;`requiresApproval` 为真时生成 decision/approval。 +- 状态:Slot 与下载任务的状态迁移在写库前先过 `transition` / `applyObservation`,非法迁移抛错即拒绝写入。 diff --git a/packages/domain/src/acquisition/budget.ts b/packages/domain/src/acquisition/budget.ts new file mode 100644 index 00000000..621983bf --- /dev/null +++ b/packages/domain/src/acquisition/budget.ts @@ -0,0 +1,59 @@ +export interface AcquisitionBudget { + maxNewTitlesPerDay?: number; + maxBytesPerDay?: number; + maxConcurrent?: number; +} + +export interface BudgetUsage { + newTitlesToday: number; + bytesToday: number; + /** 已预留但尚未计入 bytesToday 的计划字节 */ + reservedBytesToday?: number; + activeDownloads: number; +} + +export interface BudgetRequest { + countsAsNewTitle: boolean; + bytes?: number; +} + +export interface BudgetViolation { + kind: "new_titles" | "bytes" | "concurrency"; + limit: number; + current: number; + requested: number; +} + +export interface BudgetDecision { + decision: "allow" | "deny" | "unknown"; + violations: BudgetViolation[]; + /** 有字节预算但候选体积未知 */ + unknown: "bytes"[]; +} + +/** 预算检查(每日新增作品数、下载字节、并发)。已知超限 → deny;体积未知 → unknown(不放行) */ +export function checkBudget(budget: AcquisitionBudget, usage: BudgetUsage, req: BudgetRequest): BudgetDecision { + const violations: BudgetViolation[] = []; + const unknown: "bytes"[] = []; + if ( + req.countsAsNewTitle && + budget.maxNewTitlesPerDay !== undefined && + usage.newTitlesToday + 1 > budget.maxNewTitlesPerDay + ) + violations.push({ + kind: "new_titles", + limit: budget.maxNewTitlesPerDay, + current: usage.newTitlesToday, + requested: 1, + }); + if (budget.maxBytesPerDay !== undefined) { + const current = usage.bytesToday + (usage.reservedBytesToday ?? 0); + if (req.bytes === undefined) unknown.push("bytes"); + else if (current + req.bytes > budget.maxBytesPerDay) + violations.push({ kind: "bytes", limit: budget.maxBytesPerDay, current, requested: req.bytes }); + } + if (budget.maxConcurrent !== undefined && usage.activeDownloads + 1 > budget.maxConcurrent) + violations.push({ kind: "concurrency", limit: budget.maxConcurrent, current: usage.activeDownloads, requested: 1 }); + const decision = violations.length > 0 ? "deny" : unknown.length > 0 ? "unknown" : "allow"; + return { decision, violations, unknown }; +} diff --git a/packages/domain/src/acquisition/disk.ts b/packages/domain/src/acquisition/disk.ts new file mode 100644 index 00000000..6b487a27 --- /dev/null +++ b/packages/domain/src/acquisition/disk.ts @@ -0,0 +1,128 @@ +export interface VolumeSnapshot { + volumeId: string; + totalBytes: number; + usedBytes: number; +} + +export interface InflightTask { + taskId: string; + planId?: string; + volumeId: string; + /** 种子选中文件总大小;未知(磁力未取到元数据)时为 undefined */ + sizeBytes?: number; + /** 已占用磁盘的字节(已下载或已预分配,取二者较大者由调用方给出) */ + onDiskBytes: number; + /** 是否在管理标签范围内 */ + managed: boolean; + status: "queued" | "downloading" | "paused" | "completed" | "other"; +} + +export interface Reservation { + planId: string; + volumeId: string; + bytes: number; +} + +export interface ThresholdConfig { + /** 硬阈值使用率(%),例如 90 */ + hardPct: number; + /** 恢复水位(%),例如 85,必须低于 hardPct */ + resumePct: number; +} + +export interface CacheVolumeInput { + /** 读不到磁盘状态时传 null */ + volume: VolumeSnapshot | null; + config: ThresholdConfig; + inflight: readonly InflightTask[]; + reservations: readonly Reservation[]; + /** 待提交的新下载;不传表示只做巡检 */ + request?: { bytes?: number }; + /** 当前是否处于"因本阈值暂停"状态 */ + pausedByThreshold: boolean; +} + +export interface CacheVolumeDecision { + /** allow = 可提交;block = 阻止新增;pause_managed = 阻止新增并暂停纳管的下载中任务 */ + decision: "allow" | "block" | "pause_managed"; + /** 降到恢复水位:恢复"因本阈值暂停"的任务(不含用户手动暂停) */ + resumeThresholdPaused: boolean; + pauseTaskIds: string[]; + reasons: string[]; + usedPct?: number; + projectedPct?: number; + pendingBytes: number; + reservedBytes: number; + unknownSizeTaskIds: string[]; + /** 未纳管任务仍在增长:不能声称暂停后磁盘不再增长 */ + unmanagedGrowthRisk: boolean; +} + +/** + * 缓存卷硬阈值判定(REQ §8.4.1)。 + * 预测占用 = 当前已用 + 在途任务剩余需求(size - 已落盘,已预分配不重复扣) + 未提交计划预留 + 本次请求; + * 已提交的计划只按在途任务计算一次,不再算预留。多个 qB 共用卷时由调用方按 volumeId 汇总传入。 + */ +export function evaluateCacheVolume(input: CacheVolumeInput): CacheVolumeDecision { + const { config } = input; + if (!(config.resumePct < config.hardPct)) throw new Error("resumePct 必须低于 hardPct"); + const base = { + pauseTaskIds: [] as string[], + pendingBytes: 0, + reservedBytes: 0, + unknownSizeTaskIds: [] as string[], + unmanagedGrowthRisk: false, + }; + const v = input.volume; + if (!v || !(v.totalBytes > 0)) { + return { ...base, decision: "block", resumeThresholdPaused: false, reasons: ["volume_state_unknown"] }; + } + const tasks = input.inflight.filter((t) => t.volumeId === v.volumeId); + let pending = 0; + const unknownSize: string[] = []; + let unmanagedGrowth = false; + for (const t of tasks) { + if (t.status === "completed") continue; + if (t.sizeBytes === undefined) { + unknownSize.push(t.taskId); + continue; + } + const rest = Math.max(0, t.sizeBytes - t.onDiskBytes); + pending += rest; + if (!t.managed && rest > 0) unmanagedGrowth = true; + } + const submittedPlans = new Set(tasks.map((t) => t.planId).filter((p): p is string => p !== undefined)); + const reserved = input.reservations + .filter((r) => r.volumeId === v.volumeId && !submittedPlans.has(r.planId)) + .reduce((s, r) => s + r.bytes, 0); + const reqBytes = input.request?.bytes ?? 0; + const usedPct = (v.usedBytes / v.totalBytes) * 100; + const projectedPct = ((v.usedBytes + pending + reserved + reqBytes) / v.totalBytes) * 100; + const out = { + ...base, + pendingBytes: pending, + reservedBytes: reserved, + unknownSizeTaskIds: unknownSize, + unmanagedGrowthRisk: unmanagedGrowth, + usedPct, + projectedPct, + }; + const reasons: string[] = []; + if (usedPct >= config.hardPct) { + return { + ...out, + decision: "pause_managed", + resumeThresholdPaused: false, + pauseTaskIds: tasks.filter((t) => t.managed && t.status === "downloading").map((t) => t.taskId), + reasons: ["hard_threshold_reached"], + }; + } + const resume = input.pausedByThreshold && usedPct <= config.resumePct; + if (input.pausedByThreshold && !resume) reasons.push("awaiting_resume_watermark"); + if (input.request && input.request.bytes === undefined) reasons.push("request_size_unknown"); + if (input.request && unknownSize.length > 0) reasons.push("inflight_size_unknown"); + if (projectedPct >= config.hardPct) reasons.push("projected_over_threshold"); + if (resume) reasons.push("below_resume_watermark"); + const blocked = reasons.some((r) => r !== "below_resume_watermark"); + return { ...out, decision: blocked ? "block" : "allow", resumeThresholdPaused: resume, reasons }; +} diff --git a/packages/domain/src/acquisition/index.ts b/packages/domain/src/acquisition/index.ts new file mode 100644 index 00000000..0da097bd --- /dev/null +++ b/packages/domain/src/acquisition/index.ts @@ -0,0 +1,26 @@ +export { + type AcquisitionBudget, + type BudgetDecision, + type BudgetRequest, + type BudgetUsage, + type BudgetViolation, + checkBudget, +} from "./budget"; +export { + type CacheVolumeDecision, + type CacheVolumeInput, + evaluateCacheVolume, + type InflightTask, + type Reservation, + type ThresholdConfig, + type VolumeSnapshot, +} from "./disk"; +export { type AcquisitionMode, determineMode, type ModeDecision, type ModeInput } from "./mode"; +export { + DEFAULT_PRIORITY_ORDER, + type PriorityClass, + priorityOf, + type QueueItem, + sortByPriority, + validatePriorityOrder, +} from "./priority"; diff --git a/packages/domain/src/acquisition/mode.ts b/packages/domain/src/acquisition/mode.ts new file mode 100644 index 00000000..d9600d3d --- /dev/null +++ b/packages/domain/src/acquisition/mode.ts @@ -0,0 +1,60 @@ +/** "每日新资源"拆成的三种模式(REQ §6.3) */ +export type AcquisitionMode = "new_title" | "catch_up" | "upgrade"; + +export interface ModeInput { + /** 本地实体;null = 库里没有这部作品 */ + entity: { tracked: boolean; kind: "movie" | "tv" } | null; + /** 候选覆盖的 content_unit(剧集/综艺);电影留空 */ + releaseUnits: readonly string[]; + /** Slot 中已满足的 content_unit */ + satisfiedUnits: readonly string[]; + /** 电影:该 Slot 是否已满足 */ + slotSatisfied: boolean; + /** 作品发行/首播日期 YYYY-MM-DD(不是站点上传日期) */ + firstReleaseDate?: string; + /** 当前时间(ISO),由调用方注入 */ + now: string; + /** 新作品窗口(天) */ + newTitleWindowDays: number; + /** evaluateCandidate 对当前资产给出了 upgrade */ + upgradeCandidate?: boolean; +} + +export type ModeDecision = { mode: AcquisitionMode; reason: string } | { mode: null; reason: string }; + +const DAY_MS = 86_400_000; + +/** + * 判定一条候选属于哪种收录模式。 + * - 库里没有:只有发行/首播在窗口内才是新作品;站点今天上传旧片不算新片 + * - 已有剧集:候选覆盖未满足的单元 → 追更补缺;都已满足且可升级 → 升级 + * - 已有电影:Slot 缺口或更优版本 → 升级(独立于上映日期) + * - 只登记(未追踪)的作品不因新规则隐式变成自动下载 + */ +export function determineMode(input: ModeInput): ModeDecision { + const { entity } = input; + if (!entity) { + if (!input.firstReleaseDate) return { mode: null, reason: "release_date_unknown" }; + const released = Date.parse(`${input.firstReleaseDate}T00:00:00Z`); + const now = Date.parse(input.now); + if (Number.isNaN(released) || Number.isNaN(now)) return { mode: null, reason: "release_date_unknown" }; + const ageDays = (now - released) / DAY_MS; + if (ageDays < -1) return { mode: null, reason: "not_yet_released" }; + if (ageDays <= input.newTitleWindowDays) return { mode: "new_title", reason: "released_within_window" }; + return { mode: null, reason: "not_new_release" }; + } + if (!entity.tracked) return { mode: null, reason: "not_tracked" }; + if (entity.kind === "tv") { + const satisfied = new Set(input.satisfiedUnits); + const gaps = input.releaseUnits.filter((u) => !satisfied.has(u)); + if (gaps.length > 0) return { mode: "catch_up", reason: `covers_missing_units:${gaps.length}` }; + if (input.releaseUnits.length === 0) return { mode: null, reason: "no_units_resolved" }; + return input.upgradeCandidate + ? { mode: "upgrade", reason: "better_version" } + : { mode: null, reason: "already_satisfied" }; + } + if (!input.slotSatisfied) return { mode: "upgrade", reason: "slot_gap" }; + return input.upgradeCandidate + ? { mode: "upgrade", reason: "better_version" } + : { mode: null, reason: "already_satisfied" }; +} diff --git a/packages/domain/src/acquisition/priority.ts b/packages/domain/src/acquisition/priority.ts new file mode 100644 index 00000000..456046ad --- /dev/null +++ b/packages/domain/src/acquisition/priority.ts @@ -0,0 +1,37 @@ +import type { AcquisitionMode } from "./mode"; + +/** 默认:用户明确立即处理 > 追更补缺 > 新作品 > 已有版本升级(REQ §6.8) */ +export type PriorityClass = "immediate" | "catch_up" | "new_title" | "upgrade"; +export const DEFAULT_PRIORITY_ORDER: readonly PriorityClass[] = ["immediate", "catch_up", "new_title", "upgrade"]; + +export interface QueueItem { + id: string; + priorityClass: PriorityClass; + /** 入队时间 ISO;同优先级先进先出,避免饥饿 */ + enqueuedAt: string; +} + +export function priorityOf(mode: AcquisitionMode, immediate = false): PriorityClass { + return immediate ? "immediate" : mode; +} + +export function validatePriorityOrder(order: readonly PriorityClass[]): void { + const all = new Set(DEFAULT_PRIORITY_ORDER); + if (order.length !== all.size || new Set(order).size !== all.size || !order.every((o) => all.has(o))) + throw new Error(`优先级配置必须恰好包含 ${[...all].join("/")} 各一次`); +} + +/** 按优先级排序(稳定、纯函数)。优先级只影响资源分配,不越过阈值/HR/权限 */ +export function sortByPriority( + items: readonly T[], + order: readonly PriorityClass[] = DEFAULT_PRIORITY_ORDER, +): T[] { + validatePriorityOrder(order); + const rank = new Map(order.map((c, i) => [c, i])); + return [...items].sort( + (a, b) => + rank.get(a.priorityClass)! - rank.get(b.priorityClass)! || + a.enqueuedAt.localeCompare(b.enqueuedAt) || + a.id.localeCompare(b.id), + ); +} diff --git a/packages/domain/src/download-state/index.ts b/packages/domain/src/download-state/index.ts new file mode 100644 index 00000000..42d4dfe8 --- /dev/null +++ b/packages/domain/src/download-state/index.ts @@ -0,0 +1,190 @@ +/** download_task 状态机(REQ §8.4 / §8.4.2) */ +export type DownloadTaskStatus = + | "queued" + | "downloading" + | "paused_by_threshold" + | "paused_by_user" + | "completed" + | "error" + | "missing_files" + | "removed"; + +/** qB /api/v2/torrents/info 的一条记录(只取判定所需字段) */ +export interface QbTorrentObservation { + state: string; + /** 0..1 */ + progress: number; + amountLeft?: number; +} + +export interface MapContext { + /** 业务账本记录:本任务是否因磁盘阈值被系统暂停 */ + pausedByThreshold: boolean; + previous?: DownloadTaskStatus; +} + +export interface MappedStatus { + status: DownloadTaskStatus; + /** qB 返回未知状态时沿用上次状态并标记不确定 */ + uncertain?: boolean; + reason: string; +} + +const DOWNLOADING = new Set([ + "downloading", + "forceddl", + "stalleddl", + "metadl", + "forcedmetadl", + "allocating", + "checkingdl", + "moving", +]); +const SEEDING = new Set(["uploading", "stalledup", "queuedup", "forcedup", "checkingup", "pausedup", "stoppedup"]); +const NOT_SETTLED = new Set([ + "checkingdl", + "checkingup", + "checkingresumedata", + "moving", + "metadl", + "forcedmetadl", + "allocating", +]); + +/** + * qB 状态 → download_task 状态。 + * observation 为 null 表示"qB 可达且列表里没有该任务"= removed; + * qB 不可达时调用方不得调用本函数(失败 ≠ 不存在)。 + */ +export function mapQbState(obs: QbTorrentObservation | null, ctx: MapContext): MappedStatus { + if (obs === null) return { status: "removed", reason: "absent_from_downloader" }; + const s = obs.state.toLowerCase(); + if (s === "error") return { status: "error", reason: s }; + if (s === "missingfiles") return { status: "missing_files", reason: s }; + if (SEEDING.has(s)) { + if (obs.progress >= 1 && !NOT_SETTLED.has(s)) return { status: "completed", reason: s }; + return { status: "downloading", reason: `${s}:progress<1_or_checking` }; + } + if (s === "pauseddl" || s === "stoppeddl") + return ctx.pausedByThreshold + ? { status: "paused_by_threshold", reason: s } + : { status: "paused_by_user", reason: s }; + if (s === "queueddl" || s === "checkingresumedata") return { status: "queued", reason: s }; + if (DOWNLOADING.has(s)) return { status: "downloading", reason: s }; + return { status: ctx.previous ?? "queued", uncertain: true, reason: `unknown_state:${obs.state}` }; +} + +/** 完成判定:进度 100% 且不在校验/移动/出错/缺文件状态 */ +export function isDownloadComplete(obs: QbTorrentObservation | null): boolean { + if (!obs) return false; + const s = obs.state.toLowerCase(); + if (s === "error" || s === "missingfiles" || NOT_SETTLED.has(s)) return false; + if (obs.amountLeft !== undefined && obs.amountLeft > 0) return false; + return obs.progress >= 1; +} + +const ALL: DownloadTaskStatus[] = [ + "queued", + "downloading", + "paused_by_threshold", + "paused_by_user", + "completed", + "error", + "missing_files", + "removed", +]; +/** removed 为终态:同一 torrent 重新出现应建新任务/走接管 */ +const ALLOWED: Record = { + queued: ALL, + downloading: ALL, + paused_by_threshold: ALL, + paused_by_user: ALL, + completed: ALL, + error: ALL, + missing_files: ALL, + removed: ["removed"], +}; + +export class DownloadTransitionError extends Error { + override name = "DownloadTransitionError"; +} + +export interface AppliedObservation { + status: DownloadTaskStatus; + changed: boolean; + /** 需要记录但不自动处理的异常,例如完成后又回到下载中 */ + anomaly?: "completed_regressed" | "unknown_state"; +} + +export function applyObservation( + current: DownloadTaskStatus, + obs: QbTorrentObservation | null, + ctx: Omit, +): AppliedObservation { + const mapped = mapQbState(obs, { ...ctx, previous: current }); + if (!ALLOWED[current].includes(mapped.status)) + throw new DownloadTransitionError(`非法的下载任务状态迁移: ${current} → ${mapped.status}`); + const out: AppliedObservation = { status: mapped.status, changed: mapped.status !== current }; + if (current === "completed" && (mapped.status === "downloading" || mapped.status === "queued")) + out.anomaly = "completed_regressed"; + else if (mapped.uncertain) out.anomaly = "unknown_state"; + return out; +} + +export interface ProgressSample { + /** 毫秒时间戳 */ + at: number; + downloadedBytes: number; + status: DownloadTaskStatus; +} + +export interface NoProgressEvidence { + stalled: boolean; + /** 只产出证据;换源必须由用户或显式授权决定 */ + action: "none" | "record_evidence"; + stalledMs: number; + lastProgressAt?: number; + lastBytes?: number; + /** 被排除的非下载时间(暂停/排队) */ + excludedMs: number; + sampleCount: number; +} + +/** + * 无进展判定:只累计"处于 downloading 且字节未增长"的时长; + * 人工暂停、阈值暂停、正常排队的时间不计入,不误判故障。 + */ +export function detectNoProgress( + samples: readonly ProgressSample[], + opts: { thresholdMs: number }, +): NoProgressEvidence { + const xs = [...samples].sort((a, b) => a.at - b.at); + let stalledMs = 0; + let excludedMs = 0; + let best = xs[0]?.downloadedBytes ?? 0; + let lastProgressAt = xs[0]?.at; + for (let i = 1; i < xs.length; i++) { + const a = xs[i - 1]!; + const b = xs[i]!; + const dt = b.at - a.at; + if (b.downloadedBytes > best) { + best = b.downloadedBytes; + lastProgressAt = b.at; + stalledMs = 0; + continue; + } + if (a.status === "downloading" && b.status === "downloading") stalledMs += dt; + else excludedMs += dt; + } + const last = xs[xs.length - 1]; + const stalled = xs.length >= 2 && last?.status === "downloading" && stalledMs >= opts.thresholdMs; + return { + stalled, + action: stalled ? "record_evidence" : "none", + stalledMs, + ...(lastProgressAt !== undefined ? { lastProgressAt } : {}), + ...(last ? { lastBytes: best } : {}), + excludedMs, + sampleCount: xs.length, + }; +} diff --git a/packages/domain/src/identity/index.ts b/packages/domain/src/identity/index.ts new file mode 100644 index 00000000..aa30bc7b --- /dev/null +++ b/packages/domain/src/identity/index.ts @@ -0,0 +1,16 @@ +export { + type EvidenceLevel, + type IdentityCandidate, + type IdentityResolution, + type MatchOptions, + queryTitles, + type RankedCandidate, + rankCandidates, + resolveIdentity, + scoreCandidate, + type TitleMatch, + type TypeMatch, + type YearMatch, +} from "./match"; +export { normalizeTitle, stripSeasonSuffix } from "./normalize"; +export { TRAD_TO_SIMP, toSimplified } from "./zh-map"; diff --git a/packages/domain/src/identity/match.ts b/packages/domain/src/identity/match.ts new file mode 100644 index 00000000..271cbf00 --- /dev/null +++ b/packages/domain/src/identity/match.ts @@ -0,0 +1,178 @@ +import type { Confidence, ParsedRelease } from "../release-parser/types"; +import { normalizeTitle } from "./normalize"; + +export interface IdentityCandidate { + /** tmdb / local(本地别名召回)/ 其他提供方 */ + provider: string; + entityType: "movie" | "tv"; + externalId: string; + titles: string[]; + year?: number; + aliases: string[]; + /** 剧集各季首播年份(发布标题里的年份可能是季年份) */ + seasonYears?: number[]; + /** local 候选:本地实体 UUID 及其已登记的外部映射 */ + localEntityId?: string; + linkedExternalIds?: { provider: string; id: string }[]; +} + +export type EvidenceLevel = Confidence | "rejected"; +export type TitleMatch = "exact" | "alias" | "partial" | "none"; +export type YearMatch = "exact" | "season_year" | "within_tolerance" | "unknown" | "mismatch"; +export type TypeMatch = "match" | "mismatch" | "unknown"; + +export interface RankedCandidate { + candidate: IdentityCandidate; + level: EvidenceLevel; + titleMatch: TitleMatch; + yearMatch: YearMatch; + typeMatch: TypeMatch; + evidence: string[]; +} + +export interface MatchOptions { + /** 年份容差,默认 ±1 */ + yearTolerance?: number; +} + +const LEVEL_RANK: Record = { + rule_confirmed: 3, + multi_evidence: 2, + needs_confirm: 1, + rejected: 0, +}; +const TITLE_RANK: Record = { exact: 3, alias: 2, partial: 1, none: 0 }; +const YEAR_RANK: Record = { + exact: 4, + season_year: 3, + within_tolerance: 2, + unknown: 1, + mismatch: 0, +}; + +const raise = (l: EvidenceLevel): EvidenceLevel => + l === "needs_confirm" ? "multi_evidence" : l === "multi_evidence" ? "rule_confirmed" : l; + +export function queryTitles(p: ParsedRelease): string[] { + const out: string[] = []; + for (const t of [p.titleCn, p.titleEn, p.title]) { + if (!t) continue; + const n = normalizeTitle(t); + if (n && !out.includes(n)) out.push(n); + } + return out; +} + +function titleMatch(queries: string[], c: IdentityCandidate): TitleMatch { + const titles = c.titles.map(normalizeTitle).filter(Boolean); + const aliases = c.aliases.map(normalizeTitle).filter(Boolean); + if (queries.some((q) => titles.includes(q))) return "exact"; + if (queries.some((q) => aliases.includes(q))) return "alias"; + const all = [...titles, ...aliases]; + const partial = queries.some((q) => + all.some((t) => { + const [short, long] = q.length <= t.length ? [q, t] : [t, q]; + return short.length >= 2 && long.includes(short); + }), + ); + return partial ? "partial" : "none"; +} + +function yearMatch(p: ParsedRelease, c: IdentityCandidate, tol: number): YearMatch { + if (p.year === undefined || c.year === undefined) return "unknown"; + if (p.year === c.year) return "exact"; + if (c.entityType === "tv" && c.seasonYears?.includes(p.year)) return "season_year"; + if (Math.abs(p.year - c.year) <= tol) return "within_tolerance"; + // 剧集后续季的发布标题常带季年份,而非首播年份:未提供季年份时不据此拒绝 + if (c.entityType === "tv" && (p.season ?? 1) > 1 && p.year > c.year && !c.seasonYears) return "unknown"; + return "mismatch"; +} + +function typeMatch(p: ParsedRelease, c: IdentityCandidate): TypeMatch { + if (p.mediaTypeHint === "tv") return c.entityType === "tv" ? "match" : "mismatch"; + return "unknown"; +} + +/** 单个候选的证据等级(纯函数,不产生概率数字) */ +export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: MatchOptions = {}): RankedCandidate { + const tol = opts.yearTolerance ?? 1; + const tm = titleMatch(queryTitles(p), c); + const ym = yearMatch(p, c, tol); + const ty = typeMatch(p, c); + const evidence: string[] = [`title:${tm}`, `year:${ym}`, `type:${ty}`]; + const explicit = p.explicitIds.some( + (x) => + (x.provider === c.provider && x.id === c.externalId) || + c.linkedExternalIds?.some((l) => l.provider === x.provider && l.id === x.id), + ); + if (explicit) evidence.push("explicit_id"); + if (c.provider === "local") evidence.push("local_entity"); + + let level: EvidenceLevel; + if (ym === "mismatch") level = "rejected"; + else if (tm === "none") level = explicit ? "needs_confirm" : "rejected"; + else if (tm === "partial") level = "needs_confirm"; + else if (ym === "exact" || ym === "season_year") level = tm === "exact" ? "rule_confirmed" : "multi_evidence"; + else if (ym === "within_tolerance") level = "multi_evidence"; + else { + // 年份未知:剧集有季集信息且类型一致时仍算多证据,电影需确认 + level = ty === "match" && p.season !== undefined ? "multi_evidence" : "needs_confirm"; + } + if (level !== "rejected" && ty === "mismatch") { + level = "needs_confirm"; + evidence.push("type_conflict"); + } + if (explicit && level !== "rejected" && tm !== "none") level = raise(level); + return { candidate: c, level, titleMatch: tm, yearMatch: ym, typeMatch: ty, evidence }; +} + +/** 候选排序:等级 > 标题 > 年份 > 本地优先 > 原顺序 */ +export function rankCandidates( + p: ParsedRelease, + candidates: readonly IdentityCandidate[], + opts: MatchOptions = {}, +): RankedCandidate[] { + return candidates + .map((c, i) => ({ r: scoreCandidate(p, c, opts), i })) + .sort( + (a, b) => + LEVEL_RANK[b.r.level] - LEVEL_RANK[a.r.level] || + TITLE_RANK[b.r.titleMatch] - TITLE_RANK[a.r.titleMatch] || + YEAR_RANK[b.r.yearMatch] - YEAR_RANK[a.r.yearMatch] || + Number(b.r.candidate.provider === "local") - Number(a.r.candidate.provider === "local") || + a.i - b.i, + ) + .map((x) => x.r); +} + +function sameIdentity(a: IdentityCandidate, b: IdentityCandidate): boolean { + if (a.localEntityId && a.localEntityId === b.localEntityId) return true; + if (a.provider === b.provider && a.entityType === b.entityType && a.externalId === b.externalId) return true; + const linked = (x: IdentityCandidate, y: IdentityCandidate) => + x.linkedExternalIds?.some((l) => l.provider === y.provider && l.id === y.externalId) ?? false; + return linked(a, b) || linked(b, a); +} + +export interface IdentityResolution { + /** auto = 唯一且证据足够,可自动绑定;ambiguous = 进待识别池;no_match = 无可用候选 */ + status: "auto" | "ambiguous" | "no_match"; + best?: RankedCandidate; + ranked: RankedCandidate[]; + reason: string; +} + +export function resolveIdentity( + p: ParsedRelease, + candidates: readonly IdentityCandidate[], + opts: MatchOptions = {}, +): IdentityResolution { + const ranked = rankCandidates(p, candidates, opts); + const top = ranked[0]; + if (!top || top.level === "rejected") return { status: "no_match", ranked, reason: "no_viable_candidate" }; + if (top.level === "needs_confirm") return { status: "ambiguous", best: top, ranked, reason: "weak_evidence" }; + const rivals = ranked.filter( + (r) => r !== top && LEVEL_RANK[r.level] >= LEVEL_RANK[top.level] && !sameIdentity(r.candidate, top.candidate), + ); + if (rivals.length > 0) return { status: "ambiguous", best: top, ranked, reason: "multiple_candidates_same_level" }; + return { status: "auto", best: top, ranked, reason: top.level }; +} diff --git a/packages/domain/src/identity/normalize.ts b/packages/domain/src/identity/normalize.ts new file mode 100644 index 00000000..f708717b --- /dev/null +++ b/packages/domain/src/identity/normalize.ts @@ -0,0 +1,37 @@ +import { CN_NUM } from "../release-parser/cn-number"; +import { toSimplified } from "./zh-map"; + +const SEASON_SUFFIX = [ + new RegExp(`\\s*第\\s*${CN_NUM}\\s*[季部]\\s*$`, "u"), + /\s*[::-]?\s*(?:season|series)\s*\d+\s*$/iu, + /\s+S\d{1,2}\s*$/iu, + /\s*[((]\s*(?:19|20)\d{2}\s*[))]\s*$/u, +]; + +/** 去掉标题末尾的季号/年份后缀("庆余年 第二季"、"Show Season 2"、"Title (2019)") */ +export function stripSeasonSuffix(s: string): string { + let out = s.trim(); + for (let changed = true; changed; ) { + changed = false; + for (const re of SEASON_SUFFIX) { + const next = out.replace(re, ""); + if (next !== out && next.trim().length > 0) { + out = next.trim(); + changed = true; + } + } + } + return out; +} + +/** + * 标题归一化(用于比较,不用于展示): + * 全角→半角(NFKC)、繁→简、小写、& → and、去季号后缀、去标点与空白。 + */ +export function normalizeTitle(s: string): string { + const base = stripSeasonSuffix(toSimplified(s.normalize("NFKC"))); + return base + .toLowerCase() + .replace(/&/g, "and") + .replace(/[^\p{L}\p{N}]+/gu, ""); +} diff --git a/packages/domain/src/identity/zh-map.ts b/packages/domain/src/identity/zh-map.ts new file mode 100644 index 00000000..b332b99d --- /dev/null +++ b/packages/domain/src/identity/zh-map.ts @@ -0,0 +1,19 @@ +/** + * 繁 → 简 简单映射表(只覆盖影视标题常见字,不引第三方库)。 + * 格式:每两个字符为一对「繁简」。一对多/语境相关的字(乾/著/瞭 等)刻意不收。 + */ +const PAIRS = + "萬万與与專专業业叢丛東东絲丝兩两嚴严喪丧個个豐丰臨临為为麗丽舉举麼么義义烏乌樂乐喬乔習习鄉乡書书買买亂乱爭争於于虧亏雲云亞亚產产親亲億亿僅仅從从倉仓儀仪們们價价眾众優优會会傘伞偉伟傳传傷伤倫伦偽伪體体餘余傭佣俠侠侶侣偵侦側侧僑侨儂侬倆俩儉俭債债傾倾償偿儲储兒儿兇凶黨党蘭兰關关興兴養养獸兽內内岡冈冊册寫写軍军農农馮冯衝冲決决況况凍冻淨净準准涼凉減减湊凑幾几鳳凤憑凭凱凯擊击劃划劉刘則则剛刚創创刪删別别劑剂劍剑剝剥劇剧勸劝辦办務务動动勵励勁劲勞劳勢势勳勋勝胜區区醫医華华協协單单賣卖盧卢衛卫卻却廠厂廳厅曆历厲厉壓压厭厌廁厕廂厢廈厦廚厨縣县參参雙双發发變变敘叙疊叠葉叶號号嘆叹嚇吓呂吕嗎吗啟启吳吴員员響响啞哑嘩哗喚唤噴喷團团園园圍围國国圖图圓圆聖圣場场壞坏塊块堅坚壇坛壩坝墳坟墜坠壘垒墊垫牆墙壯壮聲声殼壳壺壶處处備备復复夠够頭头誇夸夾夹奪夺奮奋獎奖奧奥妝妆婦妇媽妈嬌娇娛娱嬰婴嬸婶孫孙學学寧宁寶宝實实寵宠審审憲宪宮宫寬宽賓宾寢寝對对尋寻導导壽寿將将爾尔塵尘嘗尝堯尧屍尸盡尽層层屆届屬属嶼屿歲岁豈岂崗岗島岛嶺岭峽峡嶄崭巔巅幣币帥帅師师帳帐簾帘幟帜帶带幫帮並并廣广莊庄慶庆廬庐庫库應应廟庙龐庞廢废開开異异棄弃張张彌弥彎弯彈弹強强歸归當当錄录彥彦徹彻徑径憶忆憂忧懷怀態态憐怜總总戀恋懇恳惡恶惱恼悅悦懸悬驚惊懼惧慘惨懲惩慚惭慣惯憤愤願愿懶懒戲戏戰战戶户紮扎撲扑執执擴扩掃扫揚扬擾扰撫抚拋抛搶抢護护報报擔担擬拟擁拥攔拦擇择掛挂揮挥撈捞損损撿捡換换據据擲掷攬揽攜携攝摄擺摆搖摇攤摊撐撑敵敌斂敛數数齋斋鬥斗斬斩斷断無无舊旧時时曠旷晝昼顯显晉晋曬晒曉晓暈晕暉晖暫暂術术樸朴機机殺杀雜杂權权條条來来楊杨傑杰極极構构槍枪楓枫櫃柜標标棧栈棟栋欄栏樹树棲栖樣样檔档橋桥夢梦檢检樓楼欖榄櫻樱櫥橱歡欢歐欧殲歼殘残毀毁畢毕斃毙氣气匯汇漢汉湯汤溝沟沒没淚泪瀉泻潑泼澤泽潔洁灑洒淺浅漿浆濁浊測测濟济渾浑濃浓濤涛漲涨淵渊漸渐漁渔滲渗溫温遊游灣湾濕湿潰溃濺溅滿满濾滤濫滥濱滨灘滩瀟潇潛潜瀾澜瀕濒滅灭燈灯靈灵災灾燦灿爐炉點点煉炼熾炽爍烁爛烂燭烛煙烟煩烦燒烧熱热煥焕愛爱爺爷牽牵犧牺狀状猶犹狹狭獅狮獄狱獨独獵猎豬猪貓猫獻献瑪玛環环現现瓊琼電电畫画暢畅療疗瘋疯癢痒發发皺皱盞盏鹽盐監监蓋盖盜盗盤盘睜睁瞞瞒矯矫礦矿碼码磚砖確确礙碍禮礼禍祸禪禅離离禿秃種种積积稱称穩稳穫获窮穷竊窃窯窑竄窜窩窝豎竖競竞筆笔築筑篩筛籌筹簽签簡简籃篮籬篱類类糧粮糾纠紀纪約约紅红紋纹納纳紐纽純纯紗纱紡纺紛纷級级紙纸細细紳绅終终組组結结繞绕給给絢绚絡络絕绝統统綁绑絹绢繡绣經经綜综綠绿綴缀網网綱纲緒绪續续緊紧維维綿绵緣缘編编緩缓緯纬練练縫缝縮缩織织繩绳繪绘繳缴纖纤羅罗罰罚罷罢羈羁翹翘聳耸恥耻聶聂聾聋職职聯联聰聪肅肃腸肠膚肤腫肿脅胁膽胆朧胧膠胶脈脉腦脑腳脚脫脱臉脸臘腊騰腾輿舆艦舰艙舱艱艰豔艳藝艺節节蕪芜蘆芦葦苇蒼苍蘇苏蘋苹莖茎薦荐莢荚蕩荡榮荣藥药萊莱蓮莲獲获瑩莹鶯莺蘿萝螢萤營营蕭萧薩萨蔥葱蔣蒋藍蓝薔蔷藹蔼蘊蕴虜虏慮虑虛虚蟲虫雖虽蝦虾蝕蚀蟻蚁螞蚂蠶蚕蠻蛮蝸蜗蠟蜡蠅蝇蟬蝉補补襯衬襖袄襪袜襲袭裝装褲裤見见觀观規规覓觅視视覽览覺觉觸触計计訂订認认討讨讓让訓训議议訊讯記记講讲許许論论設设訪访訣诀證证評评識识詐诈訴诉診诊詞词譯译試试詩诗誠诚話话誕诞詭诡詢询該该詳详誤误語语誘诱說说誦诵請请諸诸諾诺讀读課课誰谁調调諒谅談谈誼谊謀谋謊谎諧谐謂谓謎谜謝谢謠谣謙谦謹谨謬谬譜谱貝贝貞贞負负貢贡財财責责賢贤敗败賬账貨货質质販贩貪贪貧贫購购貫贯貼贴貴贵貸贷貿贸費费賀贺賊贼資资賦赋賭赌贖赎賞赏賜赐賠赔賴赖賺赚賽赛贈赠贊赞贏赢趙赵趕赶趨趋躍跃踐践蹤踪躲躲車车軌轨軍军軒轩轉转輪轮軟软轟轰軸轴輕轻載载較较輔辅輛辆輩辈輝辉輸输轄辖辭辞辯辩邊边遼辽達达遷迁過过邁迈運运還还這这進进遠远違违連连遲迟適适選选遜逊遞递邏逻遺遗遙遥鄧邓郵邮鄰邻鬱郁鄭郑醞酝醬酱釀酿釋释裏里鑒鉴針针釘钉鐘钟鋼钢鑰钥鈴铃鐵铁鉛铅銅铜鋁铝銀银鏈链銷销鎖锁鋒锋鋤锄鍋锅錯错錨锚錘锤錦锦鍵键鋸锯鍛锻鏡镜鐮镰鑲镶長长門门閃闪閉闭問问闖闯閑闲間间悶闷閘闸鬧闹閨闺聞闻閣阁閱阅闊阔隊队陽阳陰阴陣阵階阶際际陸陆陳陈險险隨随隱隐難难雛雏霧雾靜静韓韩韻韵頁页頂顶項项順顺須须顧顾頓顿預预領领頗颇頸颈頻频題题顏颜額额顛颠顫颤風风飄飘飛飞飯饭飲饮飾饰飽饱餅饼餓饿館馆饅馒馬马馳驰驅驱駕驾駛驶駱骆驗验騎骑騙骗騷骚驟骤髒脏鬢鬓魚鱼魯鲁鮮鲜鯨鲸鱷鳄鳥鸟雞鸡鳴鸣鴉鸦鴨鸭鴻鸿鵝鹅鵬鹏鶴鹤鷹鹰鸚鹦麥麦黃黄齊齐齒齿齡龄龍龙龔龚龜龟臺台檯台颱台週周後后裡里麵面鍊炼捨舍範范彙汇蘭兰鐵铁諜谍劍剑殭僵鎮镇"; + +export const TRAD_TO_SIMP: ReadonlyMap = (() => { + const chars = [...PAIRS]; + const m = new Map(); + for (let i = 0; i + 1 < chars.length; i += 2) m.set(chars[i]!, chars[i + 1]!); + return m; +})(); + +export function toSimplified(s: string): string { + let out = ""; + for (const c of s) out += TRAD_TO_SIMP.get(c) ?? c; + return out; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index d153a9ed..28ebc034 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,2 +1,10 @@ -// @umt/domain 公开入口;W1 起在此导出实现 +// @umt/domain 公开入口:纯业务逻辑,无 IO。模块地图与不变量见 packages/domain/README.md export const PACKAGE_NAME = "@umt/domain"; + +export * from "./acquisition"; +export * from "./download-state"; +export * from "./identity"; +export * from "./organize"; +export * from "./path"; +export * from "./release-parser"; +export * from "./slot"; diff --git a/packages/domain/src/organize/index.ts b/packages/domain/src/organize/index.ts new file mode 100644 index 00000000..8c3327ed --- /dev/null +++ b/packages/domain/src/organize/index.ts @@ -0,0 +1,16 @@ +export { + type ExcludedFile, + type ExistingPolicy, + type ExistingTarget, + type OrganizeConflict, + type OrganizeFileKind, + type OrganizeInput, + type OrganizeOperation, + type OrganizePlan, + type PlannedAction, + type PlannedFile, + type Precondition, + planOrganize, + type SourceFile, +} from "./plan"; +export { sidecarLanguageSuffix, sidecarStem } from "./sidecars"; diff --git a/packages/domain/src/organize/plan.ts b/packages/domain/src/organize/plan.ts new file mode 100644 index 00000000..1efe15e8 --- /dev/null +++ b/packages/domain/src/organize/plan.ts @@ -0,0 +1,377 @@ +import { DEFAULT_TEMPLATES, type NamingTemplateSet, renderMediaFileName } from "../path/binding"; +import { buildNamingContext } from "../path/template"; +import { type ParseOptions, parseReleasePath } from "../release-parser"; +import { fileKindOf } from "../release-parser/parse"; +import type { ParsedRelease } from "../release-parser/types"; +import { basename, dirname, sidecarLanguageSuffix, sidecarStem, splitExt } from "./sidecars"; + +export type OrganizeOperation = "copy" | "move" | "rename"; +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[]; + /** 本地实体的命名信息(不用发布标题命名) */ + entity: { title: string; originalTitle?: string; year?: number }; + /** 目标目录(宿主绝对路径,来自 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 }; + parse?: ParseOptions; +} + +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 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 ?? {}); + if (parsed.contentType === "extra" && parsed.evidence.includes("dir:extras")) { + excluded.push({ src: f.path, kind, reason: "extra_content" }); + 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", + ...(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); + let dst = `${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", + ...(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 (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") { + 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, + }); + } + 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), + }; +} diff --git a/packages/domain/src/organize/sidecars.ts b/packages/domain/src/organize/sidecars.ts new file mode 100644 index 00000000..ecb64e25 --- /dev/null +++ b/packages/domain/src/organize/sidecars.ts @@ -0,0 +1,82 @@ +/** 附属文件(字幕/外挂音轨)的语言标签,按主干名剥离后与主视频配对 */ +const LANG_TAG: Record = { + chs: "zh-Hans", + sc: "zh-Hans", + gb: "zh-Hans", + chi: "zh-Hans", + zho: "zh-Hans", + zh: "zh-Hans", + "zh-cn": "zh-Hans", + "zh-hans": "zh-Hans", + "zh-sg": "zh-Hans", + 简体: "zh-Hans", + 简中: "zh-Hans", + 简: "zh-Hans", + cht: "zh-Hant", + tc: "zh-Hant", + big5: "zh-Hant", + "zh-tw": "zh-Hant", + "zh-hk": "zh-Hant", + "zh-hant": "zh-Hant", + 繁体: "zh-Hant", + 繁體: "zh-Hant", + 繁中: "zh-Hant", + 繁: "zh-Hant", + eng: "en", + en: "en", + english: "en", + jpn: "ja", + ja: "ja", + jp: "ja", + kor: "ko", + ko: "ko", + default: "flag", + forced: "flag", + sdh: "flag", + cc: "flag", + 简英: "zh-Hans", + 繁英: "zh-Hant", + 中英: "zh-Hans", + 双语: "zh-Hans", + 简繁: "zh-Hans", +}; + +export const basename = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p; +export const dirname = (p: string) => { + const s = p.replace(/\\/g, "/"); + const i = s.lastIndexOf("/"); + return i <= 0 ? (i === 0 ? "/" : "") : s.slice(0, i); +}; +export const splitExt = (name: string): [string, string] => { + const i = name.lastIndexOf("."); + return i > 0 ? [name.slice(0, i), name.slice(i + 1)] : [name, ""]; +}; + +/** 字幕主干名:去扩展名后逐级剥掉 .chs/.eng/.default 等语言标签,返回小写主干与剥下的标签 */ +export function sidecarStem(name: string): { stem: string; tags: string[] } { + let [stem] = splitExt(basename(name)); + const tags: string[] = []; + for (;;) { + const i = stem.lastIndexOf("."); + if (i <= 0) break; + const tag = stem.slice(i + 1).toLowerCase(); + if (!(tag in LANG_TAG)) break; + tags.unshift(tag); + stem = stem.slice(0, i); + } + return { stem: stem.toLowerCase(), tags }; +} + +/** 目标字幕后缀(对照 MP:繁体优先判定 → .zh-tw;简体 → .chi.zh-cn;英 → .eng;日 → .ja) */ +export function sidecarLanguageSuffix(tags: readonly string[]): string { + const langs = tags.map((t) => LANG_TAG[t]); + let s = ""; + if (langs.includes("zh-Hant")) s = ".zh-tw"; + else if (langs.includes("zh-Hans")) s = ".chi.zh-cn"; + else if (langs.includes("en")) s = ".eng"; + else if (langs.includes("ja")) s = ".ja"; + else if (langs.includes("ko")) s = ".ko"; + if (tags.includes("default")) s += ".default"; + if (tags.includes("forced")) s += ".forced"; + return s; +} diff --git a/packages/domain/src/path/binding.ts b/packages/domain/src/path/binding.ts new file mode 100644 index 00000000..c0c94bf4 --- /dev/null +++ b/packages/domain/src/path/binding.ts @@ -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(); + 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) }; +} diff --git a/packages/domain/src/path/conflicts.ts b/packages/domain/src/path/conflicts.ts new file mode 100644 index 00000000..8e132c83 --- /dev/null +++ b/packages/domain/src/path/conflicts.ts @@ -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(); + 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(); + 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; +} diff --git a/packages/domain/src/path/index.ts b/packages/domain/src/path/index.ts new file mode 100644 index 00000000..89ca687b --- /dev/null +++ b/packages/domain/src/path/index.ts @@ -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"; diff --git a/packages/domain/src/path/mapping.ts b/packages/domain/src/path/mapping.ts new file mode 100644 index 00000000..0c4f8803 --- /dev/null +++ b/packages/domain/src/path/mapping.ts @@ -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(); + 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; +} diff --git a/packages/domain/src/path/sanitize.ts b/packages/domain/src/path/sanitize.ts new file mode 100644 index 00000000..1a44430e --- /dev/null +++ b/packages/domain/src/path/sanitize.ts @@ -0,0 +1,54 @@ +const INVALID: Record = { + "\\": "\", + "/": "/", + ":": ":", + "*": "*", + "?": "?", + '"': """, + "<": "<", + ">": ">", + "|": "|", +}; +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; +} diff --git a/packages/domain/src/path/template.ts b/packages/domain/src/path/template.ts new file mode 100644 index 00000000..1b2b9a49 --- /dev/null +++ b/packages/domain/src/path/template.ts @@ -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>; + +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, +): 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; +} diff --git a/packages/domain/src/release-parser/cn-number.ts b/packages/domain/src/release-parser/cn-number.ts new file mode 100644 index 00000000..9a25e6d5 --- /dev/null +++ b/packages/domain/src/release-parser/cn-number.ts @@ -0,0 +1,38 @@ +const DIGITS: Record = { + 零: 0, + 〇: 0, + 一: 1, + 二: 2, + 两: 2, + 兩: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, +}; +const UNITS: Record = { 十: 10, 拾: 10, 百: 100, 千: 1000 }; + +/** 中文数字/阿拉伯数字 → 整数;无法解析返回 undefined */ +export function cnToInt(s: string): number | undefined { + const t = s.trim(); + if (/^\d+$/.test(t)) return Number.parseInt(t, 10); + if (t === "" || ![...t].every((c) => c in DIGITS || c in UNITS)) return undefined; + let total = 0; + let current = 0; + for (const c of t) { + const d = DIGITS[c]; + if (d !== undefined) { + current = d; + continue; + } + const u = UNITS[c]!; + total += (current || 1) * u; + current = 0; + } + return total + current; +} + +export const CN_NUM = "[0-9零〇一二两兩三四五六七八九十拾百千]+"; diff --git a/packages/domain/src/release-parser/index.ts b/packages/domain/src/release-parser/index.ts new file mode 100644 index 00000000..ec8d63f8 --- /dev/null +++ b/packages/domain/src/release-parser/index.ts @@ -0,0 +1,15 @@ +export { cnToInt } from "./cn-number"; +export { evalEpisodeOffset, normalizeOffsetExpression, OffsetExpressionError } from "./offset"; +export { type CnMarkers, confidenceOf, fileKindOf, parseRelease, scanCnMarkers } from "./parse"; +export { mergeParsed, type PathSegments, parseReleasePath, splitReleasePath } from "./path"; +export { buildGroupRegex, RELEASE_GROUP_SEED } from "./release-groups"; +export * from "./types"; + +import type { EpisodeRange } from "./types"; +/** 展开集号区间 */ +export function expandEpisodes(r: EpisodeRange | undefined): number[] { + if (!r) return []; + const out: number[] = []; + for (let i = r.start; i <= r.end; i++) out.push(i); + return out; +} diff --git a/packages/domain/src/release-parser/offset.ts b/packages/domain/src/release-parser/offset.ts new file mode 100644 index 00000000..1252f02c --- /dev/null +++ b/packages/domain/src/release-parser/offset.ts @@ -0,0 +1,121 @@ +/** + * 集号偏移表达式的白名单求值器(禁止 eval/Function)。 + * 语法:数字、EP 占位符、+ - * / // % ** 和括号。 + * 省略 EP 时按 MP 约定补全:`-12` → `EP-12`,`+1` → `EP+1`,`*2` → `EP*2`。 + */ +export class OffsetExpressionError extends Error { + override name = "OffsetExpressionError"; +} + +type Tok = { t: "num"; v: number } | { t: "ep" } | { t: "op"; v: string } | { t: "lp" } | { t: "rp" }; + +function lex(src: string): Tok[] { + const out: Tok[] = []; + let i = 0; + while (i < src.length) { + const c = src[i]!; + if (c === " ") { + i++; + continue; + } + if (/\d/.test(c)) { + let j = i; + while (j < src.length && /\d/.test(src[j]!)) j++; + out.push({ t: "num", v: Number(src.slice(i, j)) }); + i = j; + continue; + } + if (src.slice(i, i + 2).toUpperCase() === "EP") { + out.push({ t: "ep" }); + i += 2; + continue; + } + const two = src.slice(i, i + 2); + if (two === "//" || two === "**") { + out.push({ t: "op", v: two }); + i += 2; + continue; + } + if ("+-*/%".includes(c)) { + out.push({ t: "op", v: c }); + i++; + continue; + } + if (c === "(") out.push({ t: "lp" }); + else if (c === ")") out.push({ t: "rp" }); + else throw new OffsetExpressionError(`非法字符: ${c}`); + i++; + } + return out; +} + +export function normalizeOffsetExpression(expr: string): string { + const e = expr.trim(); + if (e === "") throw new OffsetExpressionError("空表达式"); + if (/ep/i.test(e)) return e; + return /^[+\-*/%]/.test(e) ? `EP${e}` : `EP+${e}`; +} + +export function evalEpisodeOffset(expr: string, ep: number): number { + if (expr.length > 64) throw new OffsetExpressionError("表达式过长"); + const toks = lex(normalizeOffsetExpression(expr)); + let pos = 0; + const peek = () => toks[pos]; + // expr := term (('+'|'-') term)* + const parseExpr = (): number => { + let v = parseTerm(); + for (let p = peek(); p?.t === "op" && (p.v === "+" || p.v === "-"); p = peek()) { + pos++; + const r = parseTerm(); + v = p.v === "+" ? v + r : v - r; + } + return v; + }; + const parseTerm = (): number => { + let v = parsePow(); + for (let p = peek(); p?.t === "op" && ["*", "/", "//", "%"].includes(p.v); p = peek()) { + pos++; + const r = parsePow(); + if ((p.v === "/" || p.v === "//" || p.v === "%") && r === 0) throw new OffsetExpressionError("除零"); + if (p.v === "*") v *= r; + else if (p.v === "/") v /= r; + else if (p.v === "//") v = Math.floor(v / r); + else v = ((v % r) + r) % r; + } + return v; + }; + const parsePow = (): number => { + const base = parseUnary(); + const p = peek(); + if (p?.t === "op" && p.v === "**") { + pos++; + return base ** parsePow(); + } + return base; + }; + const parseUnary = (): number => { + const p = peek(); + if (p?.t === "op" && (p.v === "-" || p.v === "+")) { + pos++; + const v = parseUnary(); + return p.v === "-" ? -v : v; + } + return parseAtom(); + }; + const parseAtom = (): number => { + const p = toks[pos++]; + if (!p) throw new OffsetExpressionError("表达式不完整"); + if (p.t === "num") return p.v; + if (p.t === "ep") return ep; + if (p.t === "lp") { + const v = parseExpr(); + if (toks[pos++]?.t !== "rp") throw new OffsetExpressionError("括号不匹配"); + return v; + } + throw new OffsetExpressionError("语法错误"); + }; + const v = parseExpr(); + if (pos !== toks.length) throw new OffsetExpressionError("多余的内容"); + if (!Number.isFinite(v)) throw new OffsetExpressionError("结果不是有限数"); + return Math.trunc(v); +} diff --git a/packages/domain/src/release-parser/parse.ts b/packages/domain/src/release-parser/parse.ts new file mode 100644 index 00000000..5a2afc19 --- /dev/null +++ b/packages/domain/src/release-parser/parse.ts @@ -0,0 +1,961 @@ +import { CN_NUM, cnToInt } from "./cn-number"; +import { evalEpisodeOffset } from "./offset"; +import { buildGroupRegex, defaultGroupRegex } from "./release-groups"; +import { + type Confidence, + type ContentType, + type Cut, + type EpisodeRange, + type ExplicitId, + type FileKind, + type HdrFormat, + PARSER_VERSION, + type PackHint, + type ParsedRelease, + type ParseOptions, + type ReleaseGroup, + type ReleaseSource, + type Resolution, + type Segment, + type VideoCodec, +} from "./types"; + +// ---------------------------------------------------------------- 扩展名 + +const EXT_KIND: Record = {}; +for (const e of "mp4 mkv ts iso rmvb avi mov mpeg mpg wmv 3gp asf m4v flv m2ts strm tp f4v webm vob".split(" ")) + EXT_KIND[e] = "video"; +for (const e of "srt ass ssa sup sub idx vtt".split(" ")) EXT_KIND[e] = "subtitle"; +for (const e of "aac ac3 eac3 m4a mka dts flac mp3 thd wav".split(" ")) EXT_KIND[e] = "audio"; +EXT_KIND.nfo = "nfo"; +for (const e of "jpg jpeg png webp bmp".split(" ")) EXT_KIND[e] = "image"; +for (const e of "txt url torrent md5 sfv".split(" ")) EXT_KIND[e] = "other"; + +export function fileKindOf(name: string): FileKind | undefined { + const m = /\.([A-Za-z0-9]{2,5})$/.exec(name); + return m ? EXT_KIND[m[1]!.toLowerCase()] : undefined; +} + +// ---------------------------------------------------------------- 可变草稿 + +type Draft = ParsedRelease & { _complete?: boolean; _fin?: boolean; _disc?: boolean; _remux?: boolean }; + +function emptyDraft(raw: string): Draft { + return { + parserVersion: PARSER_VERSION, + raw, + isEncode: false, + audio: [], + hdr: [], + contentType: "unknown", + variantTags: [], + mediaTypeHint: "unknown", + isSeasonPack: false, + packHint: "unknown", + isFile: false, + explicitIds: [], + confidence: "needs_confirm", + evidence: [], + }; +} + +const MARK = " 〓 "; // 中文季集标记被剥离后的占位:之后的中文不再并入标题 +const MARK_HARD = " 〆 "; // 日期/动漫集号被剥离后的占位:之后不再取任何标题词 + +// ---------------------------------------------------------------- 发布组 + +const GROUP_SUFFIX_EXCLUDE = + /^(?:DL|HD|MA|HR|RIP|RAY|X|ES|\d+|\d+BIT|\d{3,4}[PI]|[XH]\.?26[45]|HEVC|AVC|AAC|FLAC|DTS|DDP?[\d.]*|ATMOS|WEB|BLURAY|REMUX|HDR\d*|DV|HLG|SDR|FIN|END|S\d+|E\d+|EP\d+|CD\d*|PART\d*|\d+CH|MKV|MP4)$/i; +const TECH_IN_BRACKET = /\b(?:\d{3,4}[pi]|x26[45]|hevc|avc|web-?dl|webrip|bluray|bdrip|remux)\b|^\d+$/i; + +function extractGroup(s: string, extra: readonly string[]): { s: string; group?: ReleaseGroup; ev?: string } { + const re = extra.length > 0 ? buildGroupRegex(extra) : defaultGroupRegex(); + const found: string[] = []; + for (const m of s.matchAll(re)) { + if (!found.some((f) => f.toLowerCase() === m[0].toLowerCase())) found.push(m[0]); + } + const lead = /^\s*[[【]([^\]】]+)[\]】]\s*/u.exec(s); + if (found.length > 0) { + const lowered = found.map((f) => f.toLowerCase()); + const hasKnown = (txt: string) => lowered.some((f) => txt.toLowerCase().includes(f)); + let out = s; + if (lead && hasKnown(lead[1]!)) out = out.slice(lead[0].length); + out = out.replace(/[-@]([^\s.[\]【】-]+(?:-[^\s.[\]【】-]+)?)$/u, (all, g: string) => (hasKnown(g) ? " " : all)); + return { s: out, group: { name: found.join("@"), source: "known" }, ev: "group:known" }; + } + if (lead) { + const content = lead[1]!.trim(); + const rest = s.slice(lead[0].length); + if (!TECH_IN_BRACKET.test(content) && (/[[【]/u.test(rest) || /\s-\s\d{1,3}\b/.test(rest))) { + return { s: rest, group: { name: content.replace(/&/g, "@"), source: "bracket" }, ev: "group:bracket" }; + } + } + const suf = /-\s?([A-Za-z0-9][A-Za-z0-9_@&]{0,23})$/.exec(s.trimEnd()); + if (suf && !GROUP_SUFFIX_EXCLUDE.test(suf[1]!) && /[A-Za-z]/.test(suf[1]!)) { + return { + s: s.trimEnd().slice(0, -suf[0].length), + group: { name: suf[1]!, source: "suffix" }, + ev: "group:suffix", + }; + } + return { s }; +} + +// ---------------------------------------------------------------- 剪辑版本 + +const CUT_RULES: [RegExp, Cut][] = [ + [/\b(?:Director'?s|Directors)[ .\-_]?Cut\b|导演剪辑版?|導演剪輯版?|导演剪切版/giu, "Director's Cut"], + [/\bExtended(?:[ .\-_](?:Edition|Cut|Version))?\b|加长版|加長版/giu, "Extended"], + [/\bTheatrical(?:[ .\-_](?:Edition|Cut|Version))?\b|院线版|院線版/giu, "Theatrical"], + [/\bUnrated(?:[ .\-_](?:Edition|Cut|Version))?\b/giu, "Unrated"], + [/\bUncut\b|未删减版?|未刪減版?/giu, "Uncut"], + [/\bIMAX(?:[ .\-_](?:Edition|Enhanced))?\b|IMAX版/giu, "IMAX"], + [/\bRemastered\b|重制版|數位修復版/giu, "Remastered"], + [/\bCriterion(?:[ .\-_]Collection)?\b|CC标准收藏版/giu, "Criterion"], + [/\bFinal[ .\-_]Cut\b/giu, "Final Cut"], + [/\bUltimate[ .\-_](?:Edition|Cut)\b/giu, "Ultimate"], + [/\bSpecial[ .\-_]Edition\b/giu, "Special Edition"], +]; + +// ---------------------------------------------------------------- 日期 + +function validDate(y: number, m: number, d: number): boolean { + if (y < 1990 || y > 2100 || m < 1 || m > 12 || d < 1) return false; + const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return d <= days[m - 1]!; +} +const pad2 = (n: number) => String(n).padStart(2, "0"); + +function extractAirDate(s: string): { s: string; airDate?: string; ev?: string } { + const rules: [RegExp, string][] = [ + [/(? { + if (!langs.includes(l)) langs.push(l); + }; + if (/[简簡]/u.test(run)) add("zh-Hans"); + if (/繁/u.test(run)) add("zh-Hant"); + if (/中/u.test(run) && !langs.some((l) => l.startsWith("zh"))) add("zh"); + if (/双语|雙語/u.test(run) && !langs.some((l) => l.startsWith("zh"))) add("zh"); + if (/英|双语|雙語/u.test(run)) add("en"); + if (/日/u.test(run)) add("ja"); + if (/[韩韓]/u.test(run)) add("ko"); + return langs; +} + +export function scanCnMarkers(input: string): { s: string } & CnMarkers { + let s = input; + const out: CnMarkers = { variantTags: [], evidence: [] }; + const take = (re: RegExp, fn: (m: RegExpExecArray) => unknown) => { + const m = re.exec(s); + if (m && fn(m) !== false) s = s.slice(0, m.index) + MARK + s.slice(m.index + m[0].length); + }; + const N = CN_NUM; + take(new RegExp(`第\\s*(${N})\\s*[-~~至到]\\s*(${N})\\s*季`, "u"), (m) => { + out.season = cnToInt(m[1]!); + out.seasonEnd = cnToInt(m[2]!); + out.evidence.push("season:cn_range"); + }); + if (out.season === undefined) + take(new RegExp(`第\\s*(${N})\\s*季`, "u"), (m) => { + out.season = cnToInt(m[1]!); + out.evidence.push("season:cn"); + }); + take(new RegExp(`[全共]\\s*(${N})\\s*季`, "u"), (m) => { + if (out.season !== undefined) return true; + out.season = 1; + out.seasonEnd = cnToInt(m[1]!); + out.evidence.push("season:cn_all"); + }); + take(new RegExp(`第\\s*(${N})\\s*[-~~至到]\\s*(${N})\\s*[集话話回]`, "u"), (m) => { + out.episodes = { start: cnToInt(m[1]!)!, end: cnToInt(m[2]!)! }; + out.evidence.push("episode:cn_range"); + }); + if (!out.episodes) + take(new RegExp(`第\\s*(${N})\\s*[集话話回]`, "u"), (m) => { + const n = cnToInt(m[1]!)!; + out.episodes = { start: n, end: n }; + out.evidence.push("episode:cn"); + }); + take(new RegExp(`第\\s*(${N})\\s*期`, "u"), (m) => { + out.issue = cnToInt(m[1]!); + out.evidence.push("issue:cn"); + }); + take(new RegExp(`[全共]\\s*(${N})\\s*[集话話期]|(${N})\\s*[集话話]\\s*全`, "u"), (m) => { + out.totalEpisodes = cnToInt((m[1] ?? m[2])!); + out.evidence.push("total:cn"); + }); + s = s.replace(AUDIO_LANG_RE, " "); + for (const [re, ct, ev] of CONTENT_RULES) { + take(re, () => { + out.contentType ??= ct; + out.evidence.push(ev); + }); + } + s = s.replace(VARIANT_RE, (v) => { + out.variantTags.push(v); + return " "; + }); + if (out.variantTags.length > 0) out.evidence.push("variant:cn"); + take(/完整版/u, () => { + out.segment = "full"; + out.evidence.push("segment:full"); + }); + take(/(? { + out.segment = m[1] === "上" ? "upper" : m[1] === "中" ? "middle" : "lower"; + out.evidence.push("segment:cn"); + }); + // 字幕语言:含关键词的连续汉字串 + s = s.replace(/[\p{Script=Han}]+/gu, (run) => { + if (!SUB_KEYWORDS.test(run)) return run; + const langs = scanSubtitleLangs(run); + out.subtitles = [...new Set([...(out.subtitles ?? []), ...langs])]; + out.evidence.push("subtitle:cn"); + return " "; + }); + return { s, ...out }; +} + +// ---------------------------------------------------------------- 词元归一化 + +const AUDIO_CODEC_ALT = "DDP|DD|AAC|TrueHD|DTSHDMA|DTSHDHR|DTSHD|DTSX|DTS|FLAC|LPCM|PCM|AC3|EAC3|Opus|Atmos"; + +function normalizeCompounds(s: string): string { + return s + .replace(/'/g, "") + .replace(/\bWEB[-. ]?DL\b/gi, " WEBDL ") + .replace(/\bWEB[-. ]?Rip\b/gi, " WEBRip ") + .replace(/\bBlu[-. ]?Ray\b/gi, " BluRay ") + .replace(/\bDTS[-. ]?HD[-. ]?MA\b/gi, " DTSHDMA ") + .replace(/\bDTS[-. ]?HD[-. ]?(?:HRA|HR)\b/gi, " DTSHDHR ") + .replace(/\bDTS[-. ]?HD\b/gi, " DTSHD ") + .replace(/\bDTS[-. :]?X\b/gi, " DTSX ") + .replace(/\b([HhXx])[. ]?26([45])\b/g, " $126$2 ") + .replace(/\bHDR10(?:\+|Plus|P)(?![A-Za-z0-9])/gi, " HDR10PLUS ") + .replace(/\bDD\+/gi, " DDP") + .replace(/\bE-?AC-?3\b/gi, " EAC3 ") + .replace(/\bAC-?3\b/gi, " AC3 ") + .replace(/\bDolby[ .]?Vision\b/gi, " DV ") + .replace(/\bVC-?1\b/gi, " VC1 ") + .replace(/\bMPEG-?2\b/gi, " MPEG2 ") + .replace(/\b(8|10|12)[ .\-_]?bits?\b/gi, " $1BIT ") + .replace( + new RegExp(`\\b(${AUDIO_CODEC_ALT})[ ._]*([1-9])[ .]([0-2])(?![0-9])`, "gi"), + (_all, codec: string, a: string, b: string) => ` ${codec} CH${a}${b} `, + ) + .replace(/(\p{Script=Han})([A-Za-z])/gu, "$1 $2") + .replace(/([A-Za-z])(\p{Script=Han})/gu, "$1 $2"); +} + +const TOKEN_SPLIT = /[\s._[\]()【】/~~;&|#「」,,()::"!!??、+\-《》]+/u; + +const PLATFORMS: Record = { + NF: "Netflix", + AMZN: "Amazon", + MAX: "Max", + HMAX: "HBO Max", + DSNP: "Disney+", + ATVP: "Apple TV+", + IT: "iTunes", + HULU: "Hulu", + PCOK: "Peacock", + PMTP: "Paramount+", + CR: "Crunchyroll", + IQ: "iQIYI", + IQIYI: "iQIYI", + TX: "Tencent Video", + WETV: "WeTV", + YK: "Youku", + MGTV: "MGTV", + BILI: "Bilibili", + BG: "B-Global", + VIU: "Viu", + KKTV: "KKTV", + FRIDAY: "friDay", + LINETV: "LINE TV", + HBO: "HBO", + STAN: "Stan", +}; + +const AUDIO_TOKENS: Record = { + TRUEHD: "TrueHD", + ATMOS: "Atmos", + DTSHDMA: "DTS-HD MA", + DTSHDHR: "DTS-HD HR", + DTSHD: "DTS-HD", + DTSX: "DTS:X", + DTS: "DTS", + DDP: "DDP", + DD: "DD", + EAC3: "E-AC3", + AC3: "AC3", + AAC: "AAC", + FLAC: "FLAC", + LPCM: "LPCM", + PCM: "PCM", + OPUS: "Opus", + MP3: "MP3", +}; + +const HDR_TOKENS: Record = { + HDR: "HDR10", + HDR10: "HDR10", + HDR10PLUS: "HDR10+", + DV: "DV", + DOVI: "DV", + HLG: "HLG", + HDRVIVID: "HDR Vivid", + VIVID: "HDR Vivid", +}; + +const CODEC_TOKENS: Record = { + X264: "H.264", + H264: "H.264", + AVC: "H.264", + X265: "H.265", + H265: "H.265", + HEVC: "H.265", + AV1: "AV1", + VC1: "VC-1", + MPEG2: "MPEG-2", + XVID: "XviD", + DIVX: "DivX", +}; + +const RES_ALLOWED = new Set(["4320p", "2160p", "1440p", "1080p", "1080i", "720p", "576p", "480p"]); + +function heightToRes(h: number): Resolution | undefined { + if (h >= 4000) return "4320p"; + if (h >= 2000) return "2160p"; + if (h >= 1400) return "1440p"; + if (h >= 1000) return "1080p"; + if (h >= 700) return "720p"; + if (h >= 560) return "576p"; + if (h >= 470) return "480p"; + return undefined; +} + +const WEB_TOKENS = new Set(["WEBDL", "WEBRIP", "WEB"]); + +// ---------------------------------------------------------------- 词元扫描 + +function walkTokens(tokens: string[], d: Draft): void { + const ev = d.evidence; + const en: string[] = []; + const cn: string[] = []; + let lastTitleList: string[] | undefined; + let stopped = false; + let hanClosed = false; + let nonYearStops = 0; + const sources = new Set(); + const stop = (kind: string) => { + stopped = true; + if (kind !== "year") nonYearStops++; + }; + const hasTitle = () => en.length > 0 || cn.length > 0; + const pushTitle = (t: string) => { + const list = /\p{Script=Han}/u.test(t) ? cn : /^\d+$/.test(t) ? (lastTitleList ?? en) : en; + list.push(t); + lastTitleList = list; + }; + const setEpisode = (n: number, rule: string) => { + if (!d.episodes) { + d.episodes = { start: n, end: n }; + ev.push(rule); + } else if (n > d.episodes.end) d.episodes.end = n; + }; + const nearWeb = (i: number) => { + for (let k = Math.max(0, i - 2); k <= Math.min(tokens.length - 1, i + 2); k++) + if (k !== i && WEB_TOKENS.has(tokens[k]!.toUpperCase())) return true; + return false; + }; + + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!; + const u = t.toUpperCase(); + const next = tokens[i + 1]; + if (t === "〓") { + hanClosed = true; + continue; + } + if (t === "〆") { + if (hasTitle()) stop("marker"); + continue; + } + let m: RegExpExecArray | null; + // 季集 + m = /^S(\d{1,3})((?:E[P]?\d{1,4})+)$/i.exec(t); + if (m) { + d.season ??= Number(m[1]); + for (const e of m[2]!.match(/\d+/g)!) setEpisode(Number(e), "episode:SxxEyy"); + ev.push("season:SxxEyy"); + stop("season"); + continue; + } + m = /^S(\d{1,3})D(\d{1,2})$/i.exec(t); + if (m) { + d.season ??= Number(m[1]); + d.part = `Disc${Number(m[2])}`; + ev.push("season:SxxDyy"); + stop("season"); + continue; + } + m = /^S(\d{1,3})$/i.exec(t); + if (m && (hasTitle() || stopped || i === 0)) { + const n = Number(m[1]); + if (d.season === undefined) { + d.season = n; + ev.push("season:Sxx"); + } else if (n > d.season) { + d.seasonEnd = n; + ev.push("season:Sxx_range"); + } + stop("season"); + continue; + } + m = /^EP?(\d{1,4})$/i.exec(t); + if (m && (stopped || hasTitle() || i === 0)) { + setEpisode(Number(m[1]), "episode:Exx"); + stop("episode"); + continue; + } + m = /^(\d{1,2})x(\d{2,3})$/i.exec(t); + if (m) { + d.season ??= Number(m[1]); + setEpisode(Number(m[2]), "episode:NxNN"); + stop("season"); + continue; + } + if ((u === "SEASON" || u === "EPISODE") && next && /^\d{1,4}$/.test(next)) { + if (u === "SEASON") { + if (d.season === undefined) d.season = Number(next); + else if (Number(next) > d.season) d.seasonEnd = Number(next); + ev.push("season:word"); + } else setEpisode(Number(next), "episode:word"); + i++; + stop("season"); + continue; + } + // 分辨率 + m = /^(\d{3,4})([PI])$/i.exec(t); + if (m && RES_ALLOWED.has(`${m[1]}${m[2]!.toLowerCase()}`)) { + d.resolution ??= `${m[1]}${m[2]!.toLowerCase()}` as Resolution; + ev.push("resolution"); + stop("res"); + continue; + } + m = /^([248])K$/i.exec(t); + if (m && (hasTitle() || stopped)) { + d.resolution ??= m[1] === "8" ? "4320p" : m[1] === "4" ? "2160p" : "1440p"; + ev.push("resolution:nK"); + stop("res"); + continue; + } + m = /^(\d{3,4})X(\d{3,4})$/i.exec(t); + if (m) { + const r = heightToRes(Number(m[2])); + if (r) { + d.resolution ??= r; + ev.push("resolution:WxH"); + stop("res"); + continue; + } + } + // 年份 + if (/^(?:19|20)\d{2}$/.test(t)) { + const y = Number(t); + if (!hasTitle() && !stopped) { + pushTitle(t); + continue; + } + if (d.year === undefined) { + d.year = y; + stop("year"); + continue; + } + if (nonYearStops === 0) { + pushTitle(String(d.year)); + d.year = y; + continue; + } + continue; + } + // 分盘/Part + m = /^(CD|DISC|DISK)(\d{1,2})$/i.exec(t); + if (m) { + d.part = `${m[1]!.toUpperCase() === "CD" ? "CD" : "Disc"}${Number(m[2])}`; + ev.push("part"); + stop("part"); + continue; + } + m = /^PART(\d{1,2}|[A-D]|I{1,3})?$/i.exec(t); + if (m) { + if (stopped) { + let p = m[1]; + if (!p && next && /^(\d{1,2}|[A-D]|I{1,3})$/i.test(next)) { + p = next; + i++; + } + if (p) { + d.part = `Part${p.toUpperCase()}`; + ev.push("part"); + continue; + } + continue; + } + pushTitle(t); + continue; + } + const gate = hasTitle() || stopped; + // 来源 + if (gate) { + let src: ReleaseSource | undefined; + if (u === "BLURAY" || u === "BDRIP" || u === "BRRIP" || u === "BD") src = "BluRay"; + else if (u === "WEBDL") src = "WEB-DL"; + else if (u === "WEBRIP") src = "WEBRip"; + else if (u === "WEB" && (stopped || (next !== undefined && /^\d{3,4}[pi]$/i.test(next)))) src = "WEB-DL"; + else if (u === "HDTV" || u === "UHDTV") src = "HDTV"; + else if (u === "HDRIP") src = "HDRip"; + else if (u === "DVDRIP") src = "DVDRip"; + else if (u === "DVD" || u === "DVD5" || u === "DVD9") src = "DVD"; + if (src) { + sources.add(src); + ev.push(`source:${src}`); + stop("source"); + continue; + } + if (u === "REMUX") { + d._remux = true; + ev.push("source:REMUX"); + stop("source"); + continue; + } + if (/^(BD25|BD50|BD66|BD100|BDMV|ISO)$/.test(u)) { + d._disc = true; + ev.push("source:disc"); + stop("source"); + continue; + } + if (u === "UHD") { + d.uhd = true; + stop("source"); + continue; + } + if (u === "COMPLETE") { + const n2 = next?.toUpperCase(); + if (n2 === "UHD" || n2 === "BLURAY") { + d._disc = true; + ev.push("source:disc"); + } else { + d._complete = true; + ev.push("pack:complete"); + } + stop("complete"); + continue; + } + if ((u === "FIN" || u === "END") && d.episodes) { + d._fin = true; + ev.push("pack:fin"); + stop("complete"); + continue; + } + const codec = CODEC_TOKENS[u]; + if (codec) { + d.videoCodec ??= codec; + if (u === "X264" || u === "X265") d.encoder ??= u === "X264" ? "x264" : "x265"; + ev.push("codec"); + stop("codec"); + continue; + } + m = /^(8|10|12)BIT$/.exec(u); + if (m) { + d.bitDepth ??= Number(m[1]) as 8 | 10 | 12; + stop("bit"); + continue; + } + const audio = AUDIO_TOKENS[u]; + if (audio && (stopped || u !== "DD")) { + if (!d.audio.includes(audio)) d.audio.push(audio); + stop("audio"); + continue; + } + m = /^CH(\d)(\d)$/.exec(u); + if (m && d.audio.length > 0) { + const last = d.audio.length - 1; + if (!/\d\.\d$/.test(d.audio[last]!)) d.audio[last] = `${d.audio[last]} ${m[1]}.${m[2]}`; + continue; + } + m = /^(\d)AUDIOS?$/.exec(u); + if (m) { + d.audio.push(`${m[1]}Audios`); + stop("audio"); + continue; + } + const hdr = HDR_TOKENS[u]; + if (hdr && (stopped || u !== "DV")) { + if (!d.hdr.includes(hdr)) d.hdr.push(hdr); + ev.push(`hdr:${hdr}`); + stop("hdr"); + continue; + } + if (u === "SDR" || u === "REPACK" || u === "PROPER" || u === "3D") { + stop("flag"); + continue; + } + m = /^(\d{2,3})FPS$/.exec(u); + if (m) { + d.fps = Number(m[1]); + stop("fps"); + continue; + } + const platform = PLATFORMS[u]; + if (platform && nearWeb(i)) { + d.webSource ??= platform; + ev.push("webSource"); + stop("platform"); + continue; + } + if (stopped && (u === "CHS" || u === "GB")) { + d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hans"])]; + continue; + } + if (stopped && (u === "CHT" || u === "BIG5")) { + d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hant"])]; + continue; + } + if (stopped && (u === "ENG" || u === "EN")) { + continue; + } + } + if (u === "AKA" && hasTitle()) { + stop("aka"); + continue; + } + if (stopped) continue; + if (/\p{Script=Han}/u.test(t)) { + if (!hanClosed) { + cn.push(t); + lastTitleList = cn; + } + continue; + } + pushTitle(t); + } + const enTitle = en.join(" ").trim(); + const cnTitle = cn.join(" ").trim(); + if (enTitle) d.titleEn = enTitle; + if (cnTitle) d.titleCn = cnTitle; + if (d._remux) d.source = "REMUX"; + else if (d._disc) d.source = "BluRay-Disc"; + else { + for (const s of ["BluRay", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVDRip", "DVD"] as const) { + if (sources.has(s)) { + d.source = s; + break; + } + } + } +} + +// ---------------------------------------------------------------- 主入口 + +/** + * 解析发布标题 / 文件名 / 目录名(纯函数)。 + * 与 MoviePilot MetaInfo 的行为对照及偏离点见 docs/porting/moviepilot-metadata-recognition.md。 + */ +export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRelease { + const d = emptyDraft(input); + const ev = d.evidence; + let s = input.trim(); + + // 1. 扩展名 + const kind = fileKindOf(s); + if (kind) { + const ext = /\.([A-Za-z0-9]{2,5})$/.exec(s)![1]!.toLowerCase(); + d.isFile = true; + d.fileKind = kind; + d.extension = ext; + s = s.slice(0, -(ext.length + 1)); + } else if (opts.isFile) d.isFile = true; + + // 2. 显式身份标签 + s = s.replace( + /[[{]\s*(tmdbid|tmdb|imdbid|imdb|doubanid|douban|tvdbid|tvdb)\s*[=-]\s*([A-Za-z0-9]+)\s*[\]}]/gi, + (_all, key: string, id: string) => { + const k = key.toLowerCase().replace(/id$/, "") as ExplicitId["provider"]; + if (id !== "0" && !d.explicitIds.some((x) => x.provider === k)) { + d.explicitIds.push({ provider: k, id }); + ev.push(`explicit:${k}`); + } + return " "; + }, + ); + + // 3. 纯数字文件名 → 集号 + if (d.isFile && /^\s*\d{1,4}\s*$/.test(s)) { + const n = Number(s.trim()); + d.episodes = { start: n, end: n }; + ev.push("episode:numeric_filename"); + return finalize(d, opts); + } + + // 4. 体积 + s = s.replace(/(? { + const mult = /^T/i.test(unit) ? 1024 ** 4 : /^G/i.test(unit) ? 1024 ** 3 : 1024 ** 2; + d.sizeBytes = Math.round(Number(n) * mult); + ev.push("size"); + return " "; + }); + + // 5. 发布组 + const g = extractGroup(s, opts.releaseGroups ?? []); + s = g.s; + if (g.group) { + d.releaseGroup = g.group; + ev.push(g.ev!); + } + + // 6. 剪辑版本 + for (const [re, cut] of CUT_RULES) { + s = s.replace(re, () => { + d.cut ??= cut; + return " "; + }); + } + if (d.cut) ev.push("cut"); + + // 7. 综艺播出日期(在年份识别之前,避免被当成年份或被丢弃) + const ad = extractAirDate(s); + s = ad.s; + if (ad.airDate) { + d.airDate = ad.airDate; + ev.push(ad.ev!); + } + + // 8. 中文季/集/期/分段/字幕标记 + const cm = scanCnMarkers(s); + s = cm.s; + applyCnMarkers(d, cm, ""); + + // 9. 年份区间 2019-2020 只保留前者 + s = s.replace(/(? { + const [x, y] = [Number(a), Number(b)]; + if (x >= 1900 && y >= 1900) return all; + d.episodes = { start: x, end: y }; + ev.push("episode:bracket_range"); + if (fin) { + d._fin = true; + ev.push("pack:fin"); + } + return MARK_HARD; + }, + ); + s = s.replace(/(? { + d.episodes ??= { start: Number(a), end: Number(b) }; + d._fin = true; + ev.push("pack:fin"); + return MARK_HARD; + }); + s = s.replace(/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)/u, (_all, n: string) => { + if (!d.episodes) { + d.episodes = { start: Number(n), end: Number(n) }; + ev.push("episode:anime_dash"); + } + return MARK_HARD; + }); + if (d.releaseGroup?.source === "bracket") { + s = s.replace(/[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => { + if (d.episodes) return all; + d.episodes = { start: Number(n), end: Number(n) }; + ev.push("episode:anime_bracket"); + return MARK_HARD; + }); + } + + // 11. 词元扫描 + const tokens = normalizeCompounds(s) + .split(TOKEN_SPLIT) + .filter((t) => t.length > 0); + walkTokens(tokens, d); + + // 12. 文件名只剩辅助词 + if (d.isFile && !d.titleCn && !d.titleEn && cm.subtitles && !d.season && !d.episodes) { + d.auxiliaryOnly = true; + ev.push("auxiliary_only"); + } + + // 13. 副标题补缺 + if (opts.subtitle) { + const sub = scanCnMarkers(opts.subtitle); + applyCnMarkers(d, sub, "subtitle:"); + if (!d.airDate) { + const sad = extractAirDate(opts.subtitle); + if (sad.airDate) { + d.airDate = sad.airDate; + ev.push(`subtitle:${sad.ev}`); + } + } + } + + return finalize(d, opts); +} + +function applyCnMarkers(d: Draft, cm: CnMarkers, prefix: string): void { + const ev = d.evidence; + const note = (k: string) => { + for (const e of cm.evidence) if (e.startsWith(k)) ev.push(prefix + e); + }; + if (cm.season !== undefined && d.season === undefined) { + d.season = cm.season; + if (cm.seasonEnd !== undefined && cm.seasonEnd > cm.season) d.seasonEnd = cm.seasonEnd; + note("season"); + } + if (cm.episodes && !d.episodes) { + d.episodes = cm.episodes; + note("episode"); + } + if (cm.issue !== undefined && d.issue === undefined) { + d.issue = cm.issue; + note("issue"); + } + if (cm.totalEpisodes !== undefined && d.totalEpisodes === undefined) { + d.totalEpisodes = cm.totalEpisodes; + note("total"); + } + if (cm.segment && !d.segment) { + d.segment = cm.segment; + note("segment"); + } + if (cm.contentType && d.contentType === "unknown") { + d.contentType = cm.contentType; + note("content"); + } + for (const v of cm.variantTags) if (!d.variantTags.includes(v)) d.variantTags.push(v); + if (cm.subtitles) { + d.subtitles = [...new Set([...(d.subtitles ?? []), ...cm.subtitles])]; + note("subtitle"); + } +} + +function finalize(d: Draft, opts: ParseOptions): ParsedRelease { + const ev = d.evidence; + // 集号偏移 + if (opts.episodeOffset && d.episodes) { + const start = evalEpisodeOffset(opts.episodeOffset, d.episodes.start); + const end = evalEpisodeOffset(opts.episodeOffset, d.episodes.end); + if (start >= 0 && end >= start) { + d.episodes = { start, end }; + ev.push("episode:offset"); + } else ev.push("episode:offset_rejected"); + } + if (d.totalEpisodes !== undefined && d.episodes === undefined && d.isFile) d.totalEpisodes = undefined; + + const tvSignals = + d.season !== undefined || + d.episodes !== undefined || + d.airDate !== undefined || + d.issue !== undefined || + d.totalEpisodes !== undefined; + d.mediaTypeHint = tvSignals ? "tv" : "unknown"; + + // 季号推定:有集号/总集数但无季号,且不是综艺日期/期号 + if ( + d.season === undefined && + (d.episodes !== undefined || d.totalEpisodes !== undefined) && + d.airDate === undefined && + d.issue === undefined + ) { + d.season = 1; + d.seasonInferred = true; + ev.push("season:inferred"); + } + + d.packHint = packHint(d); + d.isSeasonPack = d.packHint === "season_pack" || d.packHint === "multi_season_pack"; + + d.isEncode = d.source === "BluRay"; + if (d.isEncode) ev.push("encode:bluray"); + d.title = d.titleCn ?? d.titleEn; + d.confidence = confidenceOf(d); + + const { _complete, _fin, _disc, _remux, ...out } = d; + return out; +} + +function packHint(d: Draft): PackHint { + if (d.seasonEnd !== undefined && d.season !== undefined && d.seasonEnd > d.season) return "multi_season_pack"; + if (d.airDate !== undefined || d.issue !== undefined) return "single"; + if (d.episodes) { + if (d._fin || d._complete) return "season_pack"; + return d.episodes.end > d.episodes.start ? "multi_episode" : "single"; + } + if (d.isFile) return d.mediaTypeHint === "tv" ? "unknown" : "single"; + if (d.season !== undefined || d.totalEpisodes !== undefined || d._complete) { + return d.season !== undefined || d.totalEpisodes !== undefined ? "season_pack" : "unknown"; + } + return "unknown"; +} + +export function confidenceOf(d: ParsedRelease): Confidence { + if (!d.title || d.auxiliaryOnly) return "needs_confirm"; + if (d.variantTags.length > 0) return "needs_confirm"; + const derived = d.evidence.some((e) => e.startsWith("subtitle:") || e === "episode:offset"); + if (d.mediaTypeHint === "tv") { + if (d.season === undefined) return "needs_confirm"; // 综艺只有日期/期号,季未知 + if (d.seasonInferred || derived) return "multi_evidence"; + return "rule_confirmed"; + } + if (d.year === undefined) return "needs_confirm"; + return derived ? "multi_evidence" : "rule_confirmed"; +} diff --git a/packages/domain/src/release-parser/path.ts b/packages/domain/src/release-parser/path.ts new file mode 100644 index 00000000..bdffaafa --- /dev/null +++ b/packages/domain/src/release-parser/path.ts @@ -0,0 +1,170 @@ +import { confidenceOf, parseRelease } from "./parse"; +import type { Confidence, PackHint, ParsedRelease, ParseOptions } from "./types"; + +export interface PathSegments { + file: string; + parent?: string; + grandParent?: string; +} + +const EXTRAS_DIR = + /^(?:featurettes?|extras?|bonus(?:es)?|behind[ ._-]the[ ._-]scenes|deleted[ ._-]scenes|interviews?|trailers?|shorts|scenes|花絮|特典|特辑|特輯|幕后|幕後)$/iu; +const SAMPLE_DIR = /^samples?$/i; + +/** 把路径拆成 文件 / 父目录 / 祖父目录;附加内容目录会被跳过并记为 extra */ +export function splitReleasePath(path: string): PathSegments & { extrasDir?: string; sampleDir?: boolean } { + const parts = path.split(/[\\/]+/).filter((p) => p.length > 0); + const file = parts.pop() ?? ""; + let parent = parts.pop(); + let extrasDir: string | undefined; + let sampleDir = false; + if (parent && (EXTRAS_DIR.test(parent) || SAMPLE_DIR.test(parent))) { + if (SAMPLE_DIR.test(parent)) sampleDir = true; + else extrasDir = parent; + parent = parts.pop(); + } + const grandParent = parts.pop(); + return { + file, + ...(parent !== undefined ? { parent } : {}), + ...(grandParent !== undefined ? { grandParent } : {}), + ...(extrasDir ? { extrasDir } : {}), + ...(sampleDir ? { sampleDir } : {}), + }; +} + +/** 合并后的置信度:由证据推导,多次合并不会把降级"洗掉" */ +function mergedConfidence(r: ParsedRelease): Confidence { + let conf = confidenceOf(r); + if (r.evidence.some((e) => /_dir:/.test(e))) conf = minConfidence(conf, "multi_evidence"); + if (r.evidence.some((e) => e.endsWith(":type_inferred"))) conf = "needs_confirm"; + if (r.mediaTypeHint !== "tv" && r.evidence.some((e) => e.endsWith(":conflict_year"))) conf = "needs_confirm"; + return conf; +} + +const RANK: Record = { rule_confirmed: 2, multi_evidence: 1, needs_confirm: 0 }; +const minConfidence = (a: Confidence, b: Confidence): Confidence => (RANK[a] <= RANK[b] ? a : b); + +function filePackHint(r: ParsedRelease): PackHint { + if (r.airDate !== undefined || r.issue !== undefined) return "single"; + if (r.episodes) return r.episodes.end > r.episodes.start ? "multi_episode" : "single"; + return r.mediaTypeHint === "tv" ? "unknown" : "single"; +} + +/** + * 文件优先、上级目录只补空字段(对照 MP MetaInfoPath.merge)。 + * 偏离:文件本身无剧集信号而目录是剧集时,MP 不合并;本项目合并名称与季号, + * 打 `:type_inferred` 证据并把置信度降为 needs_confirm。 + */ +export function mergeParsed( + primary: ParsedRelease, + fallback: ParsedRelease, + level: "parent_dir" | "grandparent_dir", +): ParsedRelease { + const r: ParsedRelease = { + ...primary, + audio: [...primary.audio], + hdr: [...primary.hdr], + variantTags: [...primary.variantTags], + evidence: [...primary.evidence], + }; + const used: string[] = []; + const fbTv = fallback.mediaTypeHint === "tv"; + const deviation = primary.mediaTypeHint !== "tv" && fbTv; + + if (!primary.title) { + if (fallback.title) { + r.title = fallback.title; + r.titleCn = fallback.titleCn; + r.titleEn = fallback.titleEn; + used.push("title"); + } + } + if (r.year === undefined && fallback.year !== undefined) { + r.year = fallback.year; + used.push("year"); + } + if ((r.season === undefined || r.seasonInferred) && fallback.season !== undefined && !fallback.seasonInferred) { + r.season = fallback.season; + r.seasonInferred = undefined; + if (!r.isFile && fallback.seasonEnd !== undefined) r.seasonEnd = fallback.seasonEnd; + used.push("season"); + } + const fill = (k: K) => { + if (r[k] === undefined && fallback[k] !== undefined) { + r[k] = fallback[k]; + used.push(String(k)); + } + }; + for (const k of [ + "resolution", + "source", + "videoCodec", + "encoder", + "bitDepth", + "releaseGroup", + "cut", + "webSource", + "uhd", + "fps", + "subtitles", + ] as const) + fill(k); + if (used.includes("source")) r.isEncode = fallback.isEncode; + if (r.audio.length === 0 && fallback.audio.length > 0) { + r.audio = [...fallback.audio]; + used.push("audio"); + } + if (r.hdr.length === 0 && fallback.hdr.length > 0) { + r.hdr = [...fallback.hdr]; + used.push("hdr"); + } + if (r.explicitIds.length === 0 && fallback.explicitIds.length > 0) { + r.explicitIds = [...fallback.explicitIds]; + used.push("explicitIds"); + } + if (r.contentType === "unknown" && fallback.contentType !== "unknown") { + r.contentType = fallback.contentType; + used.push("contentType"); + } + if (fbTv) r.mediaTypeHint = "tv"; + for (const u of used) r.evidence.push(`${level}:${u}`); + + if (r.isFile) { + r.packHint = filePackHint(r); + r.isSeasonPack = false; + } + if (deviation && used.length > 0) r.evidence.push(`${level}:type_inferred`); + if (primary.year !== undefined && fallback.year !== undefined && primary.year !== fallback.year) { + // 电影合集年份冲突:以文件为准,但需要确认 + r.evidence.push(`${level}:conflict_year`); + } + r.confidence = mergedConfidence(r); + return r; +} + +/** + * 目录名 + 文件名合并识别。纯函数,不访问文件系统;最多看两级上级目录。 + * 附加内容目录(Featurettes/Extras/花絮…)不参与命名,结果标记 contentType=extra。 + */ +export function parseReleasePath(path: string | PathSegments, opts: ParseOptions = {}): ParsedRelease { + const seg = typeof path === "string" ? splitReleasePath(path) : path; + const { subtitle: _subtitle, ...dirOpts } = opts; + let r = parseRelease(seg.file, { ...opts, isFile: true }); + if (seg.parent) r = mergeParsed(r, parseRelease(seg.parent, { ...dirOpts, isFile: false }), "parent_dir"); + if (seg.grandParent) + r = mergeParsed(r, parseRelease(seg.grandParent, { ...dirOpts, isFile: false }), "grandparent_dir"); + const extras = typeof path === "string" ? splitReleasePath(path) : undefined; + if (extras?.extrasDir && seg.parent) { + // 附加内容归属父作品:名称取父目录,文件自身名称不参与身份识别 + const owner = parseRelease(seg.parent, { ...dirOpts, isFile: false }); + r.title = owner.title; + r.titleCn = owner.titleCn; + r.titleEn = owner.titleEn; + r.contentType = "extra"; + r.evidence.push("dir:extras"); + r.confidence = "needs_confirm"; + } + if (extras?.sampleDir) r.evidence.push("dir:sample"); + return r; +} diff --git a/packages/domain/src/release-parser/release-groups.ts b/packages/domain/src/release-parser/release-groups.ts new file mode 100644 index 00000000..1c3769ca --- /dev/null +++ b/packages/domain/src/release-parser/release-groups.ts @@ -0,0 +1,69 @@ +/** + * 发布组种子数据(本项目自维护,按站点分组,只是组名事实数据)。 + * 设置表可通过 ParseOptions.releaseGroups 扩展。 + */ +export const RELEASE_GROUP_SEED: Readonly> = { + mteam: ["MTeam", "MTeamTV", "MPAD", "MWeb", "TnP"], + audiences: ["Audies", "ADWeb", "ADE", "ADAudio", "ADMusic", "ADEbook"], + hhanclub: ["HHWEB", "HHCLUB"], + chdbits: ["CHD", "CHDBits", "CHDWEB", "CHDPAD", "CHDTV", "CHDHKTV"], + ourbits: ["OurBits", "OurTV", "iLoveTV", "iLoveHD", "FLTTH"], + hdsky: ["HDSky", "HDS", "HDSWEB", "HDSTV", "HDSPad"], + pthome: ["PTHome", "PTH", "PTHweb"], + cmct: ["CMCT", "CMCTV"], + frds: ["FRDS"], + ttg: ["TTG", "WiKi", "NGB", "DoA"], + hdchina: ["HDChina", "HDC"], + beitai: ["BeiTai"], + international: [ + "FraMeSToR", + "SPARKS", + "FLUX", + "NTb", + "CtrlHD", + "EbP", + "HiFi", + "BHDStudio", + "VARYG", + "ROVERS", + "EPSiLON", + "TEPES", + "playWEB", + "HONE", + "BLUTONiUM", + "DON", + "decibeL", + "ZQ", + "KiNGS", + "SMURF", + "CiNEPHiLES", + ], + anime: [ + "LoliHouse", + "喵萌奶茶屋", + "Nekomoe kissaten", + "ANi", + "Lilith-Raws", + "VCB-Studio", + "桜都字幕组", + "北宇治字幕组", + "SweetSub", + "Sakurato", + ], +}; + +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** 构造发布组匹配正则:前界定符 - @ [ 【 & ,后界定符 结尾 @ . 空白 ] [ 】 & */ +export function buildGroupRegex(extra: readonly string[] = []): RegExp { + const all = [...Object.values(RELEASE_GROUP_SEED).flat(), ...extra]; + // 长名优先,避免 CHD 抢先匹配 CHDWEB + const uniq = [...new Set(all)].sort((a, b) => b.length - a.length).map(escapeRe); + return new RegExp(`(?<=[-@\\[【&])(?:${uniq.join("|")})(?=$|[@.\\s\\]\\[】&])`, "giu"); +} + +let cached: RegExp | undefined; +export function defaultGroupRegex(): RegExp { + cached ??= buildGroupRegex(); + return cached; +} diff --git a/packages/domain/src/release-parser/types.ts b/packages/domain/src/release-parser/types.ts new file mode 100644 index 00000000..dc482160 --- /dev/null +++ b/packages/domain/src/release-parser/types.ts @@ -0,0 +1,136 @@ +/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */ +export const PARSER_VERSION = 1; + +export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p"; + +/** 交付来源。BluRay = 蓝光压制(Encode/BDRip),BluRay-Disc = 原盘 */ +export type ReleaseSource = + | "REMUX" + | "BluRay" + | "BluRay-Disc" + | "WEB-DL" + | "WEBRip" + | "HDTV" + | "HDRip" + | "DVD" + | "DVDRip"; + +export type VideoCodec = "H.264" | "H.265" | "AV1" | "VC-1" | "MPEG-2" | "XviD" | "DivX"; + +export type HdrFormat = "DV" | "HDR10" | "HDR10+" | "HLG" | "HDR Vivid"; + +/** 内容版本(剪辑版),与交付版本分开(REQ §6.1) */ +export type Cut = + | "Extended" + | "Director's Cut" + | "Theatrical" + | "Unrated" + | "Uncut" + | "IMAX" + | "Remastered" + | "Criterion" + | "Final Cut" + | "Ultimate" + | "Special Edition"; + +/** 综艺内容类型(REQ §5.1);v0.1 只识别标记,不做分段建模 */ +export type ContentType = + | "unknown" + | "main" + | "extra" + | "behind_the_scenes" + | "performance_only" + | "preview" + | "reaction"; + +export type Segment = "full" | "upper" | "middle" | "lower"; + +export type Confidence = "rule_confirmed" | "multi_evidence" | "needs_confirm"; + +export type PackHint = "single" | "multi_episode" | "season_pack" | "multi_season_pack" | "unknown"; + +export type FileKind = "video" | "subtitle" | "audio" | "nfo" | "image" | "other"; + +export interface EpisodeRange { + start: number; + end: number; +} + +export interface ReleaseGroup { + name: string; + /** known = 内置/自定义表命中;suffix = 末段 -GROUP 兜底;bracket = 开头方括号(动漫字幕组) */ + source: "known" | "suffix" | "bracket"; +} + +export interface ExplicitId { + provider: "tmdb" | "imdb" | "douban" | "tvdb"; + id: string; +} + +export interface ParsedRelease { + parserVersion: number; + /** 原始输入(文件名含扩展名) */ + raw: string; + /** 主展示名:有中文名取中文,否则英文 */ + title?: string; + titleCn?: string; + titleEn?: string; + year?: number; + season?: number; + seasonEnd?: number; + /** 季号不是从文本读出,而是按"有集号无季号 → 第 1 季"推定 */ + seasonInferred?: boolean; + episodes?: EpisodeRange; + totalEpisodes?: number; + /** 综艺播出日期 YYYYMMDD */ + airDate?: string; + /** 综艺来源期号(第N期),与 episode 分开 */ + issue?: number; + resolution?: Resolution; + source?: ReleaseSource; + /** 蓝光二次压制(非原盘、非 Remux) */ + isEncode: boolean; + /** 标题里出现 UHD 字样 */ + uhd?: boolean; + videoCodec?: VideoCodec; + /** 可识别的编码器(x264/x265 小写即压制) */ + encoder?: "x264" | "x265"; + bitDepth?: 8 | 10 | 12; + audio: string[]; + hdr: HdrFormat[]; + fps?: number; + webSource?: string; + releaseGroup?: ReleaseGroup; + cut?: Cut; + part?: string; + /** 字幕语言(zh / zh-Hans / zh-Hant / en / ja / ko);undefined = 标题无信息(未知),不是"无字幕" */ + subtitles?: string[]; + sizeBytes?: number; + contentType: ContentType; + segment?: Segment; + /** 会员版/超前点播等需判断的版本词,原样保留作为证据 */ + variantTags: string[]; + mediaTypeHint: "tv" | "unknown"; + isSeasonPack: boolean; + packHint: PackHint; + isFile: boolean; + fileKind?: FileKind; + extension?: string; + /** 文件名只含"简体/双语"之类辅助词,不能用于识别身份 */ + auxiliaryOnly?: boolean; + explicitIds: ExplicitId[]; + confidence: Confidence; + /** 命中的规则名 */ + evidence: string[]; +} + +export interface ParseOptions { + /** 站点副标题,只补主标题缺失的季集信息 */ + subtitle?: string; + /** 附加发布组(设置表扩展) */ + releaseGroups?: string[]; + /** 集号偏移表达式,如 `EP-12`、`EP*2`、`+1`(白名单解析,不用 eval) */ + episodeOffset?: string; + /** 强制视为文件(无扩展名时) */ + isFile?: boolean; +} diff --git a/packages/domain/src/slot/completion.ts b/packages/domain/src/slot/completion.ts new file mode 100644 index 00000000..4cf291fd --- /dev/null +++ b/packages/domain/src/slot/completion.ts @@ -0,0 +1,84 @@ +import type { Cut } from "../release-parser/types"; +import type { SlotGroupSpec } from "./types"; + +export interface MovieAssetFact { + assetId: string; + cut?: Cut; + verified: boolean; +} + +/** 电影完成条件:按内容版本核对,每个要求的剪辑版都有已验证资产 */ +export function movieCompletion( + requiredCuts: readonly (Cut | "default")[] | undefined, + assets: readonly MovieAssetFact[], +): { complete: boolean; missingCuts: (Cut | "default")[]; satisfiedBy: Record } { + const required = requiredCuts?.length ? requiredCuts : (["default"] as const); + const satisfiedBy: Record = {}; + const missingCuts: (Cut | "default")[] = []; + for (const cut of required) { + const a = assets.find((x) => x.verified && (x.cut ?? "default") === cut); + if (a) satisfiedBy[cut] = a.assetId; + else missingCuts.push(cut); + } + return { complete: missingCuts.length === 0, missingCuts, satisfiedBy }; +} + +export interface UnitAssetFact { + assetId: string; + verified: boolean; + /** 由实际文件清单得出的已覆盖 content_unit */ + fileUnits: readonly string[]; + /** 发布标题声称的覆盖(如 season pack),仅作证据,不计入完成 */ + claimedUnits?: readonly string[]; +} + +export interface UnitCompletion { + complete: boolean; + covered: string[]; + missing: string[]; + /** 标题声称覆盖但文件清单未证实的单元 */ + unverifiedClaims: string[]; + /** 总集数未知或未完成时继续追更 */ + keepTracking: boolean; +} + +/** + * 剧集/综艺完成条件:按 content_unit 覆盖集合计算。 + * season pack 必须由实际文件清单覆盖,不能因下载了整季包就声称完整(REQ §6.2)。 + */ +export function unitCompletion( + requiredUnits: readonly string[], + assets: readonly UnitAssetFact[], + opts: { totalKnown: boolean }, +): UnitCompletion { + const coveredSet = new Set(); + for (const a of assets) if (a.verified) for (const u of a.fileUnits) coveredSet.add(u); + const covered = requiredUnits.filter((u) => coveredSet.has(u)); + const missing = requiredUnits.filter((u) => !coveredSet.has(u)); + const unverified = new Set(); + for (const a of assets) for (const u of a.claimedUnits ?? []) if (!coveredSet.has(u)) unverified.add(u); + const complete = requiredUnits.length > 0 && missing.length === 0; + return { + complete, + covered, + missing, + unverifiedClaims: [...unverified], + keepTracking: !opts.totalKnown || !complete, + }; +} + +/** require_distinct_asset:同一物理资产不能同时满足组内两个 Slot(M2 完整实现,v0.1 先给校验) */ +export function checkDistinctAssets( + group: SlotGroupSpec, + assignments: readonly { slotId: string; assetId: string }[], +): { ok: boolean; sharedAssets: string[] } { + if (!group.requireDistinctAsset) return { ok: true, sharedAssets: [] }; + const seen = new Map(); + const shared = new Set(); + for (const a of assignments) { + const prev = seen.get(a.assetId); + if (prev !== undefined && prev !== a.slotId) shared.add(a.assetId); + seen.set(a.assetId, a.slotId); + } + return { ok: shared.size === 0, sharedAssets: [...shared] }; +} diff --git a/packages/domain/src/slot/evaluate.ts b/packages/domain/src/slot/evaluate.ts new file mode 100644 index 00000000..43309e38 --- /dev/null +++ b/packages/domain/src/slot/evaluate.ts @@ -0,0 +1,239 @@ +import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types"; +import type { HardField, QualityFacts, QualityTier, SlotEvaluation, SlotSpec, UnknownPolicy, Violation } from "./types"; + +export const DEFAULT_SOURCE_TIERS: Readonly> = { + REMUX: 3, + "BluRay-Disc": 3, + BluRay: 3, + "WEB-DL": 2, + WEBRip: 1, + HDTV: 1, + HDRip: 1, + DVDRip: 0, + DVD: 0, +}; + +export const RESOLUTION_RANK: Readonly> = { + "4320p": 7, + "2160p": 6, + "1440p": 5, + "1080p": 4, + "1080i": 3, + "720p": 2, + "576p": 1, + "480p": 0, +}; + +export function qualityTier(f: QualityFacts, spec?: SlotSpec): QualityTier { + const tiers = { ...DEFAULT_SOURCE_TIERS, ...spec?.upgrade?.sourceTiers }; + return { + source: f.source ? tiers[f.source] : -1, + resolution: f.resolution ? RESOLUTION_RANK[f.resolution] : -1, + }; +} + +function policyFor(spec: SlotSpec, field: HardField): UnknownPolicy { + const p = spec.unknownPolicy; + if (p === undefined) return "hold"; + if (typeof p === "string") return p; + return p[field] ?? "hold"; +} + +const lc = (s: string) => s.toLowerCase(); + +function subtitleSatisfied(have: string[], need: string): boolean { + return have.some((h) => h === need || (need === "zh" && h.startsWith("zh"))); +} + +/** 硬条件检查:区分"缺字段"(missing)和"字段不满足"(violations) */ +export function checkHardConditions( + spec: SlotSpec, + r: ParsedRelease, +): { violations: Violation[]; missing: HardField[]; accepted: HardField[] } { + const h = spec.hard; + const violations: Violation[] = []; + const missing: HardField[] = []; + const accepted: HardField[] = []; + const unknown = (f: HardField) => { + const p = policyFor(spec, f); + if (p === "hold") missing.push(f); + else if (p === "reject") violations.push({ field: f, code: "unknown_rejected_by_policy" }); + else accepted.push(f); + }; + + if (h.resolutions?.length) { + if (!r.resolution) unknown("resolution"); + else if (!h.resolutions.includes(r.resolution)) + violations.push({ field: "resolution", code: "not_allowed", expected: h.resolutions, actual: r.resolution }); + } + if (h.sources?.length || h.requireEncode) { + if (!r.source) unknown("source"); + else { + if (h.sources?.length && !h.sources.includes(r.source)) + violations.push({ field: "source", code: "not_allowed", expected: h.sources, actual: r.source }); + if (h.requireEncode && !r.isEncode) + violations.push({ field: "source", code: "not_encode", expected: "BluRay encode", actual: r.source }); + } + } + if (h.maxSizeBytes !== undefined || h.minSizeBytes !== undefined) { + if (r.sizeBytes === undefined) unknown("size"); + else { + if (h.maxSizeBytes !== undefined && r.sizeBytes > h.maxSizeBytes) + violations.push({ field: "size", code: "too_large", expected: h.maxSizeBytes, actual: r.sizeBytes }); + if (h.minSizeBytes !== undefined && r.sizeBytes < h.minSizeBytes) + violations.push({ field: "size", code: "too_small", expected: h.minSizeBytes, actual: r.sizeBytes }); + } + } + if (h.requiredSubtitles?.length) { + if (r.subtitles === undefined) unknown("subtitles"); + else { + const lack = h.requiredSubtitles.filter((s) => !subtitleSatisfied(r.subtitles!, s)); + if (lack.length > 0) + violations.push({ field: "subtitles", code: "missing_language", expected: lack, actual: r.subtitles }); + } + } + if (h.cuts?.length) { + const cut = r.cut ?? "default"; + if (!h.cuts.includes(cut)) violations.push({ field: "cut", code: "not_allowed", expected: h.cuts, actual: cut }); + } + if (h.excludeGroups?.length && r.releaseGroup) { + const g = lc(r.releaseGroup.name); + if (h.excludeGroups.some((x) => g.split("@").includes(lc(x)))) + violations.push({ field: "group", code: "excluded", actual: r.releaseGroup.name }); + } + if (h.contentTypes?.length) { + if (r.contentType === "unknown" && !h.contentTypes.includes("unknown")) unknown("contentType"); + else if (!h.contentTypes.includes(r.contentType)) + violations.push({ field: "contentType", code: "not_allowed", expected: h.contentTypes, actual: r.contentType }); + } + return { violations, missing, accepted }; +} + +function prefIndexScore(pref: readonly T[] | undefined, have: readonly T[]): number { + if (!pref?.length) return 0; + for (let i = 0; i < pref.length; i++) if (have.includes(pref[i]!)) return pref.length - i; + return 0; +} + +/** 软偏好排序键(越大越好,按字典序比较):HDR、发布组、来源偏好、编码、体积区间 */ +export function softRank(spec: SlotSpec, r: ParsedRelease): number[] { + const s = spec.soft ?? {}; + const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; + const size = s.preferredSizeBytes; + let sizeScore = 0; + if (size && r.sizeBytes !== undefined) { + const okMin = size.min === undefined || r.sizeBytes >= size.min; + const okMax = size.max === undefined || r.sizeBytes <= size.max; + sizeScore = okMin && okMax ? 1 : -1; + } + return [ + prefIndexScore(s.hdr, r.hdr), + prefIndexScore(s.groups?.map(lc), groups), + prefIndexScore(s.sources, r.source ? [r.source] : []), + prefIndexScore(s.codecs, r.videoCodec ? [r.videoCodec] : []), + sizeScore, + ]; +} + +export function compareRank(a: readonly number[], b: readonly number[]): number { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const d = (a[i] ?? 0) - (b[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +/** + * 候选对某个 Slot 的确定性评估。 + * - 已知不满足硬条件 → reject(列出全部违规) + * - 硬条件字段缺失且策略为 hold → unknown_field(区分缺字段与不满足) + * - 无当前资产 → accept(附软偏好排序键) + * - 有当前资产 → 仅在达到最小改进阈值时 upgrade,否则 reject(同等级不反复更换) + */ +export function evaluateCandidate(spec: SlotSpec, r: ParsedRelease, current?: QualityFacts | null): SlotEvaluation { + const { violations, missing, accepted } = checkHardConditions(spec, r); + if (violations.length > 0) return { decision: "reject", violations }; + if (missing.length > 0) return { decision: "unknown_field", missing }; + const tier = qualityTier(r, spec); + const rank = softRank(spec, r); + const reasons = accepted.map((f) => `unknown_${f}_accepted_by_policy`); + if (!current) return { decision: "accept", tier, softRank: rank, reasons: [...reasons, "hard_conditions_met"] }; + + const up = spec.upgrade; + if (!up?.enabled) return { decision: "reject", violations: [{ field: "upgrade", code: "upgrade_disabled" }] }; + if (!current.source) return { decision: "unknown_field", missing: ["current_asset"] }; + const from = qualityTier(current, spec); + if (up.ceilingTier !== undefined && from.source >= up.ceilingTier) + return { + decision: "reject", + violations: [{ field: "upgrade", code: "at_ceiling", expected: up.ceilingTier, actual: from.source }], + }; + const minSteps = up.minTierSteps ?? 1; + const dSource = tier.source - from.source; + const dRes = tier.resolution - from.resolution; + const bySource = dSource >= minSteps && dRes >= 0; + const byRes = !!up.allowResolutionUpgrade && dRes > 0 && dSource >= 0; + if (!bySource && !byRes) { + const code = + dSource === 0 && dRes === 0 ? "same_tier" : dSource < 0 || dRes < 0 ? "lower_tier" : "below_min_improvement"; + return { + decision: "reject", + violations: [{ field: "upgrade", code, expected: { minTierSteps: minSteps, from }, actual: tier }], + }; + } + if (r.isEncode && up.encodeGroupAllowlist?.length) { + const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : []; + if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g)))) + return { + decision: "reject", + violations: [ + { + field: "upgrade", + code: "encode_not_qualified", + expected: up.encodeGroupAllowlist, + actual: r.releaseGroup?.name, + }, + ], + }; + } + return { + decision: "upgrade", + tier, + fromTier: from, + softRank: rank, + reasons: [...reasons, bySource ? "source_tier_improved" : "resolution_improved"], + }; +} + +export interface RankedEvaluation { + candidate: T; + evaluation: SlotEvaluation; +} + +/** 对一批候选评估并排序:可用(accept/upgrade)在前,按质量等级再按软偏好;其余保持原顺序 */ +export function rankForSlot( + spec: SlotSpec, + candidates: readonly T[], + current?: QualityFacts | null, +): RankedEvaluation[] { + const evals = candidates.map((c, i) => ({ candidate: c, evaluation: evaluateCandidate(spec, c, current), i })); + const usable = (e: SlotEvaluation) => e.decision === "accept" || e.decision === "upgrade"; + return evals + .sort((a, b) => { + const ua = usable(a.evaluation); + const ub = usable(b.evaluation); + if (ua !== ub) return ua ? -1 : 1; + if (ua && ub) { + const ea = a.evaluation as Extract; + const eb = b.evaluation as Extract; + return ( + eb.tier.source - ea.tier.source || + eb.tier.resolution - ea.tier.resolution || + compareRank(eb.softRank, ea.softRank) || + a.i - b.i + ); + } + return a.i - b.i; + }) + .map(({ candidate, evaluation }) => ({ candidate, evaluation })); +} diff --git a/packages/domain/src/slot/index.ts b/packages/domain/src/slot/index.ts new file mode 100644 index 00000000..971994c5 --- /dev/null +++ b/packages/domain/src/slot/index.ts @@ -0,0 +1,28 @@ +export { + checkDistinctAssets, + type MovieAssetFact, + movieCompletion, + type UnitAssetFact, + type UnitCompletion, + unitCompletion, +} from "./completion"; +export { + checkHardConditions, + compareRank, + DEFAULT_SOURCE_TIERS, + evaluateCandidate, + qualityTier, + type RankedEvaluation, + RESOLUTION_RANK, + rankForSlot, + softRank, +} from "./evaluate"; +export { + canTransition, + type SlotEvent, + type SlotState, + type SlotStatus, + SlotTransitionError, + transition, +} from "./state"; +export * from "./types"; diff --git a/packages/domain/src/slot/state.ts b/packages/domain/src/slot/state.ts new file mode 100644 index 00000000..020a0099 --- /dev/null +++ b/packages/domain/src/slot/state.ts @@ -0,0 +1,97 @@ +/** Slot 状态(REQ §6.1) */ +export type SlotStatus = + | "empty" + | "reserved" + | "downloading" + | "verifying" + | "satisfied" + | "attention" + | "upgrade_pending"; + +export interface SlotState { + status: SlotStatus; + /** 是否已有可用的当前资产(升级流程中旧资产仍在,部分覆盖的剧集也可能有资产) */ + hasAsset: boolean; +} + +export type SlotEvent = + | { type: "reserve" } + | { type: "release" } + | { type: "download_started" } + | { type: "download_completed" } + | { type: "download_failed"; reason?: string } + | { type: "verify_passed"; complete: boolean } + | { type: "verify_failed"; reason?: string } + | { type: "upgrade_found" } + | { type: "upgrade_cancelled" } + | { type: "attention_resolved"; hasAsset: boolean } + | { type: "asset_missing_confirmed" }; + +export class SlotTransitionError extends Error { + override name = "SlotTransitionError"; + constructor( + readonly from: SlotStatus, + readonly event: SlotEvent["type"], + ) { + super(`非法的 Slot 状态迁移: ${from} --${event}-->`); + } +} + +/** + * Slot 状态机(纯函数)。非法迁移抛 SlotTransitionError。 + * 升级流程:satisfied → upgrade_pending → reserved → downloading → verifying → satisfied; + * 新文件验证通过前旧资产一直保留(hasAsset=true)。 + * 剧集部分覆盖:verify_passed(complete=false) 回到 empty 但 hasAsset=true,继续追更。 + */ +export function transition(state: SlotState, event: SlotEvent): SlotState { + const { status, hasAsset } = state; + const bad = (): never => { + throw new SlotTransitionError(status, event.type); + }; + switch (event.type) { + case "reserve": + if (status === "empty" || status === "attention" || status === "upgrade_pending") + return { status: "reserved", hasAsset }; + return bad(); + case "release": + if (status === "reserved") return { status: hasAsset ? "satisfied" : "empty", hasAsset }; + return bad(); + case "download_started": + if (status === "reserved") return { status: "downloading", hasAsset }; + return bad(); + case "download_completed": + if (status === "downloading") return { status: "verifying", hasAsset }; + return bad(); + case "download_failed": + if (status === "downloading" || status === "reserved") return { status: "attention", hasAsset }; + return bad(); + case "verify_passed": + if (status === "verifying") return { status: event.complete ? "satisfied" : "empty", hasAsset: true }; + return bad(); + case "verify_failed": + if (status === "verifying") return { status: "attention", hasAsset }; + return bad(); + case "upgrade_found": + if (status === "satisfied") return { status: "upgrade_pending", hasAsset }; + return bad(); + case "upgrade_cancelled": + if (status === "upgrade_pending") return { status: "satisfied", hasAsset }; + return bad(); + case "attention_resolved": + if (status === "attention") return { status: event.hasAsset ? "satisfied" : "empty", hasAsset: event.hasAsset }; + return bad(); + case "asset_missing_confirmed": + // 只接受"确认缺失"(对账多次观察后);单次观察失败不得触发(失败 ≠ 不存在) + if (status === "satisfied" || status === "upgrade_pending") return { status: "attention", hasAsset: false }; + return bad(); + } +} + +export function canTransition(state: SlotState, event: SlotEvent): boolean { + try { + transition(state, event); + return true; + } catch { + return false; + } +} diff --git a/packages/domain/src/slot/types.ts b/packages/domain/src/slot/types.ts new file mode 100644 index 00000000..f0f28601 --- /dev/null +++ b/packages/domain/src/slot/types.ts @@ -0,0 +1,95 @@ +import type { + ContentType, + Cut, + HdrFormat, + ParsedRelease, + ReleaseSource, + Resolution, + VideoCodec, +} from "../release-parser/types"; + +/** 可判定的硬条件字段 */ +export type HardField = "resolution" | "source" | "size" | "subtitles" | "cut" | "group" | "contentType"; + +/** 字段缺失时的处理:hold = 返回 unknown_field 等待补充;reject = 拒绝;accept = 放行 */ +export type UnknownPolicy = "hold" | "reject" | "accept"; + +export interface SlotHardConditions { + resolutions?: Resolution[]; + sources?: ReleaseSource[]; + /** 只接受蓝光二次压制(不含原盘/Remux) */ + requireEncode?: boolean; + maxSizeBytes?: number; + minSizeBytes?: number; + /** 需要的字幕语言;"zh" 可被 zh/zh-Hans/zh-Hant 满足 */ + requiredSubtitles?: string[]; + /** 内容版本;"default" = 标题无剪辑标记 */ + cuts?: (Cut | "default")[]; + excludeGroups?: string[]; + contentTypes?: ContentType[]; +} + +export interface SlotSoftPreferences { + /** 按偏好顺序,越靠前越好 */ + hdr?: HdrFormat[]; + groups?: string[]; + sources?: ReleaseSource[]; + codecs?: VideoCodec[]; + preferredSizeBytes?: { min?: number; max?: number }; +} + +export interface SlotUpgradePolicy { + enabled: boolean; + /** 最小改进:来源等级至少提升几级,默认 1(同等级不反复更换) */ + minTierSteps?: number; + /** 来源等级表;默认 REMUX/原盘/蓝光二压=3,WEB-DL=2,WEBRip/HDTV/HDRip=1,DVD=0 */ + sourceTiers?: Partial>; + /** 允许仅因分辨率提升而升级(同一 Slot 允许多个分辨率时) */ + allowResolutionUpgrade?: boolean; + /** 合格蓝光压制组名单;设置后名单外的压制不自动升级(低劣压制不因 BluRay 标签自动替换) */ + encodeGroupAllowlist?: string[]; + /** 当前资产来源等级达到此值后不再升级 */ + ceilingTier?: number; +} + +export type SlotCompletionSpec = { kind: "movie"; cuts?: (Cut | "default")[] } | { kind: "units" }; + +export interface SlotSpec { + id?: string; + name: string; + hard: SlotHardConditions; + unknownPolicy?: UnknownPolicy | Partial>; + soft?: SlotSoftPreferences; + upgrade?: SlotUpgradePolicy; + completion?: SlotCompletionSpec; +} + +/** 多目标组合(M2 才做校验;数据结构先定):require_distinct_asset 表示必须是不同物理资产 */ +export interface SlotGroupSpec { + slots: SlotSpec[]; + requireDistinctAsset?: boolean; +} + +/** 当前 Slot 资产的交付特征(来自入库时的解析/探测) */ +export type QualityFacts = Pick< + Partial, + "resolution" | "source" | "hdr" | "releaseGroup" | "sizeBytes" | "isEncode" | "cut" +>; + +export interface QualityTier { + source: number; + resolution: number; +} + +export interface Violation { + field: HardField | "upgrade"; + code: string; + expected?: unknown; + actual?: unknown; +} + +export type SlotEvaluation = + | { decision: "accept"; tier: QualityTier; softRank: number[]; reasons: string[] } + | { decision: "upgrade"; tier: QualityTier; fromTier: QualityTier; softRank: number[]; reasons: string[] } + | { decision: "reject"; violations: Violation[] } + | { decision: "unknown_field"; missing: HardField[] | ["current_asset"] }; diff --git a/packages/domain/test/acquisition.test.ts b/packages/domain/test/acquisition.test.ts new file mode 100644 index 00000000..bb5911c6 --- /dev/null +++ b/packages/domain/test/acquisition.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import { + checkBudget, + DEFAULT_PRIORITY_ORDER, + determineMode, + evaluateCacheVolume, + type InflightTask, + type ModeInput, + priorityOf, + sortByPriority, + validatePriorityOrder, +} from "../src"; + +const GiB = 1024 ** 3; +const base: ModeInput = { + entity: null, + releaseUnits: [], + satisfiedUnits: [], + slotSatisfied: false, + now: "2026-09-27T00:00:00Z", + newTitleWindowDays: 90, +}; + +describe("三种模式判定", () => { + it("新作品按发行日期,不按站点上传日期", () => { + expect(determineMode({ ...base, firstReleaseDate: "2026-08-01" })).toEqual({ + mode: "new_title", + reason: "released_within_window", + }); + expect(determineMode({ ...base, firstReleaseDate: "1999-03-31" }).reason).toBe("not_new_release"); + expect(determineMode(base).reason).toBe("release_date_unknown"); + expect(determineMode({ ...base, firstReleaseDate: "bad" }).reason).toBe("release_date_unknown"); + expect(determineMode({ ...base, firstReleaseDate: "2027-01-01" }).reason).toBe("not_yet_released"); + }); + it("追更补缺:覆盖未满足的单元", () => { + const tv = { ...base, entity: { tracked: true, kind: "tv" as const } }; + expect(determineMode({ ...tv, releaseUnits: ["e1", "e2"], satisfiedUnits: ["e1"] }).mode).toBe("catch_up"); + expect(determineMode({ ...tv, releaseUnits: ["e1"], satisfiedUnits: ["e1"] }).reason).toBe("already_satisfied"); + expect(determineMode({ ...tv, releaseUnits: ["e1"], satisfiedUnits: ["e1"], upgradeCandidate: true }).mode).toBe( + "upgrade", + ); + expect(determineMode({ ...tv }).reason).toBe("no_units_resolved"); + }); + it("已有电影:Slot 缺口/更优版本 → 升级;只登记的作品不自动下载", () => { + const mv = { ...base, entity: { tracked: true, kind: "movie" as const } }; + expect(determineMode(mv)).toEqual({ mode: "upgrade", reason: "slot_gap" }); + expect(determineMode({ ...mv, slotSatisfied: true }).mode).toBeNull(); + expect(determineMode({ ...mv, slotSatisfied: true, upgradeCandidate: true }).reason).toBe("better_version"); + expect(determineMode({ ...mv, entity: { tracked: false, kind: "movie" } }).reason).toBe("not_tracked"); + }); +}); + +describe("优先级", () => { + const items = [ + { id: "u", priorityClass: priorityOf("upgrade"), enqueuedAt: "2026-01-01T00:00:00Z" }, + { id: "n", priorityClass: priorityOf("new_title"), enqueuedAt: "2026-01-01T00:00:00Z" }, + { id: "c2", priorityClass: priorityOf("catch_up"), enqueuedAt: "2026-01-02T00:00:00Z" }, + { id: "c1", priorityClass: priorityOf("catch_up"), enqueuedAt: "2026-01-01T00:00:00Z" }, + { id: "i", priorityClass: priorityOf("upgrade", true), enqueuedAt: "2026-01-03T00:00:00Z" }, + ]; + it("默认 即时 > 追更 > 新作品 > 升级,同级先进先出", () => { + expect(sortByPriority(items).map((x) => x.id)).toEqual(["i", "c1", "c2", "n", "u"]); + expect(DEFAULT_PRIORITY_ORDER[0]).toBe("immediate"); + }); + it("可配置", () => { + expect(sortByPriority(items, ["immediate", "new_title", "catch_up", "upgrade"]).map((x) => x.id)).toEqual([ + "i", + "n", + "c1", + "c2", + "u", + ]); + }); + it("配置必须完整", () => { + expect(() => validatePriorityOrder(["immediate", "catch_up"])).toThrow(); + expect(() => validatePriorityOrder(["immediate", "catch_up", "catch_up", "upgrade"])).toThrow(); + }); +}); + +describe("预算", () => { + const budget = { maxNewTitlesPerDay: 3, maxBytesPerDay: 100 * GiB, maxConcurrent: 2 }; + it("放行", () => { + const r = checkBudget( + budget, + { newTitlesToday: 2, bytesToday: 10 * GiB, activeDownloads: 1 }, + { countsAsNewTitle: true, bytes: GiB }, + ); + expect(r).toEqual({ decision: "allow", violations: [], unknown: [] }); + }); + it("超限列出全部违规,预留字节计入", () => { + const r = checkBudget( + budget, + { newTitlesToday: 3, bytesToday: 60 * GiB, reservedBytesToday: 30 * GiB, activeDownloads: 2 }, + { countsAsNewTitle: true, bytes: 20 * GiB }, + ); + expect(r.decision).toBe("deny"); + expect(r.violations.map((v) => v.kind)).toEqual(["new_titles", "bytes", "concurrency"]); + expect(r.violations[1]!.current).toBe(90 * GiB); + }); + it("体积未知 → unknown,不放行;追更不占新作品额度", () => { + const r = checkBudget( + budget, + { newTitlesToday: 3, bytesToday: 0, activeDownloads: 0 }, + { countsAsNewTitle: false }, + ); + expect(r).toEqual({ decision: "unknown", violations: [], unknown: ["bytes"] }); + expect( + checkBudget({}, { newTitlesToday: 99, bytesToday: 0, activeDownloads: 99 }, { countsAsNewTitle: true }).decision, + ).toBe("allow"); + }); +}); + +describe("缓存卷硬阈值", () => { + const cfg = { hardPct: 90, resumePct: 85 }; + const vol = (usedPct: number) => ({ volumeId: "v1", totalBytes: 1000 * GiB, usedBytes: usedPct * 10 * GiB }); + const task = (over: Partial): InflightTask => ({ + taskId: "t", + volumeId: "v1", + sizeBytes: 50 * GiB, + onDiskBytes: 0, + managed: true, + status: "downloading", + ...over, + }); + it("读不到磁盘状态 → 保守阻止", () => { + const r = evaluateCacheVolume({ + volume: null, + config: cfg, + inflight: [], + reservations: [], + pausedByThreshold: false, + }); + expect(r.decision).toBe("block"); + expect(r.reasons).toEqual(["volume_state_unknown"]); + }); + it("配置校验", () => { + expect(() => + evaluateCacheVolume({ + volume: vol(1), + config: { hardPct: 80, resumePct: 85 }, + inflight: [], + reservations: [], + pausedByThreshold: false, + }), + ).toThrow(); + }); + it("达到硬阈值 → 暂停纳管的下载中任务", () => { + const r = evaluateCacheVolume({ + volume: vol(91), + config: cfg, + inflight: [ + task({ taskId: "a" }), + task({ taskId: "b", managed: false }), + task({ taskId: "c", status: "paused" }), + task({ taskId: "d", volumeId: "v2" }), + ], + reservations: [], + pausedByThreshold: false, + }); + expect(r.decision).toBe("pause_managed"); + expect(r.pauseTaskIds).toEqual(["a"]); + expect(r.unmanagedGrowthRisk).toBe(true); + }); + it("在途剩余需求 + 预留:已预分配不重复扣,已提交计划不重复算预留", () => { + const r = evaluateCacheVolume({ + volume: vol(70), + config: cfg, + inflight: [ + task({ taskId: "a", planId: "p1", sizeBytes: 100 * GiB, onDiskBytes: 100 * GiB }), + task({ taskId: "b", planId: "p2", sizeBytes: 100 * GiB, onDiskBytes: 40 * GiB }), + task({ taskId: "c", status: "completed", sizeBytes: 500 * GiB }), + ], + reservations: [ + { planId: "p2", volumeId: "v1", bytes: 100 * GiB }, + { planId: "p3", volumeId: "v1", bytes: 50 * GiB }, + { planId: "p4", volumeId: "v2", bytes: 999 * GiB }, + ], + request: { bytes: 60 * GiB }, + pausedByThreshold: false, + }); + expect(r.pendingBytes).toBe(60 * GiB); + expect(r.reservedBytes).toBe(50 * GiB); + expect(r.projectedPct).toBeCloseTo(87); + expect(r.decision).toBe("allow"); + const over = evaluateCacheVolume({ + volume: vol(70), + config: cfg, + inflight: [task({ sizeBytes: 150 * GiB })], + reservations: [], + request: { bytes: 60 * GiB }, + pausedByThreshold: false, + }); + expect(over.decision).toBe("block"); + expect(over.reasons).toContain("projected_over_threshold"); + }); + it("体积未知 → 阻止新提交", () => { + const r = evaluateCacheVolume({ + volume: vol(10), + config: cfg, + inflight: [task({ sizeBytes: undefined, taskId: "m" })], + reservations: [], + request: {}, + pausedByThreshold: false, + }); + expect(r.decision).toBe("block"); + expect(r.reasons).toEqual(["request_size_unknown", "inflight_size_unknown"]); + expect(r.unknownSizeTaskIds).toEqual(["m"]); + }); + it("恢复水位滞回:85%~90% 之间保持暂停,≤85% 恢复阈值暂停的任务", () => { + const mid = evaluateCacheVolume({ + volume: vol(87), + config: cfg, + inflight: [], + reservations: [], + pausedByThreshold: true, + }); + expect(mid.decision).toBe("block"); + expect(mid.resumeThresholdPaused).toBe(false); + const low = evaluateCacheVolume({ + volume: vol(80), + config: cfg, + inflight: [], + reservations: [], + pausedByThreshold: true, + }); + expect(low.decision).toBe("allow"); + expect(low.resumeThresholdPaused).toBe(true); + expect(low.reasons).toEqual(["below_resume_watermark"]); + }); +}); diff --git a/packages/domain/test/download-state.test.ts b/packages/domain/test/download-state.test.ts new file mode 100644 index 00000000..e5e43585 --- /dev/null +++ b/packages/domain/test/download-state.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + applyObservation, + DownloadTransitionError, + detectNoProgress, + isDownloadComplete, + mapQbState, + type ProgressSample, +} from "../src"; + +const ctx = { pausedByThreshold: false }; + +describe("qB 状态映射", () => { + it.each([ + ["downloading", 0.5, "downloading"], + ["stalledDL", 0.5, "downloading"], + ["metaDL", 0, "downloading"], + ["forcedDL", 0.1, "downloading"], + ["queuedDL", 0, "queued"], + ["checkingResumeData", 0.4, "queued"], + ["uploading", 1, "completed"], + ["stalledUP", 1, "completed"], + ["pausedUP", 1, "completed"], + ["stoppedUP", 1, "completed"], + ["queuedUP", 1, "completed"], + ["checkingUP", 1, "downloading"], + ["uploading", 0.9, "downloading"], + ["error", 1, "error"], + ["missingFiles", 1, "missing_files"], + ["pausedDL", 0.3, "paused_by_user"], + ["stoppedDL", 0.3, "paused_by_user"], + ] as const)("%s @%s → %s", (state, progress, want) => { + expect(mapQbState({ state, progress }, ctx).status).toBe(want); + }); + it("阈值暂停与用户暂停由业务账本区分", () => { + expect(mapQbState({ state: "pausedDL", progress: 0.3 }, { pausedByThreshold: true }).status).toBe( + "paused_by_threshold", + ); + }); + it("任务消失 → removed;未知状态沿用上次并标记不确定", () => { + expect(mapQbState(null, ctx).status).toBe("removed"); + expect(mapQbState({ state: "weird", progress: 0 }, { ...ctx, previous: "downloading" })).toEqual({ + status: "downloading", + uncertain: true, + reason: "unknown_state:weird", + }); + expect(mapQbState({ state: "weird", progress: 0 }, ctx).status).toBe("queued"); + }); +}); + +describe("完成判定", () => { + it("error/missingFiles/校验中不算完成", () => { + expect(isDownloadComplete({ state: "uploading", progress: 1 })).toBe(true); + expect(isDownloadComplete({ state: "error", progress: 1 })).toBe(false); + expect(isDownloadComplete({ state: "missingFiles", progress: 1 })).toBe(false); + expect(isDownloadComplete({ state: "checkingUP", progress: 1 })).toBe(false); + expect(isDownloadComplete({ state: "pausedUP", progress: 1, amountLeft: 10 })).toBe(false); + expect(isDownloadComplete({ state: "downloading", progress: 0.99 })).toBe(false); + expect(isDownloadComplete(null)).toBe(false); + }); +}); + +describe("状态机应用", () => { + it("正常迁移与异常标记", () => { + expect(applyObservation("queued", { state: "downloading", progress: 0.1 }, ctx)).toEqual({ + status: "downloading", + changed: true, + }); + expect(applyObservation("downloading", { state: "downloading", progress: 0.2 }, ctx).changed).toBe(false); + expect(applyObservation("completed", { state: "downloading", progress: 0.9 }, ctx).anomaly).toBe( + "completed_regressed", + ); + expect(applyObservation("downloading", { state: "??", progress: 0.9 }, ctx)).toEqual({ + status: "downloading", + changed: false, + anomaly: "unknown_state", + }); + }); + it("removed 是终态", () => { + expect(() => applyObservation("removed", { state: "downloading", progress: 0 }, ctx)).toThrow( + DownloadTransitionError, + ); + expect(applyObservation("removed", null, ctx).status).toBe("removed"); + }); +}); + +describe("无进展判定", () => { + const min = 60_000; + const s = (t: number, bytes: number, status: ProgressSample["status"] = "downloading"): ProgressSample => ({ + at: t * min, + downloadedBytes: bytes, + status, + }); + it("持续下载中无增长 → 只记录证据", () => { + const r = detectNoProgress([s(0, 100), s(30, 100), s(61, 100)], { thresholdMs: 60 * min }); + expect(r).toMatchObject({ + stalled: true, + action: "record_evidence", + stalledMs: 61 * min, + lastProgressAt: 0, + lastBytes: 100, + }); + }); + it("有增长则清零;暂停/排队时间不计入", () => { + expect(detectNoProgress([s(0, 100), s(50, 200), s(100, 200)], { thresholdMs: 60 * min }).stalled).toBe(false); + const paused = detectNoProgress( + [s(0, 100), s(10, 100, "paused_by_threshold"), s(200, 100, "paused_by_threshold"), s(210, 100)], + { thresholdMs: 60 * min }, + ); + expect(paused.stalled).toBe(false); + expect(paused.excludedMs).toBe(210 * min); + const ending = detectNoProgress([s(0, 1), s(100, 1), s(200, 1, "paused_by_user")], { thresholdMs: 60 * min }); + expect(ending.stalled).toBe(false); + }); + it("样本不足", () => { + expect(detectNoProgress([], { thresholdMs: 1 })).toMatchObject({ stalled: false, sampleCount: 0, action: "none" }); + expect(detectNoProgress([s(0, 1)], { thresholdMs: 0 }).stalled).toBe(false); + }); +}); diff --git a/packages/domain/test/identity.test.ts b/packages/domain/test/identity.test.ts new file mode 100644 index 00000000..8b5d6ed3 --- /dev/null +++ b/packages/domain/test/identity.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + type IdentityCandidate, + normalizeTitle, + parseRelease, + queryTitles, + rankCandidates, + resolveIdentity, + scoreCandidate, + stripSeasonSuffix, + TRAD_TO_SIMP, + toSimplified, +} from "../src"; + +const movie = (over: Partial = {}): IdentityCandidate => ({ + provider: "tmdb", + entityType: "movie", + externalId: "872585", + titles: ["Oppenheimer", "奥本海默"], + year: 2023, + aliases: [], + ...over, +}); + +describe("标题归一化", () => { + it("繁简 / 全半角 / 标点 / 大小写", () => { + expect(toSimplified("葬送的芙莉蓮")).toBe("葬送的芙莉莲"); + expect(normalizeTitle("葬送的芙莉蓮")).toBe(normalizeTitle("葬送的芙莉莲")); + expect(normalizeTitle("THE LAST OF US")).toBe("thelastofus"); + expect(normalizeTitle("Spider-Man: No Way Home")).toBe("spidermannowayhome"); + expect(normalizeTitle("Tom & Jerry")).toBe("tomandjerry"); + expect(normalizeTitle("黑暗榮耀")).toBe("黑暗荣耀"); + }); + it("去季号/年份后缀", () => { + expect(stripSeasonSuffix("庆余年 第二季")).toBe("庆余年"); + expect(stripSeasonSuffix("The Boys Season 4")).toBe("The Boys"); + expect(stripSeasonSuffix("Show S02")).toBe("Show"); + expect(stripSeasonSuffix("Title (2019)")).toBe("Title"); + expect(stripSeasonSuffix("第二季")).toBe("第二季"); + expect(normalizeTitle("庆余年 第二季")).toBe(normalizeTitle("慶餘年")); + }); + it("映射表是一一对应的字符对", () => { + expect(TRAD_TO_SIMP.size).toBeGreaterThan(500); + for (const [k, v] of TRAD_TO_SIMP) { + expect([...k].length).toBe(1); + expect([...v].length).toBe(1); + } + }); + it("queryTitles 去重", () => { + const p = parseRelease("奥本海默 Oppenheimer 2023 1080p"); + expect(queryTitles(p)).toEqual(["奥本海默", "oppenheimer"]); + }); +}); + +describe("候选打分(证据等级,无概率)", () => { + const p = parseRelease("Oppenheimer.2023.2160p.UHD.BluRay.REMUX-FraMeSToR"); + it("标题精确 + 年份一致 → rule_confirmed", () => { + const r = scoreCandidate(p, movie()); + expect(r.level).toBe("rule_confirmed"); + expect(r.titleMatch).toBe("exact"); + expect(r.yearMatch).toBe("exact"); + expect(Object.values(r).some((v) => typeof v === "number")).toBe(false); + }); + it("年份容差 ±1 → multi_evidence;±2 → rejected", () => { + expect(scoreCandidate(p, movie({ year: 2024 })).level).toBe("multi_evidence"); + expect(scoreCandidate(p, movie({ year: 2022 })).yearMatch).toBe("within_tolerance"); + expect(scoreCandidate(p, movie({ year: 2025 })).level).toBe("rejected"); + expect(scoreCandidate(p, movie({ year: 2025 }), { yearTolerance: 2 }).level).toBe("multi_evidence"); + }); + it("别名命中 → multi_evidence;部分包含 → needs_confirm;无关 → rejected", () => { + expect(scoreCandidate(p, movie({ titles: ["X"], aliases: ["Oppenheimer"] })).level).toBe("multi_evidence"); + expect(scoreCandidate(p, movie({ titles: ["Oppenheimer Returns"] })).level).toBe("needs_confirm"); + expect(scoreCandidate(p, movie({ titles: ["Barbie"] })).level).toBe("rejected"); + }); + it("年份未知:电影需确认,剧集有季号且类型一致算多证据", () => { + const noYear = parseRelease("Oppenheimer 1080p WEB-DL"); + expect(scoreCandidate(noYear, movie()).level).toBe("needs_confirm"); + const tv = parseRelease("奔跑吧 第十二季 20240426 第1期 1080p WEB-DL"); + const show: IdentityCandidate = { + provider: "tmdb", + entityType: "tv", + externalId: "1", + titles: ["奔跑吧"], + year: 2017, + aliases: [], + }; + const r = scoreCandidate(tv, show); + expect(r.yearMatch).toBe("unknown"); + expect(r.typeMatch).toBe("match"); + expect(r.level).toBe("multi_evidence"); + expect(scoreCandidate(tv, { ...show, entityType: "movie" }).level).toBe("needs_confirm"); + }); + it("剧集季年份:seasonYears 命中算 season_year;后续季无季年份信息不据此拒绝", () => { + const s2 = parseRelease("The.Last.of.Us.S02.2025.1080p.WEB-DL"); + const show: IdentityCandidate = { + provider: "tmdb", + entityType: "tv", + externalId: "100088", + titles: ["The Last of Us"], + year: 2023, + aliases: [], + }; + expect(scoreCandidate(s2, show).yearMatch).toBe("unknown"); + const withYears = scoreCandidate(s2, { ...show, seasonYears: [2023, 2025] }); + expect(withYears.yearMatch).toBe("season_year"); + expect(withYears.level).toBe("rule_confirmed"); + expect(scoreCandidate(s2, { ...show, seasonYears: [2023] }).level).toBe("rejected"); + }); + it("显式 ID 提升一级;标题不符但显式 ID 命中 → needs_confirm", () => { + const e = parseRelease("[tmdbid=872585] Oppenheimer 2024 1080p"); + const r = scoreCandidate(e, movie()); + expect(r.evidence).toContain("explicit_id"); + expect(r.level).toBe("rule_confirmed"); + const e2 = parseRelease("[tmdbid=872585] Something Else 2023 1080p"); + expect(scoreCandidate(e2, movie()).level).toBe("needs_confirm"); + const local = movie({ + provider: "local", + externalId: "uuid-1", + linkedExternalIds: [{ provider: "tmdb", id: "872585" }], + }); + expect(scoreCandidate(e2, local).evidence).toContain("explicit_id"); + }); +}); + +describe("排序与判定", () => { + const p = parseRelease("Oppenheimer.2023.1080p.BluRay.x264-CHD"); + it("本地实体同等级优先,等级 > 标题 > 年份", () => { + const tmdb = movie(); + const local = movie({ provider: "local", externalId: "uuid-1", localEntityId: "uuid-1" }); + const weak = movie({ externalId: "9", titles: ["Oppenheimer X"] }); + const ranked = rankCandidates(p, [weak, tmdb, local]); + expect(ranked.map((r) => r.candidate.externalId)).toEqual(["uuid-1", "872585", "9"]); + }); + it("唯一强候选 → auto", () => { + const res = resolveIdentity(p, [movie(), movie({ externalId: "2", titles: ["Barbie"] })]); + expect(res.status).toBe("auto"); + expect(res.best?.candidate.externalId).toBe("872585"); + }); + it("同等级两个不同身份 → ambiguous;同一身份(本地映射)不算冲突", () => { + const dup = resolveIdentity(p, [movie(), movie({ externalId: "999" })]); + expect(dup.status).toBe("ambiguous"); + expect(dup.reason).toBe("multiple_candidates_same_level"); + const local = movie({ + provider: "local", + externalId: "uuid-1", + localEntityId: "uuid-1", + linkedExternalIds: [{ provider: "tmdb", id: "872585" }], + }); + expect(resolveIdentity(p, [movie(), local]).status).toBe("auto"); + expect(resolveIdentity(p, [local, movie()]).status).toBe("auto"); + expect(resolveIdentity(p, [local, { ...local, externalId: "x" }]).status).toBe("auto"); + }); + it("弱证据 → ambiguous;无候选 → no_match", () => { + expect(resolveIdentity(p, [movie({ titles: ["Oppenheimer Returns"] })]).status).toBe("ambiguous"); + expect(resolveIdentity(p, []).status).toBe("no_match"); + expect(resolveIdentity(p, [movie({ titles: ["Barbie"] })]).status).toBe("no_match"); + }); +}); diff --git a/packages/domain/test/organize.test.ts b/packages/domain/test/organize.test.ts new file mode 100644 index 00000000..a5af70a5 --- /dev/null +++ b/packages/domain/test/organize.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { type OrganizeInput, planOrganize, sidecarLanguageSuffix, sidecarStem } from "../src"; + +const MiB = 1024 ** 2; +const GiB = 1024 ** 3; +const ROOT = "/downloads/tv/The.Last.of.Us.S01.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX"; +const tvInput = (over: Partial = {}): OrganizeInput => ({ + operation: "copy", + mediaType: "tv", + sourceRoot: ROOT, + files: [ + { path: `${ROOT}/The.Last.of.Us.S01E01.2160p.mkv`, size: 5 * GiB, device: 1 }, + { path: `${ROOT}/The.Last.of.Us.S01E02.2160p.mkv`, size: 5 * GiB, device: 1 }, + { path: `${ROOT}/The.Last.of.Us.S01E01.2160p.chs.srt`, size: 50_000, device: 1 }, + { path: `${ROOT}/The.Last.of.Us.S01E01.2160p.eng.default.srt`, size: 50_000, device: 1 }, + { path: `${ROOT}/Sample/the.last.of.us.s01e01.sample.mkv`, size: 80 * MiB, device: 1 }, + { path: `${ROOT}/The.Last.of.Us.S01.nfo`, size: 1000, device: 1 }, + { path: `${ROOT}/poster.jpg`, size: 1000, device: 1 }, + ], + entity: { title: "最后生还者", year: 2023 }, + target: { seasonDirs: { 1: "/media/tv/最后生还者 (2023)/Season 1" }, device: 2 }, + ...over, +}); + +describe("planOrganize 整季包", () => { + it("主视频 + 随行字幕,样例/nfo/图片排除,跨卷标记", () => { + const plan = planOrganize(tvInput()); + expect(plan.files.map((f) => [f.role, f.dst])).toEqual([ + ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.mkv"], + ["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E02.mkv"], + ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.chi.zh-cn.srt"], + ["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.eng.default.srt"], + ]); + expect(plan.files[0]).toMatchObject({ + kind: "video", + crossDevice: true, + season: 1, + episodes: { start: 1, end: 1 }, + }); + expect(plan.files[2]).toMatchObject({ kind: "subtitle", mainSrc: `${ROOT}/The.Last.of.Us.S01E01.2160p.mkv` }); + expect(plan.excluded.map((e) => [e.reason, e.kind])).toEqual([ + ["sample", "video"], + ["not_imported_kind", "nfo"], + ["not_imported_kind", "other"], + ]); + expect(plan.crossDevice).toBe(true); + expect(plan.requiresApproval).toBe(false); + expect(plan.preconditions.map((p) => p.kind)).toEqual([ + "free_space", + "target_absent", + "source_unchanged", + "verify_after_copy", + ]); + expect(plan.preconditions[0]).toEqual({ kind: "free_space", device: 2, bytes: 10 * GiB + 100_000 }); + }); + it("move 跨卷:不降级,只标记 crossDevice 让执行层 copy→verify→delete", () => { + const plan = planOrganize(tvInput({ operation: "move", retention: { hnrSatisfied: true } })); + expect(plan.operation).toBe("move"); + expect(plan.crossDevice).toBe(true); + expect(plan.preconditions.map((p) => p.kind)).toContain("hnr_satisfied"); + expect(plan.preconditions.map((p) => p.kind)).toContain("verify_after_copy"); + expect(plan.requiresApproval).toBe(false); + }); + it("move 同卷:无需空间/校验前置", () => { + const plan = planOrganize( + tvInput({ + operation: "move", + retention: { hnrSatisfied: true }, + target: { seasonDirs: { 1: "/m/S1" }, device: 1 }, + }), + ); + expect(plan.crossDevice).toBe(false); + expect(plan.preconditions.map((p) => p.kind)).toEqual(["hnr_satisfied", "target_absent", "source_unchanged"]); + }); + it("H&R 未满足/未知:move 变为冲突,不静默改 copy", () => { + const no = planOrganize(tvInput({ operation: "move", retention: { hnrSatisfied: false } })); + expect(no.operation).toBe("move"); + expect(no.conflicts).toContainEqual(expect.objectContaining({ kind: "hnr_unsatisfied", blocking: true })); + expect(no.requiresApproval).toBe(true); + const unk = planOrganize(tvInput({ operation: "move" })); + expect(unk.conflicts.map((c) => c.kind)).toContain("hnr_unknown"); + }); + it("缺集号:特典跳过,普通文件进待识别;季目录缺失", () => { + const plan = planOrganize( + tvInput({ + files: [ + { path: `${ROOT}/NCOP01.mkv`, size: GiB }, + { path: `${ROOT}/Random.Clip.mkv`, size: GiB }, + { path: `${ROOT}/The.Last.of.Us.S02E01.mkv`, size: GiB }, + ], + }), + ); + expect(plan.excluded.map((e) => e.reason)).toEqual(["special_extra", "no_episode", "no_target_dir"]); + expect(plan.conflicts).toContainEqual(expect.objectContaining({ kind: "no_main_video", blocking: true })); + }); + it("综艺按日期命名;单季目录兜底", () => { + const plan = planOrganize({ + operation: "copy", + mediaType: "tv", + sourceRoot: "/dl/奔跑吧 第十二季 1080p WEB-DL-HHWEB", + files: [ + { path: "/dl/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.2024.05.03.mp4", size: GiB }, + { path: "/dl/奔跑吧 第十二季 1080p WEB-DL-HHWEB/tiny.mp4", size: 10 }, + ], + entity: { title: "奔跑吧" }, + target: { seasonDirs: { 12: "/media/variety/奔跑吧/Season 12" } }, + }); + expect(plan.files[0]!.dst).toBe("/media/variety/奔跑吧/Season 12/奔跑吧 - 2024-05-03.mp4"); + expect(plan.files[0]!.airDate).toBe("20240503"); + expect(plan.excluded[0]!.reason).toBe("too_small"); + const noSeason = planOrganize({ + operation: "copy", + mediaType: "tv", + files: [{ path: "/dl/某综艺.20240503.mp4", size: GiB }], + entity: { title: "某综艺" }, + target: { seasonDirs: { 1: "/m/S1" } }, + }); + expect(noSeason.files[0]!.dst).toBe("/m/S1/某综艺 - 2024-05-03.mp4"); + }); + it("附加内容目录不入库(M2)", () => { + const plan = planOrganize({ + operation: "copy", + mediaType: "movie", + files: [{ path: "/dl/Oppenheimer.2023.1080p/Featurettes/Making.Of.mkv", size: GiB }], + entity: { title: "奥本海默", year: 2023 }, + target: { movieDir: "/media/movies/奥本海默 (2023)" }, + }); + expect(plan.excluded[0]!.reason).toBe("extra_content"); + }); +}); + +describe("planOrganize 电影与冲突", () => { + const MOVIE = "/downloads/movie/Oppenheimer.2023.1080p.BluRay.x264-SPARKS"; + const base = (over: Partial = {}): OrganizeInput => ({ + operation: "copy", + mediaType: "movie", + sourceRoot: MOVIE, + files: [ + { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.mkv`, size: 10 * GiB, sha256: "aa" }, + { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.cht.srt`, size: 10 }, + { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.繁体.srt`, size: 10 }, + { path: `${MOVIE}/other.srt`, size: 10 }, + ], + entity: { title: "奥本海默", year: 2023 }, + target: { movieDir: "/media/movies/奥本海默 (2023)/" }, + ...over, + }); + const DST = "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.mkv"; + it("电影命名、繁体字幕后缀、同名字幕去重、孤儿字幕不搬不删", () => { + const plan = planOrganize(base()); + expect(plan.files.map((f) => f.dst)).toEqual([ + DST, + "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.zh-tw.srt", + "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.zh-tw.1.srt", + ]); + expect(plan.excluded).toContainEqual( + expect.objectContaining({ reason: "orphan_sidecar", src: `${MOVIE}/other.srt` }), + ); + expect(plan.conflicts).toContainEqual(expect.objectContaining({ kind: "orphan_sidecar", blocking: false })); + expect(plan.crossDevice).toBe(false); + expect(plan.files[0]!.crossDevice).toBeUndefined(); + }); + it("目标存在:默认 conflict(hold + 需审批)", () => { + const plan = planOrganize(base({ existingTargets: [{ path: DST, size: 9 * GiB }] })); + expect(plan.files[0]!.action).toBe("hold"); + expect(plan.files[1]!.action).toBe("hold"); + expect(plan.requiresApproval).toBe(true); + }); + it("目标存在:skip 策略记录待处理但不阻塞;附属文件随主视频跳过", () => { + const plan = planOrganize(base({ onExisting: "skip", existingTargets: [{ path: DST, size: 9 * GiB }] })); + expect(plan.files[0]).toMatchObject({ action: "skip", skipReason: "target_exists" }); + expect(plan.files[1]).toMatchObject({ action: "skip", skipReason: "main_not_transferred" }); + expect(plan.requiresApproval).toBe(false); + expect(plan.preconditions).toEqual([]); + }); + it("目标存在且内容相同 → 已在库,不是冲突", () => { + const plan = planOrganize(base({ existingTargets: [{ path: DST, size: 10 * GiB, sha256: "aa" }] })); + expect(plan.files[0]).toMatchObject({ action: "skip", skipReason: "already_in_library" }); + expect(plan.conflicts.filter((c) => c.blocking)).toEqual([]); + }); + it("replace_with_verify:需授权,旧文件先隔离", () => { + const opts = { onExisting: "replace_with_verify" as const, existingTargets: [{ path: DST, size: 9 * GiB }] }; + const noGrant = planOrganize(base(opts)); + expect(noGrant.files[0]!.action).toBe("replace"); + expect(noGrant.conflicts).toContainEqual( + expect.objectContaining({ kind: "replace_requires_grant", blocking: true }), + ); + const granted = planOrganize(base({ ...opts, grants: { replace: true } })); + expect(granted.requiresApproval).toBe(false); + expect(granted.preconditions).toContainEqual({ kind: "quarantine_replaced", paths: [DST] }); + }); + it("计划内两个源写同一目标 → duplicate_target", () => { + const plan = planOrganize( + base({ + files: [ + { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.mkv`, size: 10 * GiB }, + { path: `${MOVIE}/Oppenheimer.2023.1080p.BluRay.x264-SPARKS.v2.mkv`, size: 10 * GiB }, + ], + }), + ); + expect(plan.conflicts[0]).toMatchObject({ kind: "duplicate_target", blocking: true, dst: DST }); + expect(plan.files.every((f) => f.action === "hold")).toBe(true); + }); + it("rename:库内改名,无空间与校验前置;电影缺目标目录 / 命名失败", () => { + const plan = planOrganize(base({ operation: "rename", files: base().files.slice(0, 1) })); + expect(plan.preconditions.map((p) => p.kind)).toEqual(["target_absent", "source_unchanged"]); + const noDir = planOrganize(base({ target: {} })); + expect(noDir.excluded[0]!.reason).toBe("no_target_dir"); + const noName = planOrganize(base({ entity: { title: "" } })); + expect(noName.excluded[0]).toMatchObject({ reason: "naming_failed", detail: "title" }); + }); + it("两个同名主视频(不同目录)时字幕只配同目录的", () => { + const plan = planOrganize( + base({ + sourceRoot: undefined, + files: [ + { path: "/a/Movie.2020.mkv", size: GiB }, + { path: "/b/Movie.2020.mkv", size: GiB }, + { path: "/a/Movie.2020.srt", size: 1 }, + ], + }), + ); + const sub = plan.files.find((f) => f.kind === "subtitle"); + expect(sub?.mainSrc).toBe("/a/Movie.2020.mkv"); + }); +}); + +describe("附属文件主干名", () => { + it("逐级剥离语言标签", () => { + expect(sidecarStem("/x/Show.S01E03.chs.default.srt")).toEqual({ stem: "show.s01e03", tags: ["chs", "default"] }); + expect(sidecarStem("Show.S01E03.mkv.srt").stem).toBe("show.s01e03.mkv"); + expect(sidecarStem("简体.srt")).toEqual({ stem: "简体", tags: [] }); + expect(sidecarLanguageSuffix(["jpn"])).toBe(".ja"); + expect(sidecarLanguageSuffix(["kor", "forced"])).toBe(".ko.forced"); + expect(sidecarLanguageSuffix(["简体", "繁体"])).toBe(".zh-tw"); + expect(sidecarLanguageSuffix([])).toBe(""); + }); +}); diff --git a/packages/domain/test/path.test.ts b/packages/domain/test/path.test.ts new file mode 100644 index 00000000..9c863f98 --- /dev/null +++ b/packages/domain/test/path.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "vitest"; +import { + buildNamingContext, + compileTemplate, + DEFAULT_TEMPLATES, + detectPathConflicts, + type PathBinding, + parseRelease, + renderMediaFileName, + renderTemplate, + resolveTargetDirectory, + sanitizeFileName, + sanitizeSegment, + TemplateError, + toDownloaderPath, + toHostPath, + utf8Length, + validatePathMappings, + validateTemplateSet, +} from "../src"; + +const root = { id: "lib-tv", hostPath: "/media/tv" }; +const binding = (over: Partial): PathBinding => ({ + id: "b1", + entityId: "e1", + scope: "entity", + libraryRootId: "lib-tv", + relativePath: "最后生还者 (2023)", + templateVersion: 1, + ...over, +}); + +describe("路径绑定", () => { + it("已有作品/季绑定优先且不随译名变化", () => { + const r = resolveTargetDirectory({ + entity: { id: "e1", kind: "tv" }, + naming: { title: "最后的生还者", year: 2023 }, + season: 1, + bindings: [binding({}), binding({ id: "b2", scope: "season", season: 1, relativePath: "最后生还者 (2023)/S1" })], + libraryRoot: root, + templates: DEFAULT_TEMPLATES, + }); + expect(r).toEqual({ + kind: "existing", + libraryRootId: "lib-tv", + relativePath: "最后生还者 (2023)/S1", + bindingIds: ["b2"], + }); + }); + it("作品已绑定、新季:只按模板渲染季目录,作品目录复用", () => { + const r = resolveTargetDirectory({ + entity: { id: "e1", kind: "tv" }, + naming: { title: "新译名", year: 2023 }, + season: 2, + bindings: [binding({})], + libraryRoot: { id: "other", hostPath: "/x" }, + templates: DEFAULT_TEMPLATES, + }); + expect(r).toMatchObject({ + kind: "new", + libraryRootId: "lib-tv", + relativePath: "最后生还者 (2023)/Season 2", + reusedBindingIds: ["b1"], + newBindings: [{ scope: "season", season: 2, templateVersion: 1 }], + }); + }); + it("首次:作品 + 季两级绑定,非法字符转全角", () => { + const r = resolveTargetDirectory({ + entity: { id: "e9", kind: "tv" }, + naming: { title: "Show: Part/2" }, + season: 1, + bindings: [binding({})], + libraryRoot: root, + templates: DEFAULT_TEMPLATES, + }); + expect(r).toMatchObject({ kind: "new", relativePath: "Show: Part/2/Season 1" }); + if (r.kind === "new") expect(r.newBindings).toHaveLength(2); + }); + it("电影:已有 / 首次 / 模板缺变量", () => { + const mv = { entity: { id: "m1", kind: "movie" as const }, libraryRoot: root, templates: DEFAULT_TEMPLATES }; + expect( + resolveTargetDirectory({ + ...mv, + naming: { title: "X" }, + bindings: [binding({ entityId: "m1", relativePath: "沙丘2 (2024)" })], + }), + ).toMatchObject({ kind: "existing", relativePath: "沙丘2 (2024)" }); + expect( + resolveTargetDirectory({ ...mv, naming: { title: "沙丘:第二部", year: 2024 }, bindings: [] }), + ).toMatchObject({ + kind: "new", + relativePath: "沙丘:第二部 (2024)", + }); + expect(resolveTargetDirectory({ ...mv, naming: {}, bindings: [] })).toEqual({ + kind: "error", + reason: "template_missing_vars", + missing: ["title"], + }); + }); + it("两个历史目录 → conflict,不自动合并;停用绑定忽略", () => { + const two = [binding({}), binding({ id: "b3", relativePath: "The Last of Us (2023)" })]; + expect( + resolveTargetDirectory({ + entity: { id: "e1", kind: "tv" }, + naming: { title: "x" }, + season: 1, + bindings: two, + libraryRoot: root, + templates: DEFAULT_TEMPLATES, + }), + ).toMatchObject({ kind: "conflict", reason: "multiple_entity_bindings" }); + const inactive = [binding({}), binding({ id: "b3", relativePath: "Old", active: false })]; + expect( + resolveTargetDirectory({ + entity: { id: "e1", kind: "tv" }, + naming: { title: "x" }, + season: 1, + bindings: inactive, + libraryRoot: root, + templates: DEFAULT_TEMPLATES, + }).kind, + ).toBe("new"); + const seasons = [ + binding({ id: "s1", scope: "season", season: 1, relativePath: "A/S1" }), + binding({ id: "s2", scope: "season", season: 1, relativePath: "A/Season 1" }), + ]; + expect( + resolveTargetDirectory({ + entity: { id: "e1", kind: "tv" }, + naming: { title: "x" }, + season: 1, + bindings: seasons, + libraryRoot: root, + templates: DEFAULT_TEMPLATES, + }), + ).toMatchObject({ kind: "conflict", reason: "multiple_season_bindings" }); + }); + it("剧集缺季号 / 模板缺变量", () => { + const tv = { entity: { id: "e5", kind: "tv" as const }, bindings: [], libraryRoot: root }; + expect(resolveTargetDirectory({ ...tv, naming: { title: "x" }, templates: DEFAULT_TEMPLATES })).toEqual({ + kind: "error", + reason: "season_required", + }); + expect(resolveTargetDirectory({ ...tv, naming: {}, season: 1, templates: DEFAULT_TEMPLATES })).toMatchObject({ + kind: "error", + missing: ["title"], + }); + expect( + resolveTargetDirectory({ + ...tv, + naming: { title: "x" }, + season: 1, + templates: { ...DEFAULT_TEMPLATES, seasonDir: "S{episode}" }, + }), + ).toMatchObject({ kind: "error", missing: ["episode"] }); + }); +}); + +describe("命名模板", () => { + it("电影 / 剧集 / 多集区间 / 综艺日期", () => { + const movie = buildNamingContext( + { title: "沙丘2", year: 2024 }, + parseRelease("Dune.Part.Two.2024.2160p.UHD.BluRay.x265-CHD"), + ); + expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, movie, "MKV")).toEqual({ + ok: true, + fileName: "沙丘2 (2024) - 2160p.mkv", + }); + const cut = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay")); + expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, cut, "mkv")).toMatchObject({ + fileName: "指环王 (2001) - Extended - 1080p.mkv", + }); + const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p")); + expect(ep.seasonEpisode).toBe("S02E01-E03"); + expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({ + fileName: "庆余年 - S02E01-E03.mkv", + }); + const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p")); + expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, daily, "mp4")).toMatchObject({ + fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4", + }); + expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, { title: "x" }, "mp4")).toEqual({ + ok: false, + missing: ["episode_or_air_date"], + }); + expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toEqual({ ok: false, missing: ["title"] }); + }); + it("上下文完整字段", () => { + const ctx = buildNamingContext( + { title: "T", originalTitle: "O", year: 2020 }, + parseRelease("T.2020.S01E05.CD2.2160p.WEB-DL.DV.HEVC-GRP"), + ); + expect(ctx).toMatchObject({ + originalTitle: "O", + episode: 5, + part: "CD2", + source: "WEB-DL", + videoCodec: "H.265", + hdr: "DV", + releaseGroup: "GRP", + }); + expect(buildNamingContext({ title: "T" })).toEqual({ title: "T" }); + }); + it("格式化与可选片段", () => { + expect(renderTemplate("S{season:02}E{episode:03}", { season: 1, episode: 5 })).toEqual({ + ok: true, + value: "S01E005", + }); + expect(renderTemplate("{airDate:dot}", { airDate: "20240101" })).toEqual({ ok: true, value: "2024.01.01" }); + expect(renderTemplate("{title:zz}", { title: "a" })).toEqual({ ok: true, value: "a" }); + expect(renderTemplate("A[[ - {part}]] B", { part: "" })).toEqual({ ok: true, value: "A B" }); + expect(renderTemplate("{title}/{year}", { title: "a/b" })).toEqual({ ok: false, missing: ["year"] }); + expect(renderTemplate(compileTemplate("{title}"), { title: "a/b" })).toEqual({ ok: true, value: "a/b" }); + expect(compileTemplate("{title}[[ ({year})]]").vars).toEqual(["title", "year"]); + }); + it("模板校验:白名单、括号", () => { + for (const bad of ["{evil}", "{title", "a]]", "[[a", "[[ [[x]] ]]", "x".repeat(600)]) + expect(() => compileTemplate(bad), bad).toThrow(TemplateError); + expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow(); + expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, dailyFile: "{constructor}" })).toThrow(TemplateError); + }); +}); + +describe("非法字符处理", () => { + it("转全角、去控制字符、首尾点空白、保留名、空串", () => { + expect(sanitizeSegment('A:B*C?"DF|G\\H/I')).toBe("A:B*C?"D<E>F|G\H/I"); + expect(sanitizeSegment(" ..hidden. ")).toBe("hidden"); + expect(sanitizeSegment("a\u0000b\u001f\u007fc")).toBe("abc"); + expect(sanitizeSegment("CON")).toBe("CON_"); + expect(sanitizeSegment("...")).toBe("_"); + expect(sanitizeSegment("a b")).toBe("a b"); + }); + it("UTF-8 字节截断,扩展名保留", () => { + const long = "长".repeat(200); + const s = sanitizeSegment(long); + expect(utf8Length(s)).toBeLessThanOrEqual(255); + const f = sanitizeFileName(long, ".MKV"); + expect(f.endsWith(".mkv")).toBe(true); + expect(utf8Length(f)).toBeLessThanOrEqual(255); + expect(sanitizeFileName("a", "")).toBe("a"); + }); +}); + +describe("冲突检测", () => { + it("计划内重复 / 已存在 / 大小写碰撞", () => { + const out = detectPathConflicts( + [ + { src: "1", dst: "/m/A.mkv" }, + { src: "2", dst: "/m/A.mkv" }, + { src: "3", dst: "/m/b.mkv" }, + { src: "4", dst: "/m/C.mkv" }, + { src: "5", dst: "/m/c.mkv" }, + ], + [{ path: "/m/A.mkv", size: 1 }, { path: "/m/B.mkv" }], + { caseInsensitive: true }, + ); + expect(out.map((c) => `${c.kind}:${c.dst}`)).toEqual([ + "duplicate_in_plan:/m/A.mkv", + "exists:/m/A.mkv", + "case_collision:/m/b.mkv", + "case_collision:/m/c.mkv", + ]); + expect(detectPathConflicts([{ src: "3", dst: "/m/b.mkv" }], [{ path: "/m/B.mkv" }])).toEqual([]); + }); +}); + +describe("下载器 ↔ 宿主路径映射", () => { + const maps = [ + { instanceId: "qb1", downloaderPath: "/downloads", hostPath: "/mnt/cache/qb1" }, + { instanceId: "qb1", downloaderPath: "/downloads/tv", hostPath: "/mnt/array/tv-dl" }, + { instanceId: "qb2", downloaderPath: "D:\\Downloads\\", hostPath: "/mnt/win" }, + { instanceId: "qb3", downloaderPath: "/", hostPath: "/srv/qb3" }, + ]; + it("最长前缀、按段边界、多实例", () => { + expect(toHostPath("/downloads/movie/a.mkv", "qb1", maps)).toMatchObject({ + ok: true, + path: "/mnt/cache/qb1/movie/a.mkv", + }); + expect(toHostPath("/downloads/tv/s/a.mkv", "qb1", maps)).toMatchObject({ + ok: true, + path: "/mnt/array/tv-dl/s/a.mkv", + }); + expect(toHostPath("/downloads2/a.mkv", "qb1", maps)).toEqual({ ok: false, reason: "no_mapping" }); + expect(toHostPath("/downloads", "qb1", maps)).toMatchObject({ ok: true, path: "/mnt/cache/qb1" }); + expect(toHostPath("D:\\Downloads\\x\\a.mkv", "qb2", maps)).toMatchObject({ ok: true, path: "/mnt/win/x/a.mkv" }); + expect(toHostPath("/any/a.mkv", "qb3", maps)).toMatchObject({ ok: true, path: "/srv/qb3/any/a.mkv" }); + expect(toHostPath("/downloads/a", "nope", maps)).toEqual({ ok: false, reason: "no_mapping" }); + expect(toDownloaderPath("/mnt/cache/qb1/movie/a.mkv", "qb1", maps)).toMatchObject({ + ok: true, + path: "/downloads/movie/a.mkv", + }); + expect(toDownloaderPath("/srv/qb3/a", "qb3", maps)).toMatchObject({ ok: true, path: "/a" }); + }); + it("同前缀多目标 → ambiguous / 配置校验", () => { + const bad = [ + { instanceId: "q", downloaderPath: "/d", hostPath: "/a" }, + { instanceId: "q", downloaderPath: "/d/", hostPath: "/b" }, + ]; + expect(toHostPath("/d/x", "q", bad)).toEqual({ ok: false, reason: "ambiguous" }); + expect(validatePathMappings(bad)).toHaveLength(1); + expect(validatePathMappings(maps)).toEqual([]); + }); +}); diff --git a/packages/domain/test/release-parser.test.ts b/packages/domain/test/release-parser.test.ts new file mode 100644 index 00000000..0aa98470 --- /dev/null +++ b/packages/domain/test/release-parser.test.ts @@ -0,0 +1,650 @@ +import { describe, expect, it } from "vitest"; +import { + cnToInt, + evalEpisodeOffset, + expandEpisodes, + fileKindOf, + OffsetExpressionError, + type ParsedRelease, + parseRelease, + parseReleasePath, + splitReleasePath, +} from "../src/release-parser"; + +type Expect = Partial & { group?: string; groupSource?: string }; +interface Sample { + id: string; + input: string; + subtitle?: string; + expect: Expect; + /** 与 MoviePilot 行为不同的地方,写明原因 */ + deviation?: string; +} + +function check(r: ParsedRelease, e: Expect) { + const { group, groupSource, ...rest } = e; + for (const [k, v] of Object.entries(rest)) { + expect(r[k as keyof ParsedRelease], `${r.raw} → ${k}`).toEqual(v); + } + if (group !== undefined) expect(r.releaseGroup?.name, `${r.raw} → group`).toBe(group); + if (groupSource !== undefined) expect(r.releaseGroup?.source).toBe(groupSource); +} + +// M-xx 编号对应 docs/porting/moviepilot-metadata-recognition.md §9.1 +const TITLES: Sample[] = [ + { + id: "M-01", + input: "Oppenheimer.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR", + expect: { + titleEn: "Oppenheimer", + year: 2023, + resolution: "2160p", + source: "REMUX", + isEncode: false, + videoCodec: "H.265", + hdr: ["DV", "HDR10"], + audio: ["TrueHD 7.1", "Atmos"], + group: "FraMeSToR", + confidence: "rule_confirmed", + }, + deviation: "MP 发布组表无 FraMeSToR 时 grp=None;本项目种子表收录", + }, + { + id: "M-02", + input: "奥本海默 Oppenheimer 2023 2160p UHD Blu-ray HEVC DV TrueHD 7.1-MTeam", + expect: { + title: "奥本海默", + titleCn: "奥本海默", + titleEn: "Oppenheimer", + source: "BluRay", + isEncode: true, + uhd: true, + hdr: ["DV"], + group: "MTeam", + groupSource: "known", + }, + }, + { + id: "M-03", + input: "The.Last.of.Us.S01.2023.1080p.WEB-DL.H264.DDP5.1.Atmos-ADWeb", + expect: { + titleEn: "The Last of Us", + season: 1, + episodes: undefined, + year: 2023, + audio: ["DDP 5.1", "Atmos"], + group: "ADWeb", + isSeasonPack: true, + packHint: "season_pack", + }, + }, + { + id: "M-04", + input: "The.Last.of.Us.S01E03.Long.Long.Time.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX", + expect: { + titleEn: "The Last of Us", + season: 1, + episodes: { start: 3, end: 3 }, + webSource: "Max", + videoCodec: "H.265", + hdr: ["DV", "HDR10"], + packHint: "single", + }, + }, + { + id: "M-05", + input: "Breaking.Bad.S01-S05.Complete.1080p.BluRay.x264-ROVERS", + expect: { season: 1, seasonEnd: 5, packHint: "multi_season_pack", isSeasonPack: true, encoder: "x264" }, + }, + { + id: "M-06", + input: "庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 60fps DDP5.1-HHWEB", + expect: { titleCn: "庆余年", titleEn: "Joy of Life", season: 2, fps: 60, group: "HHWEB" }, + }, + { + id: "M-07", + input: "庆余年.Joy.of.Life.S02E01-E06.2024.2160p.WEB-DL.H265.AAC-ADWeb", + expect: { season: 2, episodes: { start: 1, end: 6 }, packHint: "multi_episode", isSeasonPack: false }, + }, + { + id: "M-08", + input: + "The.Lord.of.the.Rings.The.Fellowship.of.the.Ring.2001.Extended.Edition.2160p.UHD.BluRay.x265.10bit.HDR.DTS-HD.MA.6.1-CHD", + expect: { + titleEn: "The Lord of the Rings The Fellowship of the Ring", + cut: "Extended", + bitDepth: 10, + audio: ["DTS-HD MA 6.1"], + group: "CHD", + }, + deviation: "MP 把 Extended.Edition 当干扰词删除;本项目保留为 cut", + }, + { + id: "M-09", + input: "Kill.Bill.Vol.1.2003.Part.1.1080p.BluRay.x264-CHD", + expect: { titleEn: "Kill Bill Vol 1", year: 2003, part: "Part1" }, + }, + { + id: "M-10", + input: "Avatar.The.Way.of.Water.2022.2160p.WEB-DL.DDP5.1.Atmos.HDR10Plus.HEVC-CMCT", + expect: { hdr: ["HDR10+"], group: "CMCT", source: "WEB-DL" }, + }, + { + id: "M-11", + input: "奔跑吧 第十二季 20240426 第1期 1080p WEB-DL H264 AAC-HHWEB", + expect: { + titleCn: "奔跑吧", + season: 12, + issue: 1, + airDate: "20240426", + episodes: undefined, + packHint: "single", + mediaTypeHint: "tv", + }, + deviation: "MP 把第1期当 episode、忽略 20240426;本项目 issue/airDate 分开保存", + }, + { + id: "M-12", + input: "奔跑吧.Keep.Running.S12.2024.04.26.1080p.WEB-DL.H264.AAC-HHWEB", + expect: { season: 12, airDate: "20240426", year: undefined, isSeasonPack: false, packHint: "single" }, + deviation: "MP 删除日期后判成整季包;本项目有日期即单期", + }, + { + id: "M-13", + input: "向往的生活 第八季 全12期 2024 1080p WEB-DL", + expect: { titleCn: "向往的生活", season: 8, totalEpisodes: 12, year: 2024, isSeasonPack: true }, + deviation: "MP 负向前瞻导致季号丢失", + }, + { + id: "M-14", + input: "[喵萌奶茶屋&LoliHouse] 葬送的芙莉莲 / Sousou no Frieren - 12 [WebRip 1080p HEVC-10bit AAC][简繁日内封字幕]", + expect: { + titleCn: "葬送的芙莉莲", + titleEn: "Sousou no Frieren", + episodes: { start: 12, end: 12 }, + season: 1, + seasonInferred: true, + group: "喵萌奶茶屋@LoliHouse", + bitDepth: 10, + source: "WEBRip", + subtitles: ["zh-Hans", "zh-Hant", "ja"], + confidence: "multi_evidence", + }, + deviation: "MP 季号为空;本项目推定 1 并标 seasonInferred", + }, + { + id: "M-15", + input: "Frieren.S01E12.1080p.NF.WEB-DL.DDP2.0.H.264-VARYG", + expect: { webSource: "Netflix", audio: ["DDP 2.0"], group: "VARYG", videoCodec: "H.264" }, + }, + { + id: "M-16", + input: "黑暗荣耀 The Glory 2022 S01 Complete 1080p NF WEB-DL x264 DDP5.1-HHWEB", + subtitle: "黑暗荣耀 第一季 全8集 宋慧乔", + expect: { season: 1, totalEpisodes: 8, isSeasonPack: true, confidence: "multi_evidence" }, + }, + { + id: "M-17", + input: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-MTeam", + expect: { titleEn: "Dune Part Two", part: undefined, audio: ["Atmos"], source: "REMUX" }, + }, + { + id: "M-18", + input: "Some.Show.S02E05E06.1080p.WEB-DL-GRP", + expect: { season: 2, episodes: { start: 5, end: 6 }, group: "GRP", groupSource: "suffix" }, + }, + { + id: "M-19", + input: "狂飙 The Knockout 2023 全39集 1080p WEB-DL H264 AAC-OurTV", + expect: { season: 1, seasonInferred: true, totalEpisodes: 39, packHint: "season_pack" }, + }, + { + id: "M-20", + input: "Some.Show.2019.1080p.WEB-DL-GRP", + subtitle: "第1-10集 全10集", + expect: { episodes: { start: 1, end: 10 }, totalEpisodes: 10 }, + }, + { + id: "M-21", + input: "[Nekomoe kissaten][Oshi no Ko][01-11 Fin][1080p]", + expect: { titleEn: "Oshi no Ko", episodes: { start: 1, end: 11 }, season: 1, group: "Nekomoe kissaten" }, + }, + { id: "M-22", input: "Show.Name.2019-2020.1080p.WEB-DL", expect: { titleEn: "Show Name", year: 2019 } }, + // ---- 国际/观众/馒头风格电影 + { + id: "X-01", + input: "Blade.Runner.2049.2017.1080p.BluRay.x264-SPARKS", + expect: { titleEn: "Blade Runner 2049", year: 2017, isEncode: true }, + }, + { + id: "X-02", + input: "Dune.Part.Two.2024.1080p.BluRay.x264.DTS-HD.MA.7.1-ADE", + expect: { group: "ADE", audio: ["DTS-HD MA 7.1"], isEncode: true }, + }, + { + id: "X-03", + input: "流浪地球2 The Wandering Earth II 2023 2160p UHD BluRay REMUX HEVC DV TrueHD 7.1 Atmos-MTeam", + expect: { titleCn: "流浪地球2", titleEn: "The Wandering Earth II", source: "REMUX" }, + }, + { + id: "X-04", + input: "Interstellar.2014.IMAX.1080p.BluRay.x264.DTS-HD.MA.5.1-HDS", + expect: { cut: "IMAX", group: "HDS", titleEn: "Interstellar" }, + }, + { + id: "X-05", + input: "Blade.Runner.1982.The.Final.Cut.2160p.UHD.BluRay.x265.10bit.HDR.DTS-HD.MA.5.1-CiNEPHiLES", + expect: { cut: "Final Cut", titleEn: "Blade Runner", year: 1982 }, + }, + { + id: "X-06", + input: "Apocalypse.Now.1979.Theatrical.Cut.1080p.BluRay.x264-AMIABLE", + expect: { cut: "Theatrical", group: "AMIABLE", groupSource: "suffix" }, + deviation: "未知组 MP 为 None;本项目末段兜底", + }, + { + id: "X-07", + input: "Oppenheimer.2023.1080p.BluRay.DD+5.1.x264-EbP", + expect: { audio: ["DDP 5.1"], group: "EbP" }, + }, + { + id: "X-08", + input: "Oppenheimer.2023.COMPLETE.UHD.BLURAY-SURCODE", + expect: { source: "BluRay-Disc", isEncode: false, group: "SURCODE" }, + }, + { + id: "X-09", + input: "Some.Movie.2021.BD50.1080p.AVC.DTS-HD.MA.5.1", + expect: { source: "BluRay-Disc", isEncode: false, videoCodec: "H.264" }, + }, + { id: "X-10", input: "Some.Movie.2021.1080p.HDTV.x264-GRP", expect: { source: "HDTV" } }, + { + id: "X-11", + input: "Some.Movie.2021.720p.WEBRip.x264.AAC-YTS", + expect: { source: "WEBRip", resolution: "720p", audio: ["AAC"] }, + }, + { id: "X-12", input: "Some.Movie.2021.DVDRip.XviD-GRP", expect: { source: "DVDRip", videoCodec: "XviD" } }, + { id: "X-13", input: "Movie.2020.3840x2160.WEB-DL.HEVC", expect: { resolution: "2160p", titleEn: "Movie" } }, + { id: "X-14", input: "Movie.2020.4K.WEB-DL.HEVC-GRP", expect: { resolution: "2160p" } }, + { + id: "X-15", + input: "Mission.Impossible.Dead.Reckoning.Part.One.2023.2160p.UHD.BluRay.x265.10bit.HDR.TrueHD.7.1.Atmos-MTeam", + expect: { titleEn: "Mission Impossible Dead Reckoning Part One", part: undefined, audio: ["TrueHD 7.1", "Atmos"] }, + }, + { + id: "X-16", + input: "Harry.Potter.and.the.Deathly.Hallows.Part.2.2011.1080p.BluRay.x264-SPARKS", + expect: { titleEn: "Harry Potter and the Deathly Hallows Part 2", year: 2011 }, + }, + { + id: "X-17", + input: "Movie.Title.2021.1080p.BluRay.Remux.AVC.DTS-HD.MA.5.1-FGT", + expect: { source: "REMUX", isEncode: false }, + }, + { id: "X-18", input: "The.Matrix.1999.Remastered.1080p.BluRay.x264-GRP", expect: { cut: "Remastered" } }, + { id: "X-19", input: "Movie.2020.Unrated.1080p.BluRay.x264-GRP", expect: { cut: "Unrated" } }, + { + id: "X-20", + input: "Movie.Name.2019.Directors.Cut.1080p.BluRay.x264-FOO", + expect: { cut: "Director's Cut", titleEn: "Movie Name" }, + }, + { + id: "X-21", + input: "银翼杀手 导演剪辑版 1982 1080p BluRay x264", + expect: { titleCn: "银翼杀手", cut: "Director's Cut", year: 1982 }, + }, + { + id: "X-22", + input: "让子弹飞.Let.the.Bullets.Fly.2010.1080p.BluRay.x264.DTS-CMCT", + expect: { titleCn: "让子弹飞", titleEn: "Let the Bullets Fly", audio: ["DTS"], group: "CMCT" }, + }, + { + id: "X-23", + input: "长安三万里.Chang.An.2023.2160p.WEB-DL.H265.HDR.DDP5.1-OurTV", + expect: { titleCn: "长安三万里", hdr: ["HDR10"], group: "OurTV" }, + }, + { + id: "X-24", + input: "Movie.2020.1080p.BluRay.x264-GRP 12.5GB", + expect: { sizeBytes: Math.round(12.5 * 1024 ** 3), group: "GRP" }, + }, + { id: "X-25", input: "Movie 2020 1080p WEB-DL 中英字幕", expect: { subtitles: ["zh", "en"] } }, + { id: "X-26", input: "Movie 2020 1080p 国语中字 WEB-DL", expect: { subtitles: ["zh"], titleEn: "Movie" } }, + { + id: "X-27", + input: "[简繁英字幕] Movie 2020 1080p WEB-DL", + expect: { subtitles: ["zh-Hans", "zh-Hant", "en"], releaseGroup: undefined }, + }, + { id: "X-28", input: "The Movie 2021", expect: { titleEn: "The Movie", year: 2021, confidence: "rule_confirmed" } }, + { id: "X-29", input: "The Movie 1080p WEB-DL", expect: { year: undefined, confidence: "needs_confirm" } }, + // ---- 剧集 + { + id: "T-01", + input: "三体 Three-Body S01E05 2023 1080p WEB-DL H264 AAC-OurTV", + expect: { titleCn: "三体", titleEn: "Three Body", season: 1, episodes: { start: 5, end: 5 } }, + }, + { + id: "T-02", + input: "Loki.S02E01.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX", + expect: { webSource: "Disney+", hdr: ["DV", "HDR10"] }, + }, + { + id: "T-03", + input: "The.Bear.S03.1080p.DSNP.WEB-DL.DDP5.1.H.264-MWeb", + expect: { season: 3, isSeasonPack: true, group: "MWeb" }, + }, + { + id: "T-04", + input: "Beyond.the.Clouds.2024.S01.1080p.WEB-DL.H264.AAC-ADWeb", + expect: { titleEn: "Beyond the Clouds", year: 2024, season: 1 }, + }, + { + id: "T-05", + input: "The.Office.US.S05E13-E14.720p.HDTV.x264", + expect: { titleEn: "The Office US", episodes: { start: 13, end: 14 }, source: "HDTV" }, + }, + { id: "T-06", input: "Friends.1x05.720p", expect: { season: 1, episodes: { start: 5, end: 5 } } }, + { id: "T-07", input: "Show.Season.2.1080p.BluRay.x264-GRP", expect: { season: 2, isSeasonPack: true } }, + { + id: "T-08", + input: "Show.S01E01.Pilot.1080p.WEB-DL.mkv", + expect: { isFile: true, fileKind: "video", extension: "mkv", titleEn: "Show", packHint: "single" }, + }, + { id: "T-09", input: "Show.S01E01.chs.srt", expect: { fileKind: "subtitle", subtitles: ["zh-Hans"] } }, + { id: "T-10", input: "Show.S01E01E02E03.1080p.WEB-DL", expect: { episodes: { start: 1, end: 3 } } }, + { id: "T-11", input: "Show.Name.S01.E01.1080p", expect: { season: 1, episodes: { start: 1, end: 1 } } }, + { id: "T-12", input: "Show.S01D02.1080p.BluRay", expect: { season: 1, part: "Disc2" } }, + { + id: "T-13", + input: "Planet.Earth.III.S01.2160p.iP.WEB-DL.AAC2.0.HLG.H.265-GRP", + expect: { titleEn: "Planet Earth III", hdr: ["HLG"], audio: ["AAC 2.0"] }, + }, + { id: "T-14", input: "天下长河 第1集 1080p WEB-DL", expect: { titleCn: "天下长河", segment: undefined } }, + { + id: "T-15", + input: "庆余年 第二季 第1集 范闲入京 1080p WEB-DL", + expect: { titleCn: "庆余年", season: 2, episodes: { start: 1, end: 1 } }, + }, + { + id: "T-16", + input: "[tmdbid=94997] House.of.the.Dragon.S01E03.1080p.mkv", + expect: { explicitIds: [{ provider: "tmdb", id: "94997" }], season: 1, titleEn: "House of the Dragon" }, + }, + { id: "T-17", input: "[tmdbid=0]Foo.2020", expect: { explicitIds: [], titleEn: "Foo" } }, + // ---- 动漫 + { + id: "A-01", + input: "[ANi] 葬送的芙莉蓮 - 13 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4", + expect: { group: "ANi", episodes: { start: 13, end: 13 }, subtitles: ["zh-Hant"], titleCn: "葬送的芙莉蓮" }, + }, + { + id: "A-02", + input: "[Lilith-Raws] Sousou no Frieren - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]", + expect: { group: "Lilith-Raws", titleEn: "Sousou no Frieren", episodes: { start: 1, end: 1 } }, + }, + { + id: "A-03", + input: "[UnknownSub][Some Anime][05][1080p]", + expect: { group: "UnknownSub", groupSource: "bracket", episodes: { start: 5, end: 5 }, titleEn: "Some Anime" }, + }, + { + id: "A-04", + input: "[Sub] Show - 26 Fin [1080p]", + expect: { episodes: { start: 26, end: 26 } }, + }, + { id: "A-05", input: "Show 01-26 Fin 1080p", expect: { episodes: { start: 1, end: 26 }, isSeasonPack: true } }, + // ---- 综艺 + { + id: "V-01", + input: "快乐大本营 20210903 1080p WEB-DL H264 AAC-HHWEB", + expect: { airDate: "20210903", season: undefined, confidence: "needs_confirm", packHint: "single" }, + }, + { + id: "V-02", + input: "乘风2024 第5期 下 20240524 1080p WEB-DL", + expect: { titleCn: "乘风2024", issue: 5, segment: "lower", airDate: "20240524" }, + }, + { + id: "V-03", + input: "披荆斩棘 第三季 第12期 加更版 1080p WEB-DL-HHWEB", + expect: { season: 3, issue: 12, contentType: "extra", titleCn: "披荆斩棘" }, + }, + { + id: "V-04", + input: "歌手2024 第1期 会员版 1080p", + expect: { variantTags: ["会员版"], confidence: "needs_confirm", issue: 1 }, + }, + { + id: "V-05", + input: "声生不息 纯享版 20220501 1080p WEB-DL", + expect: { contentType: "performance_only", airDate: "20220501" }, + }, + { + id: "V-06", + input: "The.Voice.2024.05.03.1080p.WEB.h264-GRP", + expect: { titleEn: "The Voice", airDate: "20240503", source: "WEB-DL", year: undefined }, + }, + { + id: "V-07", + input: "Late.Night.2024.05.03.Guest.Name.1080p.WEB.h264-GRP", + expect: { titleEn: "Late Night", airDate: "20240503" }, + }, + { + id: "V-08", + input: "极限挑战 第十季 20240505 第2期 上 1080p WEB-DL", + expect: { season: 10, issue: 2, segment: "upper" }, + }, + { id: "V-09", input: "声生不息 第2期(上) 1080p", expect: { issue: 2, segment: "upper" } }, + { id: "V-10", input: "奔跑吧 第十二季 第3期 下 1080p", expect: { issue: 3, segment: "lower", season: 12 } }, + { id: "V-11", input: "某综艺 2024年5月3日 完整版 1080p", expect: { airDate: "20240503", segment: "full" } }, + { id: "V-12", input: "某综艺 第1期 花絮", expect: { contentType: "behind_the_scenes" } }, + { id: "V-13", input: "某综艺 先导片", expect: { contentType: "preview" } }, + { id: "V-14", input: "某综艺 第3期 陪看", expect: { contentType: "reaction" } }, + { id: "V-15", input: "某综艺 第3期 正片", expect: { contentType: "main" } }, + { id: "V-16", input: "某剧 全3季 1080p", expect: { season: 1, seasonEnd: 3, packHint: "multi_season_pack" } }, + { id: "V-17", input: "某剧 第1-3季 1080p", expect: { season: 1, seasonEnd: 3 } }, + { id: "V-18", input: "某剧 第1-10集 1080p", expect: { episodes: { start: 1, end: 10 } } }, +]; + +describe("parseRelease 样本表", () => { + it("样本数量 ≥ 60", () => { + expect(TITLES.length).toBeGreaterThanOrEqual(60); + }); + for (const s of TITLES) { + it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => { + const r = parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {}); + check(r, s.expect); + expect(r.parserVersion).toBe(1); + expect(r.raw).toBe(s.input); + }); + } +}); + +// P-xx 对应迁移文档 §9.2 +const PATHS: Sample[] = [ + { + id: "P-01", + input: "/downloads/The.Last.of.Us.S01.2023.1080p.WEB-DL-ADWeb/The.Last.of.Us.S01E03.1080p.WEB-DL-ADWeb.mkv", + expect: { season: 1, episodes: { start: 3, end: 3 }, year: 2023, confidence: "multi_evidence" }, + }, + { + id: "P-02", + input: "/downloads/庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 DDP5.1-HHWEB/01.mp4", + expect: { title: "庆余年", season: 2, year: 2024, episodes: { start: 1, end: 1 }, seasonInferred: undefined }, + }, + { + id: "P-03", + input: "/downloads/庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 DDP5.1-HHWEB/第02集.mkv", + expect: { episodes: { start: 2, end: 2 }, season: 2, group: "HHWEB", audio: ["DDP 5.1"] }, + }, + { + id: "P-04", + input: "/downloads/Breaking.Bad.S01-S05.1080p.BluRay.x264-ROVERS/S03/07.mkv", + expect: { title: "Breaking Bad", season: 3, episodes: { start: 7, end: 7 }, seasonEnd: undefined }, + }, + { + id: "P-05", + input: "/d/Oppenheimer.2023.2160p.UHD.BluRay.REMUX-FraMeSToR/Featurettes/Making.Of.mkv", + expect: { title: "Oppenheimer", contentType: "extra", confidence: "needs_confirm" }, + deviation: "MP 把附加内容识别成名为 Making Of 的电影", + }, + { + id: "P-07", + input: "/downloads/The Glory 2022 S01 1080p NF WEB-DL-HHWEB/简体.srt", + expect: { auxiliaryOnly: true, title: "The Glory", confidence: "needs_confirm", fileKind: "subtitle" }, + deviation: "MP 取到根目录名 Downloads", + }, + { + id: "P-08", + input: "/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.20240426.第1期.mp4", + expect: { season: 12, issue: 1, airDate: "20240426", episodes: undefined }, + }, + { + id: "P-09", + input: "/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.2024.05.03.mp4", + expect: { mediaTypeHint: "tv", season: 12, airDate: "20240503", year: undefined, confidence: "multi_evidence" }, + deviation: "MP 文件无类型、不与 TV 目录合并", + }, + { + id: "P-10", + input: "/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.20240503.加更版.mp4", + expect: { contentType: "extra", airDate: "20240503", season: 12 }, + }, + { + id: "P-11", + input: "/downloads/Kill.Bill.2003.1080p.BluRay.x264-CHD/CD1.mkv", + expect: { part: "CD1", title: "Kill Bill", year: 2003 }, + deviation: "MP 因 Part 门槛丢失 CD1", + }, + { + id: "P-12", + input: "/media/tv/庆余年 (2019)/Season 2/庆余年 - S02E03 - 第 3 集.mkv", + expect: { season: 2, episodes: { start: 3, end: 3 }, year: 2019 }, + }, + { + id: "P-13", + input: + "/dl/[LoliHouse] Sousou no Frieren [01-28][WebRip 1080p]/[LoliHouse] Sousou no Frieren - 13 [WebRip 1080p].mkv", + expect: { episodes: { start: 13, end: 13 }, titleEn: "Sousou no Frieren" }, + }, + { + id: "P-14", + input: "/dl/Some.Show.S01.1080p/Making.Of.Special.mkv", + expect: { mediaTypeHint: "tv", season: 1, confidence: "needs_confirm" }, + deviation: "文件无剧集信号、目录是剧集:合并但降级为 needs_confirm", + }, + { + id: "P-15", + input: "/dl/Collection.1080p/The.Matrix.Reloaded.2003.1080p.BluRay.x264.mkv", + expect: { year: 2003, confidence: "rule_confirmed" }, + }, + { + id: "P-16", + input: "/dl/The.Matrix.1999.1080p/The.Matrix.Reloaded.2003.1080p.mkv", + expect: { year: 2003, confidence: "needs_confirm" }, + }, + { + id: "P-17", + input: "/dl/Show.S01.1080p.WEB-DL-GRP/Sample/show.s01e01.sample.mkv", + expect: { season: 1 }, + }, +]; + +describe("parseReleasePath 目录 + 文件名合并", () => { + for (const s of PATHS) { + it(`${s.id} ${s.input}${s.deviation ? ` [deviation: ${s.deviation}]` : ""}`, () => { + check(parseReleasePath(s.input), s.expect); + }); + } + it("接受已拆分的路径段", () => { + const r = parseReleasePath({ file: "E05.mkv", parent: "Show.S02.1080p.WEB-DL-GRP" }); + expect(r.season).toBe(2); + expect(r.episodes).toEqual({ start: 5, end: 5 }); + expect(r.title).toBe("Show"); + }); + it("sample 目录打证据", () => { + const r = parseReleasePath("/dl/Show.S01/Sample/x.mkv"); + expect(r.evidence).toContain("dir:sample"); + }); + it("splitReleasePath 跳过附加内容目录", () => { + expect(splitReleasePath("/a/b/Extras/c.mkv")).toEqual({ + file: "c.mkv", + parent: "b", + grandParent: "a", + extrasDir: "Extras", + }); + expect(splitReleasePath("c.mkv")).toEqual({ file: "c.mkv" }); + }); +}); + +describe("集号偏移(安全求值)", () => { + it("W-03: 按表达式偏移集号", () => { + const r = parseRelease("[Sub] Kimetsu no Yaiba - 45 [1080p].mkv", { episodeOffset: "EP-44" }); + expect(r.episodes).toEqual({ start: 1, end: 1 }); + expect(r.evidence).toContain("episode:offset"); + expect(r.confidence).toBe("multi_evidence"); + }); + it("负结果被拒绝,保留原集号", () => { + const r = parseRelease("Show.S01E03.1080p", { episodeOffset: "-10" }); + expect(r.episodes).toEqual({ start: 3, end: 3 }); + expect(r.evidence).toContain("episode:offset_rejected"); + }); + it("各运算符", () => { + expect(evalEpisodeOffset("EP-12", 13)).toBe(1); + expect(evalEpisodeOffset("+1", 1)).toBe(2); + expect(evalEpisodeOffset("*2", 3)).toBe(6); + expect(evalEpisodeOffset("5", 3)).toBe(8); + expect(evalEpisodeOffset("(EP+1)//2", 5)).toBe(3); + expect(evalEpisodeOffset("EP%4", 10)).toBe(2); + expect(evalEpisodeOffset("EP/2", 5)).toBe(2); + expect(evalEpisodeOffset("2**3+ep", 1)).toBe(9); + expect(evalEpisodeOffset("-EP+20", 5)).toBe(15); + expect(evalEpisodeOffset("+(EP)", 5)).toBe(5); + }); + it("拒绝非白名单输入", () => { + for (const bad of ["", "EP; process.exit()", "EP+", "(EP+1", "EP/0", "EP 1", "a".repeat(80), "EP)", "2**2000"]) { + expect(() => evalEpisodeOffset(bad, 1), bad).toThrow(OffsetExpressionError); + } + }); +}); + +describe("辅助函数", () => { + it("cnToInt", () => { + expect(cnToInt("十二")).toBe(12); + expect(cnToInt("二十")).toBe(20); + expect(cnToInt("二十三")).toBe(23); + expect(cnToInt("一百零五")).toBe(105); + expect(cnToInt("两")).toBe(2); + expect(cnToInt("08")).toBe(8); + expect(cnToInt("abc")).toBeUndefined(); + expect(cnToInt("")).toBeUndefined(); + }); + it("fileKindOf", () => { + expect(fileKindOf("a.MKV")).toBe("video"); + expect(fileKindOf("a.ass")).toBe("subtitle"); + expect(fileKindOf("a.mka")).toBe("audio"); + expect(fileKindOf("a.nfo")).toBe("nfo"); + expect(fileKindOf("a.jpg")).toBe("image"); + expect(fileKindOf("a.txt")).toBe("other"); + expect(fileKindOf("a")).toBeUndefined(); + }); + it("expandEpisodes", () => { + expect(expandEpisodes({ start: 2, end: 4 })).toEqual([2, 3, 4]); + expect(expandEpisodes(undefined)).toEqual([]); + }); + it("自定义发布组扩展", () => { + const r = parseRelease("Movie.2020.1080p.WEB-DL-MyGroup", { releaseGroups: ["MyGroup"] }); + expect(r.releaseGroup).toEqual({ name: "MyGroup", source: "known" }); + }); + it("纯数字/Exx 文件名", () => { + expect(parseRelease("05.mkv").episodes).toEqual({ start: 5, end: 5 }); + expect(parseRelease("E07.mkv").episodes).toEqual({ start: 7, end: 7 }); + expect(parseRelease("EP07.mp4").episodes).toEqual({ start: 7, end: 7 }); + expect(parseRelease("S03").season).toBe(3); + expect(parseRelease("Season 2").season).toBe(2); + }); + it("isFile 选项", () => { + expect(parseRelease("12", { isFile: true }).episodes).toEqual({ start: 12, end: 12 }); + expect(parseRelease("12").episodes).toBeUndefined(); + }); +}); diff --git a/packages/domain/test/slot.test.ts b/packages/domain/test/slot.test.ts new file mode 100644 index 00000000..91b20f77 --- /dev/null +++ b/packages/domain/test/slot.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { + canTransition, + checkDistinctAssets, + compareRank, + evaluateCandidate, + movieCompletion, + type ParsedRelease, + parseRelease, + qualityTier, + rankForSlot, + type SlotSpec, + type SlotState, + SlotTransitionError, + transition, + unitCompletion, +} from "../src"; + +const GiB = 1024 ** 3; +const rel = (t: string, over: Partial = {}): ParsedRelease => ({ ...parseRelease(t), ...over }); + +const archival4k: SlotSpec = { + id: "s-4k", + name: "4K 蓝光压制", + hard: { resolutions: ["2160p"], sources: ["BluRay", "WEB-DL"], maxSizeBytes: 40 * GiB }, + soft: { hdr: ["DV", "HDR10"], groups: ["CHD", "FraMeSToR"], preferredSizeBytes: { max: 30 * GiB } }, + upgrade: { enabled: true }, +}; + +describe("evaluateCandidate 硬条件", () => { + it("满足 → accept 并给软偏好排序键", () => { + const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265.DV-CHD 20GB")); + expect(r.decision).toBe("accept"); + if (r.decision !== "accept") return; + expect(r.softRank).toEqual([2, 2, 0, 0, 1]); + expect(r.tier).toEqual({ source: 3, resolution: 6 }); + }); + it("已知不满足 → reject,列出全部违规", () => { + const r = evaluateCandidate(archival4k, rel("Dune.2021.1080p.HDTV.x264-GRP 50GB")); + expect(r.decision).toBe("reject"); + if (r.decision !== "reject") return; + expect(r.violations.map((v) => `${v.field}:${v.code}`)).toEqual([ + "resolution:not_allowed", + "source:not_allowed", + "size:too_large", + ]); + }); + it("缺字段 → unknown_field(与不满足区分)", () => { + const r = evaluateCandidate(archival4k, rel("Dune 2021 BluRay")); + expect(r).toEqual({ decision: "unknown_field", missing: ["resolution", "size"] }); + }); + it("unknownPolicy 可按字段配置 reject / accept", () => { + const spec: SlotSpec = { ...archival4k, unknownPolicy: { size: "accept", resolution: "reject" } }; + const r = evaluateCandidate(spec, rel("Dune 2021 BluRay")); + expect(r.decision).toBe("reject"); + const ok = evaluateCandidate({ ...archival4k, unknownPolicy: "accept" }, rel("Dune 2021 BluRay")); + expect(ok.decision).toBe("accept"); + if (ok.decision === "accept") expect(ok.reasons).toContain("unknown_size_accepted_by_policy"); + }); + it("必需字幕:未知 hold,缺语言 reject,zh 可被 zh-Hans 满足", () => { + const spec: SlotSpec = { name: "zh", hard: { requiredSubtitles: ["zh"] } }; + expect(evaluateCandidate(spec, rel("Movie 2020 1080p")).decision).toBe("unknown_field"); + expect(evaluateCandidate(spec, rel("Movie 2020 1080p 简体字幕")).decision).toBe("accept"); + const r = evaluateCandidate({ name: "en", hard: { requiredSubtitles: ["en"] } }, rel("Movie 2020 简体字幕")); + expect(r.decision).toBe("reject"); + }); + it("内容版本 / 仅二压 / 排除组 / 内容类型 / 最小体积", () => { + const cutSpec: SlotSpec = { name: "c", hard: { cuts: ["Extended"] } }; + expect(evaluateCandidate(cutSpec, rel("LOTR 2001 Extended 1080p")).decision).toBe("accept"); + expect(evaluateCandidate(cutSpec, rel("LOTR 2001 1080p")).decision).toBe("reject"); + const enc: SlotSpec = { name: "e", hard: { requireEncode: true } }; + expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay REMUX")).decision).toBe("reject"); + expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay x264")).decision).toBe("accept"); + expect(evaluateCandidate(enc, rel("Movie 2020 1080p")).decision).toBe("unknown_field"); + const ex: SlotSpec = { name: "x", hard: { excludeGroups: ["badgrp"] } }; + expect(evaluateCandidate(ex, rel("Movie.2020.1080p.WEB-DL-BADGRP")).decision).toBe("reject"); + const ct: SlotSpec = { name: "m", hard: { contentTypes: ["main"] } }; + expect(evaluateCandidate(ct, rel("某综艺 第1期 加更")).decision).toBe("reject"); + expect(evaluateCandidate(ct, rel("某综艺 第1期")).decision).toBe("unknown_field"); + expect(evaluateCandidate(ct, rel("某综艺 第1期 正片")).decision).toBe("accept"); + const min: SlotSpec = { name: "min", hard: { minSizeBytes: 2 * GiB } }; + expect(evaluateCandidate(min, rel("Movie 2020 1080p 1GB")).decision).toBe("reject"); + }); +}); + +describe("升级规则", () => { + const cur4kWeb = { resolution: "2160p" as const, source: "WEB-DL" as const }; + it("WEB-DL → 蓝光二压:upgrade", () => { + const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb); + expect(r.decision).toBe("upgrade"); + if (r.decision === "upgrade") expect(r.fromTier.source).toBe(2); + }); + it("同等级不反复更换", () => { + const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"), cur4kWeb); + expect(r).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] }); + const remuxToEncode = evaluateCandidate( + { ...archival4k, hard: { resolutions: ["2160p"] } }, + rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), + { resolution: "2160p", source: "REMUX" }, + ); + expect(remuxToEncode).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] }); + }); + it("更低等级 / 未开启升级 / 当前资产字段未知 / 达到上限", () => { + const hi = { resolution: "2160p" as const, source: "BluRay" as const }; + expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL 20GB"), hi)).toMatchObject({ + violations: [{ code: "lower_tier" }], + }); + expect( + evaluateCandidate({ ...archival4k, upgrade: { enabled: false } }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb), + ).toMatchObject({ violations: [{ code: "upgrade_disabled" }] }); + expect( + evaluateCandidate({ ...archival4k, upgrade: undefined }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb), + ).toMatchObject({ decision: "reject" }); + expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay 2GB"), {})).toEqual({ + decision: "unknown_field", + missing: ["current_asset"], + }); + expect( + evaluateCandidate( + { ...archival4k, upgrade: { enabled: true, ceilingTier: 2 } }, + rel("Dune.2021.2160p.BluRay 2GB"), + cur4kWeb, + ), + ).toMatchObject({ violations: [{ code: "at_ceiling" }] }); + }); + it("最小改进阈值 2 级:WEBRip → WEB-DL 不够", () => { + const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, minTierSteps: 2 } }; + const r = evaluateCandidate(spec, rel("Movie 2020 1080p WEB-DL"), { resolution: "1080p", source: "WEBRip" }); + expect(r).toMatchObject({ violations: [{ code: "below_min_improvement" }] }); + }); + it("分辨率升级需显式允许", () => { + const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, allowResolutionUpgrade: true } }; + const cur = { resolution: "1080p" as const, source: "WEB-DL" as const }; + expect(evaluateCandidate(spec, rel("M 2020 2160p WEB-DL"), cur).decision).toBe("upgrade"); + expect(evaluateCandidate({ ...spec, upgrade: { enabled: true } }, rel("M 2020 2160p WEB-DL"), cur).decision).toBe( + "reject", + ); + }); + it("合格压制组名单:名单外压制不自动升级", () => { + const spec: SlotSpec = { ...archival4k, upgrade: { enabled: true, encodeGroupAllowlist: ["CHD"] } }; + expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb).decision).toBe("upgrade"); + expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-LOWQ 20GB"), cur4kWeb)).toMatchObject({ + violations: [{ code: "encode_not_qualified" }], + }); + }); + it("自定义来源等级表", () => { + expect( + qualityTier( + { source: "REMUX", resolution: "1080p" }, + { name: "x", hard: {}, upgrade: { enabled: true, sourceTiers: { REMUX: 5 } } }, + ), + ).toEqual({ + source: 5, + resolution: 4, + }); + expect(qualityTier({})).toEqual({ source: -1, resolution: -1 }); + }); +}); + +describe("rankForSlot 排序", () => { + it("可用在前;按等级再按软偏好", () => { + const cands = [ + rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"), + rel("Dune.2021.1080p.BluRay-CHD 10GB"), + rel("Dune.2021.2160p.BluRay.x265.HDR-SPARKS 35GB"), + rel("Dune.2021.2160p.BluRay.x265.DV-CHD 25GB"), + ]; + const ranked = rankForSlot(archival4k, cands); + expect(ranked.map((r) => r.candidate.releaseGroup?.name)).toEqual(["CHD", "SPARKS", "FLUX", "CHD"]); + expect(ranked[3]!.evaluation.decision).toBe("reject"); + }); + it("compareRank 字典序", () => { + expect(compareRank([1, 0], [0, 5])).toBeGreaterThan(0); + expect(compareRank([1], [1, 0])).toBe(0); + }); +}); + +describe("Slot 状态机", () => { + const s = (status: SlotState["status"], hasAsset = false): SlotState => ({ status, hasAsset }); + it("主路径 empty→reserved→downloading→verifying→satisfied", () => { + let st = s("empty"); + st = transition(st, { type: "reserve" }); + st = transition(st, { type: "download_started" }); + st = transition(st, { type: "download_completed" }); + st = transition(st, { type: "verify_passed", complete: true }); + expect(st).toEqual({ status: "satisfied", hasAsset: true }); + }); + it("升级路径保留旧资产,取消回到 satisfied", () => { + let st = transition(s("satisfied", true), { type: "upgrade_found" }); + expect(st.status).toBe("upgrade_pending"); + expect(transition(st, { type: "upgrade_cancelled" }).status).toBe("satisfied"); + st = transition(st, { type: "reserve" }); + expect(transition(st, { type: "release" })).toEqual({ status: "satisfied", hasAsset: true }); + expect(transition(s("reserved"), { type: "release" }).status).toBe("empty"); + }); + it("失败/校验失败进 attention,解决后按是否有资产回退", () => { + expect(transition(s("downloading"), { type: "download_failed" }).status).toBe("attention"); + expect(transition(s("reserved"), { type: "download_failed" }).status).toBe("attention"); + expect(transition(s("verifying"), { type: "verify_failed" }).status).toBe("attention"); + expect(transition(s("attention"), { type: "attention_resolved", hasAsset: true }).status).toBe("satisfied"); + expect(transition(s("attention"), { type: "attention_resolved", hasAsset: false }).status).toBe("empty"); + expect(transition(s("attention"), { type: "reserve" }).status).toBe("reserved"); + expect(transition(s("satisfied", true), { type: "asset_missing_confirmed" })).toEqual({ + status: "attention", + hasAsset: false, + }); + }); + it("部分覆盖回到 empty 但记住已有资产", () => { + expect(transition(s("verifying"), { type: "verify_passed", complete: false })).toEqual({ + status: "empty", + hasAsset: true, + }); + }); + it("非法迁移抛错", () => { + const illegal: [SlotState["status"], Parameters[1]][] = [ + ["empty", { type: "download_started" }], + ["satisfied", { type: "reserve" }], + ["empty", { type: "release" }], + ["reserved", { type: "download_completed" }], + ["empty", { type: "verify_passed", complete: true }], + ["downloading", { type: "verify_failed" }], + ["empty", { type: "upgrade_found" }], + ["satisfied", { type: "upgrade_cancelled" }], + ["empty", { type: "attention_resolved", hasAsset: false }], + ["empty", { type: "asset_missing_confirmed" }], + ["satisfied", { type: "download_failed" }], + ]; + for (const [from, ev] of illegal) { + expect(() => transition(s(from), ev), `${from} ${ev.type}`).toThrow(SlotTransitionError); + expect(canTransition(s(from), ev)).toBe(false); + } + expect(canTransition(s("empty"), { type: "reserve" })).toBe(true); + }); +}); + +describe("完成条件", () => { + it("电影按内容版本", () => { + expect(movieCompletion(undefined, [{ assetId: "a", verified: true }]).complete).toBe(true); + expect(movieCompletion(undefined, [{ assetId: "a", verified: false }]).complete).toBe(false); + const r = movieCompletion(["default", "Extended"], [{ assetId: "a", verified: true }]); + expect(r).toEqual({ complete: false, missingCuts: ["Extended"], satisfiedBy: { default: "a" } }); + }); + it("剧集按 content_unit;season pack 需实际文件清单覆盖", () => { + const req = ["e1", "e2", "e3"]; + const pack = { assetId: "p", verified: true, fileUnits: ["e1", "e2"], claimedUnits: ["e1", "e2", "e3"] }; + const r = unitCompletion(req, [pack], { totalKnown: true }); + expect(r).toEqual({ + complete: false, + covered: ["e1", "e2"], + missing: ["e3"], + unverifiedClaims: ["e3"], + keepTracking: true, + }); + const full = unitCompletion(req, [pack, { assetId: "x", verified: true, fileUnits: ["e3"] }], { totalKnown: true }); + expect(full.complete).toBe(true); + expect(full.keepTracking).toBe(false); + expect( + unitCompletion(req, [{ assetId: "x", verified: false, fileUnits: req }], { totalKnown: true }).complete, + ).toBe(false); + expect( + unitCompletion(["e1"], [{ assetId: "x", verified: true, fileUnits: ["e1"] }], { totalKnown: false }), + ).toMatchObject({ + complete: true, + keepTracking: true, + }); + expect(unitCompletion([], [], { totalKnown: false }).complete).toBe(false); + }); + it("require_distinct_asset 校验", () => { + const g = { slots: [], requireDistinctAsset: true }; + expect( + checkDistinctAssets(g, [ + { slotId: "a", assetId: "1" }, + { slotId: "b", assetId: "1" }, + ]), + ).toEqual({ + ok: false, + sharedAssets: ["1"], + }); + expect( + checkDistinctAssets(g, [ + { slotId: "a", assetId: "1" }, + { slotId: "b", assetId: "2" }, + ]).ok, + ).toBe(true); + expect( + checkDistinctAssets({ slots: [] }, [ + { slotId: "a", assetId: "1" }, + { slotId: "b", assetId: "1" }, + ]).ok, + ).toBe(true); + }); +});