Files
UHD_Media_Tool/packages/adapters/src/sites/mediainfo.ts
T
rootandClaude Opus 5.5 9be9813021 feat(sites): structure release detail pages and rework the detail drawer (DETAIL-1)
Adapters (pure functions + tests):
- detail-description: strip HTML/BBCode residue, drop site template blocks and
  ad/signature lines (configurable rules), parse PT info blocks (◎ / 【】 / 『』 /
  「键:」) into infoBlock and body paragraphs
- mediainfo: MediaInfo and BDInfo (quick summary + full report) into a summary
  of container/duration/bitrate/size, video (HDR/DV), audio and subtitle tracks
- file-tree: file list into a flattened directory tree with sizes and
  main/sample/subtitle/extra roles
- formatDetailPage combines them; the server computes it when reading the
  cached detail page, so existing caches benefit without a refetch

Contracts: SiteReleasePage gains infoBlock, paragraphs, media, fileTree.

Web: drawer now reads top-down: meta row (promo/H&R, size, peers, upload time,
IMDb/TMDB/豆瓣 tags) → 资料 → 简介 (collapsible) → 技术规格 (raw folded) →
截图 grid with keyboard lightbox → 文件 tree; inventory overlay moved into the
fixed footer next to 下载.

Fixtures: 6 desensitized samples from the local cache (site.detail and RSS
descriptions) plus synthetic multi-track MediaInfo, BDInfo, bracket blocks and
directory file lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:50:42 +08:00

