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, Violation } 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 = { 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 = { 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 = { 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 = (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 { 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; if (o.specVersion !== VERSION_SPEC_VERSION) return null; const str = (k: string) => (typeof o[k] === "string" ? (o[k] as string) : undefined); const arr = (k: string) => (Array.isArray(o[k]) ? (o[k] as T[]) : []); const spec: VersionSpec = { hdr: sortHdr(arr("hdr")), audio: arr("audio"), subtitleLanguages: arr("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; sizeBytes: number | null }[], ): VersionSummary { const groups = new Map(); 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[]; /** 差距原因码(`version_resolution:2160p`、`version_hdr` …),界面经 @umt/contracts matchGapText 转成人话 */ codes: 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: [], codes: [] }; 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 rejected = near.ev.decision === "reject" ? near.ev.violations : null; const reasons = rejected ? uniq(rejected.map(describeViolation)) : ["规格未知"]; const codes = rejected ? uniq(rejected.map((v) => gapCode(v, target))) : ["version_spec_unknown"]; return { slotId, state: "upgradeable", assetId: near.v.assetId, reasons, codes }; } const up = target.upgrade; if (!up?.enabled) return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] }; 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}`], codes: ["version_priority"], } : { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] }; } 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 ? "来源还能升级" : "分辨率还能升级"], codes: [bySource ? "version_source_upgrade" : withParam("version_resolution_upgrade", topResolution(target))], }; return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] }; } const withParam = (code: string, p: string | null | undefined) => (p ? `${code}:${p}` : code); /** 目标允许的最高分辨率名 */ function topResolution(spec: SlotSpec, allowed?: readonly string[]): string | null { const list = (allowed ?? spec.hard.resolutions ?? Object.keys(RESOLUTION_RANK)) as Resolution[]; const known = list.filter((r) => RESOLUTION_RANK[r] !== undefined); return known.sort((a, b) => RESOLUTION_RANK[b] - RESOLUTION_RANK[a])[0] ?? null; } /** 目标允许的最好来源 */ function topSource(spec: SlotSpec, allowed: readonly string[]): string | null { const tiers: Record = { ...DEFAULT_SOURCE_TIERS, ...spec.upgrade?.sourceTiers }; return [...allowed].sort((a, b) => (tiers[b] ?? -1) - (tiers[a] ?? -1))[0] ?? null; } const strList = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); /** * 本地版本与收录版本的差距 → 原因码(POLISH-1)。码表与文案在 @umt/contracts reasons.ts(matchGapText), * 冒号后是参数(目标分辨率、来源、编码、字幕语言)。 */ export function gapCode(v: Violation, spec: SlotSpec): string { const expected = strList(v.expected); switch (`${v.field}.${v.code}`) { case "resolution.not_allowed": return withParam("version_resolution", topResolution(spec, expected)); case "source.not_allowed": return withParam("version_source", topSource(spec, expected)); case "source.excluded": return "version_source"; case "source.not_encode": return "version_encode"; case "hdr.missing_required": return expected.length && expected.every((h) => h === "DV") ? "version_hdr:DV" : "version_hdr"; case "hdr.not_sdr": return "version_sdr"; case "hdr.excluded": return "version_hdr_excluded"; case "codec.not_allowed": return withParam("version_codec", expected[0]); case "codec.excluded": return "version_codec"; case "subtitles.missing_language": return withParam("version_subtitles", expected.join("/")); case "cut.not_allowed": return "version_edition"; case "group.excluded": return "version_group"; case "contentType.not_allowed": return "version_content_type"; default: return v.field === "size" ? "version_size" : "version_rules"; } }