/** * 详情页文件清单 → 目录树(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 | 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; }