406 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MediaInfo / BDInfo 文本 → 技术规格概要(DETAIL-1,纯函数)。原文另存,这里只取展示需要的字段:
* 概要(容器、时长、总码率、大小)、视频轨、音频轨、字幕轨。认不出的字段留 null,不猜。
*/
export interface MediaGeneral {
fileName: string | null;
container: string | null;
duration: string | null;
overallBitrate: string | null;
size: string | null;
}
export interface MediaVideoTrack {
codec: string | null;
resolution: string | null;
frameRate: string | null;
bitrate: string | null;
bitDepth: string | null;
/** HDR10 / HDR10+ / Dolby Vision P5 / HLG … 空数组 = SDR 或未知 */
hdr: string[];
}
export interface MediaAudioTrack {
language: string | null;
codec: string | null;
channels: string | null;
bitrate: string | null;
title: string | null;
isDefault: boolean | null;
}
export interface MediaTextTrack {
language: string | null;
format: string | null;
title: string | null;
isDefault: boolean | null;
forced: boolean | null;
}
export interface MediaSummary {
kind: "mediainfo" | "bdinfo";
general: MediaGeneral;
video: MediaVideoTrack[];
audio: MediaAudioTrack[];
subtitles: MediaTextTrack[];
}
const LANG: Record<string, string> = {
chinese: "中文",
mandarin: "国语",
cantonese: "粤语",
zh: "中文",
chi: "中文",
zho: "中文",
"zh-cn": "简体中文",
"zh-hans": "简体中文",
"zh-tw": "繁体中文",
"zh-hk": "繁体中文",
"zh-hant": "繁体中文",
english: "英语",
en: "英语",
eng: "英语",
japanese: "日语",
ja: "日语",
jpn: "日语",
korean: "韩语",
ko: "韩语",
kor: "韩语",
french: "法语",
fr: "法语",
german: "德语",
de: "德语",
spanish: "西班牙语",
es: "西班牙语",
italian: "意大利语",
russian: "俄语",
thai: "泰语",
portuguese: "葡萄牙语",
hindi: "印地语",
};
function language(v: string | null | undefined): string | null {
if (!v) return null;
const t = v.trim();
return LANG[t.toLowerCase()] ?? t;
}
function yesNo(v: string | undefined): boolean | null {
if (!v) return null;
if (/^(yes|是)$/i.test(v.trim())) return true;
if (/^(no|否)$/i.test(v.trim())) return false;
return null;
}
/** 「3 840 pixels」→ 3840 */
const num = (v: string | undefined): number | null => {
const m = v ? /([\d\s]+)/.exec(v.replace(/(\d)\s+(?=\d)/g, "$1")) : null;
return m ? Number.parseInt(m[1]!.replace(/\s/g, ""), 10) || null : null;
};
/** 码率:去掉数字里的千分位空格 「4 561 kb/s」→「4561 kb/s」 */
const rate = (v: string | undefined): string | null => (v ? v.replace(/(\d)\s+(?=\d)/g, "$1").trim() : null);
function frameRate(v: string | undefined): string | null {
const m = v ? /([\d.]+)/.exec(v) : null;
if (!m) return null;
const n = Number.parseFloat(m[1]!);
return Number.isFinite(n) ? `${Number.isInteger(n) ? n : Number(n.toFixed(3))} fps` : null;
}
/** 声道数 + 布局 → 2.0 / 5.1 / 7.1 */
function channels(count: string | undefined, layout: string | undefined): string | null {
const n = num(count);
if (!n) return null;
const lfe = layout ? /\bLFE\b/.test(layout) : n === 6 || n === 8;
return lfe ? `${n - 1}.1` : `${n}.0`;
}
function hdrOf(fields: Record<string, string>): string[] {
const out: string[] = [];
const f = `${fields["HDR format"] ?? ""} ${fields.HDR_Format ?? ""}`;
const dv = /Dolby Vision[^,]*,[^,]*,\s*Profile\s*([\d.]+)/i.exec(f);
if (dv) out.push(`Dolby Vision P${dv[1]}`);
else if (/Dolby Vision/i.test(f)) out.push("Dolby Vision");
if (/HDR10\+|SMPTE ST 2094/i.test(f)) out.push("HDR10+");
if (
/HDR10(?!\+)|SMPTE ST 2086/i.test(f) ||
(!out.length && /PQ|SMPTE ST 2084/.test(fields["Transfer characteristics"] ?? ""))
)
out.push("HDR10");
if (/HLG/i.test(`${f} ${fields["Transfer characteristics"] ?? ""}`)) out.push("HLG");
return [...new Set(out)];
}
/** 音频编码短名:MLP FBA → TrueHD,E-AC-3 → DDP,DTS XLL → DTS-HD MA;商业名带 Atmos 的加上 Atmos */
function audioCodec(f: Record<string, string>): string | null {
const fmt = f.Format?.trim();
if (!fmt) return f["Commercial name"] ?? null;
const commercial = f["Commercial name"] ?? "";
let name = fmt;
if (/^MLP FBA/i.test(fmt)) name = "TrueHD";
else if (/^E-AC-3/i.test(fmt)) name = "DDP";
else if (/^AC-3/i.test(fmt)) name = "AC-3";
else if (/^DTS/i.test(fmt))
name = /XLL X|DTS:X/i.test(`${fmt} ${commercial}`)
? "DTS:X"
: /XLL|Master Audio/i.test(`${fmt} ${commercial}`)
? "DTS-HD MA"
: "DTS";
else if (/^AAC/i.test(fmt)) name = "AAC";
else if (/^PCM/i.test(fmt)) name = "LPCM";
if (/Atmos/i.test(`${fmt} ${commercial}`) && !/Atmos/i.test(name)) name += " Atmos";
return name;
}
/** 中文版 MediaInfo 键 → 英文键 */
const ZH_KEYS: Record<string, string> = {
完整名称: "Complete name",
格式: "Format",
文件大小: "File size",
时长: "Duration",
总体码率: "Overall bit rate",
码率: "Bit rate",
宽度: "Width",
高度: "Height",
帧率: "Frame rate",
位深: "Bit depth",
声道数: "Channel(s)",
声道布局: "Channel layout",
语言: "Language",
标题: "Title",
默认: "Default",
强制: "Forced",
商业名称: "Commercial name",
"HDR 格式": "HDR format",
传输特质: "Transfer characteristics",
};
type Section = { type: "general" | "video" | "audio" | "text" | "other"; fields: Record<string, string> };
const SECTION_HEAD =
/^(General|Video|Audio|Text|Menu|Chapters|Image|Other|概览|常规|概要|视频|音频|文本|字幕|菜单|章节)(?:\s*#\s*\d+)?\s*$/i;
function sectionType(name: string): Section["type"] {
const n = name.toLowerCase();
if (/^(general|概览|常规|概要)/.test(n)) return "general";
if (/^(video|视频)/.test(n)) return "video";
if (/^(audio|音频)/.test(n)) return "audio";
if (/^(text|文本|字幕)/.test(n)) return "text";
return "other";
}
function parseSections(text: string): Section[] {
const out: Section[] = [];
let cur: Section | null = null;
for (const raw of text.replace(/\r/g, "").split("\n")) {
const line = raw.trim();
if (!line) continue;
const head = SECTION_HEAD.exec(line);
if (head) {
cur = { type: sectionType(head[1]!), fields: {} };
out.push(cur);
continue;
}
if (!cur) continue;
const m = /^(.+?)\s*[::]\s(.*)$/.exec(line) ?? /^(.+?)\s{2,}[::]\s*(.*)$/.exec(line);
if (!m) continue;
const key = ZH_KEYS[m[1]!.trim()] ?? m[1]!.trim();
if (!(key in cur.fields)) cur.fields[key] = m[2]!.trim();
}
return out;
}
function baseName(p: string | undefined): string | null {
return p ? (p.split(/[\\/]/).pop() ?? p).trim() || null : null;
}
function parseMediaInfoText(text: string): MediaSummary | null {
const sections = parseSections(text);
const g = sections.find((s) => s.type === "general")?.fields;
const videos = sections.filter((s) => s.type === "video");
if (!g && !videos.length) return null;
const general: MediaGeneral = {
fileName: baseName(g?.["Complete name"] ?? g?.["Movie name"]),
container: g?.Format ?? null,
duration: g?.Duration ?? null,
overallBitrate: rate(g?.["Overall bit rate"]),
size: g?.["File size"] ?? null,
};
const video = videos.map(({ fields: f }): MediaVideoTrack => {
const w = num(f.Width);
const h = num(f.Height);
return {
codec: f.Format ?? null,
resolution: w && h ? `${w}×${h}` : null,
frameRate: frameRate(f["Frame rate"] ?? f["Original frame rate"]),
bitrate: rate(f["Bit rate"] ?? f["Nominal bit rate"]),
bitDepth: num(f["Bit depth"]) ? `${num(f["Bit depth"])} bit` : null,
hdr: hdrOf(f),
};
});
const audio = sections
.filter((s) => s.type === "audio")
.map(
({ fields: f }): MediaAudioTrack => ({
language: language(f.Language),
codec: audioCodec(f),
channels: channels(f["Channel(s)"], f["Channel layout"] ?? f["Channel positions"]),
bitrate: rate(f["Bit rate"]),
title: f.Title ?? null,
isDefault: yesNo(f.Default),
}),
);
const subtitles = sections
.filter((s) => s.type === "text")
.map(
({ fields: f }): MediaTextTrack => ({
language: language(f.Language),
format: f.Format ?? null,
title: f.Title ?? null,
isDefault: yesNo(f.Default),
forced: yesNo(f.Forced),
}),
);
return { kind: "mediainfo", general, video, audio, subtitles };
}
// ---------------- BDInfo ----------------
function bdVideo(codec: string, desc: string, bitrate: string | null): MediaVideoTrack {
const parts = desc.split(/\s+\/\s+/).map((s) => s.trim());
const res = parts.find((p) => /^\d{3,4}[pi]$/i.test(p)) ?? null;
const hdr: string[] = [];
for (const p of parts) {
if (/^Dolby Vision/i.test(p)) hdr.push(p.replace(/^Dolby Vision\s*/i, "Dolby Vision ").trim());
else if (/^HDR10\+?$/i.test(p) || /^HLG$/i.test(p)) hdr.push(p.toUpperCase());
}
return {
codec: codec.replace(/\s*Video$/i, "").trim() || null,
resolution: res,
frameRate: frameRate(parts.find((p) => /fps$/i.test(p))),
bitrate: bitrate ?? parts.find((p) => /bps$/i.test(p)) ?? null,
bitDepth: parts.find((p) => /^\d+ bits?$/i.test(p))?.replace(/bits?$/i, "bit") ?? null,
hdr,
};
}
function bdAudioCodec(codec: string): string | null {
const c = codec.replace(/\s*Audio$/i, "").trim();
if (!c) return null;
if (/TrueHD\/Atmos/i.test(c)) return "TrueHD Atmos";
if (/TrueHD/i.test(c)) return "TrueHD";
if (/Digital Plus/i.test(c)) return /Atmos/i.test(c) ? "DDP Atmos" : "DDP";
if (/^Dolby Digital$/i.test(c)) return "AC-3";
if (/DTS:X/i.test(c)) return "DTS:X";
if (/DTS-HD Master/i.test(c)) return "DTS-HD MA";
return c;
}
function bdAudio(codec: string, lang: string | null, desc: string, bitrate: string | null): MediaAudioTrack {
const parts = desc.split(/\s+\/\s+/).map((s) => s.trim());
return {
language: language(lang),
codec: bdAudioCodec(codec),
channels: parts.find((p) => /^\d\.\d(?:\s|$|-)/.test(p))?.slice(0, 3) ?? null,
bitrate: bitrate ?? parts.find((p) => /kbps$/i.test(p)) ?? null,
title: null,
isDefault: null,
};
}
/** 「61,812,345,678 bytes」→「57.57 GiB」;认不出原样返回 */
function bdSize(v: string | null): string | null {
const m = v ? /^([\d,]+)\s*bytes$/i.exec(v.trim()) : null;
if (!m) return v;
const n = Number(m[1]!.replace(/,/g, ""));
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let x = n;
let i = 0;
while (x >= 1024 && i < units.length - 1) {
x /= 1024;
i++;
}
return `${i ? x.toFixed(2) : x} ${units[i]}`;
}
/** 「2:22:48.521」→「2 h 22 min」,与 MediaInfo 写法一致 */
function bdDuration(v: string | null): string | null {
const m = v ? /^(\d+):(\d{2}):(\d{2})/.exec(v.trim()) : null;
if (!m) return v;
const h = Number(m[1]);
const min = Number(m[2]);
return h ? `${h} h ${min} min` : `${min} min ${Number(m[3])} s`;
}
function parseBdInfoText(text: string): MediaSummary | null {
const lines = text.replace(/\r/g, "").split("\n");
const kv = (label: RegExp) => {
for (const l of lines) {
const m = new RegExp(`^\\s*${label.source}\\s*:\\s*(.+)$`, "i").exec(l);
if (m) return m[1]!.trim();
}
return null;
};
const general: MediaGeneral = {
fileName: kv(/(?:Disc Title|Disc Label)/),
container: "Blu-ray",
duration: bdDuration(kv(/Length/)),
overallBitrate: kv(/Total Bitrate/),
size: bdSize(kv(/(?:Disc Size|Size)/)),
};
const video: MediaVideoTrack[] = [];
const audio: MediaAudioTrack[] = [];
const subtitles: MediaTextTrack[] = [];
// 简版:「Video: codec / bitrate / 2160p / …」「Audio: lang / codec / 7.1 / …」「Subtitle: lang / bitrate」
for (const l of lines) {
const m = /^\s*(Video|Audio|Subtitle)\s*:\s*(.+)$/i.exec(l);
if (!m) continue;
const parts = m[2]!.split(/\s+\/\s+/).map((s) => s.trim());
const kind = m[1]!.toLowerCase();
if (kind === "video") video.push(bdVideo(parts[0] ?? "", parts.slice(1).join(" / "), parts[1] ?? null));
else if (kind === "audio") audio.push(bdAudio(parts[1] ?? "", parts[0] ?? null, parts.slice(2).join(" / "), null));
else subtitles.push({ language: language(parts[0]), format: "PGS", title: null, isDefault: null, forced: null });
}
// 完整报告:VIDEO: / AUDIO: / SUBTITLES: 下的表格,列之间两个以上空格
if (!video.length && !audio.length) {
let section: "video" | "audio" | "subs" | null = null;
for (const l of lines) {
const head =
/^\s*(VIDEO|AUDIO|SUBTITLES|FILES|CHAPTERS|STREAM DIAGNOSTICS|PLAYLIST REPORT|DISC INFO)\s*:\s*$/.exec(l);
if (head) {
section =
head[1] === "VIDEO" ? "video" : head[1] === "AUDIO" ? "audio" : head[1] === "SUBTITLES" ? "subs" : null;
continue;
}
if (!section || !l.trim() || /^\s*(Codec|-----)/.test(l)) continue;
const cols = l.trim().split(/\s{2,}/);
if (section === "video") video.push(bdVideo(cols[0] ?? "", cols[2] ?? "", cols[1] ?? null));
else if (section === "audio") audio.push(bdAudio(cols[0] ?? "", cols[1] ?? null, cols[3] ?? "", cols[2] ?? null));
else
subtitles.push({
language: language(cols[1]),
format: /Presentation Graphics/i.test(cols[0] ?? "") ? "PGS" : (cols[0] ?? null),
title: null,
isDefault: null,
forced: null,
});
}
}
if (!video.length && !audio.length) return null;
return { kind: "bdinfo", general, video, audio, subtitles };
}
const isBdInfo = (t: string) =>
/(^|\n)\s*(DISC INFO:|Disc Title\s*:|Disc Label\s*:|BDInfo\s*:|PLAYLIST REPORT:)/i.test(t);
/** MediaInfo 或 BDInfo 原文 → 概要;两种都认不出返回 null(前端只显示原文) */
export function parseMediaSummary(text: string | null | undefined): MediaSummary | null {
if (!text?.trim()) return null;
if (isBdInfo(text)) return parseBdInfoText(text) ?? parseMediaInfoText(text);
return parseMediaInfoText(text);
}