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]>
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|