feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)
- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue, cut, release group suffix fallback, dir+file merge, safe episode offset) - identity: title normalization (NFKC, trad->simp map, season suffix) and evidence-level candidate ranking (year tolerance ±1, no probabilities) - slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine, movie/unit completion (season pack must be covered by actual file list) - acquisition: new_title/catch_up/upgrade modes, priority order, budget, cache volume hard threshold with resume watermark (no double counting) - path: immutable path bindings, restricted naming templates, sanitize, conflict detection, downloader<->host path mapping - organize: planOrganize with sidecar pairing, sample exclusion, existing target policy, crossDevice marking, H&R -> conflict for move - download-state: qB state mapping, completion, no-progress evidence 230 tests, ~97% statement coverage. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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<PriorityClass>(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<T extends QueueItem>(
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -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<DownloadTaskStatus, readonly DownloadTaskStatus[]> = {
|
||||
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<MapContext, "previous">,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<EvidenceLevel, number> = {
|
||||
rule_confirmed: 3,
|
||||
multi_evidence: 2,
|
||||
needs_confirm: 1,
|
||||
rejected: 0,
|
||||
};
|
||||
const TITLE_RANK: Record<TitleMatch, number> = { exact: 3, alias: 2, partial: 1, none: 0 };
|
||||
const YEAR_RANK: Record<YearMatch, number> = {
|
||||
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 };
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 繁 → 简 简单映射表(只覆盖影视标题常见字,不引第三方库)。
|
||||
* 格式:每两个字符为一对「繁简」。一对多/语境相关的字(乾/著/瞭 等)刻意不收。
|
||||
*/
|
||||
const PAIRS =
|
||||
"萬万與与專专業业叢丛東东絲丝兩两嚴严喪丧個个豐丰臨临為为麗丽舉举麼么義义烏乌樂乐喬乔習习鄉乡書书買买亂乱爭争於于虧亏雲云亞亚產产親亲億亿僅仅從从倉仓儀仪們们價价眾众優优會会傘伞偉伟傳传傷伤倫伦偽伪體体餘余傭佣俠侠侶侣偵侦側侧僑侨儂侬倆俩儉俭債债傾倾償偿儲储兒儿兇凶黨党蘭兰關关興兴養养獸兽內内岡冈冊册寫写軍军農农馮冯衝冲決决況况凍冻淨净準准涼凉減减湊凑幾几鳳凤憑凭凱凯擊击劃划劉刘則则剛刚創创刪删別别劑剂劍剑剝剥劇剧勸劝辦办務务動动勵励勁劲勞劳勢势勳勋勝胜區区醫医華华協协單单賣卖盧卢衛卫卻却廠厂廳厅曆历厲厉壓压厭厌廁厕廂厢廈厦廚厨縣县參参雙双發发變变敘叙疊叠葉叶號号嘆叹嚇吓呂吕嗎吗啟启吳吴員员響响啞哑嘩哗喚唤噴喷團团園园圍围國国圖图圓圆聖圣場场壞坏塊块堅坚壇坛壩坝墳坟墜坠壘垒墊垫牆墙壯壮聲声殼壳壺壶處处備备復复夠够頭头誇夸夾夹奪夺奮奋獎奖奧奥妝妆婦妇媽妈嬌娇娛娱嬰婴嬸婶孫孙學学寧宁寶宝實实寵宠審审憲宪宮宫寬宽賓宾寢寝對对尋寻導导壽寿將将爾尔塵尘嘗尝堯尧屍尸盡尽層层屆届屬属嶼屿歲岁豈岂崗岗島岛嶺岭峽峡嶄崭巔巅幣币帥帅師师帳帐簾帘幟帜帶带幫帮並并廣广莊庄慶庆廬庐庫库應应廟庙龐庞廢废開开異异棄弃張张彌弥彎弯彈弹強强歸归當当錄录彥彦徹彻徑径憶忆憂忧懷怀態态憐怜總总戀恋懇恳惡恶惱恼悅悦懸悬驚惊懼惧慘惨懲惩慚惭慣惯憤愤願愿懶懒戲戏戰战戶户紮扎撲扑執执擴扩掃扫揚扬擾扰撫抚拋抛搶抢護护報报擔担擬拟擁拥攔拦擇择掛挂揮挥撈捞損损撿捡換换據据擲掷攬揽攜携攝摄擺摆搖摇攤摊撐撑敵敌斂敛數数齋斋鬥斗斬斩斷断無无舊旧時时曠旷晝昼顯显晉晋曬晒曉晓暈晕暉晖暫暂術术樸朴機机殺杀雜杂權权條条來来楊杨傑杰極极構构槍枪楓枫櫃柜標标棧栈棟栋欄栏樹树棲栖樣样檔档橋桥夢梦檢检樓楼欖榄櫻樱櫥橱歡欢歐欧殲歼殘残毀毁畢毕斃毙氣气匯汇漢汉湯汤溝沟沒没淚泪瀉泻潑泼澤泽潔洁灑洒淺浅漿浆濁浊測测濟济渾浑濃浓濤涛漲涨淵渊漸渐漁渔滲渗溫温遊游灣湾濕湿潰溃濺溅滿满濾滤濫滥濱滨灘滩瀟潇潛潜瀾澜瀕濒滅灭燈灯靈灵災灾燦灿爐炉點点煉炼熾炽爍烁爛烂燭烛煙烟煩烦燒烧熱热煥焕愛爱爺爷牽牵犧牺狀状猶犹狹狭獅狮獄狱獨独獵猎豬猪貓猫獻献瑪玛環环現现瓊琼電电畫画暢畅療疗瘋疯癢痒發发皺皱盞盏鹽盐監监蓋盖盜盗盤盘睜睁瞞瞒矯矫礦矿碼码磚砖確确礙碍禮礼禍祸禪禅離离禿秃種种積积稱称穩稳穫获窮穷竊窃窯窑竄窜窩窝豎竖競竞筆笔築筑篩筛籌筹簽签簡简籃篮籬篱類类糧粮糾纠紀纪約约紅红紋纹納纳紐纽純纯紗纱紡纺紛纷級级紙纸細细紳绅終终組组結结繞绕給给絢绚絡络絕绝統统綁绑絹绢繡绣經经綜综綠绿綴缀網网綱纲緒绪續续緊紧維维綿绵緣缘編编緩缓緯纬練练縫缝縮缩織织繩绳繪绘繳缴纖纤羅罗罰罚罷罢羈羁翹翘聳耸恥耻聶聂聾聋職职聯联聰聪肅肃腸肠膚肤腫肿脅胁膽胆朧胧膠胶脈脉腦脑腳脚脫脱臉脸臘腊騰腾輿舆艦舰艙舱艱艰豔艳藝艺節节蕪芜蘆芦葦苇蒼苍蘇苏蘋苹莖茎薦荐莢荚蕩荡榮荣藥药萊莱蓮莲獲获瑩莹鶯莺蘿萝螢萤營营蕭萧薩萨蔥葱蔣蒋藍蓝薔蔷藹蔼蘊蕴虜虏慮虑虛虚蟲虫雖虽蝦虾蝕蚀蟻蚁螞蚂蠶蚕蠻蛮蝸蜗蠟蜡蠅蝇蟬蝉補补襯衬襖袄襪袜襲袭裝装褲裤見见觀观規规覓觅視视覽览覺觉觸触計计訂订認认討讨讓让訓训議议訊讯記记講讲許许論论設设訪访訣诀證证評评識识詐诈訴诉診诊詞词譯译試试詩诗誠诚話话誕诞詭诡詢询該该詳详誤误語语誘诱說说誦诵請请諸诸諾诺讀读課课誰谁調调諒谅談谈誼谊謀谋謊谎諧谐謂谓謎谜謝谢謠谣謙谦謹谨謬谬譜谱貝贝貞贞負负貢贡財财責责賢贤敗败賬账貨货質质販贩貪贪貧贫購购貫贯貼贴貴贵貸贷貿贸費费賀贺賊贼資资賦赋賭赌贖赎賞赏賜赐賠赔賴赖賺赚賽赛贈赠贊赞贏赢趙赵趕赶趨趋躍跃踐践蹤踪躲躲車车軌轨軍军軒轩轉转輪轮軟软轟轰軸轴輕轻載载較较輔辅輛辆輩辈輝辉輸输轄辖辭辞辯辩邊边遼辽達达遷迁過过邁迈運运還还這这進进遠远違违連连遲迟適适選选遜逊遞递邏逻遺遗遙遥鄧邓郵邮鄰邻鬱郁鄭郑醞酝醬酱釀酿釋释裏里鑒鉴針针釘钉鐘钟鋼钢鑰钥鈴铃鐵铁鉛铅銅铜鋁铝銀银鏈链銷销鎖锁鋒锋鋤锄鍋锅錯错錨锚錘锤錦锦鍵键鋸锯鍛锻鏡镜鐮镰鑲镶長长門门閃闪閉闭問问闖闯閑闲間间悶闷閘闸鬧闹閨闺聞闻閣Line truncated
|
||||
|
||||
export const TRAD_TO_SIMP: ReadonlyMap<string, string> = (() => {
|
||||
const chars = [...PAIRS];
|
||||
const m = new Map<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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<number, string>; device?: string | number };
|
||||
templates?: NamingTemplateSet;
|
||||
existingTargets?: readonly ExistingTarget[];
|
||||
/** 目标已存在时的策略,默认 conflict(待处理) */
|
||||
onExisting?: ExistingPolicy;
|
||||
/** H&R/做种保留是否已满足;move 必需 */
|
||||
retention?: { hnrSatisfied: boolean | "unknown" };
|
||||
/** 主视频最小体积,默认 50 MiB;低于此值视为样例/垃圾 */
|
||||
minVideoBytes?: number;
|
||||
grants?: { replace?: boolean };
|
||||
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<typeof renderMediaFileName>;
|
||||
if (input.mediaType === "movie") {
|
||||
dir = input.target.movieDir;
|
||||
named = renderMediaFileName("movie", templates, buildNamingContext(input.entity, parsed), ext);
|
||||
} else {
|
||||
if (!parsed.episodes && !parsed.airDate) {
|
||||
const special = SPECIAL_RE.test(splitExt(basename(f.path))[0]);
|
||||
excluded.push({ src: f.path, kind, reason: special ? "special_extra" : "no_episode" });
|
||||
continue;
|
||||
}
|
||||
const seasonDirs = input.target.seasonDirs ?? {};
|
||||
let season = parsed.season;
|
||||
const keys = Object.keys(seasonDirs).map(Number);
|
||||
if (season === undefined && keys.length === 1) season = keys[0];
|
||||
dir = season !== undefined ? seasonDirs[season] : undefined;
|
||||
const ctx = buildNamingContext(input.entity, { ...parsed, ...(season !== undefined ? { season } : {}) });
|
||||
named = renderMediaFileName("tv", templates, ctx, ext);
|
||||
parsed.season = season;
|
||||
}
|
||||
if (!dir) {
|
||||
excluded.push({ src: f.path, kind, reason: "no_target_dir", detail: `season=${parsed.season ?? "?"}` });
|
||||
continue;
|
||||
}
|
||||
if (!named.ok) {
|
||||
excluded.push({ src: f.path, kind, reason: "naming_failed", detail: named.missing.join(",") });
|
||||
continue;
|
||||
}
|
||||
videos.push({ f, parsed, dst: `${dir.replace(/\/+$/, "")}/${named.fileName}` });
|
||||
}
|
||||
|
||||
if (videos.length === 0 && input.files.length > 0)
|
||||
conflicts.push({ kind: "no_main_video", blocking: true, detail: "没有可入库的主视频" });
|
||||
|
||||
// 主视频
|
||||
for (const v of videos) {
|
||||
planned.push({
|
||||
src: v.f.path,
|
||||
dst: v.dst,
|
||||
size: v.f.size,
|
||||
kind: "video",
|
||||
role: "main",
|
||||
action: "transfer",
|
||||
...(sameDevice(v.f.device, targetDevice) !== undefined
|
||||
? { crossDevice: sameDevice(v.f.device, targetDevice) }
|
||||
: {}),
|
||||
...(v.parsed.season !== undefined ? { season: v.parsed.season } : {}),
|
||||
...(v.parsed.episodes ? { episodes: v.parsed.episodes } : {}),
|
||||
...(v.parsed.airDate ? { airDate: v.parsed.airDate } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// 附属文件:同目录、主干名唯一匹配才随行;否则孤儿,不搬不删
|
||||
const mainByDirStem = new Map<string, { f: SourceFile; dst: string }[]>();
|
||||
for (const v of videos) {
|
||||
const key = `${dirname(v.f.path)}\u0000${splitExt(basename(v.f.path))[0].toLowerCase()}`;
|
||||
mainByDirStem.set(key, [...(mainByDirStem.get(key) ?? []), { f: v.f, dst: v.dst }]);
|
||||
}
|
||||
const usedSidecarDst = new Set<string>();
|
||||
for (const s of sidecars) {
|
||||
const kind = kindOf(s.path);
|
||||
const { stem, tags } = sidecarStem(s.path);
|
||||
const mains = mainByDirStem.get(`${dirname(s.path)}\u0000${stem}`) ?? [];
|
||||
if (mains.length !== 1) {
|
||||
excluded.push({
|
||||
src: s.path,
|
||||
kind,
|
||||
reason: "orphan_sidecar",
|
||||
detail: mains.length > 1 ? "ambiguous" : "no_match",
|
||||
});
|
||||
conflicts.push({ kind: "orphan_sidecar", blocking: false, src: s.path });
|
||||
continue;
|
||||
}
|
||||
const main = mains[0]!;
|
||||
const [mainStem] = splitExt(main.dst);
|
||||
const [, ext] = splitExt(basename(s.path));
|
||||
const suffix = sidecarLanguageSuffix(tags);
|
||||
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<string, PlannedFile[]>();
|
||||
for (const p of planned) byDst.set(p.dst, [...(byDst.get(p.dst) ?? []), p]);
|
||||
for (const [dst, ps] of byDst) {
|
||||
if (ps.length < 2) continue;
|
||||
for (const p of ps) p.action = "hold";
|
||||
conflicts.push({ kind: "duplicate_target", blocking: true, dst, detail: ps.map((p) => p.src).join(" | ") });
|
||||
}
|
||||
|
||||
// 目标已存在
|
||||
const replaced: string[] = [];
|
||||
for (const p of planned) {
|
||||
if (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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/** 附属文件(字幕/外挂音轨)的语言标签,按主干名剥离后与主视频配对 */
|
||||
const LANG_TAG: Record<string, "zh-Hans" | "zh-Hant" | "en" | "ja" | "ko" | "flag"> = {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { sanitizeFileName, sanitizeSegment } from "./sanitize";
|
||||
import { compileTemplate, type NamingContext, renderTemplate, type TemplateVar } from "./template";
|
||||
|
||||
export interface LibraryRoot {
|
||||
id: string;
|
||||
/** 宿主机上的根路径(由 settings/挂载映射解析) */
|
||||
hostPath: string;
|
||||
}
|
||||
|
||||
export interface PathBinding {
|
||||
id: string;
|
||||
entityId: string;
|
||||
scope: "entity" | "season";
|
||||
season?: number;
|
||||
libraryRootId: string;
|
||||
/** 相对库根的路径,POSIX 风格 */
|
||||
relativePath: string;
|
||||
templateVersion: number;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface NamingTemplateSet {
|
||||
version: number;
|
||||
movieDir: string;
|
||||
tvDir: string;
|
||||
seasonDir: string;
|
||||
movieFile: string;
|
||||
episodeFile: string;
|
||||
/** 综艺按日期命名 */
|
||||
dailyFile: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATES: NamingTemplateSet = {
|
||||
version: 1,
|
||||
movieDir: "{title}[[ ({year})]]",
|
||||
tvDir: "{title}[[ ({year})]]",
|
||||
seasonDir: "Season {season}",
|
||||
movieFile: "{title}[[ ({year})]][[ - {cut}]][[ - {part}]][[ - {resolution}]]",
|
||||
episodeFile: "{title} - {seasonEpisode}[[ - {part}]]",
|
||||
dailyFile: "{title} - {airDate:dash}[[ - 第{issue}期]]",
|
||||
};
|
||||
|
||||
/** 校验模板集合(白名单变量、括号成对);发布模板版本前调用 */
|
||||
export function validateTemplateSet(t: NamingTemplateSet): void {
|
||||
for (const k of ["movieDir", "tvDir", "seasonDir", "movieFile", "episodeFile", "dailyFile"] as const) {
|
||||
compileTemplate(t[k]);
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewBinding {
|
||||
scope: "entity" | "season";
|
||||
season?: number;
|
||||
libraryRootId: string;
|
||||
relativePath: string;
|
||||
templateVersion: number;
|
||||
}
|
||||
|
||||
export type TargetDirectoryDecision =
|
||||
| { kind: "existing"; libraryRootId: string; relativePath: string; bindingIds: string[] }
|
||||
| {
|
||||
kind: "new";
|
||||
libraryRootId: string;
|
||||
relativePath: string;
|
||||
newBindings: NewBinding[];
|
||||
reusedBindingIds: string[];
|
||||
}
|
||||
| { kind: "conflict"; reason: "multiple_entity_bindings" | "multiple_season_bindings"; bindings: PathBinding[] }
|
||||
| { kind: "error"; reason: "season_required" | "template_missing_vars"; missing?: TemplateVar[] };
|
||||
|
||||
export interface ResolveTargetInput {
|
||||
entity: { id: string; kind: "movie" | "tv" };
|
||||
/** 命名上下文(首次绑定才会用到) */
|
||||
naming: NamingContext;
|
||||
season?: number;
|
||||
bindings: readonly PathBinding[];
|
||||
/** 首次绑定时使用的库根 */
|
||||
libraryRoot: LibraryRoot;
|
||||
templates: NamingTemplateSet;
|
||||
}
|
||||
|
||||
const joinRel = (...parts: string[]) => parts.filter((p) => p.length > 0).join("/");
|
||||
|
||||
function distinct(bs: PathBinding[]): PathBinding[] {
|
||||
const seen = new Map<string, PathBinding>();
|
||||
for (const b of bs) seen.set(`${b.libraryRootId}\u0000${b.relativePath}`, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标目录决策(REQ §7.1):已有绑定优先且不可变,不因译名/模板变化重新渲染;
|
||||
* 只有首次建立作品/季目录时才按当前模板渲染,并记录模板版本。
|
||||
* 同一作品存在多个不同的历史目录 → conflict,不自动合并。
|
||||
*/
|
||||
export function resolveTargetDirectory(input: ResolveTargetInput): TargetDirectoryDecision {
|
||||
const mine = input.bindings.filter((b) => b.entityId === input.entity.id && b.active !== false);
|
||||
const entityBindings = mine.filter((b) => b.scope === "entity");
|
||||
if (distinct(entityBindings).length > 1)
|
||||
return { kind: "conflict", reason: "multiple_entity_bindings", bindings: entityBindings };
|
||||
const entityBinding = entityBindings[0];
|
||||
const t = input.templates;
|
||||
|
||||
if (input.entity.kind === "movie") {
|
||||
if (entityBinding)
|
||||
return {
|
||||
kind: "existing",
|
||||
libraryRootId: entityBinding.libraryRootId,
|
||||
relativePath: entityBinding.relativePath,
|
||||
bindingIds: [entityBinding.id],
|
||||
};
|
||||
const dir = renderTemplate(t.movieDir, input.naming);
|
||||
if (!dir.ok) return { kind: "error", reason: "template_missing_vars", missing: dir.missing };
|
||||
const rel = sanitizeSegment(dir.value);
|
||||
return {
|
||||
kind: "new",
|
||||
libraryRootId: input.libraryRoot.id,
|
||||
relativePath: rel,
|
||||
newBindings: [
|
||||
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: rel, templateVersion: t.version },
|
||||
],
|
||||
reusedBindingIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (input.season === undefined) return { kind: "error", reason: "season_required" };
|
||||
const seasonBindings = mine.filter((b) => b.scope === "season" && b.season === input.season);
|
||||
if (distinct(seasonBindings).length > 1)
|
||||
return { kind: "conflict", reason: "multiple_season_bindings", bindings: seasonBindings };
|
||||
const seasonBinding = seasonBindings[0];
|
||||
if (seasonBinding)
|
||||
return {
|
||||
kind: "existing",
|
||||
libraryRootId: seasonBinding.libraryRootId,
|
||||
relativePath: seasonBinding.relativePath,
|
||||
bindingIds: [seasonBinding.id],
|
||||
};
|
||||
const seasonDir = renderTemplate(t.seasonDir, { ...input.naming, season: input.season });
|
||||
if (!seasonDir.ok) return { kind: "error", reason: "template_missing_vars", missing: seasonDir.missing };
|
||||
const seasonSeg = sanitizeSegment(seasonDir.value);
|
||||
if (entityBinding) {
|
||||
const rel = joinRel(entityBinding.relativePath, seasonSeg);
|
||||
return {
|
||||
kind: "new",
|
||||
libraryRootId: entityBinding.libraryRootId,
|
||||
relativePath: rel,
|
||||
newBindings: [
|
||||
{
|
||||
scope: "season",
|
||||
season: input.season,
|
||||
libraryRootId: entityBinding.libraryRootId,
|
||||
relativePath: rel,
|
||||
templateVersion: t.version,
|
||||
},
|
||||
],
|
||||
reusedBindingIds: [entityBinding.id],
|
||||
};
|
||||
}
|
||||
const showDir = renderTemplate(t.tvDir, input.naming);
|
||||
if (!showDir.ok) return { kind: "error", reason: "template_missing_vars", missing: showDir.missing };
|
||||
const showRel = sanitizeSegment(showDir.value);
|
||||
const rel = joinRel(showRel, seasonSeg);
|
||||
return {
|
||||
kind: "new",
|
||||
libraryRootId: input.libraryRoot.id,
|
||||
relativePath: rel,
|
||||
newBindings: [
|
||||
{ scope: "entity", libraryRootId: input.libraryRoot.id, relativePath: showRel, templateVersion: t.version },
|
||||
{
|
||||
scope: "season",
|
||||
season: input.season,
|
||||
libraryRootId: input.libraryRoot.id,
|
||||
relativePath: rel,
|
||||
templateVersion: t.version,
|
||||
},
|
||||
],
|
||||
reusedBindingIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export type FileNameResult =
|
||||
| { ok: true; fileName: string }
|
||||
| { ok: false; missing: TemplateVar[] | ["episode_or_air_date"] };
|
||||
|
||||
/** 渲染视频文件名:电影用 movieFile;剧集有集号用 episodeFile;只有播出日期用 dailyFile */
|
||||
export function renderMediaFileName(
|
||||
kind: "movie" | "tv",
|
||||
templates: NamingTemplateSet,
|
||||
ctx: NamingContext,
|
||||
ext: string,
|
||||
): FileNameResult {
|
||||
let tpl: string;
|
||||
if (kind === "movie") tpl = templates.movieFile;
|
||||
else if (ctx.seasonEpisode !== undefined) tpl = templates.episodeFile;
|
||||
else if (ctx.airDate !== undefined) tpl = templates.dailyFile;
|
||||
else return { ok: false, missing: ["episode_or_air_date"] };
|
||||
const r = renderTemplate(tpl, ctx);
|
||||
if (!r.ok) return r;
|
||||
return { ok: true, fileName: sanitizeFileName(r.value, ext) };
|
||||
}
|
||||
@@ -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<string, string[]>();
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<string, string>();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const INVALID: Record<string, string> = {
|
||||
"\\": "\",
|
||||
"/": "/",
|
||||
":": ":",
|
||||
"*": "*",
|
||||
"?": "?",
|
||||
'"': """,
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"|": "|",
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -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<Record<TemplateVar, string | number>>;
|
||||
|
||||
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<ParsedRelease>,
|
||||
): 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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const DIGITS: Record<string, number> = {
|
||||
零: 0,
|
||||
〇: 0,
|
||||
一: 1,
|
||||
二: 2,
|
||||
两: 2,
|
||||
兩: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
};
|
||||
const UNITS: Record<string, number> = { 十: 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零〇一二两兩三四五六七八九十拾百千]+";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, FileKind> = {};
|
||||
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][] = [
|
||||
[/(?<!\d)((?:19|20)\d{2})年(\d{1,2})月(\d{1,2})日?/u, "airDate:cn"],
|
||||
[/(?<!\d)((?:19|20)\d{2})[.\-_ /](\d{1,2})[.\-_ /](\d{1,2})(?!\d)/, "airDate:ymd_sep"],
|
||||
[/(?<!\d)((?:19|20)\d{2})(\d{2})(\d{2})(?!\d)/, "airDate:ymd8"],
|
||||
];
|
||||
for (const [re, ev] of rules) {
|
||||
const m = re.exec(s);
|
||||
if (!m) continue;
|
||||
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
if (!validDate(y, mo, d)) continue;
|
||||
return {
|
||||
s: s.slice(0, m.index) + MARK_HARD + s.slice(m.index + m[0].length),
|
||||
airDate: `${y}${pad2(mo)}${pad2(d)}`,
|
||||
ev,
|
||||
};
|
||||
}
|
||||
return { s };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 中文标记
|
||||
|
||||
export interface CnMarkers {
|
||||
season?: number;
|
||||
seasonEnd?: number;
|
||||
episodes?: EpisodeRange;
|
||||
issue?: number;
|
||||
totalEpisodes?: number;
|
||||
segment?: Segment;
|
||||
contentType?: ContentType;
|
||||
variantTags: string[];
|
||||
subtitles?: string[];
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
const CONTENT_RULES: [RegExp, ContentType, string][] = [
|
||||
[/(?:会员|會員)?加更版?/u, "extra", "content:extra"],
|
||||
[/花絮|幕后|幕後|彩蛋/u, "behind_the_scenes", "content:behind_the_scenes"],
|
||||
[/纯享版?|純享版?|直拍/u, "performance_only", "content:performance_only"],
|
||||
[/预告片?|預告片?|先导片|先導片/u, "preview", "content:preview"],
|
||||
[/陪看|(?<![A-Za-z])reaction(?![A-Za-z])/iu, "reaction", "content:reaction"],
|
||||
[/正片/u, "main", "content:main"],
|
||||
];
|
||||
const VARIANT_RE = /会员版|會員版|超前点播|超前點播|精编版|精編版|未播版?|独家版|獨家版|加长版?(?=\s|$)/gu;
|
||||
const SUB_KEYWORDS =
|
||||
/字幕|中字|双字|雙字|内封|內封|内嵌|內嵌|外挂|外掛|简繁|簡繁|简中|繁中|简体|简日|繁日|繁体|繁體|簡體|简英|繁英|中英|双语|雙語/u;
|
||||
const AUDIO_LANG_RE = /国粤双语|國粵雙語|国英双语|國英雙語|国语配音|國語配音|国配|國配|国语|國語|粤语|粵語/gu;
|
||||
|
||||
function scanSubtitleLangs(run: string): string[] {
|
||||
const langs: string[] = [];
|
||||
const add = (l: 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(/(?<!\p{Script=Han})[((【[]?\s*([上中下])(?:集|期|篇|部)?\s*[))】\]]?(?!\p{Script=Han})/u, (m) => {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, HdrFormat> = {
|
||||
HDR: "HDR10",
|
||||
HDR10: "HDR10",
|
||||
HDR10PLUS: "HDR10+",
|
||||
DV: "DV",
|
||||
DOVI: "DV",
|
||||
HLG: "HLG",
|
||||
HDRVIVID: "HDR Vivid",
|
||||
VIVID: "HDR Vivid",
|
||||
};
|
||||
|
||||
const CODEC_TOKENS: Record<string, VideoCodec> = {
|
||||
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<ReleaseSource>();
|
||||
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(/(?<![\w.])(\d+(?:\.\d+)?)\s?(TB|TiB|GB|GiB|MB|MiB)(?![A-Za-z])/gi, (_all, n: string, unit: string) => {
|
||||
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(/(?<!\d)((?:19|20)\d{2})\s*[-~]\s*(?:19|20)\d{2}(?!\d)/, "$1");
|
||||
|
||||
// 10. 动漫式集号:" - 12 "、"[01-11 Fin]"、"[12]"
|
||||
s = s.replace(
|
||||
/[[【]\s*(\d{1,4})\s*[-~]\s*(\d{1,4})(\s*(?:Fin|END|完))?\s*[\]】]/iu,
|
||||
(all, a: string, b: string, fin) => {
|
||||
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)(\d{1,3})\s*-\s*(\d{1,3})\s+(?:Fin|END)\b/i, (_all, a: string, b: string) => {
|
||||
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";
|
||||
}
|
||||
@@ -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<Confidence, number> = { 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 不合并;本项目合并名称与季号,
|
||||
* 打 `<level>: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 extends keyof ParsedRelease>(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;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 发布组种子数据(本项目自维护,按站点分组,只是组名事实数据)。
|
||||
* 设置表可通过 ParseOptions.releaseGroups 扩展。
|
||||
*/
|
||||
export const RELEASE_GROUP_SEED: Readonly<Record<string, readonly string[]>> = {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> } {
|
||||
const required = requiredCuts?.length ? requiredCuts : (["default"] as const);
|
||||
const satisfiedBy: Record<string, string> = {};
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, string>();
|
||||
const shared = new Set<string>();
|
||||
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] };
|
||||
}
|
||||
@@ -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<Record<ReleaseSource, number>> = {
|
||||
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<Record<Resolution, number>> = {
|
||||
"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<T>(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<T extends ParsedRelease = ParsedRelease> {
|
||||
candidate: T;
|
||||
evaluation: SlotEvaluation;
|
||||
}
|
||||
|
||||
/** 对一批候选评估并排序:可用(accept/upgrade)在前,按质量等级再按软偏好;其余保持原顺序 */
|
||||
export function rankForSlot<T extends ParsedRelease>(
|
||||
spec: SlotSpec,
|
||||
candidates: readonly T[],
|
||||
current?: QualityFacts | null,
|
||||
): RankedEvaluation<T>[] {
|
||||
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<SlotEvaluation, { tier: QualityTier }>;
|
||||
const eb = b.evaluation as Extract<SlotEvaluation, { tier: QualityTier }>;
|
||||
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 }));
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<Record<ReleaseSource, number>>;
|
||||
/** 允许仅因分辨率提升而升级(同一 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<Record<HardField, UnknownPolicy>>;
|
||||
soft?: SlotSoftPreferences;
|
||||
upgrade?: SlotUpgradePolicy;
|
||||
completion?: SlotCompletionSpec;
|
||||
}
|
||||
|
||||
/** 多目标组合(M2 才做校验;数据结构先定):require_distinct_asset 表示必须是不同物理资产 */
|
||||
export interface SlotGroupSpec {
|
||||
slots: SlotSpec[];
|
||||
requireDistinctAsset?: boolean;
|
||||
}
|
||||
|
||||
/** 当前 Slot 资产的交付特征(来自入库时的解析/探测) */
|
||||
export type QualityFacts = Pick<
|
||||
Partial<ParsedRelease>,
|
||||
"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"] };
|
||||
Reference in new issue
Block a user