diff --git a/apps/server/src/modules/file-manager/download.ts b/apps/server/src/modules/file-manager/download.ts index 8834f8de..8ba3dbb3 100644 --- a/apps/server/src/modules/file-manager/download.ts +++ b/apps/server/src/modules/file-manager/download.ts @@ -4,6 +4,7 @@ import type { Principal } from "../../http/auth.ts"; import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; import { audit } from "./ledger.ts"; import { kindOfName } from "./list.ts"; +import { previewKindOf } from "./preview-parse.ts"; // GET /files/download(W5-A2):仅管理员会话;只下载 NFO/字幕/图片小文件(视频不经浏览器,规格 §1 边界); // 路径守卫同其它接口(realpath 在根内、符号链接目标也须在根内,打开前再复核一次); @@ -19,6 +20,7 @@ const MIME: Record = { webp: "image/webp", bmp: "image/bmp", avif: "image/avif", + torrent: "application/x-bittorrent", nfo: "text/plain; charset=utf-8", xml: "text/plain; charset=utf-8", srt: "text/plain; charset=utf-8", @@ -48,7 +50,10 @@ export async function downloadFile( if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录"); const name = basename(r.abs); const kind = kindOfName(name); - if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`); + // FM-2:可预览的文本类(txt/log/md/json…)、.torrent、avif/gif 也允许下载 + const pk = previewKindOf(name); + if (!ALLOWED.has(kind) && !pk) + throw new FilesError("forbidden", `只能下载 NFO、字幕、图片、文本和 .torrent,此文件类型为 ${kind}`); const real = await recheck(r.abs, r.rootReal); const file = await Deno.open(real, { read: true }); let size: number; @@ -67,7 +72,7 @@ export async function downloadFile( } const ext = extname(name).replace(/^\./, "").toLowerCase(); // SVG 可带脚本:不内联、不给 image/svg+xml - const inline = q.inline && kind === "image" && ext !== "svg"; + const inline = q.inline && (kind === "image" || pk === "image") && ext !== "svg"; await audit(d, principal, "download", { root: q.root, path: r.rel, size, inline }); return new Response(file.readable, { status: 200, diff --git a/apps/server/src/modules/file-manager/preview-parse.ts b/apps/server/src/modules/file-manager/preview-parse.ts new file mode 100644 index 00000000..4e63abaf --- /dev/null +++ b/apps/server/src/modules/file-manager/preview-parse.ts @@ -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 = {}; +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()); +} diff --git a/apps/server/src/modules/file-manager/preview.ts b/apps/server/src/modules/file-manager/preview.ts new file mode 100644 index 00000000..0f3e2d31 --- /dev/null +++ b/apps/server/src/modules/file-manager/preview.ts @@ -0,0 +1,150 @@ +import { open } from "node:fs/promises"; +import { basename } from "node:path"; +import { BencodeError, type BencodeValue, bdecode, parseTorrent } from "@umt/adapters"; +import type { FilePreview, FilesPreviewQuery } from "@umt/contracts"; +import type { Principal } from "../../http/auth.ts"; +import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; +import { + CUE_LIMIT, + decodeText, + extOf, + IMAGE_MIME, + imageSize, + maskTracker, + parseAss, + parseSrtLike, + parseSup, + previewKindOf, +} from "./preview-parse.ts"; + +// GET /files/preview(FM-2):仅管理员会话;路径守卫同下载(realpath 在根内、符号链接目标也须在根内,打开前复核)。 +// 只读文件头:文本/字幕 ≤ 1 MiB、图片头 512 KiB;.torrent 整个读但上限 4 MiB。只回 JSON,不回原始内容。 + +export const TEXT_PREVIEW_MAX = 1024 * 1024; +const IMAGE_HEAD = 512 * 1024; +const TORRENT_MAX = 4 * 1024 * 1024; +const TORRENT_FILES = 500; + +async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; size: number }> { + const fh = await open(abs, "r"); + try { + const st = await fh.stat(); + if (!st.isFile()) throw new FilesError("bad_request", "不是普通文件"); + const n = Math.min(st.size, max); + const buf = new Uint8Array(n); + let off = 0; + while (off < n) { + const { bytesRead } = await fh.read(buf, off, n - off, off); + if (!bytesRead) break; + off += bytesRead; + } + return { buf: buf.subarray(0, off), size: st.size }; + } finally { + await fh.close(); + } +} + +const str = (v: BencodeValue | undefined) => (v instanceof Uint8Array ? new TextDecoder().decode(v) : null); + +function trackersOf(root: { [k: string]: BencodeValue }): string[] { + const out: string[] = []; + const tiers = root["announce-list"]; + if (Array.isArray(tiers)) + for (const tier of tiers) for (const t of Array.isArray(tier) ? tier : [tier]) out.push(str(t) ?? ""); + out.push(str(root.announce) ?? ""); + return [...new Set(out.filter(Boolean).map(maskTracker))]; +} + +export async function previewFile( + d: FmDeps, + principal: Principal | undefined, + q: FilesPreviewQuery, +): Promise { + if (!isAdminSession(principal)) throw new FilesError("forbidden", "预览仅限控制台管理员会话"); + const r = await resolveIn(d, q.root, q.path); + if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能预览文件,不能预览目录"); + const name = basename(r.abs); + const kind = previewKindOf(name); + const ext = extOf(name); + if (!kind) throw new FilesError("forbidden", `不支持预览 .${ext || "无扩展名"} 文件`); + const real = await recheck(r.abs, r.rootReal); + + if (kind === "image") { + const { buf, size } = await readHead(real, IMAGE_HEAD); + const dim = imageSize(buf); + return { + type: "image", + size, + mime: IMAGE_MIME[ext] ?? "application/octet-stream", + width: dim?.width ?? null, + height: dim?.height ?? null, + }; + } + + if (kind === "torrent") { + const { buf, size } = await readHead(real, TORRENT_MAX); + if (size > TORRENT_MAX) throw new FilesError("too_large", `.torrent 文件 ${size} 字节,超过预览上限 4 MiB`); + try { + const meta = parseTorrent(buf); + const root = bdecode(buf) as { [k: string]: BencodeValue }; + const info = root.info as { [k: string]: BencodeValue } | undefined; + const created = typeof root["creation date"] === "number" ? root["creation date"] : null; + return { + type: "torrent", + size, + name: meta.name, + hash: meta.hash, + infohashV1: meta.infohashV1, + infohashV2: meta.infohashV2, + private: meta.private, + totalSize: meta.totalSize, + fileCount: meta.files.length, + files: meta.files.slice(0, TORRENT_FILES).map((f) => ({ path: f.path, length: Math.max(0, f.length) })), + trackers: trackersOf(root), + comment: str(root["comment.utf-8"]) ?? str(root.comment), + createdBy: str(root["created by"]), + createdAt: created && created > 0 ? new Date(created * 1000).toISOString() : null, + pieceLength: typeof info?.["piece length"] === "number" ? info["piece length"] : null, + }; + } catch (e) { + if (e instanceof BencodeError) throw new FilesError("bad_request", "不是有效的 .torrent 文件"); + throw e; + } + } + + const { buf, size } = await readHead(real, TEXT_PREVIEW_MAX); + const truncated = size > buf.length; + + if (kind === "subtitle") { + if (ext === "sup") { + const all = parseSup(buf); + return { + type: "subtitle", + size, + format: "sup", + encoding: null, + cues: all.slice(0, CUE_LIMIT), + parsed: all.length, + truncated, + title: null, + }; + } + const { text, encoding } = decodeText(buf, truncated); + const ass = ext === "ass" || ext === "ssa"; + const parsed = ass ? parseAss(text) : { cues: parseSrtLike(text), title: null }; + const cues = [...parsed.cues].sort((a, b) => a.start - b.start); + return { + type: "subtitle", + size, + format: ext as "srt" | "vtt" | "ass" | "ssa", + encoding, + cues: cues.slice(0, CUE_LIMIT), + parsed: cues.length, + truncated, + title: parsed.title, + }; + } + + const { text, encoding } = decodeText(buf, truncated); + return { type: "text", size, format: ext, encoding, text, truncated }; +} diff --git a/apps/server/src/modules/file-manager/routes.ts b/apps/server/src/modules/file-manager/routes.ts index 4aef9aa7..de89ccfe 100644 --- a/apps/server/src/modules/file-manager/routes.ts +++ b/apps/server/src/modules/file-manager/routes.ts @@ -14,6 +14,7 @@ import { idempotent } from "./ledger.ts"; import { listDir, listRoots, statEntry } from "./list.ts"; import { mkdirIn, renameEntry } from "./mutate.ts"; import { organizeFiles } from "./organize.ts"; +import { previewFile } from "./preview.ts"; import { probeFiles, scrape, scrapeBatch } from "./probe.ts"; import { findRecords } from "./records.ts"; import { suggestName } from "./suggest-name.ts"; @@ -113,6 +114,7 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) { mount(app, F.probe, async (_c, { body }) => probeFiles(d, body)); mount(app, F.records, async (_c, { query }) => findRecords(d, query)); mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true); + mount(app, F.preview, async (c, { query }) => previewFile(d, c.get("principal"), query)); // W9-H 文件管理 v2 mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body)); mount(app, F.scrapeBatch, async (c, { body }) => diff --git a/apps/server/test/api-registry.test.ts b/apps/server/test/api-registry.test.ts index 2cc0dad7..01a2248b 100644 --- a/apps/server/test/api-registry.test.ts +++ b/apps/server/test/api-registry.test.ts @@ -68,6 +68,7 @@ describe("Api 注册表鉴权标注", () => { "PATCH /auth/passkeys/{id}", "DELETE /auth/passkeys/{id}", "GET /files/download", + "GET /files/preview", "PATCH /approvals/{id}", "POST /approvals", "POST /auth/password", diff --git a/apps/server/test/file-manager/preview-parse.test.ts b/apps/server/test/file-manager/preview-parse.test.ts new file mode 100644 index 00000000..30ff4798 --- /dev/null +++ b/apps/server/test/file-manager/preview-parse.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { + decodeText, + imageSize, + maskTracker, + parseAss, + parseSrtLike, + parseSup, + previewKindOf, +} from "../../src/modules/file-manager/preview-parse.ts"; + +// FM-2 预览纯解析:编码探测、字幕、PGS、图片尺寸(不碰 PG)。 + +const enc = (s: string) => new TextEncoder().encode(s); +const u16le = (s: string) => { + const b = new Uint8Array(s.length * 2); + for (let i = 0; i < s.length; i++) { + b[i * 2] = s.charCodeAt(i) & 0xff; + b[i * 2 + 1] = s.charCodeAt(i) >> 8; + } + return b; +}; + +describe("previewKindOf", () => { + it("按扩展名分类,大小写不敏感", () => { + expect(previewKindOf("a.NFO")).toBe("text"); + expect(previewKindOf("x.log")).toBe("text"); + expect(previewKindOf("x.ass")).toBe("subtitle"); + expect(previewKindOf("x.AVIF")).toBe("image"); + expect(previewKindOf("x.torrent")).toBe("torrent"); + expect(previewKindOf("x.mkv")).toBeNull(); + expect(previewKindOf("x.svg")).toBeNull(); + expect(previewKindOf("README")).toBeNull(); + }); +}); + +describe("decodeText", () => { + it("BOM:UTF-8 / UTF-16LE / UTF-16BE", () => { + expect(decodeText(new Uint8Array([0xef, 0xbb, 0xbf, ...enc("中文")]))).toEqual({ text: "中文", encoding: "utf-8" }); + expect(decodeText(new Uint8Array([0xff, 0xfe, ...u16le("Hi 中")]))).toEqual({ + text: "Hi 中", + encoding: "utf-16le", + }); + expect(decodeText(new Uint8Array([0xfe, 0xff, 0x00, 0x41]))).toEqual({ text: "A", encoding: "utf-16be" }); + }); + it("无 BOM 的 UTF-16LE 按零字节分布识别", () => { + expect(decodeText(u16le("[Script Info]\nTitle: x\n")).encoding).toBe("utf-16le"); + }); + it("UTF-8;截断在多字节字符中间也按 UTF-8", () => { + const b = enc("沙丘 2 字幕"); + expect(decodeText(b).encoding).toBe("utf-8"); + expect(decodeText(b.subarray(0, b.length - 1), true)).toEqual({ text: "沙丘 2 字", encoding: "utf-8" }); + }); + it("GBK 与 Big5", () => { + // "你好,世界。沙丘" 的 GBK 字节 + const gbk = new Uint8Array([ + 0xc4, 0xe3, 0xba, 0xc3, 0xa3, 0xac, 0xca, 0xc0, 0xbd, 0xe7, 0xa1, 0xa3, 0xc9, 0xb3, 0xc7, 0xf0, + ]); + expect(decodeText(gbk)).toEqual({ text: "你好,世界。沙丘", encoding: "gb18030" }); + // "電影字幕" 的 Big5 字节 + const big5 = new Uint8Array([0xb9, 0x71, 0xbc, 0x76, 0xa6, 0x72, 0xb9, 0xf5]); + expect(decodeText(big5)).toEqual({ text: "電影字幕", encoding: "big5" }); + }); + it("都不像时退回 windows-1252", () => { + expect(decodeText(new Uint8Array([0x43, 0x61, 0x66, 0xe9])).encoding).toBe("windows-1252"); + }); +}); + +describe("字幕", () => { + it("SRT:时间轴、多行、去标签", () => { + const cues = parseSrtLike( + "1\r\n00:00:01,000 --> 00:00:04,500\r\n厄拉科斯\r\n第二行\r\n\r\n2\r\n00:01:05,20 --> 00:01:07,000\r\n{\\an8}上方\r\n", + ); + expect(cues).toEqual([ + { start: 1000, end: 4500, text: "厄拉科斯\n第二行" }, + { start: 65200, end: 67000, text: "上方" }, + ]); + }); + it("VTT:mm:ss.mmm", () => { + expect(parseSrtLike("WEBVTT\n\n00:01.500 --> 00:02.000 align:start\nhi\n")).toEqual([ + { start: 1500, end: 2000, text: "hi" }, + ]); + }); + it("ASS:按 Format 取列,Text 里的逗号保留,覆写标签与 \\N 处理", () => { + const text = [ + "[Script Info]", + "Title: 沙丘 简中", + "[Events]", + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", + "Dialogue: 0,0:00:02.50,0:00:04.00,Default,,0,0,0,,{\\fad(100,100)}你好,世界,再见\\N第二行", + "Comment: 0,0:00:05.00,0:00:06.00,Default,,0,0,0,,注释", + ].join("\n"); + expect(parseAss(text)).toEqual({ + title: "沙丘 简中", + cues: [{ start: 2500, end: 4000, text: "你好,世界,再见\n第二行" }], + }); + }); + it("PGS:带对象的组合段起、空组合段止", () => { + const seg = (ptsMs: number, type: number, body: number[]) => { + const pts = ptsMs * 90; + return [ + 0x50, + 0x47, + (pts >>> 24) & 255, + (pts >>> 16) & 255, + (pts >>> 8) & 255, + pts & 255, + 0, + 0, + 0, + 0, + type, + 0, + body.length, + ...body, + ]; + }; + const pcs = (objects: number) => [ + 7, + 128, + 4, + 56, + 0x10, + 0, + 0, + 0x80, + 0, + 0, + objects, + ...(objects ? [0, 0, 0, 0, 0, 0, 0, 0] : []), + ]; + const buf = new Uint8Array([ + ...seg(1000, 0x16, pcs(1)), + ...seg(1000, 0x80, []), + ...seg(3000, 0x16, pcs(0)), + ...seg(5000, 0x16, pcs(1)), + ]); + expect(parseSup(buf)).toEqual([{ start: 1000, end: 3000, text: "" }]); + }); +}); + +describe("imageSize", () => { + it("PNG / GIF / JPEG / WebP VP8X", () => { + const png = new Uint8Array(24); + png.set([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 7, 128, 0, 0, 4, 56, + ]); + expect(imageSize(png)).toEqual({ width: 1920, height: 1080 }); + const gif = new Uint8Array(24); + gif.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x2c, 0x01, 0xc2, 0x01]); + expect(imageSize(gif)).toEqual({ width: 300, height: 450 }); + const jpg = new Uint8Array([ + 0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 17, 8, 0x05, 0xdc, 0x03, 0xe8, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]); + expect(imageSize(jpg)).toEqual({ width: 1000, height: 1500 }); + const webp = new Uint8Array(30); + webp.set([...enc("RIFF"), 0, 0, 0, 0, ...enc("WEBP"), ...enc("VP8X"), 10, 0, 0, 0, 0, 0, 0, 0]); + webp.set([0x7f, 0x07, 0x00, 0x37, 0x04, 0x00], 24); + expect(imageSize(webp)).toEqual({ width: 1920, height: 1080 }); + expect(imageSize(enc("not an image at all, just text"))).toBeNull(); + }); +}); + +describe("maskTracker", () => { + it("参数里的 passkey、路径里的长密钥段打码", () => { + expect(maskTracker("https://t.example/announce.php?passkey=0123456789abcdef0123&foo=1")).toBe( + "https://t.example/announce.php?passkey=***&foo=1", + ); + expect(maskTracker("https://t.example/0123456789abcdef0123456789abcdef/announce")).toBe( + "https://t.example/***/announce", + ); + expect(maskTracker("udp://tracker.opentrackr.org:1337/announce")).toBe( + "udp://tracker.opentrackr.org:1337/announce", + ); + }); +}); diff --git a/apps/server/test/file-manager/preview.test.ts b/apps/server/test/file-manager/preview.test.ts new file mode 100644 index 00000000..fb4ffb9a --- /dev/null +++ b/apps/server/test/file-manager/preview.test.ts @@ -0,0 +1,125 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { makeTorrent } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createFmHarness, EXEC_KEY, type FmHarness, pgAvailable, READ_KEY, SESSION } from "./harness.ts"; + +// FM-2:GET /files/preview —— 仅管理员会话;路径守卫同下载;文本编码探测、字幕前 N 条、.torrent 解析、图片尺寸。 + +const skip = !(await pgAvailable()); + +describe.skipIf(skip)("files preview FM-2", () => { + let h: FmHarness; + beforeAll(async () => { + h = await createFmHarness("fm2pv", {}, { runTransfers: false }); + const dir = join(h.lib, "pv"); + await mkdir(dir, { recursive: true }); + // GBK "你好,世界。" + await writeFile( + join(dir, "readme.txt"), + new Uint8Array([0xc4, 0xe3, 0xba, 0xc3, 0xa3, 0xac, 0xca, 0xc0, 0xbd, 0xe7, 0xa1, 0xa3]), + ); + await writeFile(join(dir, "movie.nfo"), "沙丘"); + await writeFile(join(dir, "big.log"), "a".repeat(1024 * 1024 + 10)); + const srt = Array.from( + { length: 250 }, + (_, i) => + `${i + 1}\n00:00:${String(i % 60).padStart(2, "0")},000 --> 00:00:${String(i % 60).padStart(2, "0")},500\n第 ${i + 1} 句\n`, + ).join("\n"); + await writeFile(join(dir, "a.srt"), srt); + const t = makeTorrent({ + name: "Dune.Part.Two.2024", + files: [ + { path: "Dune.mkv", length: 1000 }, + { path: "Subs/chs.srt", length: 20 }, + ], + private: true, + announce: "https://tracker.example/announce", + }); + await writeFile(join(dir, "dune.torrent"), t.bytes); + await writeFile(join(dir, "bad.torrent"), "not bencode"); + const png = new Uint8Array(32); + png.set([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 3, 232, 0, 0, 5, 220, + ]); + await writeFile(join(dir, "poster.png"), png); + await writeFile(join(dir, "v.mkv"), "video"); + await symlink(join(h.outside, "secret.txt"), join(dir, "leak.txt")); + (h as unknown as { torrentHash: string }).torrentHash = t.hash; + }); + afterAll(async () => h?.close()); + + const url = (path: string) => `/files/preview?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}`; + + it("只接受管理员会话", async () => { + expect((await h.api("GET", url("pv/movie.nfo"), undefined, READ_KEY)).status).toBe(403); + expect((await h.api("GET", url("pv/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403); + expect((await h.api("GET", url("pv/movie.nfo"))).status).toBe(403); + }); + + it("文本:编码探测、1 MiB 截断", async () => { + const gbk = await h.api("GET", url("pv/readme.txt"), undefined, SESSION); + expect(gbk.status).toBe(200); + expect(gbk.body.data).toMatchObject({ + type: "text", + format: "txt", + encoding: "gb18030", + text: "你好,世界。", + truncated: false, + }); + const nfo = await h.api("GET", url("pv/movie.nfo"), undefined, SESSION); + expect(nfo.body.data).toMatchObject({ type: "text", encoding: "utf-8" }); + const big = await h.api("GET", url("pv/big.log"), undefined, SESSION); + expect(big.body.data.truncated).toBe(true); + expect(big.body.data.text.length).toBe(1024 * 1024); + expect(big.body.data.size).toBe(1024 * 1024 + 10); + }); + + it("字幕:按时间排序、只回前 200 条", async () => { + const r = await h.api("GET", url("pv/a.srt"), undefined, SESSION); + expect(r.body.data.type).toBe("subtitle"); + expect(r.body.data.cues.length).toBe(200); + expect(r.body.data.parsed).toBe(250); + expect(r.body.data.cues[0]).toEqual({ start: 0, end: 500, text: "第 1 句" }); + }); + + it(".torrent:文件列表、tracker、hash;坏文件 400", async () => { + const r = await h.api("GET", url("pv/dune.torrent"), undefined, SESSION); + expect(r.status).toBe(200); + expect(r.body.data).toMatchObject({ + type: "torrent", + name: "Dune.Part.Two.2024", + hash: (h as unknown as { torrentHash: string }).torrentHash, + private: true, + fileCount: 2, + totalSize: 1020, + trackers: ["https://tracker.example/announce"], + }); + expect(r.body.data.files[1]).toEqual({ path: "Dune.Part.Two.2024/Subs/chs.srt", length: 20 }); + const bad = await h.api("GET", url("pv/bad.torrent"), undefined, SESSION); + expect(bad.status).toBe(400); + }); + + it("图片只回尺寸;视频/目录/根外链接拒绝", async () => { + const img = await h.api("GET", url("pv/poster.png"), undefined, SESSION); + expect(img.body.data).toEqual({ type: "image", size: 32, mime: "image/png", width: 1000, height: 1500 }); + expect((await h.api("GET", url("pv/v.mkv"), undefined, SESSION)).status).toBe(403); + expect((await h.api("GET", url("pv"), undefined, SESSION)).status).toBe(400); + const leak = await h.api("GET", url("pv/leak.txt"), undefined, SESSION); + expect(leak.status).toBe(400); + expect(leak.body.error.message).toMatch(/^path_escape:/); + expect((await h.api("GET", url("pv/none.txt"), undefined, SESSION)).status).toBe(404); + }); + + it("下载也放行文本与 .torrent(附件)", async () => { + const r = await h.raw( + "GET", + `/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent("pv/dune.torrent")}`, + SESSION, + ); + expect(r.status).toBe(200); + expect(r.headers.get("content-type")).toBe("application/x-bittorrent"); + expect(r.headers.get("content-disposition")).toMatch(/^attachment;/); + await r.arrayBuffer(); + }); +}); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 51b93d72..367e1cdb 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -8,6 +8,7 @@ import { defineConfig } from "vitest/config"; // 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。 const UNIT = [ "test/api-registry.test.ts", + "test/file-manager/preview-parse.test.ts", "test/auth.test.ts", "test/health.test.ts", "test/sources-identity/identity-differential.test.ts", diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts index f0ff1239..b0b4f5b5 100644 --- a/apps/web/app/api/files.ts +++ b/apps/web/app/api/files.ts @@ -2,6 +2,7 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClie import { cursorPage, type FileEntry, + FilePreview, type FileRef, FileRootList, FileStat, @@ -241,57 +242,17 @@ export function fileDownloadUrl(root: string, path: string, inline = false): str return `/api/v1/files/download?${qs}`; } -/** 文本预览最多读前 256 KB(规格 §9) */ -export const TEXT_PREVIEW_BYTES = 256 * 1024; - -export type TextPreview = { text: string; truncated: boolean }; - -export function useTextPreview(root: string | null, path: string | null, enabled: boolean) { +/** 结构化预览(FM-2):文本/NFO 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;服务端只读文件头 */ +export function useFilePreview(root: string | null, path: string | null, enabled: boolean) { return useQuery({ queryKey: ["files", "preview", root, path], enabled: enabled && !!root && !!path, retry: false, - queryFn: async ({ signal }): Promise => { - if (filesMockEnabled()) - return { text: await (await loadMock()).mockPreview(root ?? "", path ?? ""), truncated: false }; - const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal }); - if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`); - return readTextHead(res, TEXT_PREVIEW_BYTES); - }, + staleTime: 30_000, + queryFn: ({ signal }) => call(FilePreview, "/files/preview", { params: { root, path }, signal }), }); } -/** 只读响应体前 max 字节(流式,读够就取消),按 UTF-8 解码 */ -async function readTextHead(res: Response, max: number): Promise { - const reader = res.body?.getReader(); - if (!reader) { - const t = await res.text(); - return { text: t.slice(0, max), truncated: t.length > max }; - } - const chunks: Uint8Array[] = []; - let n = 0; - let truncated = false; - while (n < max) { - const { done, value } = await reader.read(); - if (done || !value) break; - chunks.push(value); - n += value.length; - } - if (n >= max) { - truncated = n > max || !(await reader.read()).done; - await reader.cancel().catch(() => {}); - } - const buf = new Uint8Array(Math.min(n, max)); - let off = 0; - for (const c of chunks) { - const part = c.subarray(0, Math.min(c.length, buf.length - off)); - buf.set(part, off); - off += part.length; - if (off >= buf.length) break; - } - return { text: new TextDecoder("utf-8", { fatal: false }).decode(buf), truncated }; -} - /** 整理记录回查:ids 或 planIds(执行后映射到记录);执行中每 3 秒刷新 */ export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enabled = true) { const ids = q.ids ?? []; diff --git a/apps/web/app/components/files/file-detail.tsx b/apps/web/app/components/files/file-detail.tsx index 18e6a8de..d1825fd9 100644 --- a/apps/web/app/components/files/file-detail.tsx +++ b/apps/web/app/components/files/file-detail.tsx @@ -1,17 +1,8 @@ -import { Download, ExternalLink, LoaderCircle, ScanSearch } from "lucide-react"; +import { LoaderCircle, ScanSearch } from "lucide-react"; import type { ReactNode } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; -import { - type FileEntry, - type FileRoot, - fileDownloadUrl, - filesErrorText, - filesMockEnabled, - useFileStat, - useProbe, - useTextPreview, -} from "~/api/files.ts"; +import { type FileEntry, type FileRoot, filesErrorText, useFileStat, useProbe } from "~/api/files.ts"; import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; import { LoadingState } from "~/components/console/empty-state.tsx"; @@ -19,14 +10,13 @@ import { KvList } from "~/components/console/kv-list.tsx"; import { Timeline } from "~/components/console/timeline.tsx"; import { SLOT_STATUS } from "~/components/library/labels.ts"; import type { WizardEntity } from "~/components/organize-wizard/model.ts"; -import { Button, buttonVariants } from "~/components/ui/button.tsx"; +import { Button } from "~/components/ui/button.tsx"; import { downloadLabel } from "~/lib/labels.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; import { EntryStatus } from "./file-list.tsx"; +import { FilePreviewSection } from "./file-preview.tsx"; import { IdentifyPanel } from "./identify-panel.tsx"; -import { baseName } from "./model.ts"; - -const PREVIEW_MAX = 20 * 1024 * 1024; +import { baseName, previewKindOf } from "./model.ts"; const ACTION_LABEL: Record = { copy: "复制", @@ -78,7 +68,7 @@ function RecordsSection({ entityId }: { entityId: string }) { ); } -/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */ +/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与预览 */ export function FileDetail({ root, entry, @@ -99,11 +89,7 @@ export function FileDetail({ const stat = useFileStat(root.id, entry.path); const probe = useProbe(); const e = stat.data?.entry ?? entry; - const previewable = - e.type === "file" && - e.size <= PREVIEW_MAX && - (e.fileKind === "nfo" || e.fileKind === "subtitle" || e.fileKind === "image"); - const text = useTextPreview(root.id, e.path, previewable && e.fileKind !== "image"); + const pk = previewKindOf(e); const media = probe.data?.items[0]?.summary ?? e.media; return ( @@ -113,6 +99,7 @@ export function FileDetail({ title={e.name} description={} footer={actions} + width={pk && pk !== "image" ? "lg" : "md"} > ) : null} - {previewable ? ( - - - 下载 - - ) - } - > - {e.fileKind === "image" ? ( - filesMockEnabled() ? ( -

