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:
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());
|
||||
}
|
||||
Reference in new issue
Block a user