feat(files): wide file manager layout and basic previews (FM-2)

- Files page uses a wide layout (route handle layout:"wide"): no max width,
  full-height directory tree docked next to the sidebar, resizable
  (200-520px, remembered, double-click resets) and collapsible; mobile keeps
  the tree in a drawer.
- GET /files/preview (admin console session only, same path guards as
  download): text/NFO with encoding detection (BOM/UTF-16/UTF-8/GB18030/Big5),
  subtitles first 200 cues (SRT/VTT/ASS/SSA, PGS timings), .torrent file
  list/trackers (passkey masked)/infohash, image dimensions. Reads at most
  1 MiB of text; .torrent capped at 4 MiB.
- Download also allows text files, .torrent and avif/gif.
- Research notes in docs/design/file-preview-research.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 21:44:13 +08:00
1 parent 3add4bab41
commit 289e8b297a
20 files changed
+1294 -159

No files matched your search

@@ -0,0 +1,281 @@
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<string, PreviewKind> = {};
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<string, string> = {
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());
}