演示数据没有图片内容。

- ) : ( - - {e.name} - - 新窗口看原图 - - - ) - ) : text.isPending ? ( - - ) : text.error ? ( -

{filesErrorText(text.error, "预览失败")}

- ) : ( - <> -
-                {text.data?.text}
-              
- {text.data?.truncated ?

只显示前 256 KB

: null} - - )} -
- ) : null} + ); } diff --git a/apps/web/app/components/files/file-preview.tsx b/apps/web/app/components/files/file-preview.tsx new file mode 100644 index 00000000..8eeebde2 --- /dev/null +++ b/apps/web/app/components/files/file-preview.tsx @@ -0,0 +1,236 @@ +import type { FilePreview } from "@umt/contracts"; +import { Download, ExternalLink } from "lucide-react"; +import { + type FileEntry, + type FileRoot, + fileDownloadUrl, + filesErrorText, + filesMockEnabled, + useFilePreview, +} from "~/api/files.ts"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; +import { LoadingState } from "~/components/console/empty-state.tsx"; +import { KvList } from "~/components/console/kv-list.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { TechInfo } from "~/components/console/tech-info.tsx"; +import { buttonVariants } from "~/components/ui/button.tsx"; +import { formatBytes, formatDateTime } from "~/lib/utils.ts"; +import { cueTime, prettyJson, previewKindOf } from "./model.ts"; + +/** 图片原图经 /files/download 内联,上限与下载一致 */ +const IMAGE_INLINE_MAX = 20 * 1024 * 1024; + +const ENCODING_LABEL: Record = { + "utf-8": "UTF-8", + "utf-16le": "UTF-16 LE", + "utf-16be": "UTF-16 BE", + gb18030: "GBK / GB18030", + big5: "Big5", + "windows-1252": "Latin-1", +}; +const encLabel = (e: string | null) => (e ? (ENCODING_LABEL[e] ?? e) : null); + +const preBox = + "max-h-[28rem] overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap break-words"; + +function Meta({ items }: { items: (string | null | false | undefined)[] }) { + return

