import type { SubtitleCue } from "@umt/contracts"; // 预览用的纯解析函数(FM-2):编码探测、字幕(SRT/VTT/ASS/SSA/PGS)、图片尺寸。 // 输入都是已按上限截取的字节,不做 IO;非法输入一律返回空结果而不是抛错。 export type PreviewKind = "text" | "subtitle" | "torrent" | "image"; const EXT_PREVIEW: Record = {}; for (const e of "nfo txt log md markdown json xml yaml yml ini conf cfg csv tsv cue sfv md5 sha1 sha256 url".split(" ")) EXT_PREVIEW[e] = "text"; for (const e of "srt vtt ass ssa sup".split(" ")) EXT_PREVIEW[e] = "subtitle"; for (const e of "jpg jpeg png webp avif gif bmp".split(" ")) EXT_PREVIEW[e] = "image"; EXT_PREVIEW.torrent = "torrent"; export const extOf = (name: string) => /\.([A-Za-z0-9]{1,8})$/.exec(name)?.[1]?.toLowerCase() ?? ""; export const previewKindOf = (name: string): PreviewKind | null => EXT_PREVIEW[extOf(name)] ?? null; // ── 编码探测 ── function tryDecode(label: string, buf: Uint8Array): string | null { try { return new TextDecoder(label, { fatal: true, ignoreBOM: true }).decode(buf); } catch { return null; } } /** 截断读取时末尾可能落在多字节字符中间:最多去掉 3 个字节再试 */ function tryDecodeTail(label: string, buf: Uint8Array, truncated: boolean): string | null { const max = truncated ? 3 : 0; for (let cut = 0; cut <= max && cut <= buf.length; cut++) { const t = tryDecode(label, cut ? buf.subarray(0, buf.length - cut) : buf); if (t !== null) return t; } return null; } /** 双字节编码的"像不像":常用汉字越多越像,生僻区/私用区/控制字符扣分 */ function cjkScore(s: string): number { let score = 0; for (const ch of s) { const c = ch.codePointAt(0) ?? 0; if (c >= 0x4e00 && c <= 0x9fa5) score += 1; else if (c >= 0x3000 && c <= 0x303f) score += 1; else if (c >= 0xff00 && c <= 0xffef) score += 0.5; else if ((c >= 0x3400 && c <= 0x4dbf) || (c >= 0xe000 && c <= 0xf8ff) || c > 0xffff) score -= 3; else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) score -= 3; } return score; } function utf16Guess(buf: Uint8Array): "utf-16le" | "utf-16be" | null { const n = Math.min(buf.length, 4096) & ~1; if (n < 4) return null; let evenZero = 0; let oddZero = 0; for (let i = 0; i < n; i += 2) { if (buf[i] === 0) evenZero++; if (buf[i + 1] === 0) oddZero++; } const pairs = n / 2; if (oddZero > pairs * 0.3 && evenZero < pairs * 0.05) return "utf-16le"; if (evenZero > pairs * 0.3 && oddZero < pairs * 0.05) return "utf-16be"; return null; } /** 按 BOM → UTF-16 零字节分布 → UTF-8 → GB18030/Big5(择优)→ windows-1252 顺序探测 */ export function decodeText(buf: Uint8Array, truncated = false): { text: string; encoding: string } { if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return { text: new TextDecoder("utf-8").decode(buf.subarray(3)), encoding: "utf-8" }; if (buf[0] === 0xff && buf[1] === 0xfe) return { text: new TextDecoder("utf-16le").decode(buf.subarray(2)), encoding: "utf-16le" }; if (buf[0] === 0xfe && buf[1] === 0xff) return { text: new TextDecoder("utf-16be").decode(buf.subarray(2)), encoding: "utf-16be" }; const u16 = utf16Guess(buf); if (u16) return { text: new TextDecoder(u16).decode(buf.subarray(0, buf.length & ~1)), encoding: u16 }; const utf8 = tryDecodeTail("utf-8", buf, truncated); if (utf8 !== null) return { text: utf8, encoding: "utf-8" }; const cands = (["gb18030", "big5"] as const) .map((enc) => ({ enc, text: tryDecodeTail(enc, buf, truncated) })) .filter((c): c is { enc: "gb18030" | "big5"; text: string } => c.text !== null) .map((c) => ({ ...c, score: cjkScore(c.text) })) .sort((a, b) => b.score - a.score); const best = cands[0]; if (best && best.score > 0) return { text: best.text, encoding: best.enc }; return { text: new TextDecoder("windows-1252").decode(buf), encoding: "windows-1252" }; } // ── 字幕 ── export const CUE_LIMIT = 200; const TIME_RE = /(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/; function parseTime(s: string): number | null { const m = TIME_RE.exec(s); if (!m) return null; const [, h, mi, se, frac] = m; const ms = Number((frac ?? "0").padEnd(3, "0")); return ((Number(h ?? 0) * 60 + Number(mi)) * 60 + Number(se)) * 1000 + ms; } const stripTags = (s: string) => s .replace(/\{\\[^}]*\}/g, "") .replace(/<[^>]+>/g, "") .trim(); /** SRT 与 WebVTT:按空行分块,取含 --> 的时间行 */ export function parseSrtLike(text: string): SubtitleCue[] { const out: SubtitleCue[] = []; for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) { const lines = block.split("\n"); const i = lines.findIndex((l) => l.includes("-->")); if (i < 0) continue; const [a, b] = (lines[i] ?? "").split("-->"); const start = parseTime(a ?? ""); const end = parseTime(b ?? ""); if (start === null || end === null) continue; out.push({ start, end, text: stripTags(lines.slice(i + 1).join("\n")) }); } return out; } /** ASS/SSA:[Events] 段的 Format 决定 Start/End/Text 列;Text 是最后一列,可含逗号 */ export function parseAss(text: string): { cues: SubtitleCue[]; title: string | null } { const cues: SubtitleCue[] = []; let title: string | null = null; let section = ""; let fields: string[] = ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]; for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) { const line = raw.trim(); if (/^\[.+\]$/.test(line)) { section = line.toLowerCase(); continue; } if (section === "[script info]" && /^title\s*:/i.test(line)) title = line.replace(/^title\s*:/i, "").trim() || null; if (section !== "[events]") continue; if (/^format\s*:/i.test(line)) { fields = line .replace(/^format\s*:/i, "") .split(",") .map((f) => f.trim().toLowerCase()); continue; } if (!/^dialogue\s*:/i.test(line)) continue; const parts = line.replace(/^dialogue\s*:/i, "").split(","); const textIdx = fields.indexOf("text"); const vals = [...parts.slice(0, textIdx), parts.slice(textIdx).join(",")]; const start = parseTime(vals[fields.indexOf("start")] ?? ""); const end = parseTime(vals[fields.indexOf("end")] ?? ""); if (start === null || end === null) continue; const body = (vals[textIdx] ?? "") .replace(/\{[^}]*\}/g, "") .replace(/\\[Nn]/g, "\n") .replace(/\\h/g, " ") .trim(); cues.push({ start, end, text: body }); } return { cues, title }; } /** * PGS(.sup):段头 "PG" + PTS(90 kHz)+ DTS + 类型 + 长度。 * 类型 0x16 是显示组合段:带对象的算一条起点,之后第一个不带对象的算终点。 */ export function parseSup(buf: Uint8Array): SubtitleCue[] { const cues: SubtitleCue[] = []; let open: number | null = null; let p = 0; while (p + 13 <= buf.length) { if (buf[p] !== 0x50 || buf[p + 1] !== 0x47) break; const pts = (((buf[p + 2]! << 24) >>> 0) + (buf[p + 3]! << 16) + (buf[p + 4]! << 8) + buf[p + 5]!) / 90; const type = buf[p + 10]; const len = (buf[p + 11]! << 8) + buf[p + 12]!; const body = p + 13; if (body + len > buf.length) break; if (type === 0x16 && len >= 11) { const objects = buf[body + 10] ?? 0; const t = Math.round(pts); if (objects > 0 && open === null) open = t; else if (objects === 0 && open !== null) { cues.push({ start: open, end: Math.max(open, t), text: "" }); open = null; } } p = body + len; } return cues; } // ── 图片尺寸 ── const u16be = (b: Uint8Array, o: number) => (b[o]! << 8) | b[o + 1]!; const u16le = (b: Uint8Array, o: number) => b[o]! | (b[o + 1]! << 8); const u32be = (b: Uint8Array, o: number) => ((b[o]! << 24) >>> 0) + (b[o + 1]! << 16) + (b[o + 2]! << 8) + b[o + 3]!; const u24le = (b: Uint8Array, o: number) => b[o]! | (b[o + 1]! << 8) | (b[o + 2]! << 16); const ascii = (b: Uint8Array, o: number, n: number) => String.fromCharCode(...b.subarray(o, o + n)); export const IMAGE_MIME: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", avif: "image/avif", gif: "image/gif", bmp: "image/bmp", }; /** 读文件头里的宽高(PNG/JPEG/GIF/BMP/WebP/AVIF);认不出返回 null */ export function imageSize(b: Uint8Array): { width: number; height: number } | null { if (b.length < 24) return null; if (b[0] === 0x89 && ascii(b, 1, 3) === "PNG") return { width: u32be(b, 16), height: u32be(b, 20) }; if (ascii(b, 0, 3) === "GIF") return { width: u16le(b, 6), height: u16le(b, 8) }; if (ascii(b, 0, 2) === "BM" && b.length >= 26) return { width: Math.abs(new DataView(b.buffer, b.byteOffset).getInt32(18, true)), height: Math.abs(new DataView(b.buffer, b.byteOffset).getInt32(22, true)), }; if (ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") { const chunk = ascii(b, 12, 4); if (chunk === "VP8 " && b.length >= 30) return { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff }; if (chunk === "VP8L" && b.length >= 25) { const bits = b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24); return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }; } if (chunk === "VP8X" && b.length >= 30) return { width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 }; return null; } if (b[0] === 0xff && b[1] === 0xd8) { let p = 2; while (p + 9 < b.length) { if (b[p] !== 0xff) { p++; continue; } const marker = b[p + 1]!; if (marker === 0xff) { p++; continue; } if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { p += 2; continue; } const len = u16be(b, p + 2); const sof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; if (sof) return { width: u16be(b, p + 7), height: u16be(b, p + 5) }; p += 2 + len; } return null; } if (ascii(b, 4, 4) === "ftyp") { // AVIF/HEIF:在头部里找 ispe(image spatial extents)盒 for (let p = 8; p + 20 <= b.length; p++) { if (b[p] === 0x69 && ascii(b, p, 4) === "ispe") return { width: u32be(b, p + 8), height: u32be(b, p + 12) }; } } return null; } // ── tracker ── const SECRET_KEY = /pass|key|token|auth|secret|sign|uid|hash/i; /** tracker 地址里的 passkey 不外露:敏感参数值与路径里的长十六进制/Base64 段替换成 *** */ export function maskTracker(url: string): string { let u: URL; try { u = new URL(url); } catch { return url.replace(/[0-9a-f]{16,}/gi, "***"); } for (const [k] of [...u.searchParams]) if (SECRET_KEY.test(k)) u.searchParams.set(k, "***"); u.pathname = u.pathname .split("/") .map((seg) => (/^[0-9a-z_-]{16,}$/i.test(seg) && /\d/.test(seg) ? "***" : seg)) .join("/"); u.username = ""; u.password = ""; return decodeURIComponent(u.toString()); }