Merge branch 'libv1'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
3f1ff01689
19 files changed
+2060
-9
No files matched your search
@@ -13,6 +13,7 @@ import * as execution from "./execution.ts";
|
||||
import * as files from "./files.ts";
|
||||
import * as identity from "./identity.ts";
|
||||
import * as instances from "./instances.ts";
|
||||
import * as libraryVersions from "./library-versions.ts";
|
||||
import * as mcp from "./mcp.ts";
|
||||
import * as ops from "./ops.ts";
|
||||
import * as organization from "./organization.ts";
|
||||
@@ -2181,6 +2182,15 @@ export const Api = {
|
||||
params: IdParam,
|
||||
response: organization.LibraryDetail,
|
||||
}),
|
||||
versions: ep({
|
||||
method: "get",
|
||||
path: "/library/{id}/versions",
|
||||
scope: "read",
|
||||
summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
|
||||
params: IdParam,
|
||||
query: libraryVersions.LibraryVersionsQuery,
|
||||
response: libraryVersions.LibraryVersions,
|
||||
}),
|
||||
reconcile: ep({
|
||||
method: "post",
|
||||
path: "/library/reconcile",
|
||||
|
||||
@@ -19,6 +19,16 @@ export const DeliveryTraits = z.object({
|
||||
sourceType: z.string().optional(),
|
||||
releaseGroup: z.string().optional(),
|
||||
subtitles: z.array(z.string()).optional(),
|
||||
// LIBV-1 版本规格(domain deliveryOfSpec 写入;specVersion 标记口径,缺省 = 旧数据待回填)
|
||||
specVersion: z.number().int().optional(),
|
||||
specFrom: z.array(z.enum(["name", "probe"])).optional(),
|
||||
isEncode: z.boolean().optional(),
|
||||
bitDepth: z.number().int().optional(),
|
||||
audio: z
|
||||
.array(z.object({ codec: z.string().optional(), channels: z.string().optional(), language: z.string().optional() }))
|
||||
.optional(),
|
||||
edition: z.string().optional(),
|
||||
durationSec: z.number().int().optional(),
|
||||
});
|
||||
export type DeliveryTraits = z.infer<typeof DeliveryTraits>;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./events.ts";
|
||||
export * from "./execution.ts";
|
||||
export * from "./identity.ts";
|
||||
export * from "./instances.ts";
|
||||
export * from "./library-versions.ts";
|
||||
export * from "./ops.ts";
|
||||
export * from "./organization.ts";
|
||||
export * from "./pi.ts";
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { z } from "zod";
|
||||
import { AssetStatus, LocationStatus } from "./assets.ts";
|
||||
import { Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
|
||||
import { MediaKind } from "./content.ts";
|
||||
|
||||
// LIBV-1 入库版本(GET /library/{id}/versions):一集(或一部电影)可挂多个本地文件,每个文件是一个版本。
|
||||
// 规格来自文件名解析 + ffprobe(domain versionSpecOf);季 / 作品给汇总;
|
||||
// 配了版本目标时每集给出比对结果 matches(目前只比一个目标,数组形态留给多目标)。
|
||||
|
||||
export const VersionAudio = z.object({
|
||||
codec: z.string().optional(),
|
||||
channels: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
});
|
||||
export type VersionAudio = z.infer<typeof VersionAudio>;
|
||||
|
||||
export const AssetVersion = z.object({
|
||||
assetId: Id,
|
||||
status: AssetStatus,
|
||||
resolution: z.string().nullable(),
|
||||
/** REMUX / BluRay(压制)/ BluRay-Disc(原盘)/ WEB-DL / WEBRip / HDTV … */
|
||||
source: z.string().nullable(),
|
||||
isEncode: z.boolean(),
|
||||
videoCodec: z.string().nullable(),
|
||||
bitDepth: z.number().int().nullable(),
|
||||
hdr: z.array(z.string()),
|
||||
audio: z.array(VersionAudio),
|
||||
subtitleLanguages: z.array(z.string()),
|
||||
releaseGroup: z.string().nullable(),
|
||||
/** 剪辑版本(导演剪辑版等),无标记为 null */
|
||||
edition: z.string().nullable(),
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
durationSec: NonNegInt.nullable(),
|
||||
fileName: z.string(),
|
||||
relativePath: z.string().nullable(),
|
||||
/** 库根本地路径 + 相对路径;库根未知时为 null */
|
||||
absolutePath: z.string().nullable(),
|
||||
libraryRootId: Id.nullable(),
|
||||
locationStatus: LocationStatus.nullable(),
|
||||
/** 入库时间(资产登记时间) */
|
||||
organizedAt: Timestamp,
|
||||
downloadTaskId: Id.nullable(),
|
||||
sourceReleaseId: Id.nullable(),
|
||||
/** 规格来源:name = 文件名,probe = ffprobe */
|
||||
specFrom: z.array(z.enum(["name", "probe"])),
|
||||
/** delivery 已按当前口径写回;false = 本次现算,可跑回填脚本 */
|
||||
specStored: z.boolean(),
|
||||
/** ffprobe 原始摘要(技术信息折叠里展示) */
|
||||
probe: JsonObject.nullable(),
|
||||
/** 覆盖的内容单元(合集文件覆盖多集) */
|
||||
unitIds: z.array(Id),
|
||||
});
|
||||
export type AssetVersion = z.infer<typeof AssetVersion>;
|
||||
|
||||
export const VersionSpecGroup = z.object({
|
||||
resolution: z.string().nullable(),
|
||||
source: z.string().nullable(),
|
||||
hdr: z.array(z.string()),
|
||||
count: NonNegInt,
|
||||
});
|
||||
export const VersionSummary = z.object({
|
||||
versionCount: NonNegInt,
|
||||
sizeBytes: NonNegInt,
|
||||
/** 按 分辨率 + 来源 + HDR 分组,多的在前 */
|
||||
specs: z.array(VersionSpecGroup),
|
||||
});
|
||||
export type VersionSummary = z.infer<typeof VersionSummary>;
|
||||
|
||||
export const UnitMatchState = z.enum(["satisfied", "missing", "upgradeable"]);
|
||||
export type UnitMatchState = z.infer<typeof UnitMatchState>;
|
||||
export const UnitMatch = z.object({
|
||||
slotId: Id.nullable(),
|
||||
state: UnitMatchState,
|
||||
assetId: Id.nullable(),
|
||||
reasons: z.array(z.string()),
|
||||
});
|
||||
export type UnitMatch = z.infer<typeof UnitMatch>;
|
||||
export const MatchCounts = z.object({ satisfied: NonNegInt, missing: NonNegInt, upgradeable: NonNegInt });
|
||||
export type MatchCounts = z.infer<typeof MatchCounts>;
|
||||
|
||||
export const EpisodeVersions = z.object({
|
||||
unitId: Id,
|
||||
episodeNumber: z.number().int(),
|
||||
segment: z.string().nullable(),
|
||||
versions: z.array(AssetVersion),
|
||||
/** 与各版本目标的比对;没有版本目标时不出现 */
|
||||
matches: z.array(UnitMatch).optional(),
|
||||
});
|
||||
export type EpisodeVersions = z.infer<typeof EpisodeVersions>;
|
||||
|
||||
export const SeasonVersions = z.object({
|
||||
seasonNumber: z.number().int(),
|
||||
unitId: Id.nullable(),
|
||||
/** 该季总集数(元数据);未知为 null */
|
||||
episodeTotal: NonNegInt.nullable(),
|
||||
/** 至少有一个版本的集数 */
|
||||
episodesWithVersion: NonNegInt,
|
||||
summary: VersionSummary,
|
||||
episodes: z.array(EpisodeVersions),
|
||||
/** 按第一个版本目标统计;没有版本目标时不出现 */
|
||||
matchCounts: MatchCounts.optional(),
|
||||
});
|
||||
export type SeasonVersions = z.infer<typeof SeasonVersions>;
|
||||
|
||||
export const LibraryVersionsQuery = z.object({
|
||||
/** 比对哪个版本目标;缺省取作品的主版本目标 */
|
||||
slotId: z.uuid().optional(),
|
||||
});
|
||||
export type LibraryVersionsQuery = z.infer<typeof LibraryVersionsQuery>;
|
||||
|
||||
export const LibraryVersions = z.object({
|
||||
entityId: Id,
|
||||
kind: MediaKind,
|
||||
/** 参与比对的版本目标(当前最多一个);空 = 未配置 */
|
||||
targets: z.array(z.object({ slotId: Id, name: z.string() })),
|
||||
summary: VersionSummary,
|
||||
seasons: z.array(SeasonVersions),
|
||||
/** 电影:全部版本与比对 */
|
||||
movie: z.object({ versions: z.array(AssetVersion), matches: z.array(UnitMatch).optional() }).nullable(),
|
||||
/** 剧集里没对上具体某一集的视频文件 */
|
||||
unplaced: z.array(AssetVersion),
|
||||
});
|
||||
export type LibraryVersions = z.infer<typeof LibraryVersions>;
|
||||
@@ -4,6 +4,7 @@ export const PACKAGE_NAME = "@umt/domain";
|
||||
export * from "./acquisition/index.ts";
|
||||
export * from "./download-state/index.ts";
|
||||
export * from "./identity/index.ts";
|
||||
export * from "./library/versions.ts";
|
||||
export * from "./naming/index.ts";
|
||||
export * from "./organize/index.ts";
|
||||
export * from "./path/index.ts";
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { parseReleasePath } from "../release-parser/path.ts";
|
||||
import type { Cut, HdrFormat, ParsedRelease, ReleaseSource, Resolution, VideoCodec } from "../release-parser/types.ts";
|
||||
import {
|
||||
currentPri,
|
||||
DEFAULT_SOURCE_TIERS,
|
||||
describeViolation,
|
||||
evaluateCandidate,
|
||||
qualityTier,
|
||||
RESOLUTION_RANK,
|
||||
} from "../slot/evaluate.ts";
|
||||
import type { SlotSpec } from "../slot/types.ts";
|
||||
|
||||
// LIBV-1 入库版本:一集(或一部电影)可以挂多个本地文件,每个文件是一个"版本"。
|
||||
// 版本规格 = 文件名解析 + ffprobe 结果合并:技术事实(分辨率、编码、HDR、音轨、字幕、时长)以探测为准,
|
||||
// 来源(原盘/Remux/压制/WEB-DL)、制作组、剪辑版本只能从文件名得到。
|
||||
// 规范化后的规格写回 media_asset.delivery(specVersion 标记口径),读取时口径不符就现算。
|
||||
|
||||
export const VERSION_SPEC_VERSION = 1;
|
||||
|
||||
/** ffprobe 结果的结构子集(domain 不依赖 adapters) */
|
||||
export interface ProbeLike {
|
||||
status?: string;
|
||||
container?: string;
|
||||
durationSec?: number;
|
||||
video?: { codec?: string; profile?: string; width?: number; height?: number; pixFmt?: string; hdr?: string }[];
|
||||
audio?: {
|
||||
codec?: string;
|
||||
profile?: string;
|
||||
channels?: number;
|
||||
channelLayout?: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
}[];
|
||||
subtitles?: { codec?: string; language?: string; title?: string; forced?: boolean }[];
|
||||
}
|
||||
|
||||
export interface VersionAudio {
|
||||
/** TrueHD Atmos / DDP / DTS-HD MA / AAC … */
|
||||
codec?: string;
|
||||
/** 7.1 / 5.1 / 2.0 */
|
||||
channels?: string;
|
||||
/** zh / en / ja …;未知不给 */
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface VersionSpec {
|
||||
resolution?: Resolution;
|
||||
source?: ReleaseSource;
|
||||
/** 蓝光二次压制 */
|
||||
isEncode?: boolean;
|
||||
videoCodec?: VideoCodec;
|
||||
/** 按 DV → HDR10+ → HDR10 → HLG → HDR Vivid 排序;空 = SDR 或未知 */
|
||||
hdr: HdrFormat[];
|
||||
bitDepth?: number;
|
||||
audio: VersionAudio[];
|
||||
/** 内嵌 + 外挂字幕语言(zh-Hans / zh-Hant / zh / en …) */
|
||||
subtitleLanguages: string[];
|
||||
releaseGroup?: string;
|
||||
edition?: Cut;
|
||||
durationSec?: number;
|
||||
/** 规格来自哪里:name = 文件名解析,probe = ffprobe */
|
||||
specFrom: ("name" | "probe")[];
|
||||
}
|
||||
|
||||
const HDR_ORDER: HdrFormat[] = ["DV", "HDR10+", "HDR10", "HLG", "HDR Vivid"];
|
||||
|
||||
export function resolutionFromDims(width?: number, height?: number): Resolution | undefined {
|
||||
const w = width ?? 0;
|
||||
const h = height ?? 0;
|
||||
if (!w && !h) return undefined;
|
||||
if (w >= 7000 || h >= 4000) return "4320p";
|
||||
if (w >= 3200 || h >= 1800) return "2160p";
|
||||
if (w >= 2400 || h >= 1300) return "1440p";
|
||||
if (w >= 1800 || h >= 1000) return "1080p";
|
||||
if (w >= 1200 || h >= 700) return "720p";
|
||||
if (w >= 1000 || h >= 540) return "576p";
|
||||
return "480p";
|
||||
}
|
||||
|
||||
const VIDEO_CODEC: Record<string, VideoCodec> = {
|
||||
hevc: "H.265",
|
||||
h265: "H.265",
|
||||
h264: "H.264",
|
||||
avc: "H.264",
|
||||
av1: "AV1",
|
||||
vc1: "VC-1",
|
||||
mpeg2video: "MPEG-2",
|
||||
};
|
||||
|
||||
export function videoCodecOf(codec?: string): VideoCodec | undefined {
|
||||
return codec ? VIDEO_CODEC[codec.toLowerCase()] : undefined;
|
||||
}
|
||||
|
||||
/** ffprobe 音频编码 → 常用写法;Atmos / DTS:X 从 profile 或轨道标题识别 */
|
||||
export function audioCodecOf(codec?: string, profile?: string, title?: string): string | undefined {
|
||||
if (!codec) return undefined;
|
||||
const c = codec.toLowerCase();
|
||||
const hint = `${profile ?? ""} ${title ?? ""}`;
|
||||
const atmos = /atmos/i.test(hint) ? " Atmos" : "";
|
||||
if (c === "truehd") return `TrueHD${atmos}`;
|
||||
if (c === "eac3") return `DDP${atmos}`;
|
||||
if (c === "ac3") return "DD";
|
||||
if (c === "dts") {
|
||||
if (/dts[:-]?x\b/i.test(hint)) return "DTS:X";
|
||||
if (/\bma\b|master/i.test(hint)) return "DTS-HD MA";
|
||||
if (/hra|high res/i.test(hint)) return "DTS-HD HRA";
|
||||
return "DTS";
|
||||
}
|
||||
if (c === "aac") return "AAC";
|
||||
if (c === "flac") return "FLAC";
|
||||
if (c === "opus") return "Opus";
|
||||
if (c === "mp3") return "MP3";
|
||||
if (c.startsWith("pcm_")) return "LPCM";
|
||||
return codec.toUpperCase();
|
||||
}
|
||||
|
||||
const CHANNELS: Record<number, string> = { 1: "1.0", 2: "2.0", 3: "2.1", 6: "5.1", 7: "6.1", 8: "7.1" };
|
||||
|
||||
export function channelsLabel(channels?: number, layout?: string): string | undefined {
|
||||
const m = layout?.match(/^(\d\.\d)/);
|
||||
if (m) return m[1];
|
||||
if (layout === "stereo") return "2.0";
|
||||
if (layout === "mono") return "1.0";
|
||||
if (channels === undefined) return undefined;
|
||||
return CHANNELS[channels] ?? `${channels}ch`;
|
||||
}
|
||||
|
||||
const LANG: Record<string, string> = {
|
||||
chi: "zh",
|
||||
zho: "zh",
|
||||
chs: "zh-Hans",
|
||||
cht: "zh-Hant",
|
||||
sc: "zh-Hans",
|
||||
tc: "zh-Hant",
|
||||
eng: "en",
|
||||
jpn: "ja",
|
||||
jap: "ja",
|
||||
kor: "ko",
|
||||
fre: "fr",
|
||||
fra: "fr",
|
||||
ger: "de",
|
||||
deu: "de",
|
||||
spa: "es",
|
||||
ita: "it",
|
||||
rus: "ru",
|
||||
por: "pt",
|
||||
tha: "th",
|
||||
cantonese: "yue",
|
||||
yue: "yue",
|
||||
};
|
||||
|
||||
/** 轨道语言 → 短码;中文按标题区分简繁;und / 空 → undefined */
|
||||
export function languageOf(language?: string, title?: string): string | undefined {
|
||||
const l = (language ?? "").toLowerCase();
|
||||
const t = title ?? "";
|
||||
if (!l || l === "und") {
|
||||
if (/简|chs|simplified/i.test(t)) return "zh-Hans";
|
||||
if (/繁|cht|traditional/i.test(t)) return "zh-Hant";
|
||||
return undefined;
|
||||
}
|
||||
const code = LANG[l] ?? l;
|
||||
if (code === "zh") {
|
||||
if (/简|chs|simplified|gb/i.test(t)) return "zh-Hans";
|
||||
if (/繁|cht|traditional|big5/i.test(t)) return "zh-Hant";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 外挂字幕文件名里的语言记号:xxx.chs.srt / xxx.zh-Hant.ass / xxx.eng.srt */
|
||||
export function sidecarLanguageOf(fileName: string): string | undefined {
|
||||
const parts = fileName.toLowerCase().split(".");
|
||||
parts.pop();
|
||||
for (const p of parts.slice(-2).reverse()) {
|
||||
if (p === "zh-hans" || p === "zh-cn") return "zh-Hans";
|
||||
if (p === "zh-hant" || p === "zh-tw" || p === "zh-hk") return "zh-Hant";
|
||||
if (p === "zh" || p === "chi" || p === "zho") return "zh";
|
||||
if (LANG[p]) return LANG[p];
|
||||
if (p === "en" || p === "ja" || p === "ko") return p;
|
||||
}
|
||||
if (/简|chs/i.test(fileName)) return "zh-Hans";
|
||||
if (/繁|cht/i.test(fileName)) return "zh-Hant";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uniq = <T>(xs: readonly T[]) => [...new Set(xs)];
|
||||
|
||||
export function sortHdr(xs: readonly string[]): HdrFormat[] {
|
||||
const set = new Set(xs.filter((x): x is HdrFormat => (HDR_ORDER as string[]).includes(x)));
|
||||
if (set.has("HDR10+")) set.delete("HDR10");
|
||||
return HDR_ORDER.filter((h) => set.has(h));
|
||||
}
|
||||
|
||||
/** 文件名里的音频记号 "DDP 5.1" + "Atmos" → [{ codec: "DDP Atmos", channels: "5.1" }] */
|
||||
function audioFromName(tokens: readonly string[]): VersionAudio[] {
|
||||
const out: VersionAudio[] = [];
|
||||
for (const t of tokens) {
|
||||
if (/^atmos$/i.test(t)) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.codec && !/atmos/i.test(last.codec)) last.codec = `${last.codec} Atmos`;
|
||||
else if (!last) out.push({ codec: "Atmos" });
|
||||
continue;
|
||||
}
|
||||
const m = t.match(/^(.*?)\s*(\d\.\d)$/);
|
||||
out.push(m ? { codec: m[1] || undefined, channels: m[2] } : { codec: t });
|
||||
}
|
||||
return out.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v !== undefined)) as VersionAudio);
|
||||
}
|
||||
|
||||
export interface VersionSpecInput {
|
||||
/** 库内相对路径(含作品/季目录,解析时目录名参与) */
|
||||
path: string;
|
||||
probe?: ProbeLike | null;
|
||||
/** 外挂字幕文件名(同主视频的附属资产) */
|
||||
sidecarSubtitles?: readonly string[];
|
||||
/** 已解析的结果(省一次解析,测试用) */
|
||||
parsed?: ParsedRelease;
|
||||
}
|
||||
|
||||
/** 文件名解析 + 探测 → 版本规格 */
|
||||
export function versionSpecOf(input: VersionSpecInput): VersionSpec {
|
||||
const r = input.parsed ?? parseReleasePath(input.path, { isFile: true });
|
||||
const p = input.probe?.status === "ok" ? input.probe : null;
|
||||
const v = p?.video?.[0];
|
||||
const from: VersionSpec["specFrom"] = [];
|
||||
const nameHas = !!(r.resolution || r.source || r.videoCodec || r.releaseGroup || r.hdr.length || r.audio.length);
|
||||
if (nameHas) from.push("name");
|
||||
if (p) from.push("probe");
|
||||
const audio = p?.audio?.length
|
||||
? p.audio.map((a) => {
|
||||
const o: VersionAudio = {};
|
||||
const codec = audioCodecOf(a.codec, a.profile, a.title);
|
||||
const ch = channelsLabel(a.channels, a.channelLayout);
|
||||
const lang = languageOf(a.language, a.title);
|
||||
if (codec) o.codec = codec;
|
||||
if (ch) o.channels = ch;
|
||||
if (lang) o.language = lang;
|
||||
return o;
|
||||
})
|
||||
: audioFromName(r.audio);
|
||||
const subs = [
|
||||
...(p?.subtitles ?? []).map((s) => languageOf(s.language, s.title)),
|
||||
...(r.subtitles ?? []),
|
||||
...(input.sidecarSubtitles ?? []).map(sidecarLanguageOf),
|
||||
].filter((x): x is string => !!x);
|
||||
const bitDepth = v?.pixFmt?.match(/p(10|12)(le|be)?$/)?.[1];
|
||||
const spec: VersionSpec = {
|
||||
hdr: sortHdr([...(v?.hdr ? [v.hdr] : []), ...r.hdr]),
|
||||
audio,
|
||||
subtitleLanguages: uniq(subs),
|
||||
specFrom: from,
|
||||
};
|
||||
const res = resolutionFromDims(v?.width, v?.height) ?? r.resolution;
|
||||
if (res) spec.resolution = res;
|
||||
if (r.source) spec.source = r.source;
|
||||
if (r.source) spec.isEncode = r.isEncode;
|
||||
const vc = videoCodecOf(v?.codec) ?? r.videoCodec;
|
||||
if (vc) spec.videoCodec = vc;
|
||||
const bd = bitDepth ? Number(bitDepth) : r.bitDepth;
|
||||
if (bd) spec.bitDepth = bd;
|
||||
if (r.releaseGroup) spec.releaseGroup = r.releaseGroup.name;
|
||||
if (r.cut) spec.edition = r.cut;
|
||||
if (p?.durationSec) spec.durationSec = Math.round(p.durationSec);
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** 写回 media_asset.delivery 的形态(DeliveryTraits 的超集,旧字段保持同名) */
|
||||
export function deliveryOfSpec(s: VersionSpec): Record<string, unknown> {
|
||||
return {
|
||||
specVersion: VERSION_SPEC_VERSION,
|
||||
specFrom: s.specFrom,
|
||||
...(s.resolution ? { resolution: s.resolution } : {}),
|
||||
...(s.source ? { sourceType: s.source, isEncode: !!s.isEncode } : {}),
|
||||
...(s.videoCodec ? { videoCodec: s.videoCodec } : {}),
|
||||
...(s.bitDepth ? { bitDepth: s.bitDepth } : {}),
|
||||
hdr: s.hdr,
|
||||
audio: s.audio,
|
||||
audioCodecs: uniq(s.audio.map((a) => a.codec).filter((x): x is string => !!x)),
|
||||
subtitles: s.subtitleLanguages,
|
||||
...(s.releaseGroup ? { releaseGroup: s.releaseGroup } : {}),
|
||||
...(s.edition ? { edition: s.edition } : {}),
|
||||
...(s.durationSec ? { durationSec: s.durationSec } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 已按当前口径写回的 delivery → 规格;口径不符(旧数据)返回 null,由调用方现算 */
|
||||
export function specFromDelivery(d: unknown): VersionSpec | null {
|
||||
if (!d || typeof d !== "object") return null;
|
||||
const o = d as Record<string, unknown>;
|
||||
if (o.specVersion !== VERSION_SPEC_VERSION) return null;
|
||||
const str = (k: string) => (typeof o[k] === "string" ? (o[k] as string) : undefined);
|
||||
const arr = <T>(k: string) => (Array.isArray(o[k]) ? (o[k] as T[]) : []);
|
||||
const spec: VersionSpec = {
|
||||
hdr: sortHdr(arr<string>("hdr")),
|
||||
audio: arr<VersionAudio>("audio"),
|
||||
subtitleLanguages: arr<string>("subtitles"),
|
||||
specFrom: arr<"name" | "probe">("specFrom"),
|
||||
};
|
||||
if (str("resolution")) spec.resolution = str("resolution") as Resolution;
|
||||
if (str("sourceType")) {
|
||||
spec.source = str("sourceType") as ReleaseSource;
|
||||
spec.isEncode = o.isEncode === true;
|
||||
}
|
||||
if (str("videoCodec")) spec.videoCodec = str("videoCodec") as VideoCodec;
|
||||
if (typeof o.bitDepth === "number") spec.bitDepth = o.bitDepth;
|
||||
if (str("releaseGroup")) spec.releaseGroup = str("releaseGroup");
|
||||
if (str("edition")) spec.edition = str("edition") as Cut;
|
||||
if (typeof o.durationSec === "number") spec.durationSec = o.durationSec;
|
||||
return spec;
|
||||
}
|
||||
|
||||
// ── 汇总 ──
|
||||
|
||||
export interface SpecGroup {
|
||||
resolution: Resolution | null;
|
||||
source: ReleaseSource | null;
|
||||
hdr: HdrFormat[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VersionSummary {
|
||||
versionCount: number;
|
||||
sizeBytes: number;
|
||||
/** 按 分辨率 + 来源 + HDR 分组,数量多的在前 */
|
||||
specs: SpecGroup[];
|
||||
}
|
||||
|
||||
export function summarizeVersions(
|
||||
items: readonly { spec: Pick<VersionSpec, "resolution" | "source" | "hdr">; sizeBytes: number | null }[],
|
||||
): VersionSummary {
|
||||
const groups = new Map<string, SpecGroup>();
|
||||
let size = 0;
|
||||
for (const it of items) {
|
||||
size += it.sizeBytes ?? 0;
|
||||
const hdr = it.spec.hdr.filter((h) => h !== "HDR10" || !it.spec.hdr.includes("DV"));
|
||||
const key = `${it.spec.resolution ?? ""}|${it.spec.source ?? ""}|${hdr.join("+")}`;
|
||||
const g = groups.get(key);
|
||||
if (g) g.count++;
|
||||
else groups.set(key, { resolution: it.spec.resolution ?? null, source: it.spec.source ?? null, hdr, count: 1 });
|
||||
}
|
||||
const rank = (g: SpecGroup) => (g.resolution ? RESOLUTION_RANK[g.resolution] : -1);
|
||||
return {
|
||||
versionCount: items.length,
|
||||
sizeBytes: size,
|
||||
specs: [...groups.values()].sort((a, b) => b.count - a.count || rank(b) - rank(a)),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 与版本目标比对 ──
|
||||
|
||||
export type UnitMatchState = "satisfied" | "missing" | "upgradeable";
|
||||
|
||||
export interface UnitMatch {
|
||||
slotId: string | null;
|
||||
state: UnitMatchState;
|
||||
/** 满足目标的最好版本(satisfied / 可升级但已达标时);都不达标时为最接近的版本 */
|
||||
assetId: string | null;
|
||||
/** 可读原因:不达标的条件、升级余量 */
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface LocalVersion {
|
||||
assetId: string;
|
||||
path: string;
|
||||
spec: VersionSpec;
|
||||
sizeBytes: number | null;
|
||||
/** 该文件覆盖的内容单元数(合集文件按集折算体积) */
|
||||
unitCount?: number;
|
||||
}
|
||||
|
||||
/** 本地文件与站点资源不同:站点范围、做种数、促销、标题关键词不适用;剧集按单集体积比较 */
|
||||
export function localSlotSpec(spec: SlotSpec, isEpisode: boolean): SlotSpec {
|
||||
const { sites: _s, excludeSites: _xs, minSeeders: _m, freeOnly: _f, include: _i, exclude: _x, ...hard } = spec.hard;
|
||||
if (isEpisode) {
|
||||
delete hard.maxSizeBytes;
|
||||
delete hard.minSizeBytes;
|
||||
}
|
||||
return { ...spec, hard, unknownPolicy: "accept" };
|
||||
}
|
||||
|
||||
function pseudoRelease(v: LocalVersion): ParsedRelease {
|
||||
const s = v.spec;
|
||||
return {
|
||||
parserVersion: 0,
|
||||
raw: v.path,
|
||||
isEncode: !!s.isEncode,
|
||||
audio: s.audio.map((a) => [a.codec, a.channels].filter(Boolean).join(" ")),
|
||||
hdr: s.hdr,
|
||||
contentType: "unknown",
|
||||
variantTags: [],
|
||||
mediaTypeHint: "unknown",
|
||||
isSeasonPack: false,
|
||||
packHint: "single",
|
||||
isFile: true,
|
||||
explicitIds: [],
|
||||
confidence: "rule_confirmed",
|
||||
evidence: [],
|
||||
...(s.resolution ? { resolution: s.resolution } : {}),
|
||||
...(s.source ? { source: s.source } : {}),
|
||||
...(s.videoCodec ? { videoCodec: s.videoCodec } : {}),
|
||||
...(s.releaseGroup ? { releaseGroup: { name: s.releaseGroup, source: "known" as const } } : {}),
|
||||
...(s.edition ? { cut: s.edition } : {}),
|
||||
// 只有探测过才知道内嵌字幕;只凭文件名时视为未知(不据此判不达标)
|
||||
...(s.specFrom.includes("probe") || s.subtitleLanguages.length ? { subtitles: s.subtitleLanguages } : {}),
|
||||
...(v.sizeBytes !== null ? { sizeBytes: v.sizeBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 目标允许的最高来源等级(未限制来源时为等级表最高) */
|
||||
function ceilingOf(spec: SlotSpec): { source: number; resolution: number } {
|
||||
const tiers = { ...DEFAULT_SOURCE_TIERS, ...spec.upgrade?.sourceTiers };
|
||||
const allowed = (spec.hard.sources?.length ? spec.hard.sources : (Object.keys(tiers) as ReleaseSource[])).filter(
|
||||
(s) => !spec.hard.excludeSources?.includes(s),
|
||||
);
|
||||
const res = spec.hard.resolutions?.length ? spec.hard.resolutions : (Object.keys(RESOLUTION_RANK) as Resolution[]);
|
||||
return {
|
||||
source: Math.max(-1, ...allowed.map((s) => tiers[s])),
|
||||
resolution: Math.max(-1, ...res.map((r) => RESOLUTION_RANK[r])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一集(或一部电影)的全部版本与一个版本目标比对:
|
||||
* missing = 没有版本;satisfied = 有版本达标且没有升级余量;
|
||||
* upgradeable = 有版本但都不达标,或达标版本在目标的升级策略下还能换更好的。
|
||||
*/
|
||||
export function matchVersions(
|
||||
target: SlotSpec,
|
||||
versions: readonly LocalVersion[],
|
||||
opts: { isEpisode: boolean },
|
||||
): UnitMatch {
|
||||
const slotId = target.id ?? null;
|
||||
if (versions.length === 0) return { slotId, state: "missing", assetId: null, reasons: [] };
|
||||
const spec = localSlotSpec(target, opts.isEpisode);
|
||||
const evals = versions.map((v) => {
|
||||
const r = pseudoRelease(v);
|
||||
const ev = evaluateCandidate(spec, r, null, v.unitCount ? { unitCount: v.unitCount } : {});
|
||||
return { v, r, ev, tier: qualityTier(r, spec) };
|
||||
});
|
||||
const better = (a: (typeof evals)[number], b: (typeof evals)[number]) =>
|
||||
(b.ev.decision === "accept" ? (b.ev.pri ?? 0) : 0) - (a.ev.decision === "accept" ? (a.ev.pri ?? 0) : 0) ||
|
||||
b.tier.source - a.tier.source ||
|
||||
b.tier.resolution - a.tier.resolution;
|
||||
const ok = evals.filter((e) => e.ev.decision === "accept").sort(better);
|
||||
const best = ok[0];
|
||||
if (!best) {
|
||||
const near = [...evals].sort(better)[0]!;
|
||||
const reasons = near.ev.decision === "reject" ? uniq(near.ev.violations.map(describeViolation)) : ["规格未知"];
|
||||
return { slotId, state: "upgradeable", assetId: near.v.assetId, reasons };
|
||||
}
|
||||
const up = target.upgrade;
|
||||
if (!up?.enabled) return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
const tiered = !!target.soft?.tiers?.length || !!target.rules?.groups.length;
|
||||
const basis = up.basis ?? (tiered ? "priority" : "tier");
|
||||
if (basis === "priority") {
|
||||
const pri = best.ev.decision === "accept" ? (best.ev.pri ?? currentPri(spec, best.r)) : 0;
|
||||
const goal = up.targetPri ?? 100;
|
||||
return pri < goal
|
||||
? { slotId, state: "upgradeable", assetId: best.v.assetId, reasons: [`优先级 ${pri},目标 ${goal}`] }
|
||||
: { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
}
|
||||
const ceil = ceilingOf(target);
|
||||
const belowCeiling = up.ceilingTier === undefined || best.tier.source < up.ceilingTier;
|
||||
const bySource = belowCeiling && best.tier.source + (up.minTierSteps ?? 1) <= ceil.source;
|
||||
const byRes = !!up.allowResolutionUpgrade && best.tier.resolution < ceil.resolution;
|
||||
if (bySource || byRes)
|
||||
return {
|
||||
slotId,
|
||||
state: "upgradeable",
|
||||
assetId: best.v.assetId,
|
||||
reasons: [bySource ? "来源还能升级" : "分辨率还能升级"],
|
||||
};
|
||||
return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
audioCodecOf,
|
||||
channelsLabel,
|
||||
deliveryOfSpec,
|
||||
type LocalVersion,
|
||||
languageOf,
|
||||
matchVersions,
|
||||
type ProbeLike,
|
||||
resolutionFromDims,
|
||||
type SlotSpec,
|
||||
sidecarLanguageOf,
|
||||
specFromDelivery,
|
||||
summarizeVersions,
|
||||
VERSION_SPEC_VERSION,
|
||||
versionSpecOf,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const GB = 1e9;
|
||||
const DV_FILE = "人生切割术 (2022)/Season 1/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv";
|
||||
const BD_FILE = "人生切割术 (2022)/Season 1/Severance.S01E01.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD.mkv";
|
||||
|
||||
const PROBE_DV: ProbeLike = {
|
||||
status: "ok",
|
||||
durationSec: 3421.6,
|
||||
video: [{ codec: "hevc", width: 3840, height: 1606, pixFmt: "yuv420p10le", hdr: "DV" }],
|
||||
audio: [
|
||||
{
|
||||
codec: "eac3",
|
||||
profile: "Dolby Digital Plus + Dolby Atmos",
|
||||
channels: 6,
|
||||
channelLayout: "5.1(side)",
|
||||
language: "eng",
|
||||
},
|
||||
{ codec: "aac", channels: 2, language: "chi", title: "国语" },
|
||||
],
|
||||
subtitles: [
|
||||
{ codec: "subrip", language: "chi", title: "简体" },
|
||||
{ codec: "subrip", language: "chi", title: "繁體 cht" },
|
||||
{ codec: "subrip", language: "eng" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("版本规格:文件名 + 探测", () => {
|
||||
it("探测优先给技术事实,来源 / 制作组来自文件名", () => {
|
||||
const s = versionSpecOf({ path: DV_FILE, probe: PROBE_DV });
|
||||
expect(s).toMatchObject({
|
||||
resolution: "2160p",
|
||||
source: "WEB-DL",
|
||||
isEncode: false,
|
||||
videoCodec: "H.265",
|
||||
hdr: ["DV", "HDR10"],
|
||||
bitDepth: 10,
|
||||
releaseGroup: "FLUX",
|
||||
durationSec: 3422,
|
||||
specFrom: ["name", "probe"],
|
||||
});
|
||||
expect(s.audio).toEqual([
|
||||
{ codec: "DDP Atmos", channels: "5.1", language: "en" },
|
||||
{ codec: "AAC", channels: "2.0", language: "zh" },
|
||||
]);
|
||||
expect(s.subtitleLanguages).toEqual(["zh-Hans", "zh-Hant", "en"]);
|
||||
});
|
||||
|
||||
it("没有探测结果只用文件名;外挂字幕按文件名记语言", () => {
|
||||
const s = versionSpecOf({
|
||||
path: BD_FILE,
|
||||
sidecarSubtitles: ["Severance.S01E01.chs.srt", "Severance.S01E01.eng.ass"],
|
||||
});
|
||||
expect(s).toMatchObject({
|
||||
resolution: "1080p",
|
||||
source: "BluRay",
|
||||
isEncode: true,
|
||||
videoCodec: "H.264",
|
||||
hdr: [],
|
||||
releaseGroup: "CHD",
|
||||
specFrom: ["name"],
|
||||
});
|
||||
expect(s.audio).toEqual([{ codec: "DTS-HD MA", channels: "5.1" }]);
|
||||
expect(s.subtitleLanguages).toEqual(["zh-Hans", "en"]);
|
||||
expect(s.durationSec).toBeUndefined();
|
||||
});
|
||||
|
||||
it("裁切过的 4K(3840x1600)仍算 2160p;小工具函数", () => {
|
||||
expect(resolutionFromDims(3840, 1600)).toBe("2160p");
|
||||
expect(resolutionFromDims(1920, 800)).toBe("1080p");
|
||||
expect(resolutionFromDims(1280, 536)).toBe("720p");
|
||||
expect(resolutionFromDims()).toBeUndefined();
|
||||
expect(audioCodecOf("truehd", undefined, "TrueHD Atmos 7.1")).toBe("TrueHD Atmos");
|
||||
expect(audioCodecOf("dts", "DTS-HD MA + DTS:X")).toBe("DTS:X");
|
||||
expect(audioCodecOf("dts", "DTS-HD MA")).toBe("DTS-HD MA");
|
||||
expect(channelsLabel(8)).toBe("7.1");
|
||||
expect(channelsLabel(undefined, "stereo")).toBe("2.0");
|
||||
expect(languageOf("und")).toBeUndefined();
|
||||
expect(languageOf("jpn")).toBe("ja");
|
||||
expect(sidecarLanguageOf("x.zh-Hant.ass")).toBe("zh-Hant");
|
||||
});
|
||||
|
||||
it("写回 delivery 与读回互为逆;旧口径返回 null", () => {
|
||||
const s = versionSpecOf({ path: DV_FILE, probe: PROBE_DV });
|
||||
const d = deliveryOfSpec(s);
|
||||
expect(d.specVersion).toBe(VERSION_SPEC_VERSION);
|
||||
expect(d.sourceType).toBe("WEB-DL");
|
||||
expect(d.audioCodecs).toEqual(["DDP Atmos", "AAC"]);
|
||||
expect(specFromDelivery(JSON.parse(JSON.stringify(d)))).toEqual(s);
|
||||
expect(specFromDelivery({ resolution: "2160p", sourceType: "WEB-DL" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("汇总", () => {
|
||||
it("按 分辨率 + 来源 + HDR 分组,DV 带 HDR10 兜底只算 DV", () => {
|
||||
const dv = versionSpecOf({ path: DV_FILE, probe: PROBE_DV });
|
||||
const bd = versionSpecOf({ path: BD_FILE });
|
||||
const sum = summarizeVersions([
|
||||
{ spec: dv, sizeBytes: 14.2 * GB },
|
||||
{ spec: dv, sizeBytes: 13 * GB },
|
||||
{ spec: bd, sizeBytes: 8 * GB },
|
||||
]);
|
||||
expect(sum.versionCount).toBe(3);
|
||||
expect(sum.sizeBytes).toBe(35.2 * GB);
|
||||
expect(sum.specs).toEqual([
|
||||
{ resolution: "2160p", source: "WEB-DL", hdr: ["DV"], count: 2 },
|
||||
{ resolution: "1080p", source: "BluRay", hdr: [], count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("与版本目标比对", () => {
|
||||
const v = (id: string, path: string, probe?: ProbeLike, size = 10 * GB): LocalVersion => ({
|
||||
assetId: id,
|
||||
path,
|
||||
spec: versionSpecOf({ path, probe: probe ?? null }),
|
||||
sizeBytes: size,
|
||||
unitCount: 1,
|
||||
});
|
||||
const target: SlotSpec = {
|
||||
id: "slot-4k",
|
||||
name: "4K",
|
||||
hard: { resolutions: ["2160p"], minSeeders: 5, sites: ["only-site"], include: ["FLUX"], maxSizeBytes: 1 },
|
||||
};
|
||||
|
||||
it("没有版本 = missing", () => {
|
||||
expect(matchVersions(target, [], { isEpisode: true })).toEqual({
|
||||
slotId: "slot-4k",
|
||||
state: "missing",
|
||||
assetId: null,
|
||||
reasons: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("两个版本里有一个达标 = satisfied,指向达标的那个;站点/做种/关键词/整包体积条件不参与", () => {
|
||||
const m = matchVersions(target, [v("bd", BD_FILE), v("dv", DV_FILE, PROBE_DV)], { isEpisode: true });
|
||||
expect(m).toMatchObject({ state: "satisfied", assetId: "dv" });
|
||||
});
|
||||
|
||||
it("都不达标 = upgradeable,附不达标原因", () => {
|
||||
const m = matchVersions(target, [v("bd", BD_FILE)], { isEpisode: true });
|
||||
expect(m.state).toBe("upgradeable");
|
||||
expect(m.assetId).toBe("bd");
|
||||
expect(m.reasons.join(" ")).toMatch(/1080p/);
|
||||
});
|
||||
|
||||
it("达标但升级策略还有余量 = upgradeable;到顶 = satisfied", () => {
|
||||
const up: SlotSpec = { id: "s", name: "x", hard: {}, upgrade: { enabled: true } };
|
||||
const web = matchVersions(up, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true });
|
||||
expect(web).toMatchObject({ state: "upgradeable", assetId: "dv", reasons: ["来源还能升级"] });
|
||||
const capped: SlotSpec = { ...up, upgrade: { enabled: true, ceilingTier: 2 } };
|
||||
expect(matchVersions(capped, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).state).toBe("satisfied");
|
||||
const onlyWeb: SlotSpec = { ...up, hard: { sources: ["WEB-DL"] } };
|
||||
expect(matchVersions(onlyWeb, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).state).toBe("satisfied");
|
||||
});
|
||||
|
||||
it("只凭文件名时字幕未知不判不达标;探测过且缺中字则不达标", () => {
|
||||
const needZh: SlotSpec = { id: "z", name: "中字", hard: { requiredSubtitles: ["zh"] } };
|
||||
expect(matchVersions(needZh, [v("bd", BD_FILE)], { isEpisode: true }).state).toBe("satisfied");
|
||||
const noZh: ProbeLike = { ...PROBE_DV, subtitles: [{ language: "eng" }] };
|
||||
expect(matchVersions(needZh, [v("dv", DV_FILE, noZh)], { isEpisode: true }).state).toBe("upgradeable");
|
||||
});
|
||||
|
||||
it("电影保留整片体积条件", () => {
|
||||
const small: SlotSpec = { id: "m", name: "m", hard: { maxSizeBytes: 5 * GB } };
|
||||
const movie = "Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR10.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv";
|
||||
expect(matchVersions(small, [v("r", movie, undefined, 80 * GB)], { isEpisode: false }).state).toBe("upgradeable");
|
||||
expect(matchVersions(small, [v("r", movie, undefined, 80 * GB)], { isEpisode: true }).state).toBe("satisfied");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user