{items.filter(Boolean).join(" · ")}

; +} + +function TextView({ p }: { p: Extract }) { + const body = p.format === "json" ? prettyJson(p.text, p.truncated) : p.text; + return ( + <> + {body.trim() ? ( +
{body}
+ ) : ( +

空文件。

+ )} + {p.truncated ?

只显示前 1 MB

: null} + + + ); +} + +function SubtitleView({ p }: { p: Extract }) { + const graphic = p.format === "sup"; + const count = + p.parsed > p.cues.length + ? `共 ${p.parsed}${p.truncated ? "+" : ""} 条,显示前 ${p.cues.length} 条` + : `${p.cues.length} 条`; + return ( + <> + + {graphic ?

PGS 图形字幕没有文字,只列时间轴。

: null} + {p.cues.length ? ( +
    + {p.cues.map((c, i) => ( +
  1. + + {cueTime(c.start)} + {graphic ? ` → ${cueTime(c.end)}` : ""} + + {graphic ? null : {c.text}} +
  2. + ))} +
+ ) : ( +

没有读到字幕条目。

+ )} + {p.truncated ?

只读了文件前 1 MB

: null} + + + ); +} + +function TorrentView({ p }: { p: Extract }) { + const prefix = `${p.name}/`; + return ( +
+ 私有 : "否" }, + ...(p.comment ? [{ label: "注释", value: p.comment, wide: true }] : []), + ]} + /> +
+

