Merge branch 'detail1'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
3067b668d8
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(/<| |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+| |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&bAB <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 ?? []),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 大雪覆门,妖修突袭,屠尽墨门,少年路朝歌痛失至亲,绝境之中觉醒绑定逆天系统,靠水系启灵死里逃生,立誓肃清世间妖邪。十年蛰伏苦修,他困于初境寸步难行,一朝契机重启系统,携妹妹路冬梨下山寻灵草破境。途中巧破异兽难题,逆天引九道天道敕令启灵,连破五重境界,还直面剑宗天骄邀战,昔日废柴掌门,自此强势逆袭。<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 亚历山大·杜金 Alexander Dugin<br />\n 李世默 Eric Li<br />\n 范勇鹏 Yongpeng Fan<br />\n 余亮 Liang Yu<br />\n<br />\n◎简 介<br />\n<br />\n 该节目创新性地采用“演讲+真人秀”的模式,从当下国内外老百姓关心的一个个热点、难点时政问题切入讨论,作为主讲人的张维为教授,则以自己深刻的政治观和独到的视角,通过演讲的方式为观众答疑解惑,并在现场与年轻人们展开热烈讨论甚至辩论,最终把中国制度、中国理论、中国道路、中国文化的优势和先进性讲清楚,传达出“民族自信”的相关核心精神。<br />\n 《这就是中国》并没有将节目形式局限于纸上谈兵。相反,该节目突破了传统理论宣讲节目的单向形式,由三个“角色组合”搭配两条主线来完成节目的多角度呈现。<br />\n<br />\n◎获奖情况<br />\n<br />\n 第28届上海电视节白玉兰奖 (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 周也 Ye Zhou<br />\n 白澍 Shu Bai<br />\n 闵春晓 Chunxiao Min<br />\n 柯颖 Ying Ke<br />\n 陈意涵 Estelle Chen<br />\n 张瑶 Yao Zhang<br />\n 苗圃 Pu Miao<br />\n 徐振轩 Zhenxuan Xu<br />\n 黄晓娟 Xiaojuan Huang<br />\n 富大龙 Dalong Fu<br />\n 赫子铭 Lanpeng He<br />\n 苏棋 Suqi Wang<br />\n 王祖一 Zuyi Wang<br />\n 孙毅 Yi Sun<br />\n 纪燃 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
|
||||
|
||||
【简 介】
|
||||
<p> 一位科学家在沙漠深处发现了改变世界的秘密。<他必须>在时间耗尽前做出选择。</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,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 {
|
||||
|
||||
Reference in new issue
Block a user