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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:50:42 +08:00
1 parent c846e91cc4
commit 9be9813021
16 files changed
+1992 -175

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
/**
* 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);
}