+ 文件 {p.fileCount} + {p.fileCount > p.files.length ? `,显示前 ${p.files.length} 个` : ""} +

+
    + {p.files.map((f) => ( +
  • + + {f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path} + + {formatBytes(f.length, 1)} +
  • + ))} +
+
+ +
+

Tracker {p.trackers.length}(passkey 已隐藏)

+ {p.trackers.length ? ( +
    + {p.trackers.map((t) => ( +
  • {t}
  • + ))} +
+ ) : ( +

没有 tracker,只能走 DHT。

+ )} +
+
+
+ ); +} + +function ImageView({ + root, + entry, + p, +}: { + root: FileRoot; + entry: FileEntry; + p: Extract; +}) { + const dims = p.width && p.height ? `${p.width} × ${p.height}` : null; + const meta = ; + if (filesMockEnabled()) + return ( + <> + {meta} +

演示数据没有图片内容。

+ + ); + if (p.size > IMAGE_INLINE_MAX) + return ( + <> + {meta} +

图片超过 20 MB,不在这里显示。

+ + ); + const src = fileDownloadUrl(root.id, entry.path, true); + return ( + <> + {meta} + + {entry.name} + + 新窗口看原图 + + + + ); +} + +/** 详情抽屉的「预览」节:文本/NFO、字幕、.torrent、图片;其它类型不渲染 */ +export function FilePreviewSection({ root, entry }: { root: FileRoot; entry: FileEntry }) { + const kind = previewKindOf(entry); + const q = useFilePreview(root.id, entry.path, !!kind && !entry.escape); + if (!kind || entry.escape) return null; + return ( + IMAGE_INLINE_MAX ? null : ( + + + 下载 + + ) + } + > + {q.isPending ? ( + + ) : q.error ? ( +

{filesErrorText(q.error, "预览失败")}

+ ) : q.data.type === "text" ? ( + + ) : q.data.type === "subtitle" ? ( + + ) : q.data.type === "torrent" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/components/files/mock.ts b/apps/web/app/components/files/mock.ts index f941cdf4..a4606000 100644 --- a/apps/web/app/components/files/mock.ts +++ b/apps/web/app/components/files/mock.ts @@ -5,6 +5,7 @@ import type { FileEntry, FileIdentifyItem, FileKindTag, + FilePreview, FileRef, FileRoot, FileStat, @@ -238,6 +239,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record } { }), f("Nosferatu.2024.2160p.WEB-DL.mkv.!qB", 2.1 * GB, 0.1, { status: "temp" }), f("README.txt", 1_024, 700), + f("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX-FraMeSToR.torrent", 186_000, 30), f("escape-link", 0, 700, { escape: true }), { name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 }, f(".hidden-note", 64, 700), @@ -922,6 +924,8 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise return list(p); case "/files/stat": return stat(p); + case "/files/preview": + return mockFilePreview(p); case "/files/mkdir": { const { node } = find(b.root, b.path); if ((node.children ?? []).some((c) => c.name === b.name)) @@ -1167,11 +1171,82 @@ function mockRecord(id: string) { }; } -export async function mockPreview(root: string, path: string): Promise { - await delay(100); - if (path.endsWith(".nfo")) - return `\n\n\n 沙丘 2\n 2024\n`; - if (/\.(srt|ass)$/.test(path)) - return "1\n00:00:01,000 --> 00:00:04,000\n 厄拉科斯…\n\n2\n00:00:05,000 --> 00:00:07,500\n 一切都从这里开始。"; - return `(${root}:${path})`; +function mockFilePreview(p: Record): FilePreview { + const path = String(p.path ?? ""); + const { node } = find(String(p.root ?? ""), path); + const size = node.size; + const ext = /\.([A-Za-z0-9]{1,8})$/.exec(path)?.[1]?.toLowerCase() ?? ""; + if (ext === "nfo") + return { + type: "text", + size, + format: "nfo", + encoding: "utf-8", + truncated: false, + text: `\n\n 沙丘 2\n Dune: Part Two\n 2024\n 693134\n 科幻\n 166\n`, + }; + if (ext === "txt") + return { + type: "text", + size, + format: "txt", + encoding: "gb18030", + truncated: false, + text: ["本目录是 qBittorrent 的默认保存路径。", "做种中的文件不要改名或移动。"].join("\n"), + }; + if (ext === "srt" || ext === "ass") { + const lines = [ + "厄拉科斯。", + "一切都从这里开始。", + "香料必须流动。", + "我看见了未来。", + "沙虫来了!", + "保罗·厄崔迪。", + ]; + const cues = Array.from({ length: 24 }, (_, i) => ({ + start: 62_000 + i * 3_400, + end: 64_500 + i * 3_400, + text: lines[i % lines.length] as string, + })); + return { + type: "subtitle", + size, + format: ext, + encoding: ext === "ass" ? "utf-16le" : "utf-8", + cues, + parsed: 1_342, + truncated: false, + title: ext === "ass" ? "Severance S02E01 简中" : null, + }; + } + if (ext === "torrent") + return { + type: "torrent", + size, + name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR", + hash: "3f786850e387550fdab836ed7e6dc881de23001b", + infohashV1: "3f786850e387550fdab836ed7e6dc881de23001b", + infohashV2: null, + private: true, + totalSize: Math.round(71.2 * GB + 96_000), + fileCount: 2, + files: [ + { + path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv", + length: Math.round(71.2 * GB), + }, + { + path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/chs.srt", + length: 96_000, + }, + ], + trackers: ["https://tracker.example.org/announce.php?passkey=…"], + comment: null, + createdBy: "mktorrent 1.1", + createdAt: ago(40), + pieceLength: 16 * MB, + }; + if (IMG.has(ext)) + return { type: "image", size, mime: `image/${ext === "jpg" ? "jpeg" : ext}`, width: 2000, height: 3000 }; + throw new ApiRequestError("forbidden", `forbidden:不支持预览 .${ext} 文件`); } diff --git a/apps/web/app/components/files/model.test.ts b/apps/web/app/components/files/model.test.ts index 9db096db..310fc6d4 100644 --- a/apps/web/app/components/files/model.test.ts +++ b/apps/web/app/components/files/model.test.ts @@ -4,6 +4,7 @@ import { applyIdentifyCompleted, clickSelect, collapseCrumbs, + cueTime, deleteBlockText, emptySelection, entryStatusView, @@ -18,6 +19,8 @@ import { opApplies, opDisabledReason, parseEpisodes, + prettyJson, + previewKindOf, pruneSelection, selectAll, shiftEpisodes, @@ -329,3 +332,20 @@ describe("识别远端回填(S-7)", () => { expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]); }); }); + +describe("预览(FM-2)", () => { + it("按扩展名分预览类型,目录与未知扩展名不预览", () => { + expect(previewKindOf({ type: "file", ext: "NFO" })).toBe("text"); + expect(previewKindOf({ type: "file", ext: "ass" })).toBe("subtitle"); + expect(previewKindOf({ type: "file", ext: "torrent" })).toBe("torrent"); + expect(previewKindOf({ type: "file", ext: "avif" })).toBe("image"); + expect(previewKindOf({ type: "file", ext: "mkv" })).toBeNull(); + expect(previewKindOf({ type: "dir", ext: null })).toBeNull(); + }); + it("时间轴与 JSON 美化", () => { + expect(cueTime(3_723_045)).toBe("01:02:03.045"); + expect(prettyJson('{"a":1}', false)).toBe('{\n "a": 1\n}'); + expect(prettyJson('{"a":', false)).toBe('{"a":'); + expect(prettyJson('{"a":1}', true)).toBe('{"a":1}'); + }); +}); diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index ea0457c2..b647ef78 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -519,3 +519,39 @@ export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyComple items: r.items.map((it) => by.get(identifyKey(it)) ?? it), }; } + +// ── 预览(FM-2)── + +export type PreviewKind = "text" | "subtitle" | "torrent" | "image"; +const PREVIEW_EXT: 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(" ")) + PREVIEW_EXT[e] = "text"; +for (const e of "srt vtt ass ssa sup".split(" ")) PREVIEW_EXT[e] = "subtitle"; +for (const e of "jpg jpeg png webp avif gif bmp".split(" ")) PREVIEW_EXT[e] = "image"; +PREVIEW_EXT.torrent = "torrent"; + +/** 与服务端 preview-parse.ts 同一张扩展名表;目录、链接、无扩展名返回 null */ +export function previewKindOf(e: Pick): PreviewKind | null { + if (e.type !== "file" || !e.ext) return null; + return PREVIEW_EXT[e.ext.toLowerCase()] ?? null; +} + +/** 毫秒 → 00:01:02.345 */ +export function cueTime(ms: number): string { + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + const s = Math.floor((ms % 60_000) / 1000); + const f = ms % 1000; + const p = (n: number, w = 2) => String(n).padStart(w, "0"); + return `${p(h)}:${p(m)}:${p(s)}.${p(f, 3)}`; +} + +/** JSON 文本美化;解析失败或已截断时原样返回 */ +export function prettyJson(text: string, truncated: boolean): string { + if (truncated) return text; + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } +} diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index 741770a5..c20510b7 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -52,7 +52,6 @@ import { useTrash, } from "~/api/files.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; -import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx"; import { @@ -88,7 +87,7 @@ import { ShortcutsDialog } from "~/components/files/shortcuts-dialog.tsx"; import { useIsMobile } from "~/components/files/use-mobile.ts"; import type { WizardEntity } from "~/components/organize-wizard/model.ts"; import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx"; -import { Button, buttonVariants } from "~/components/ui/button.tsx"; +import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; @@ -113,7 +112,13 @@ type Dialog = | { kind: "wizard"; source: OrganizeSource } | { kind: "shortcuts" }; +/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */ +export const handle = { layout: "wide" }; + const TREE_W_KEY = "umt-files-tree-w"; +const TREE_W_DEFAULT = 280; +const TREE_W_MIN = 200; +const TREE_W_MAX = 520; const TREE_OPEN_KEY = "umt-files-tree-open"; const LAST_LOC_KEY = "umt-files-last"; const FILES_CHANGED = "files.changed"; @@ -150,13 +155,13 @@ function writeStore(key: string, v: string) { } function useTreeWidth() { - const [w, setW] = useState(260); + const [w, setW] = useState(TREE_W_DEFAULT); useEffect(() => { const v = Number(readStore(TREE_W_KEY)); - if (v >= 200 && v <= 440) setW(v); + if (v >= TREE_W_MIN && v <= TREE_W_MAX) setW(v); }, []); const save = (v: number) => { - const c = Math.max(200, Math.min(440, Math.round(v))); + const c = Math.max(TREE_W_MIN, Math.min(TREE_W_MAX, Math.round(v))); setW(c); writeStore(TREE_W_KEY, String(c)); }; @@ -876,45 +881,47 @@ export default function FilesPage() { return ( <> - - {filesMockEnabled() ? 演示数据 : null} - - - 回收站{trashCount && trashCount !== "0" ? ` ${trashCount}` : ""} - - - ) - } - /> -
- {/* 桌面左栏:登记目录 + 目录树(可折叠、可拖宽) */} + {/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */} +

文件管理

+
+ {/* 桌面左栏:贴着侧边栏的整高目录树(可折叠、可拖宽,宽度记在本机) */} {!mobile ? ( ) : null}
diff --git a/apps/web/app/routes/console/layout.tsx b/apps/web/app/routes/console/layout.tsx index 732e0146..9c101a86 100644 --- a/apps/web/app/routes/console/layout.tsx +++ b/apps/web/app/routes/console/layout.tsx @@ -1,7 +1,7 @@ import type { SessionInfo } from "@umt/contracts"; import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Link, Outlet, useLocation } from "react-router"; +import { Link, Outlet, useLocation, useMatches } from "react-router"; import { useSession } from "~/api/auth.ts"; import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx"; import { AccountMenu } from "~/components/console/account-menu.tsx"; @@ -176,6 +176,8 @@ function ConsoleShell({ session }: { session: SessionInfo }) { const [collapsed, setCollapsed] = useState(false); const [sheetOpen, setSheetOpen] = useState(false); const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME; + // 路由 handle.layout === "wide"(文件管理):内容区不限最大宽度、不留外边距,页面自己排版 + const wide = useMatches().some((m) => (m.handle as { layout?: string } | undefined)?.layout === "wide"); return (
@@ -245,8 +247,8 @@ function ConsoleShell({ session }: { session: SessionInfo }) { {/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */} -
-
+
+
diff --git a/docs/design/file-preview-research.md b/docs/design/file-preview-research.md new file mode 100644 index 00000000..4cf5ec83 --- /dev/null +++ b/docs/design/file-preview-research.md @@ -0,0 +1,37 @@ +# 文件管理基础预览调研(FM-2) + +2026-09-29。范围:文件管理详情抽屉里除视频播放以外的预览。前提:单容器 4C/8G;前端库只能来自 npm;所有读取走 `resolveIn` + `recheck`(realpath 在登记目录内、符号链接目标也须在内),仅管理员控制台会话。 + +**核实**:镜像 `/usr/local/bin` 只有 `ffprobe` 9.0.2(141 MB,取自 `mwader/static-ffmpeg:9.0.2`),**没有 ffmpeg**。同一镜像里的 ffmpeg 静态二进制也是 141 MB,加进来要改 Dockerfile 和编译参数 `--allow-run`。 + +**MP 对照**:MoviePilot 文件管理只有图片预览(`/storage/image` 取原图转 blob),另有识别和刮削,没有文本、字幕和种子预览。 + +## 逐项结论 + +| 类别 | 用户价值 | 实现 | 安全 | 工作量 | 建议 | +| --- | --- | --- | --- | --- | --- | +| 图片 jpg/png/webp/avif/gif | 高:核对海报、背景是否刮对 | 原图经 `/files/download?inline` 显示(≤20 MB,`loading=lazy`);服务端读文件头回宽高 | SVG 不内联(可带脚本);`nosniff` + `sandbox` CSP | S | **已做** | +| 图片缩略图 | 中:列表网格、海报墙 | 需 ffmpeg(+141 MB)或 sharp/libvips(npm 原生模块,`deno compile` 嵌入风险)或 wasm 解码(内存峰值高) | 解码炸弹(超大分辨率):限像素数 | M | 后做,等有网格视图再定 | +| 文本 nfo/txt/log/md/json/xml/yaml | 高:看 NFO、发布说明、日志 | 服务端读前 1 MB,编码探测 BOM → UTF-16 → UTF-8 → GB18030/Big5 择优 → Latin-1;json 美化 | 一律按纯文本渲染(React 转义),md 不渲染 HTML | S | **已做** | +| Markdown 渲染 | 低 | markdown-it + DOMPurify(约 60 KB) | 必须消毒 | S | 不做,纯文本够用 | +| 字幕 srt/vtt/ass/ssa | 高:确认语言、编码、时间轴对不对 | 服务端解析前 1 MB,按时间排序回前 200 条;ASS 取 `Title`、去覆写标签 | 纯文本输出 | S | **已做** | +| 字幕 sup(PGS) | 中 | 解析段头,只列起止时间 | 只读段头 | S | **已做**;渲染图形不做 | +| 音频 flac/mp3 试听 | 低:库里音频多是音轨附件 | 浏览器 `