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

@@ -1,4 +1,4 @@
import type { SiteDetailPage } from "@umt/adapters";
import { formatDetailPage, type SiteDetailPage } from "@umt/adapters";
import type { SiteReleasePage } from "@umt/contracts";
import { sql } from "@umt/db";
import { sharedCache, storageSettings } from "../../../shared-cache.ts";
@@ -99,6 +99,12 @@ export async function detailScreenshotRequest(
export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise<SiteReleasePage | null> {
const p = await loadStored(rt, srId);
if (!p) return null;
// 展示结构每次现算:旧缓存也能用,解析规则改了不必清缓存
const formatted = formatDetailPage({
descriptionText: p.descriptionText ?? null,
mediaInfo: p.mediaInfo ?? null,
files: p.files ?? [],
});
return {
fetchedAt: p.fetchedAt,
descriptionText: p.descriptionText,
@@ -118,6 +124,7 @@ export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise<
files: p.files,
tags: p.tags,
uploader: p.uploader,
...formatted,
};
}
@@ -88,6 +88,14 @@ describe.skipIf(!up)("站点详情页扩展内容:按需抓 + 24 小时缓存 +
expect(page.mediaInfo).toMatch(/^General\nComplete name : Dune.mkv/);
expect(page.descriptionText).toContain("◎片名 沙丘2");
expect(page.descriptionText).not.toContain("Complete name");
// 展示结构:读缓存时现算
expect(page.infoBlock).toContainEqual({ key: "片名", value: "沙丘2" });
expect(page.media).toMatchObject({
kind: "mediainfo",
general: { fileName: "Dune.mkv", container: "Matroska", size: "78 GiB", duration: "2 h 46 min" },
video: [{ codec: "HEVC" }],
});
expect(Array.isArray(page.fileTree)).toBe(true);
const row = await h.db
.selectFrom("source_release")
.select(["raw"])
@@ -35,7 +35,7 @@ export function DetailSheet({
className={cn("w-full gap-0 p-0", width === "lg" ? "sm:max-w-2xl" : "sm:max-w-lg", className)}
>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="truncate text-lead">{title}</SheetTitle>
<SheetTitle className="line-clamp-2 text-lead break-words">{title}</SheetTitle>
{description ? <SheetDescription className="break-words">{description}</SheetDescription> : null}
</SheetHeader>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
@@ -1,5 +1,5 @@
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
import { Bot, Download, RefreshCw } from "lucide-react";
import { ArrowDown, ArrowUp, Bot, Check, Download, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
@@ -15,9 +15,9 @@ import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { DownloadDialog } from "./download-dialog.tsx";
import { categoryLabel, hrOf, ORIGIN_LABEL, relTime } from "./release-fields.ts";
import { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts";
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
import { ReleasePageContent } from "./release-page-section.tsx";
import { ExternalIdTags, externalLinks, ReleasePageSections } from "./release-page-section.tsx";
const DECISION_LABEL = {
accept: { label: "符合,可收录", tone: "success" },
@@ -45,7 +45,6 @@ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry:
const slots = detail.data?.slots ?? [];
return (
<div className="space-y-3">
<InventoryOverlay release={release} entry={entry} loading={false} />
{entry?.versions.length ? (
<ul className="space-y-1 text-caption">
{entry.versions.map((v) => (
@@ -136,6 +135,19 @@ export function ReleaseDetailSheet({
refresh.mutate({ siteId, rid });
}, [needPage, rid, siteId, refresh]);
const page = detail.data?.page ?? null;
const snatched = r ? snatchedOf(r) : null;
const doRefresh = () =>
r &&
refresh.mutate(
{ siteId, rid: r.id },
{
onSuccess: (d) =>
d.refreshError ? toast.warning(`复核失败,显示上次数据:${d.refreshError}`) : toast.success("已复核"),
onError: (e) => toast.error(errorMessage(e)),
},
);
return (
<DetailSheet
open={!!release}
@@ -146,6 +158,9 @@ export function ReleaseDetailSheet({
footer={
r ? (
<>
<div className="mr-auto min-w-0 flex-1 basis-60">
<InventoryOverlay release={r} entry={entry} loading={false} />
</div>
<Button
variant="ghost"
size="sm"
@@ -170,91 +185,94 @@ export function ReleaseDetailSheet({
>
{!r ? null : (
<>
<div className="flex flex-wrap gap-1.5">
<ReleaseBadges r={r} />
<StatusBadge>{ORIGIN_LABEL[r.origin]}</StatusBadge>
</div>
<DetailSection
title="易变字段复核"
actions={
<Button
variant="outline"
size="xs"
disabled={refresh.isPending}
onClick={() =>
refresh.mutate(
{ siteId, rid: r.id },
{
onSuccess: (d) =>
d.refreshError
? toast.warning(`复核失败,显示上次数据:${d.refreshError}`)
: toast.success("已复核"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-muted-foreground">
<ReleaseBadges r={r} showUnknownHr />
<span className="text-foreground">{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 2)}</span>
<span title="做种 / 下载 / 完成">
<ArrowUp className="inline size-3" />
{countText(r.seeders)}
<ArrowDown className="ml-1.5 inline size-3" />
{countText(r.leechers)}
<Check className="ml-1.5 inline size-3" />
{countText(snatched)}
</span>
<span title={r.sourcePublishedAt ? absTime(r.sourcePublishedAt) : undefined}>
{r.sourcePublishedAt ? `${relTime(r.sourcePublishedAt)}上传` : "上传时间未知"}
</span>
<Button variant="outline" size="xs" className="ml-auto" disabled={refresh.isPending} onClick={doRefresh}>
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
复核详情
</Button>
}
>
{detail.isPending ? (
<LoadingState rows={2} />
) : detail.error ? (
<ErrorState plain error={detail.error} onRetry={() => void detail.refetch()} />
) : detail.data ? (
<>
{detail.data.refreshError ? (
<p className="mb-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
最近一次复核失败,以下是过期数据:{detail.data.refreshError}
</p>
) : null}
<KvList
items={[
{ label: "促销观察于", value: formatDateTime(detail.data.volatile.promotionObservedAt) },
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
{
label: "做种 / 下载",
value: `${r.seeders ?? "—"} / ${r.leechers ?? "—"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
},
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
]}
/>
<p className="mt-2 text-meta text-muted-foreground">提交下载时会再次核对免费和做种状态。</p>
</>
</div>
{page && (externalLinks(page).length || page.tags.length || page.uploader) ? (
<div className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
<ExternalIdTags page={page} />
{page.tags.map((t) => (
<span key={t} className="rounded-md border border-border/70 px-1.5 leading-5">
{t}
</span>
))}
{page.uploader ? <span className="ml-1">发布者 {page.uploader}</span> : null}
</div>
) : null}
</DetailSection>
<DetailSection title="站点详情页">
{detail.data?.page ? (
<ReleasePageContent page={detail.data.page} />
) : refresh.isPending || detail.isPending ? (
<LoadingState rows={2} />
) : detail.data && !detail.data.detailSupported ? (
<p className="text-caption text-muted-foreground">这个站点不支持抓详情页。</p>
) : (
<p className="text-caption text-muted-foreground">
还没有详情页内容{detail.data?.refreshError ? `(${detail.data.refreshError})` : ""}
。点「复核详情」抓取。
{detail.data?.refreshError ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
最近一次复核失败,下面是之前的数据:{detail.data.refreshError}
</p>
)}
</DetailSection>
) : null}
</div>
{page ? (
<ReleasePageSections page={page} />
) : refresh.isPending || detail.isPending ? (
<LoadingState rows={3} />
) : detail.error ? (
<ErrorState plain error={detail.error} onRetry={() => void detail.refetch()} />
) : detail.data && !detail.data.detailSupported ? (
<p className="text-caption text-muted-foreground">这个站点不支持抓详情页。</p>
) : (
<p className="text-caption text-muted-foreground">还没有详情页内容。点「复核详情」抓取。</p>
)}
{!page?.fileTree.length && r.fileList?.length ? (
<DetailSection title={`文件(${r.fileList.length})`}>
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
{r.fileList.map((f) => (
<li key={f.path} className="flex justify-between gap-3">
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
<span className="shrink-0 text-muted-foreground">{formatBytes(f.size, 1)}</span>
</li>
))}
</ul>
</DetailSection>
) : null}
<DetailSection title="本地库存与候选理由">
<LocalInventory release={r} entry={entry} />
</DetailSection>
{detail.data ? (
<DetailSection title="数据时效">
<KvList
items={[
{ label: "促销观察于", value: formatDateTime(detail.data.volatile.promotionObservedAt) },
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
{ label: "做种人数观察于", value: formatDateTime(detail.data.volatile.peersObservedAt) },
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
]}
/>
<p className="mt-2 text-meta text-muted-foreground">提交下载时会再次核对免费和做种状态。</p>
</DetailSection>
) : null}
<DetailSection title="资源信息">
<KvList
items={[
{ label: "分类", value: categoryLabel(r) },
{ label: "大小", value: r.sizeBytes === null ? "未知" : formatBytes(r.sizeBytes, 2) },
{ label: "H&R", value: { yes: "有", no: "无", unknown: "未知(按有义务处理)" }[hrOf(r)] },
{ label: "站点上传", value: formatDateTime(r.sourcePublishedAt) },
{ label: "首次发现", value: formatDateTime(r.firstSeenAt) },
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
{ label: "站点上传", value: formatDateTime(r.sourcePublishedAt) },
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
{ label: "Feed 观察次数", value: r.observedByFeeds },
{ label: "站点 ID", value: r.siteReleaseId, mono: true },
{ label: "infohash", value: r.infohashV1 ?? r.infohashV2, mono: true, wide: true },
@@ -277,19 +295,6 @@ export function ReleaseDetailSheet({
</DetailSection>
) : null}
{r.fileList?.length ? (
<DetailSection title={`文件(${r.fileList.length})`}>
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
{r.fileList.map((f) => (
<li key={f.path} className="flex justify-between gap-3">
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
<span className="shrink-0 text-muted-foreground">{formatBytes(f.size, 1)}</span>
</li>
))}
</ul>
</DetailSection>
) : null}
<JsonView label="解析结果" value={r.parsed} />
</>
)}
@@ -1,99 +1,381 @@
import type { SiteReleasePage } from "@umt/contracts";
import { ExternalLink } from "lucide-react";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import type { SiteFileTreeRow, SiteMediaSummary, SiteReleasePage } from "@umt/contracts";
import { ChevronDown, ChevronLeft, ChevronRight, ExternalLink, FileVideo, Folder, FolderOpen } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
/** 详情页扩展内容里的外部站点链接 */
export function externalLinks(p: Pick<SiteReleasePage, "imdbId" | "doubanId" | "tmdb">) {
const out: { label: string; href: string }[] = [];
if (p.tmdb) out.push({ label: `TMDB ${p.tmdb.id}`, href: `https://www.themoviedb.org/${p.tmdb.type}/${p.tmdb.id}` });
if (p.imdbId) out.push({ label: `IMDb ${p.imdbId}`, href: `https://www.imdb.com/title/${p.imdbId}/` });
if (p.doubanId) out.push({ label: `豆瓣 ${p.doubanId}`, href: `https://movie.douban.com/subject/${p.doubanId}/` });
if (p.imdbId) out.push({ label: "IMDb", href: `https://www.imdb.com/title/${p.imdbId}/` });
if (p.tmdb) out.push({ label: "TMDB", href: `https://www.themoviedb.org/${p.tmdb.type}/${p.tmdb.id}` });
if (p.doubanId) out.push({ label: "豆瓣", href: `https://movie.douban.com/subject/${p.doubanId}/` });
return out;
}
export function ReleasePageContent({ page }: { page: SiteReleasePage }) {
/** IMDb / TMDB / 豆瓣小标签(新窗口打开) */
export function ExternalIdTags({ page }: { page: Pick<SiteReleasePage, "imdbId" | "doubanId" | "tmdb"> }) {
const links = externalLinks(page);
const empty =
!links.length &&
!page.uploader &&
!page.tags.length &&
!page.descriptionText &&
!page.mediaInfo &&
!page.screenshots.length &&
!page.files.length;
if (!links.length) return null;
return (
<div className="space-y-3">
{links.length || page.uploader ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
{links.map((l) => (
<a
key={l.href}
href={l.href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-foreground underline underline-offset-4"
>
{l.label}
<ExternalLink className="size-3" />
</a>
))}
{page.uploader ? <span className="text-muted-foreground">上传者 {page.uploader}</span> : null}
<>
{links.map((l) => (
<a
key={l.href}
href={l.href}
target="_blank"
rel="noreferrer noopener"
title={l.href}
className="inline-flex items-center gap-1 rounded-md border border-border px-1.5 text-meta leading-5 text-foreground hover:bg-muted"
>
{l.label}
<ExternalLink className="size-3 text-muted-foreground" />
</a>
))}
</>
);
}
/** 长文本:超出时折叠,点「展开」看全部 */
function Clamp({ children, long, lines = 3 }: { children: ReactNode; long: boolean; lines?: 3 | 6 }) {
const [open, setOpen] = useState(false);
return (
<>
<div className={cn(long && !open && (lines === 3 ? "line-clamp-3" : "line-clamp-6"))}>{children}</div>
{long ? (
<button
type="button"
className="mt-1 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
onClick={() => setOpen((v) => !v)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</>
);
}
function InfoTable({ items }: { items: SiteReleasePage["infoBlock"] }) {
return (
<dl className="grid grid-cols-[minmax(4.5rem,auto)_1fr] gap-x-4 gap-y-1.5 text-caption">
{items.map((it) => (
<div key={it.key} className="contents">
<dt className="text-muted-foreground">{it.key}</dt>
<dd className="min-w-0 break-words whitespace-pre-line text-foreground">
<Clamp long={it.value.length > 90}>{it.value}</Clamp>
</dd>
</div>
) : null}
{page.tags.length ? (
<div className="flex flex-wrap gap-1.5">
{page.tags.map((t) => (
<StatusBadge key={t}>{t}</StatusBadge>
))}
</div>
) : null}
{page.descriptionText ? (
<p className="max-h-72 overflow-auto text-caption break-words whitespace-pre-wrap text-foreground">
{page.descriptionText}
</p>
) : null}
{page.mediaInfo ? (
<details className="rounded-lg border border-border/70 px-3 py-2">
<summary className="cursor-pointer text-caption text-foreground">MediaInfo</summary>
<pre className="mt-2 max-h-80 overflow-auto font-mono text-meta whitespace-pre text-muted-foreground">
{page.mediaInfo}
</pre>
</details>
) : null}
{page.screenshots.length ? (
<div className="grid grid-cols-2 gap-2">
{page.screenshots.map((s, i) => (
<a key={s.url} href={s.url} target="_blank" rel="noreferrer noopener" title={s.host}>
<img
src={s.url}
alt={`截图 ${i + 1}`}
loading="lazy"
className="aspect-video w-full rounded-md border border-border/70 bg-muted object-cover"
/>
</a>
))}
</div>
) : null}
{page.files.length ? (
<details className="rounded-lg border border-border/70 px-3 py-2">
<summary className="cursor-pointer text-caption text-foreground">文件清单({page.files.length})</summary>
<ul className="mt-2 max-h-60 space-y-1 overflow-auto text-meta">
{page.files.map((f, i) => (
<li key={`${i}-${f.name}`} className="flex justify-between gap-3">
<span className="min-w-0 font-mono break-all text-foreground">{f.name}</span>
<span className="shrink-0 text-muted-foreground">
{f.sizeBytes === null ? "" : formatBytes(f.sizeBytes, 1)}
</span>
</li>
))}
</ul>
</details>
) : null}
{empty ? <p className="text-caption text-muted-foreground">详情页上没有解析到简介、截图或外部链接。</p> : null}
<p className="text-meta text-muted-foreground">
抓取于 {formatDateTime(page.fetchedAt)},缓存 24 小时;截图经服务端代理加载。
</p>
))}
</dl>
);
}
function Synopsis({ paragraphs }: { paragraphs: string[] }) {
const total = paragraphs.reduce((n, p) => n + p.length, 0);
return (
<div className="text-caption leading-relaxed text-foreground">
<Clamp long={total > 320 || paragraphs.length > 4} lines={6}>
{paragraphs.map((p, i) => (
<p key={`${i}-${p.slice(0, 16)}`} className={cn("break-words", i > 0 && "mt-2")}>
{p}
</p>
))}
</Clamp>
</div>
);
}
const dot = (xs: (string | null | undefined)[]) => xs.filter(Boolean).join(" · ");
function TrackTable({ head, rows }: { head: string[]; rows: ReactNode[][] }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-meta">
<thead className="text-muted-foreground">
<tr>
{head.map((h) => (
<th key={h} className="py-1 pr-3 font-normal whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="text-foreground">
{rows.map((r, i) => (
<tr key={i} className="border-t border-border/60">
{r.map((c, j) => (
<td key={j} className={cn("py-1 pr-3 align-top", j < r.length - 1 && "whitespace-nowrap")}>
{c ?? "—"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
const flag = (on: boolean | null, label: string) =>
on ? <span className="rounded-md border border-border px-1 text-micro text-muted-foreground">{label}</span> : null;
function TechSpecs({ media, raw }: { media: SiteMediaSummary | null; raw: string | null }) {
return (
<div className="space-y-3">
{media ? (
<>
<p className="text-caption text-foreground">
{dot([media.general.container, media.general.duration, media.general.overallBitrate, media.general.size]) ||
"—"}
</p>
{media.video.length ? (
<TrackTable
head={["视频", "分辨率", "帧率", "码率", "位深", "HDR"]}
rows={media.video.map((v) => [
v.codec,
v.resolution,
v.frameRate,
v.bitrate,
v.bitDepth,
v.hdr.length ? v.hdr.join(" + ") : "SDR",
])}
/>
) : null}
{media.audio.length ? (
<TrackTable
head={[`音轨(${media.audio.length})`, "编码", "声道", "码率", "标题"]}
rows={media.audio.map((a) => [
<span key="l" className="inline-flex items-center gap-1">
{a.language ?? "未标语言"}
{flag(a.isDefault, "默认")}
</span>,
a.codec,
a.channels,
a.bitrate,
a.title,
])}
/>
) : null}
{media.subtitles.length ? (
<TrackTable
head={[`字幕(${media.subtitles.length})`, "格式", "标题"]}
rows={media.subtitles.map((s) => [
<span key="l" className="inline-flex items-center gap-1">
{s.language ?? "未标语言"}
{flag(s.isDefault, "默认")}
{flag(s.forced, "强制")}
</span>,
s.format,
s.title,
])}
/>
) : null}
</>
) : null}
{raw ? (
<details className="rounded-lg border border-border/70 px-3 py-2" open={!media}>
<summary className="cursor-pointer text-caption text-muted-foreground">
{media?.kind === "bdinfo" ? "BDInfo 原文" : "MediaInfo 原文"}
</summary>
<pre className="mt-2 max-h-80 overflow-auto font-mono text-meta whitespace-pre text-muted-foreground">
{raw}
</pre>
</details>
) : null}
</div>
);
}
/** 截图网格 + 灯箱(左右方向键切换,Esc 关闭) */
function Screenshots({ shots }: { shots: SiteReleasePage["screenshots"] }) {
const [open, setOpen] = useState<number | null>(null);
const n = shots.length;
const go = (d: number) => setOpen((i) => (i === null ? i : (i + d + n) % n));
const cur = open === null ? null : shots[open];
return (
<>
<div className={cn("grid gap-2", n === 1 ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3")}>
{shots.map((s, i) => (
<button
key={s.url}
type="button"
onClick={() => setOpen(i)}
title={s.host}
aria-label={`查看截图 ${i + 1}`}
className="overflow-hidden rounded-md border border-border/70 bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<img
src={s.url}
alt={`截图 ${i + 1}`}
loading="lazy"
className={cn("w-full", n === 1 ? "max-h-96 object-contain" : "aspect-video object-cover")}
/>
</button>
))}
</div>
<Dialog open={open !== null} onOpenChange={(o) => !o && setOpen(null)}>
<DialogContent
className="gap-3 p-3 sm:max-w-5xl"
onKeyDown={(e) => {
if (e.key === "ArrowLeft") go(-1);
else if (e.key === "ArrowRight") go(1);
}}
>
<DialogTitle className="sr-only">截图</DialogTitle>
<DialogDescription className="sr-only">左右方向键切换</DialogDescription>
{cur ? (
<img
src={cur.url}
alt={`截图 ${(open ?? 0) + 1}`}
className="max-h-[78vh] w-full rounded-lg bg-muted object-contain"
/>
) : null}
<div className="flex items-center justify-between gap-2 pr-10">
<span className="text-meta text-muted-foreground">
{(open ?? 0) + 1} / {n}
{cur?.host ? ` · ${cur.host}` : ""}
</span>
{n > 1 ? (
<div className="flex gap-1">
<Button variant="outline" size="icon-sm" aria-label="上一张" onClick={() => go(-1)}>
<ChevronLeft />
</Button>
<Button variant="outline" size="icon-sm" aria-label="下一张" onClick={() => go(1)}>
<ChevronRight />
</Button>
</div>
) : null}
</div>
</DialogContent>
</Dialog>
</>
);
}
const ROLE_TAG: Partial<Record<NonNullable<SiteFileTreeRow["role"]>, string>> = {
sample: "Sample",
subtitle: "字幕",
extra: "附件",
};
/** 文件目录树:目录可折叠,文件多时默认只展开第一层 */
export function FileTree({ rows }: { rows: SiteFileTreeRow[] }) {
const [collapsed, setCollapsed] = useState<Set<string>>(
() => new Set(rows.length > 40 ? rows.filter((r) => r.dir && r.depth >= 1).map((r) => r.path) : []),
);
const visible = useMemo(
() => rows.filter((r) => ![...collapsed].some((c) => r.path.startsWith(`${c}/`))),
[rows, collapsed],
);
const toggle = (p: string) =>
setCollapsed((s) => {
const n = new Set(s);
if (n.has(p)) n.delete(p);
else n.add(p);
return n;
});
return (
<ul className="max-h-80 overflow-auto text-meta">
{visible.map((r) => {
const pad = { paddingLeft: `${r.depth * 1.25}rem` };
if (r.dir) {
const shut = collapsed.has(r.path);
const Icon = shut ? Folder : FolderOpen;
return (
<li key={r.path}>
<button
type="button"
onClick={() => toggle(r.path)}
aria-expanded={!shut}
className="flex w-full items-center gap-1.5 rounded-md py-1 pr-1 text-left hover:bg-muted"
style={pad}
>
<ChevronDown className={cn("size-3 shrink-0 text-muted-foreground", shut && "-rotate-90")} />
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">{r.name}</span>
<span className="shrink-0 text-muted-foreground">
{dot([
r.fileCount ? `${r.fileCount} 个文件` : null,
r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null,
])}
</span>
</button>
</li>
);
}
const main = r.role === "main";
const tag = r.role ? ROLE_TAG[r.role] : undefined;
return (
<li key={r.path} className="flex items-center gap-1.5 py-1 pr-1" style={pad}>
<span className="size-3 shrink-0" />
{main ? (
<FileVideo className="size-3.5 shrink-0 text-foreground" />
) : (
<span className="size-3.5 shrink-0" />
)}
<span
className={cn("min-w-0 flex-1 break-all", main ? "text-foreground" : "text-muted-foreground")}
title={r.path}
>
{r.name}
</span>
{tag ? (
<span className="shrink-0 rounded-md border border-border/70 px-1 text-micro text-muted-foreground">
{tag}
</span>
) : null}
<span className="w-16 shrink-0 text-right text-muted-foreground">
{r.sizeBytes === null ? "" : formatBytes(r.sizeBytes, 1)}
</span>
</li>
);
})}
</ul>
);
}
/** 资料 → 简介 → 技术规格 → 截图 → 文件;没内容的节不出现 */
export function ReleasePageSections({ page }: { page: SiteReleasePage }) {
const fileCount = page.fileTree.filter((r) => !r.dir).length;
const empty =
!page.infoBlock.length &&
!page.paragraphs.length &&
!page.media &&
!page.mediaInfo &&
!page.screenshots.length &&
!page.fileTree.length;
return (
<>
{page.infoBlock.length ? (
<DetailSection title="资料">
<InfoTable items={page.infoBlock} />
</DetailSection>
) : null}
{page.paragraphs.length ? (
<DetailSection title="简介">
<Synopsis paragraphs={page.paragraphs} />
</DetailSection>
) : null}
{page.media || page.mediaInfo ? (
<DetailSection title="技术规格">
<TechSpecs media={page.media} raw={page.mediaInfo} />
</DetailSection>
) : null}
{page.screenshots.length ? (
<DetailSection title={`截图(${page.screenshots.length})`}>
<Screenshots shots={page.screenshots} />
</DetailSection>
) : null}
{page.fileTree.length ? (
<DetailSection title={`文件(${fileCount})`}>
<FileTree rows={page.fileTree} />
</DetailSection>
) : null}
{empty ? (
<p className="text-caption text-muted-foreground">详情页上没有简介、技术规格、截图和文件清单。</p>
) : null}
</>
);
}
@@ -0,0 +1,283 @@
/**
* 详情页简介清洗与结构化(DETAIL-1):纯函数,输入是抓取时得到的简介文本(可能残留 HTML / BBCode),
* 输出「信息块」键值对(◎片名 / 【年代】/ 『导演』/ 片名: 等)与正文段落。
* 清洗规则可配置:站点模板段、广告签名按正则整段或整行去掉,调用方可在默认规则上追加。
*/
export interface DetailInfoItem {
key: string;
value: string;
}
export interface StructuredDescription {
infoBlock: DetailInfoItem[];
paragraphs: string[];
}
export interface DescriptionRules {
/** 整段去掉(对整篇文本匹配,可跨行):站点模板段、更新提示等 */
dropBlocks: RegExp[];
/** 整行去掉:广告、群号、转载声明、感谢签名、分隔线 */
dropLines: RegExp[];
}
export const DEFAULT_DESCRIPTION_RULES: DescriptionRules = {
dropBlocks: [
// 憨憨豆瓣信息块末尾:「注意:该信息最后更新于…点击 这里 更新信息」
/注意[::]\s*该信息最后更新于[\s\S]{0,80}?更新信息/g,
],
dropLines: [
/^[-=_*~—─━·.]{5,}$/,
/^(?:QQ|qq|微信|TG|Telegram|电报|Discord)\s*群?(?:号)?\s*[::]/,
/(?:禁止|严禁|请勿|不得).{0,12}转载|转载.{0,8}(?:请注明|须注明)/,
/^(?:感谢|鸣谢|Thanks?\s+to)\s*.{0,20}(?:分享|发布|制作|压制|上传)/i,
/^(?:本资源|本种子?|资源)?由\s*\S{1,24}\s*(?:小组|组)?\s*(?:制作|压制|发布|分享)\S{0,4}$/,
/^(?:点击|更多).{0,10}(?:下载|资源).{0,20}(?:访问|请到|见)/,
],
};
/** 常见信息块键(去掉空白后比较);冒号式 / 【】/『』 只认这些,避免把正文里的「【第一版】」当键 */
const KNOWN_KEYS = [
"译名",
"片名",
"原名",
"又名",
"别名",
"中文名",
"英文名",
"年代",
"年份",
"产地",
"国家",
"地区",
"国家地区",
"制片国家/地区",
"类别",
"类型",
"语言",
"对白语言",
"字幕",
"上映日期",
"首播",
"首播日期",
"IMDb评分",
"IMDB评分",
"IMDb链接",
"IMDB链接",
"豆瓣评分",
"豆瓣链接",
"TMDB",
"TMDb链接",
"评分",
"票数",
"集数",
"季数",
"片长",
"单集片长",
"视频尺寸",
"文件格式",
"文件大小",
"导演",
"编剧",
"主演",
"演员",
"配音",
"制片人",
"标签",
"标语",
"简介",
"剧情简介",
"内容简介",
"剧情概要",
"剧情介绍",
"获奖情况",
];
const KNOWN = new Set(KNOWN_KEYS.map((k) => k.toLowerCase()));
/** 值就是正文的键:内容进段落 */
const DESC_KEYS = new Set(["简介", "剧情简介", "内容简介", "剧情概要", "剧情介绍"]);
/** 人名列表:续行合并,修复「Kai Xu周也」这类元素拼接 */
const PEOPLE_KEYS = new Set(["导演", "编剧", "主演", "演员", "配音", "制片人"]);
const WS = "[\\s\\u3000]";
const KEY_ALTERNATION = [...KNOWN_KEYS]
.sort((a, b) => b.length - a.length)
.map((k) => [...k].map((c) => c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join(`${WS}*`))
.join("|");
/** ◎ 标记:认已知键,否则取到第一个空白 / 冒号为止的短词 */
const MARK_KNOWN = new RegExp(`^◎${WS}*(${KEY_ALTERNATION})${WS}*[::]?${WS}*(.*)$`, "i");
const MARK_ANY = /^◎[\s ]*([^\s ::]{1,12})[\s ]*[::]?[\s ]*(.*)$/;
const BRACKET = new RegExp(`^(?:【|『|\\[)${WS}*(${KEY_ALTERNATION})${WS}*(?:】|』|\\])${WS}*[::]?${WS}*(.*)$`, "i");
const COLON = new RegExp(`^(${KEY_ALTERNATION})${WS}*[::]${WS}*(.*)$`, "i");
const ENTITIES: Record<string, string> = {
nbsp: " ",
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
ensp: " ",
emsp: " ",
};
function decodeEntities(s: string): string {
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => {
if (e[0] === "#") {
const code = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number.parseInt(e.slice(1), 10);
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all;
}
return ENTITIES[e.toLowerCase()] ?? all;
});
}
/** 去 HTML / BBCode 残留:图片整段去掉,链接留文字,块级标签换行,实体解码 */
export function stripMarkup(text: string): string {
return decodeEntities(
text
.replace(/\r/g, "")
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "")
// 站点截断在标签中间:「…<br…」
.replace(/<[a-z/][^>\n]*…?$/i, "…")
.replace(/<img\b[^>]*>/gi, "")
.replace(/<br\s*\/?>[ \t]*\n?/gi, "\n")
.replace(/<\/(?:p|div|li|tr|h[1-6]|blockquote|fieldset|pre)>[ \t]*\n?/gi, "\n")
.replace(/<[a-z/!][^>]*>/gi, "")
.replace(/\[img(?:=[^\]]*)?\][^[]*\[\/img\]|\[img=[^\]]+\]/gi, "")
.replace(/\[url=[^\]]*\]([\s\S]*?)\[\/url\]/gi, "$1")
.replace(
/\[\/?(?:b|i|u|s|color|size|font|align|center|left|right|url|quote|code|spoiler|hide|box|list|\*|pre|table|tr|td)(?:=[^\]]*)?\]/gi,
"",
),
).replace(/ /g, " ");
}
/** 清洗:去标记、按规则删段删行、行尾空白、连续空行压成一行 */
export function cleanDescription(text: string, rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES): string {
let t = stripMarkup(text);
for (const re of rules.dropBlocks) t = t.replace(re, "");
return t
.split("\n")
.map((l) => l.replace(/[\s ]+$/, ""))
.filter((l) => {
const s = l.trim().replace(/^ +/, "");
return !s || !rules.dropLines.some((re) => re.test(s));
})
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
interface KeyHit {
key: string;
value: string;
}
const normKey = (k: string) => k.replace(/[\s ]+/g, "");
function matchKey(line: string): KeyHit | null {
const m = MARK_KNOWN.exec(line) ?? MARK_ANY.exec(line) ?? BRACKET.exec(line) ?? COLON.exec(line);
if (!m) return null;
const key = normKey(m[1]!);
if (!key) return null;
// 冒号式:键后面要么没东西(值在下一行),要么是冒号分隔的值;「片名」开头的正文句子不当键
if (!line.startsWith("◎") && !KNOWN.has(key.toLowerCase())) return null;
return { key, value: (m[2] ?? "").trim() };
}
const isSeparator = (l: string) => /^[-=_*~—─━·.]{5,}$/.test(l);
const SENTENCE_END = /[。!?!?.…」』"”))]$/;
/** 相邻行合并成段:英文硬换行(下一行小写开头且上一行没有句末标点)接成一行,其余每行一段 */
function toParagraphs(lines: string[]): string[] {
const out: string[] = [];
let prevJoinable = false;
for (const raw of lines) {
const l = raw.replace(/^[\s ]+/, "").trim();
if (!l) {
prevJoinable = false;
continue;
}
if (prevJoinable && /^[a-z]/.test(l) && out.length) out[out.length - 1] += ` ${l}`;
else out.push(l);
prevJoinable = /[A-Za-z,]$/.test(l) && !SENTENCE_END.test(l);
}
return out;
}
function tidyValue(key: string, lines: string[]): string {
const parts = lines.map((l) => l.replace(/[\s ]+/g, " ").trim()).filter(Boolean);
if (PEOPLE_KEYS.has(key)) {
return parts
.flatMap((p) => p.replace(/([a-z])(?=[一-鿿])/g, "$1\u0000").split("\u0000"))
.map((p) => p.trim())
.filter(Boolean)
.join(" / ");
}
return parts.every((p) => p.length <= 40) ? parts.join(" / ") : parts.join("\n");
}
const URL_ONLY = /^(?:https?:\/\/\S+|www\.\S+)$/i;
const isLinkKey = (k: string) => /(?:链接|网址|link)$/i.test(k) || /^tmdb$/i.test(k);
/**
* 信息块 + 段落。信息块的键:◎ 开头任意短键;【】『』[] 包裹或「键:」开头只认常见键。
* 键后的值可在同一行,也可在下一个非空行(憨憨豆瓣块);续行(主演多行)并入上一键,空行结束。
* 简介类键的内容进段落;信息块之前的零碎短行(海报卡片的片名 / 年份 / 评分)在有信息块时丢弃。
*/
export function structureDescription(
text: string | null | undefined,
rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES,
): StructuredDescription {
if (!text?.trim()) return { infoBlock: [], paragraphs: [] };
const lines = cleanDescription(text, rules).split("\n");
const info: { key: string; lines: string[] }[] = [];
const lead: string[] = [];
const body: string[] = [];
let cur: { key: string; lines: string[] } | "desc" | null = null;
const pushBody = (l: string) => (info.length ? body : lead).push(l);
for (const line of lines) {
const trimmed = line.trim();
if (isSeparator(trimmed)) {
if (cur && cur !== "desc" && cur.lines.length) cur = null;
if (cur === "desc") body.push("");
continue;
}
const hit = trimmed ? matchKey(trimmed) : null;
if (hit) {
if (DESC_KEYS.has(hit.key)) {
cur = "desc";
body.push("");
if (hit.value) body.push(hit.value);
} else {
const entry = { key: hit.key, lines: hit.value ? [hit.value] : [] };
info.push(entry);
cur = entry;
}
continue;
}
if (!trimmed) {
if (cur === "desc") body.push("");
else if (cur?.lines.length) cur = null;
else if (!cur) pushBody("");
continue;
}
if (cur && cur !== "desc") cur.lines.push(line);
else pushBody(line);
}
const seen = new Set<string>();
const infoBlock: DetailInfoItem[] = [];
for (const e of info) {
const value = tidyValue(e.key, e.lines);
if (!value || seen.has(e.key)) continue;
if (isLinkKey(e.key) && value.split(/\s+/).every((v) => URL_ONLY.test(v) || !v)) continue;
seen.add(e.key);
infoBlock.push({ key: e.key, value });
}
// 信息块前面只有零碎短行(卡片摘要)就丢掉;有像样的句子则保留为正文
const leadParas = toParagraphs(lead);
const keepLead = !infoBlock.length || leadParas.some((p) => p.length > 24 || /[。!?!?]$/.test(p));
const paragraphs = [...(keepLead ? leadParas : []), ...toParagraphs(body)];
return { infoBlock, paragraphs };
}
@@ -0,0 +1,287 @@
import {
REAL_DETAIL_HH_DV_ONLY,
REAL_DETAIL_HH_INFO_CARD,
REAL_RSS_DESCR_AWARDS,
REAL_RSS_DESCR_CAST,
REAL_RSS_DESCR_INFO_BLOCK,
REAL_RSS_DESCR_PLAIN,
SYNTH_BDINFO_FULL,
SYNTH_BDINFO_QUICK,
SYNTH_DESCR_BRACKETS,
SYNTH_FILES_BDMV,
SYNTH_FILES_DIRECTORY,
SYNTH_MEDIAINFO_MULTI_TRACK,
} from "@umt/testkit";
import { describe, expect, it } from "vitest";
import {
cleanDescription,
DEFAULT_DESCRIPTION_RULES,
stripMarkup,
structureDescription,
} from "./detail-description.ts";
import { formatDetailPage } from "./detail-format.ts";
import { buildFileTree } from "./file-tree.ts";
import { parseMediaSummary } from "./mediainfo.ts";
const info = (text: string | null) =>
Object.fromEntries(structureDescription(text).infoBlock.map((x) => [x.key, x.value]));
describe("简介结构化:真实缓存样本", () => {
it("憨憨豆瓣信息块:「片名:」换行取值,卡片摘要与更新提示去掉,主演拆开", () => {
const d = structureDescription(REAL_DETAIL_HH_INFO_CARD.descriptionText);
expect(Object.fromEntries(d.infoBlock.map((x) => [x.key, x.value]))).toMatchObject({
片名: "一瓯春",
年代: "2026",
片长: "45分钟",
类别: "爱情,古装",
导演: "潘安子 Anzi Pan",
编剧: "尤四姐",
主演: "许凯 Kai Xu / 周也 Ye Zhou / 白澍 Shu Bai",
});
// 空值键(别名 / 标语)不出现
expect(d.infoBlock.map((x) => x.key)).not.toContain("别名");
expect(d.infoBlock.map((x) => x.key)).not.toContain("标语");
expect(d.paragraphs).toHaveLength(2);
expect(d.paragraphs[0]).toMatch(/^“杀伐千面腹黑男”/);
expect(d.paragraphs.join("\n")).not.toMatch(/该信息最后更新于|更新信息|^-{5,}|\(11053\)/m);
});
it("◎ 信息块 + ◎简介:链接键去掉,简介进段落", () => {
const d = structureDescription(REAL_RSS_DESCR_INFO_BLOCK);
expect(d.infoBlock.map((x) => x.key)).toEqual([
"译名",
"片名",
"年代",
"产地",
"类别",
"语言",
"上映日期",
"集数",
"片长",
"主演",
]);
expect(info(REAL_RSS_DESCR_INFO_BLOCK).译名).toBe("Stay Low Profile, Sect Chief / 掌门低调点 3D动画版");
expect(d.paragraphs).toHaveLength(1);
expect(d.paragraphs[0]).toMatch(/^大雪覆门/);
expect(JSON.stringify(d)).not.toMatch(/<|&nbsp;|douban\.com/);
});
it("主演多行续行合并;◎获奖情况进信息块;站点截断的半个标签去掉", () => {
const i = info(REAL_RSS_DESCR_AWARDS);
expect(i.主演).toBe(
"张维为 Weiwei Zhang / 亚历山大·杜金 Alexander Dugin / 李世默 Eric Li / 范勇鹏 Yongpeng Fan / 余亮 Liang Yu",
);
expect(i.获奖情况).toBe("第28届上海电视节白玉兰奖 (2023) / 综艺节目 最佳电视综艺节目(提名)…");
const d = structureDescription(REAL_RSS_DESCR_AWARDS);
expect(d.paragraphs).toHaveLength(2);
expect(d.paragraphs[1]).toMatch(/^《这就是中国》/);
});
it("豆瓣评分 / 导演 / 编剧,长主演列表", () => {
const i = info(REAL_RSS_DESCR_CAST);
expect(i).toMatchObject({ 豆瓣评分: "5.4/10 from 11075 users", 导演: "潘安子 Anzi Pan", 编剧: "尤四姐" });
expect(i.主演!.split(" / ")).toHaveLength(16);
expect(structureDescription(REAL_RSS_DESCR_CAST).paragraphs).toEqual([]);
});
it("没有信息块:整段是正文,海报图去掉", () => {
const d = structureDescription(REAL_RSS_DESCR_PLAIN);
expect(d.infoBlock).toEqual([]);
expect(d.paragraphs).toHaveLength(1);
expect(d.paragraphs[0]).toMatch(/^21岁的玉雕师方雾/);
});
it("只有 MediaInfo 的详情页:信息块和段落都为空", () => {
expect(structureDescription(REAL_DETAIL_HH_DV_ONLY.descriptionText)).toEqual({ infoBlock: [], paragraphs: [] });
});
});
describe("简介清洗:【】『』、BBCode / HTML 残留、广告签名", () => {
const d = structureDescription(SYNTH_DESCR_BRACKETS);
it("【】『』键认得,正文里的「【第一版】」不当键", () => {
expect(Object.fromEntries(d.infoBlock.map((x) => [x.key, x.value]))).toMatchObject({
译名: "示例电影 / Example Movie",
字幕: "中英双语",
IMDb评分: "8.1/10 from 12,345 users",
导演: "某导演 Some Director",
主演: "演员甲 Actor A / 演员乙 Actor B",
});
expect(d.infoBlock.map((x) => x.key)).not.toContain("IMDb链接");
expect(structureDescription("◎简  介\n\n  【第一版】仙界至暗,众生皆材。").paragraphs).toEqual([
"【第一版】仙界至暗,众生皆材。",
]);
});
it("段落:标记、实体、多余空行、转载声明 / 群号 / 感谢签名 / 分隔线都去掉", () => {
expect(d.paragraphs).toEqual([
"一位科学家在沙漠深处发现了改变世界的秘密。<他必须>在时间耗尽前做出选择。",
"影片获得多项提名,查看获奖名单。",
]);
const cleaned = cleanDescription(SYNTH_DESCR_BRACKETS);
expect(cleaned).not.toMatch(/\[\/?\w+|<\/?\w+|&nbsp;|QQ群|转载|感谢|\n{3,}|-{5,}/);
});
it("规则可配置:追加整行规则", () => {
const rules = { ...DEFAULT_DESCRIPTION_RULES, dropLines: [...DEFAULT_DESCRIPTION_RULES.dropLines, /^影片获得/] };
expect(structureDescription(SYNTH_DESCR_BRACKETS, rules).paragraphs).toHaveLength(1);
});
it("英文硬换行接回一段", () => {
expect(
structureDescription("A scientist discovers a secret\nthat changes the world.\n\nSecond one.").paragraphs,
).toEqual(["A scientist discovers a secret that changes the world.", "Second one."]);
});
it("stripMarkup:实体与数字引用", () => {
expect(stripMarkup("a&amp;b&#65;&#x42;&nbsp;<b>c</b>[b]d[/b]")).toBe("a&bAB cd");
});
});
describe("MediaInfo / BDInfo 概要", () => {
it("真实样本:中文摘要头 + General / Video / Audio", () => {
expect(parseMediaSummary(REAL_DETAIL_HH_INFO_CARD.mediaInfo)).toEqual({
kind: "mediainfo",
general: {
fileName: "Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
container: "Matroska",
duration: "43 min 55 s",
overallBitrate: "15.6 Mb/s",
size: "4.79 GiB",
},
video: [
{
codec: "HEVC",
resolution: "3840×1608",
frameRate: "60 fps",
bitrate: "15.3 Mb/s",
bitDepth: "10 bit",
hdr: [],
},
],
audio: [
{
language: "中文",
codec: "AAC",
channels: "2.0",
bitrate: "256 kb/s",
title: "中文 [AAC 2.0]",
isDefault: true,
},
],
subtitles: [],
});
});
it("真实样本:杜比视界 Profile 5", () => {
const m = parseMediaSummary(REAL_DETAIL_HH_DV_ONLY.mediaInfo)!;
expect(m.video[0]).toMatchObject({ hdr: ["Dolby Vision P5"], frameRate: "25 fps" });
});
it("多音轨多字幕:编码短名、声道、默认 / 强制、语言译名", () => {
const m = parseMediaSummary(SYNTH_MEDIAINFO_MULTI_TRACK)!;
expect(m.video[0]).toMatchObject({
resolution: "3840×2160",
frameRate: "23.976 fps",
hdr: ["Dolby Vision P7.6", "HDR10"],
});
expect(m.audio.map((a) => [a.language, a.codec, a.channels, a.bitrate, a.isDefault])).toEqual([
["英语", "TrueHD Atmos", "7.1", "4561 kb/s", true],
["中文", "AC-3", "5.1", "640 kb/s", false],
["英语", "AAC", "2.0", "192 kb/s", false],
]);
expect(m.subtitles.map((s) => [s.language, s.format, s.isDefault, s.forced])).toEqual([
["英语", "PGS", false, true],
["中文", "UTF-8", true, false],
["繁体中文", "ASS", false, false],
["英语", "PGS", false, false],
]);
});
it("BDInfo 简版", () => {
const m = parseMediaSummary(SYNTH_BDINFO_QUICK)!;
expect(m.kind).toBe("bdinfo");
expect(m.general).toMatchObject({
container: "Blu-ray",
duration: "2 h 22 min",
size: "57.57 GiB",
overallBitrate: "55.20 Mbps",
});
expect(m.video.map((v) => [v.resolution, v.hdr])).toEqual([
["2160p", ["HDR10"]],
["1080p", ["Dolby Vision FEL"]],
]);
expect(m.audio[0]).toMatchObject({
language: "英语",
codec: "TrueHD Atmos",
channels: "7.1",
bitrate: "4561 kbps",
});
expect(m.subtitles.map((s) => s.language)).toEqual(["英语", "中文", "中文"]);
});
it("BDInfo 完整报告表格", () => {
const m = parseMediaSummary(SYNTH_BDINFO_FULL)!;
expect(m.general.duration).toBe("52 min 10 s");
expect(m.video).toEqual([
{
codec: "MPEG-4 AVC",
resolution: "1080p",
frameRate: "23.976 fps",
bitrate: "31456 kbps",
bitDepth: null,
hdr: [],
},
]);
expect(m.audio.map((a) => [a.language, a.codec, a.channels])).toEqual([
["日语", "DTS-HD MA", "5.1"],
["日语", "LPCM", "2.0"],
]);
expect(m.subtitles).toHaveLength(2);
});
it("认不出返回 null", () => {
expect(parseMediaSummary("just some text")).toBeNull();
expect(parseMediaSummary(null)).toBeNull();
});
});
describe("文件清单 → 目录树", () => {
it("真实样本:8 集平铺,全是主视频", () => {
const t = buildFileTree(REAL_DETAIL_HH_INFO_CARD.files);
expect(t).toHaveLength(REAL_DETAIL_HH_INFO_CARD.files.length);
expect(t.every((r) => !r.dir && r.depth === 0 && r.role === "main")).toBe(true);
});
it("目录型:目录在前、大小求和,主视频 / sample / 字幕 / 附件", () => {
const t = buildFileTree(SYNTH_FILES_DIRECTORY);
expect(t[0]).toMatchObject({ dir: true, depth: 0, fileCount: 6, sizeBytes: 15_085_574_144 });
const role = (name: string) => t.find((r) => r.name === name)?.role;
expect(role("Example.Movie.2023.1080p.BluRay.x264-GROUP.mkv")).toBe("main");
expect(role("example.movie.sample.mkv")).toBe("sample");
expect(role("Example.Movie.2023.chs.srt")).toBe("subtitle");
expect(role("poster.jpg")).toBe("extra");
expect(t.map((r) => `${" ".repeat(r.depth)}${r.name}`).slice(0, 4)).toEqual([
"Example.Movie.2023.1080p.BluRay.x264-GROUP",
" Sample",
" example.movie.sample.mkv",
" Subs",
]);
});
it("原盘:最大的 m2ts 是正片,小片段不是", () => {
const t = buildFileTree(SYNTH_FILES_BDMV);
expect(t.find((r) => r.name === "00001.m2ts")?.role).toBe("main");
expect(t.find((r) => r.name === "00002.m2ts")?.role).toBe("video");
});
});
describe("formatDetailPage", () => {
it("组合四部分", () => {
const f = formatDetailPage(REAL_DETAIL_HH_INFO_CARD);
expect(f.infoBlock.length).toBeGreaterThan(5);
expect(f.paragraphs).toHaveLength(2);
expect(f.media?.kind).toBe("mediainfo");
expect(f.fileTree).toHaveLength(8);
});
});
@@ -0,0 +1,33 @@
/**
* 详情页扩展内容的展示结构(DETAIL-1):简介 → 信息块 + 段落,MediaInfo / BDInfo → 技术规格概要,文件清单 → 目录树。
* 纯函数;服务端读缓存时现算(旧缓存也能用),原文(descriptionText / mediaInfo / files)照旧保留。
*/
import {
DEFAULT_DESCRIPTION_RULES,
type DescriptionRules,
type DetailInfoItem,
structureDescription,
} from "./detail-description.ts";
import { buildFileTree, type FileTreeRow } from "./file-tree.ts";
import { type MediaSummary, parseMediaSummary } from "./mediainfo.ts";
import type { SiteDetailPage } from "./types.ts";
export interface FormattedDetailPage {
infoBlock: DetailInfoItem[];
paragraphs: string[];
media: MediaSummary | null;
fileTree: FileTreeRow[];
}
export function formatDetailPage(
page: Pick<SiteDetailPage, "descriptionText" | "mediaInfo" | "files">,
rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES,
): FormattedDetailPage {
const { infoBlock, paragraphs } = structureDescription(page.descriptionText, rules);
return {
infoBlock,
paragraphs,
media: parseMediaSummary(page.mediaInfo),
fileTree: buildFileTree(page.files ?? []),
};
}
+103
View File
@@ -0,0 +1,103 @@
/**
* 详情页文件清单 → 目录树(DETAIL-1,纯函数)。按「/」或「\」拆路径,目录大小为子项之和;
* 标出主视频(最大视频的 30% 以上,排除 sample)、sample、字幕与附件。输出按先序展平,前端按 depth 缩进。
*/
import type { SiteDetailFile } from "./types.ts";
export type FileRole = "main" | "sample" | "subtitle" | "extra" | "video" | "other";
export interface FileTreeRow {
/** 完整路径(目录也有),作 key 与折叠状态 */
path: string;
name: string;
depth: number;
dir: boolean;
/** 目录:子项之和;有未知大小的子项时仍给已知部分之和 */
sizeBytes: number | null;
/** 目录为 null */
role: FileRole | null;
/** 目录下的文件数(递归);文件为 null */
fileCount: number | null;
}
const VIDEO = /\.(mkv|mp4|m2ts|ts|avi|wmv|mov|m4v|iso|rmvb|flv|webm|vob|mpg|mpeg)$/i;
const SUB = /\.(srt|ass|ssa|sup|sub|idx|vtt|smi)$/i;
const EXTRA = /\.(nfo|txt|jpe?g|png|gif|webp|bmp|md5|sfv|sha1|sha256|url|html?|pdf|cue|log|torrent)$/i;
const SAMPLE = /(^|[\\/._\-\s])sample([\\/._\-\s]|$)/i;
interface Node {
name: string;
path: string;
children: Map<string, Node> | null;
sizeBytes: number | null;
}
export function buildFileTree(files: SiteDetailFile[]): FileTreeRow[] {
const root: Node = { name: "", path: "", children: new Map(), sizeBytes: null };
for (const f of files) {
const parts = f.name.split(/[\\/]+/).filter(Boolean);
if (!parts.length) continue;
let node = root;
parts.forEach((part, i) => {
const last = i === parts.length - 1;
const path = node.path ? `${node.path}/${part}` : part;
let child = node.children!.get(part);
if (!child) {
child = { name: part, path, children: last ? null : new Map(), sizeBytes: last ? f.sizeBytes : null };
node.children!.set(part, child);
} else if (!last && !child.children) {
child.children = new Map();
}
node = child;
});
}
const videoSizes = files.filter((f) => VIDEO.test(f.name) && !SAMPLE.test(f.name)).map((f) => f.sizeBytes ?? 0);
const maxVideo = Math.max(0, ...videoSizes);
const roleOf = (n: Node): FileRole => {
if (SUB.test(n.name)) return "subtitle";
if (VIDEO.test(n.name)) {
if (SAMPLE.test(n.path)) return "sample";
// 大小未知时所有视频都当主视频;已知时按最大视频的 30% 划线(剧集各集、原盘正片)
if (!maxVideo || n.sizeBytes === null || n.sizeBytes >= maxVideo * 0.3) return "main";
return "video";
}
if (EXTRA.test(n.name)) return "extra";
return "other";
};
const out: FileTreeRow[] = [];
const walk = (n: Node, depth: number): { size: number | null; count: number } => {
const kids = [...n.children!.values()].sort((a, b) =>
!!a.children === !!b.children ? a.name.localeCompare(b.name, "zh-CN", { numeric: true }) : a.children ? -1 : 1,
);
let size: number | null = null;
let count = 0;
for (const k of kids) {
if (k.children) {
const idx = out.length;
out.push({ path: k.path, name: k.name, depth, dir: true, sizeBytes: null, role: null, fileCount: null });
const sub = walk(k, depth + 1);
out[idx]!.sizeBytes = sub.size;
out[idx]!.fileCount = sub.count;
if (sub.size !== null) size = (size ?? 0) + sub.size;
count += sub.count;
} else {
out.push({
path: k.path,
name: k.name,
depth,
dir: false,
sizeBytes: k.sizeBytes,
role: roleOf(k),
fileCount: null,
});
if (k.sizeBytes !== null) size = (size ?? 0) + k.sizeBytes;
count += 1;
}
}
return { size, count };
};
walk(root, 0);
return out;
}
+19
View File
@@ -16,12 +16,23 @@ export {
SpiderFilterSchema,
} from "./definitions/index.ts";
export type { SiteAdapterDeps } from "./deps.ts";
export {
cleanDescription,
DEFAULT_DESCRIPTION_RULES,
type DescriptionRules,
type DetailInfoItem,
type StructuredDescription,
stripMarkup,
structureDescription,
} from "./detail-description.ts";
export { type FormattedDetailPage, formatDetailPage } from "./detail-format.ts";
export {
type FeedMediaType,
normalizeSiteFeedItem,
type SiteFeedItemFields,
siteCategoryMediaType,
} from "./feed-item.ts";
export { buildFileTree, type FileRole, type FileTreeRow } from "./file-tree.ts";
export {
classifyLoginError,
loginFailureText,
@@ -33,6 +44,14 @@ export {
type SiteLoginFailure,
totpCode,
} from "./login/nexusphp.ts";
export {
type MediaAudioTrack,
type MediaGeneral,
type MediaSummary,
type MediaTextTrack,
type MediaVideoTrack,
parseMediaSummary,
} from "./mediainfo.ts";
export { createMteamAdapter, mteamTorrentToRelease } from "./mteam/adapter.ts";
export { type MteamApiPath, mteamPost } from "./mteam/client.ts";
export { type MteamDefinition, mteamApiBase, mteamDefinition } from "./mteam/definition.ts";
+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);
}
+59
View File
@@ -636,6 +636,60 @@ export const WorkbenchReleasePage = z.object({
});
export type WorkbenchReleasePage = z.infer<typeof WorkbenchReleasePage>;
/** MediaInfo / BDInfo 概要;字段认不出为 null */
export const SiteMediaSummary = z.object({
kind: z.enum(["mediainfo", "bdinfo"]),
general: z.object({
fileName: z.string().nullable(),
container: z.string().nullable(),
duration: z.string().nullable(),
overallBitrate: z.string().nullable(),
size: z.string().nullable(),
}),
video: z.array(
z.object({
codec: z.string().nullable(),
resolution: z.string().nullable(),
frameRate: z.string().nullable(),
bitrate: z.string().nullable(),
bitDepth: z.string().nullable(),
hdr: z.array(z.string()),
}),
),
audio: z.array(
z.object({
language: z.string().nullable(),
codec: z.string().nullable(),
channels: z.string().nullable(),
bitrate: z.string().nullable(),
title: z.string().nullable(),
isDefault: z.boolean().nullable(),
}),
),
subtitles: z.array(
z.object({
language: z.string().nullable(),
format: z.string().nullable(),
title: z.string().nullable(),
isDefault: z.boolean().nullable(),
forced: z.boolean().nullable(),
}),
),
});
export type SiteMediaSummary = z.infer<typeof SiteMediaSummary>;
/** 文件目录树(先序展平,按 depth 缩进);role:主视频 / sample / 字幕 / 附件 / 其他视频 / 其他,目录为 null */
export const SiteFileTreeRow = z.object({
path: z.string(),
name: z.string(),
depth: z.number().int(),
dir: z.boolean(),
sizeBytes: z.number().int().nullable(),
role: z.enum(["main", "sample", "subtitle", "extra", "video", "other"]).nullable(),
fileCount: z.number().int().nullable(),
});
export type SiteFileTreeRow = z.infer<typeof SiteFileTreeRow>;
/**
* 站点详情页扩展内容(按需抓详情时得到,服务端缓存 24 小时;过期后需再次复核详情)。
* 截图地址是服务端图片代理路径(/api/v1/img/site-detail/{releaseId}/{index}),不直连外站。
@@ -651,6 +705,11 @@ export const SiteReleasePage = z.object({
files: z.array(z.object({ name: z.string(), sizeBytes: z.number().int().nullable() })),
tags: z.array(z.string()),
uploader: z.string().nullable(),
/** 展示结构(读缓存时由上面的原文现算):信息块、正文段落、技术规格概要、文件目录树 */
infoBlock: z.array(z.object({ key: z.string(), value: z.string() })),
paragraphs: z.array(z.string()),
media: SiteMediaSummary.nullable(),
fileTree: z.array(SiteFileTreeRow),
});
export type SiteReleasePage = z.infer<typeof SiteReleasePage>;
@@ -0,0 +1,2 @@
export * from "./real.ts";
export * from "./synthetic.ts";
@@ -0,0 +1,87 @@
/**
* DETAIL-1 详情格式化样本:取自用户库里已缓存的真实数据(2026-09-29,憨憨),已脱敏——
* MediaInfo 的 Unique ID 换成占位值,图床 / 站点域名换成 example 域;片名、公开的 IMDb / 豆瓣 / TMDB 编号保留。
* - REAL_DETAIL_*:kv_cache site.detail 里的详情页扩展内容(SiteDetailPage 形状,去掉 fetchedAt / probe)
* - REAL_RSS_DESCR_*:observation.raw.description(RSS 条目简介原样 HTML,与详情页 #kdescr 同源,站点截断在 1000 字)
*/
/** 憨憨详情页:没有 #kdescr,简介来自「豆瓣」信息块(「片名:」下一行是值),MediaInfo 带中文摘要头;8 集文件清单 */
export const REAL_DETAIL_HH_INFO_CARD = {
tags: ["官方", "国语", "中字"],
tmdb: {
id: "294990",
type: "tv",
},
files: [
{
name: "Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 5143223337,
},
{
name: "Spring.of.the.Blade.S01E19.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 5121748500,
},
{
name: "Spring.of.the.Blade.S01E20.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 5025111736,
},
{
name: "Spring.of.the.Blade.S01E21.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 5121748500,
},
{
name: "Spring.of.the.Blade.S01E22.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 5293547192,
},
{
name: "Spring.of.the.Blade.S01E23.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 5400921375,
},
{
name: "Spring.of.the.Blade.S01E24.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 4982162063,
},
{
name: "Spring.of.the.Blade.S01E25.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv",
sizeBytes: 4928474972,
},
],
imdbId: "tt45956347",
doubanId: "35644140",
uploader: "匿名",
mediaInfo:
"文件名:Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv\n体 积: 4.79 GiB\n时 长: 43 min 55 s\n分辨率: 3 840 pixels x 1 608 pixels\n码 率: 15.3 Mb/s\n帧 率: 60.000 FPS\n\n音 频: 01: AAC LC 2 channels 256 kb/s 中文 [AAC 2.0]\n\nGeneral\nUnique ID : 100000000000000000000000000000000001 (0x0000000000000000000000000000000A)\nComplete name : Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv\nFormat : Matroska\nFormat version : Version 4\nFile size : 4.79 GiB\nDuration : 43 min 55 s\nOverall bit rate : 15.6 Mb/s\nFrame rate : 60.000 FPS\nWriting application : mkvmerge v99.0 (\\'Buka\\') 64-bit\nWriting library : libebml v1.4.5 + libmatroska v1.7.1\nGroup : HHWEB\nIMDB : tt45956347\nTMDB : tv/294990\nTVDB2 : series/476903\n\nVideo\nID : 1\nFormat : HEVC\nFormat/Info : High Efficiency Video Coding\nFormat profile : Main [email protected]@Main\nCodec ID : V_MPEGH/ISO/HEVC\nDuration : 43 min 55 s\nBit rate : 15.3 Mb/s\nWidth : 3 840 pixels\nHeight : 1 608 pixels\nDisplay aspect ratio : 2.39:1\nFrame rate mode : Constant\nFrame rate : 60.000 FPS\nColor space : YUV\nChroma subsampling : 4:2:0\nBit depth : 10 bits\nBits/(Pixel*Frame) : 0.041\nStream size : 4.71 GiB (98%)\nLanguage : Chinese\nDefault : Yes\nForced : No\nColor range : Limited\nColor primaries : BT.709\nTransfer characteristics : BT.709\nMatrix coefficients : BT.709\n\nAudio\nID : 2\nFormat : AAC LC\nFormat/Info : Advanced Audio Codec Low Complexity\nCodec ID : A_AAC-2\nDuration : 43 min 55 s\nBit rate : 256 kb/s\nChannel(s) : 2 channels\nChannel layout : L R\nSampling rate : 44.1 kHz\nFrame rate : 43.066 FPS (1024 SPF)\nCompression mode : Lossy\nStream size : 80.4 MiB (2%)\nTitle : 中文 [AAC 2.0]\nLanguage : Chinese\nDefault : Yes\nForced : No",
screenshots: ["https://img.example.org/img/2026/09/29/6abb943050a2e.jpg"],
descriptionText:
"一瓯春\n2026\n\n电视剧\n\n5.4\n\n(11053)\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n片名:\n一瓯春\n\n别名:\n\n年代:\n2026\n\n片长:\n45分钟\n\n票数:\n11053\n\n评分:\n5.4/10 from 11053 users\n\n对白语言:\n汉语普通话\n\n国家:\n中国大陆\n\n类别:\n爱情,古装\n\n标语:\n\n导演:\n\n潘安子 Anzi Pan\n\n编剧:\n尤四姐\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n剧情概要:\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n“杀伐千面腹黑男”沈润与“人间清醒黑莲花”谢清圆互为刀刃交错,在理智与情感中携手复仇,于朝堂官场上搅弄风云,于高门大户里明争暗斗。最终他们走出暗流深渊,奔赴璀璨新生。\n该剧改编自尤四姐的同名小说。\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n主演:\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n许凯 Kai Xu周也 Ye Zhou白澍 Shu Bai\n注意:该信息最后更新于2026-09-29 18:34:54,可能已过期,点击\n这里\n更新信息",
};
/** 憨憨详情页:只有 MediaInfo(杜比视界 Profile 5),没有简介和截图 */
export const REAL_DETAIL_HH_DV_ONLY = {
tags: ["官方", "国语", "中字", "杜比视界"],
tmdb: null,
files: [],
imdbId: null,
doubanId: "35644140",
uploader: "匿名",
mediaInfo:
"文件名:Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.DV.HQ.AAC2.0-HHWEB.mkv\n体 积: 5.04 GiB\n时 长: 43 min 55 s\n分辨率: 3 840 pixels x 1 608 pixels\n码 率: 16.2 Mb/s\n帧 率: 25.000 FPS\n\n音 频: 01: AAC LC 2 channels 256 kb/s 中文 [AAC 2.0]\n\nGeneral\nUnique ID : 100000000000000000000000000000000001 (0x0000000000000000000000000000000A)\nComplete name : Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.DV.HQ.AAC2.0-HHWEB.mkv\nFormat : Matroska\nFormat version : Version 4\nFile size : 5.04 GiB\nDuration : 43 min 55 s\nOverall bit rate : 16.4 Mb/s\nFrame rate : 25.000 FPS\nWriting application : mkvmerge v99.0 (\\'Buka\\') 64-bit\nWriting library : libebml v1.4.5 + libmatroska v1.7.1\nGroup : HHWEB\nIMDB : tt45956347\nTMDB : tv/294990\nTVDB2 : series/476903\n\nVideo\nID : 1\nFormat : HEVC\nFormat/Info : High Efficiency Video Coding\nFormat profile : Main [email protected]@Main\nHDR format : Dolby Vision, Version 1.0, Profile 5, dvhe.05.06, BL+RPU, no metadata compression\nCodec ID : V_MPEGH/ISO/HEVC\nDuration : 43 min 55 s\nBit rate : 16.2 Mb/s\nWidth : 3 840 pixels\nHeight : 1 608 pixels\nDisplay aspect ratio : 2.39:1\nFrame rate mode : Constant\nFrame rate : 25.000 FPS\nColor space : YUV\nChroma subsampling : 4:2:0\nBit depth : 10 bits\nBits/(Pixel*Frame) : 0.105\nStream size : 4.96 GiB (98%)\nLanguage : Chinese\nDefault : Yes\nForced : No\nColor range : Full\n\nAudio\nID : 2\nFormat : AAC LC\nFormat/Info : Advanced Audio Codec Low Complexity\nCodec ID : A_AAC-2\nDuration : 43 min 55 s\nBit rate : 256 kb/s\nChannel(s) : 2 channels\nChannel layout : L R\nSampling rate : 44.1 kHz\nFrame rate : 43.066 FPS (1024 SPF)\nCompression mode : Lossy\nStream size : 80.4 MiB (2%)\nTitle : 中文 [AAC 2.0]\nLanguage : Chinese\nDefault : Yes\nForced : No",
screenshots: [],
descriptionText: null,
};
/** ◎ 信息块 + ◎简介 段落(动画剧集) */
export const REAL_RSS_DESCR_INFO_BLOCK =
'<img style="max-width: 100%" id="" alt="image" src="https://img.example.net/images/6052/775942706_p2932936483.jpg" /><br />\n<br />\n◎译  名 Stay Low Profile, Sect Chief / 掌门低调点 3D动画版<br />\n◎片  名 掌门低调点<br />\n◎年  代 2026<br />\n◎产  地 中国大陆<br />\n◎类  别 喜剧 / 动作 / 动画<br />\n◎语  言 汉语普通话<br />\n◎上映日期 2026-08-07(中国大陆)<br />\n◎豆瓣链接 <a class="faqlink" href="https://movie.douban.com/subject/37842527/">https://movie.douban.com/subject/37842527/</a><br />\n◎集  数 40<br />\n◎片  长 7分钟<br />\n◎主  演 宁冀荣 Lemon<br />\n<br />\n◎简  介<br />\n<br />\n &nbsp; &nbsp;大雪覆门,妖修突袭,屠尽墨门,少年路朝歌痛失至亲,绝境之中觉醒绑定逆天系统,靠水系启灵死里逃生,立誓肃清世间妖邪。十年蛰伏苦修,他困于初境寸步难行,一朝契机重启系统,携妹妹路冬梨下山寻灵草破境。途中巧破异兽难题,逆天引九道天道敕令启灵,连破五重境界,还直面剑宗天骄邀战,昔日废柴掌门,自此强势逆袭。<br />';
/** ◎ 信息块,主演多行续行,◎简介 两段 + ◎获奖情况(站点截断) */
export const REAL_RSS_DESCR_AWARDS =
'<img style="max-width: 100%" id="" alt="image" src="https://img.example.net/images/6053/775953154_p2544971070.jpg" /><br />\n<br />\n◎译  名 China Now<br />\n◎片  名 这就是中国<br />\n◎年  代 2019<br />\n◎产  地 中国大陆<br />\n◎类  别 脱口秀<br />\n◎语  言 汉语普通话<br />\n◎上映日期 2019-01-07(中国大陆)<br />\n◎豆瓣链接 <a class="faqlink" href="https://movie.douban.com/subject/30425880/">https://movie.douban.com/subject/30425880/</a><br />\n◎集  数 45<br />\n◎片  长 42分钟<br />\n◎主  演 张维为 Weiwei Zhang<br />\n &nbsp; &nbsp;亚历山大·杜金 Alexander Dugin<br />\n &nbsp; &nbsp;李世默 Eric Li<br />\n &nbsp; &nbsp;范勇鹏 Yongpeng Fan<br />\n &nbsp; &nbsp;余亮 Liang Yu<br />\n<br />\n◎简  介<br />\n<br />\n &nbsp; &nbsp;该节目创新性地采用“演讲+真人秀”的模式,从当下国内外老百姓关心的一个个热点、难点时政问题切入讨论,作为主讲人的张维为教授,则以自己深刻的政治观和独到的视角,通过演讲的方式为观众答疑解惑,并在现场与年轻人们展开热烈讨论甚至辩论,最终把中国制度、中国理论、中国道路、中国文化的优势和先进性讲清楚,传达出“民族自信”的相关核心精神。<br />\n &nbsp; &nbsp;《这就是中国》并没有将节目形式局限于纸上谈兵。相反,该节目突破了传统理论宣讲节目的单向形式,由三个“角色组合”搭配两条主线来完成节目的多角度呈现。<br />\n<br />\n◎获奖情况<br />\n<br />\n &nbsp; &nbsp;第28届上海电视节白玉兰奖 &nbsp;(2023)<br />\n综艺节目 最佳电视综艺节目(提名)<br…';
/** ◎ 信息块带豆瓣评分 / 导演 / 编剧,长主演列表被站点截断 */
export const REAL_RSS_DESCR_CAST =
'<img style="max-width: 100%" id="" alt="image" src="https://img.example.net/images/6062/776108679_p2935819627.jpg" /><br />\n<br />\n◎片  名 一瓯春<br />\n◎年  代 2026<br />\n◎产  地 中国大陆<br />\n◎类  别 爱情 / 古装<br />\n◎语  言 汉语普通话<br />\n◎上映日期 2026-09-17(中国大陆)<br />\n◎豆瓣评分 5.4/10 from 11075 users<br />\n◎豆瓣链接 <a class="faqlink" href="https://movie.douban.com/subject/35644140/">https://movie.douban.com/subject/35644140/</a><br />\n◎集  数 30<br />\n◎片  长 45分钟<br />\n◎导  演 潘安子 Anzi Pan<br />\n◎编  剧 尤四姐<br />\n◎主  演 许凯 Kai Xu<br />\n &nbsp; &nbsp;周也 Ye Zhou<br />\n &nbsp; &nbsp;白澍 Shu Bai<br />\n &nbsp; &nbsp;闵春晓 Chunxiao Min<br />\n &nbsp; &nbsp;柯颖 Ying Ke<br />\n &nbsp; &nbsp;陈意涵 Estelle Chen<br />\n &nbsp; &nbsp;张瑶 Yao Zhang<br />\n &nbsp; &nbsp;苗圃 Pu Miao<br />\n &nbsp; &nbsp;徐振轩 Zhenxuan Xu<br />\n &nbsp; &nbsp;黄晓娟 Xiaojuan Huang<br />\n &nbsp; &nbsp;富大龙 Dalong Fu<br />\n &nbsp; &nbsp;赫子铭 Lanpeng He<br />\n &nbsp; &nbsp;苏棋 Suqi Wang<br />\n &nbsp; &nbsp;王祖一 Zuyi Wang<br />\n &nbsp; &nbsp;孙毅 Yi Sun<br />\n &nbsp; &nbsp;纪燃 Ran …';
/** 没有信息块:海报图 + 一段剧情 */
export const REAL_RSS_DESCR_PLAIN =
'<img style="max-width: 100%" id="" alt="image" src="https://img.example.org/img/2026/09/29/6abb545109486.png" /><br />21岁的玉雕师方雾,十一岁时被荣景御从暗市救下,后来寄养在沈家,却被长期霸凌和操控。十年后,两人在京市重逢,方雾早已不记得荣景御。荣景御隐瞒身份接近她,替她出头、支持她创业,也一步步帮她摆脱沈家的控制。随着护身玉牌、沈家收养真相和旧日离别被揭开,方雾终于找回勇敢的自己,在玉雕事业与感情中重新选择人生。';
@@ -0,0 +1,236 @@
/**
* DETAIL-1 详情格式化样本(构造):用户库缓存里没有的形态——多音轨多字幕 MediaInfo、BDInfo(简版 / 完整报告)、
* 【】『』信息块夹 BBCode / HTML 残留与广告签名、带目录的文件清单。格式照 MediaInfo CLI / BDInfo 0.7.x 输出与
* 常见 PT 简介模板手写,数值均为示例。
*/
/** 电影 remux:HDR10 + 杜比视界双层,三条音轨(一条默认),四条字幕(一条强制) */
export const SYNTH_MEDIAINFO_MULTI_TRACK = `General
Unique ID : 100000000000000000000000000000000002 (0x0000000000000000000000000000000B)
Complete name : Example.Movie.2023.2160p.BluRay.REMUX.HEVC.DV.HDR.TrueHD.7.1.Atmos-GROUP.mkv
Format : Matroska
Format version : Version 4
File size : 71.3 GiB
Duration : 2 h 22 min
Overall bit rate mode : Variable
Overall bit rate : 71.5 Mb/s
Frame rate : 23.976 FPS
Video
ID : 1
Format : HEVC
Format/Info : High Efficiency Video Coding
Format profile : Main [email protected]@High
HDR format : Dolby Vision, Version 1.0, Profile 7.6, dvhe.07.06, BL+EL+RPU, Blu-ray compatible / SMPTE ST 2086, HDR10 compatible
Codec ID : V_MPEGH/ISO/HEVC
Duration : 2 h 22 min
Bit rate : 62.4 Mb/s
Width : 3 840 pixels
Height : 2 160 pixels
Display aspect ratio : 16:9
Frame rate mode : Constant
Frame rate : 23.976 (24000/1001) FPS
Bit depth : 10 bits
Language : English
Default : Yes
Forced : No
Color primaries : BT.2020
Transfer characteristics : PQ
Mastering display color primaries : Display P3
Mastering display luminance : min: 0.0001 cd/m2, max: 1000 cd/m2
Audio #1
ID : 2
Format : MLP FBA 16-ch
Format/Info : Meridian Lossless Packing FBA with 16-channel presentation
Commercial name : Dolby TrueHD with Dolby Atmos
Codec ID : A_TRUEHD
Duration : 2 h 22 min
Bit rate mode : Variable
Bit rate : 4 561 kb/s
Channel(s) : 8 channels
Channel layout : L R C LFE Ls Rs Lb Rb
Sampling rate : 48.0 kHz
Title : TrueHD 7.1 Atmos
Language : English
Default : Yes
Forced : No
Audio #2
ID : 3
Format : AC-3
Format/Info : Audio Coding 3
Commercial name : Dolby Digital
Codec ID : A_AC3
Duration : 2 h 22 min
Bit rate mode : Constant
Bit rate : 640 kb/s
Channel(s) : 6 channels
Channel layout : L R C LFE Ls Rs
Sampling rate : 48.0 kHz
Title : 国语 DD 5.1
Language : Chinese
Default : No
Forced : No
Audio #3
ID : 4
Format : AAC LC
Codec ID : A_AAC-2
Bit rate : 192 kb/s
Channel(s) : 2 channels
Sampling rate : 48.0 kHz
Title : Commentary
Language : English
Default : No
Forced : No
Text #1
ID : 5
Format : PGS
Codec ID : S_HDMV/PGS
Title : Forced
Language : English
Default : No
Forced : Yes
Text #2
ID : 6
Format : UTF-8
Codec ID : S_TEXT/UTF8
Title : 简体中文
Language : Chinese
Default : Yes
Forced : No
Text #3
ID : 7
Format : ASS
Codec ID : S_TEXT/ASS
Title : 繁體中文
Language : zh-Hant
Default : No
Forced : No
Text #4
ID : 8
Format : PGS
Codec ID : S_HDMV/PGS
Title : SDH
Language : English
Default : No
Forced : No
Menu
00:00:00.000 : en:Chapter 01
00:07:12.140 : en:Chapter 02`;
/** BDInfo 简版(Quick Summary) */
export const SYNTH_BDINFO_QUICK = `Disc Title: EXAMPLE_MOVIE
Disc Size: 61,812,345,678 bytes
Protection: AACS2
Extras: Ultra HD, BD-Java
BDInfo: 0.7.5.5
Playlist: 00800.MPLS
Size: 59,123,456,789 bytes
Length: 2:22:48.521
Total Bitrate: 55.20 Mbps
Video: MPEG-H HEVC Video / 43981 kbps / 2160p / 23.976 fps / 16:9 / Main 10 @ Level 5.1 @ High / 10 bits / HDR10 / BT.2020
Video: MPEG-H HEVC Video / 3526 kbps / 1080p / 23.976 fps / 16:9 / Main 10 @ Level 5.1 @ High / 10 bits / Dolby Vision FEL / BT.2020
Audio: English / Dolby TrueHD/Atmos Audio / 7.1 / 48 kHz / 4561 kbps / 24-bit (AC3 Embedded: 5.1 / 48 kHz / 640 kbps / DN -31dB)
Audio: Chinese / Dolby Digital Audio / 5.1 / 48 kHz / 640 kbps / DN -31dB
Audio: English / Dolby Digital Audio / 2.0 / 48 kHz / 192 kbps / DN -27dB
Subtitle: English / 32.145 kbps
Subtitle: Chinese / 25.012 kbps
Subtitle: Chinese / 24.100 kbps`;
/** BDInfo 完整报告(DISC INFO / PLAYLIST REPORT / VIDEO / AUDIO / SUBTITLES 表格) */
export const SYNTH_BDINFO_FULL = `DISC INFO:
Disc Title: EXAMPLE_SHOW_S01_D1
Disc Size: 45,678,901,234 bytes
Protection: AACS
BDInfo: 0.7.5.5
PLAYLIST REPORT:
Name: 00001.MPLS
Length: 0:52:10.211 (h:m:s.ms)
Size: 18,234,567,890 bytes
Total Bitrate: 46.61 Mbps
VIDEO:
Codec Bitrate Description
----- ------- -----------
MPEG-4 AVC Video 31456 kbps 1080p / 23.976 fps / 16:9 / High Profile 4.1
AUDIO:
Codec Language Bitrate Description
----- -------- ------- -----------
DTS-HD Master Audio Japanese 3456 kbps 5.1 / 48 kHz / 3456 kbps / 24-bit (DTS Core: 5.1 / 48 kHz / 1509 kbps / 24-bit)
LPCM Audio Japanese 2304 kbps 2.0 / 48 kHz / 2304 kbps / 24-bit
SUBTITLES:
Codec Language Bitrate Description
----- -------- ------- -----------
Presentation Graphics Chinese 28.431 kbps
Presentation Graphics English 30.021 kbps`;
/** 【】/『』信息块混 BBCode 与 HTML 残留、站点模板和广告签名 */
export const SYNTH_DESCR_BRACKETS = `[quote][b]本资源由 EXAMPLE 小组制作,禁止转载至其他站点[/b][/quote]
[img]https://img.example.net/poster.jpg[/img]
[size=3][color=Red]【译  名】示例电影 / Example Movie[/color][/size]
【片  名】Example Movie
【年  代】2023
【产  地】美国
【类  别】剧情 / 科幻
【语  言】英语
【字  幕】中英双语
【IMDb评分】8.1/10 from 12,345 users
【IMDb链接】https://www.imdb.com/title/tt0000001/
【片  长】142 分钟
『导  演』某导演 Some Director
『主  演』演员甲 Actor A
      演员乙 Actor B
&nbsp;
【简  介】
<p>&nbsp;&nbsp;一位科学家在沙漠深处发现了改变世界的秘密。&lt;他必须&gt;在时间耗尽前做出选择。</p>
<br><br><br>
  影片获得多项提名,[url=https://example.org/awards]查看获奖名单[/url]。
QQ群:123456789
-----------------------------------------
[i]感谢原发布者分享[/i]`;
/** 带目录的文件清单:主视频、Sample、字幕与附件、原盘结构 */
export const SYNTH_FILES_DIRECTORY = [
{
name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Example.Movie.2023.1080p.BluRay.x264-GROUP.mkv",
sizeBytes: 15_032_385_536,
},
{ name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Sample/example.movie.sample.mkv", sizeBytes: 52_428_800 },
{
name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Example.Movie.2023.1080p.BluRay.x264-GROUP.nfo",
sizeBytes: 6_144,
},
{ name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Subs/Example.Movie.2023.chs.srt", sizeBytes: 98_304 },
{ name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Subs/Example.Movie.2023.cht.ass", sizeBytes: 131_072 },
{ name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/poster.jpg", sizeBytes: 524_288 },
];
/** 原盘:BDMV/STREAM 下一个大 m2ts 是正片,其余小文件 */
export const SYNTH_FILES_BDMV = [
{ name: "EXAMPLE_MOVIE/BDMV/index.bdmv", sizeBytes: 280 },
{ name: "EXAMPLE_MOVIE/BDMV/PLAYLIST/00800.mpls", sizeBytes: 3_072 },
{ name: "EXAMPLE_MOVIE/BDMV/STREAM/00001.m2ts", sizeBytes: 58_999_000_000 },
{ name: "EXAMPLE_MOVIE/BDMV/STREAM/00002.m2ts", sizeBytes: 120_000_000 },
{ name: "EXAMPLE_MOVIE/BDMV/STREAM/00003.m2ts", sizeBytes: 90_000_000 },
{ name: "EXAMPLE_MOVIE/CERTIFICATE/id.bdmv", sizeBytes: 100 },
];
+1
View File
@@ -1,4 +1,5 @@
export * from "./fixtures/definitions/index.ts";
export * from "./fixtures/details/index.ts";
export * from "./fixtures/spiders/index.ts";
export * from "./fixtures/user-parsers/index.ts";
export {