Adapters (pure functions + tests): - detail-description: strip HTML/BBCode residue, drop site template blocks and ad/signature lines (configurable rules), parse PT info blocks (◎ / 【】 / 『』 / 「键:」) into infoBlock and body paragraphs - mediainfo: MediaInfo and BDInfo (quick summary + full report) into a summary of container/duration/bitrate/size, video (HDR/DV), audio and subtitle tracks - file-tree: file list into a flattened directory tree with sizes and main/sample/subtitle/extra roles - formatDetailPage combines them; the server computes it when reading the cached detail page, so existing caches benefit without a refetch Contracts: SiteReleasePage gains infoBlock, paragraphs, media, fileTree. Web: drawer now reads top-down: meta row (promo/H&R, size, peers, upload time, IMDb/TMDB/豆瓣 tags) → 资料 → 简介 (collapsible) → 技术规格 (raw folded) → 截图 grid with keyboard lightbox → 文件 tree; inventory overlay moved into the fixed footer next to 下载. Fixtures: 6 desensitized samples from the local cache (site.detail and RSS descriptions) plus synthetic multi-track MediaInfo, BDInfo, bracket blocks and directory file lists. Co-Authored-By: Claude Opus 5.5 <[email protected]>
104 lines
3.7 KiB
TypeScript
104 lines
3.7 KiB
TypeScript
/**
|
|
* 详情页文件清单 → 目录树(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;
|
|
}
|