Merge branch 'fm2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-29 21:44:42 +08:00
commit 54e8e2206a
20 files changed
+1294 -159

No files matched your search

@@ -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<string, string> = {
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,
@@ -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());
}
@@ -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<FilePreview> {
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 };
}
@@ -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 }) =>
+1
View File
@@ -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",
@@ -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<i>厄拉科斯</i>\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",
);
});
});
@@ -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"), "<movie><title>沙丘</title></movie>");
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();
});
});
+1
View File
@@ -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",
+5 -44
View File
@@ -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<TextPreview> => {
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<TextPreview> {
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 ?? [];
+9 -66
View File
@@ -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<string, string> = {
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={<EntryStatus e={e} />}
footer={actions}
width={pk && pk !== "image" ? "lg" : "md"}
>
<DetailSection title="文件">
<KvList
@@ -292,51 +279,7 @@ export function FileDetail({
</DetailSection>
) : null}
{previewable ? (
<DetailSection
title="预览"
actions={
filesMockEnabled() ? null : (
<a
className={buttonVariants({ size: "xs", variant: "outline" })}
href={fileDownloadUrl(root.id, e.path)}
download={e.name}
>
<Download />
下载
</a>
)
}
>
{e.fileKind === "image" ? (
filesMockEnabled() ? (
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
) : (
<a href={fileDownloadUrl(root.id, e.path, true)} target="_blank" rel="noreferrer" className="block">
<img
src={fileDownloadUrl(root.id, e.path, true)}
alt={e.name}
className="max-h-80 rounded-xl border border-border object-contain"
/>
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口看原图
</span>
</a>
)
) : text.isPending ? (
<LoadingState rows={3} />
) : text.error ? (
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
) : (
<>
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
{text.data?.text}
</pre>
{text.data?.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 256 KB</p> : null}
</>
)}
</DetailSection>
) : null}
<FilePreviewSection root={root} entry={e} />
</DetailSheet>
);
}
@@ -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<string, string> = {
"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 <p className="mb-2 text-meta text-muted-foreground">{items.filter(Boolean).join(" · ")}</p>;
}
function TextView({ p }: { p: Extract<FilePreview, { type: "text" }> }) {
const body = p.format === "json" ? prettyJson(p.text, p.truncated) : p.text;
return (
<>
{body.trim() ? (
<pre className={preBox}>{body}</pre>
) : (
<p className="text-caption text-muted-foreground">空文件。</p>
)}
{p.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 1 MB</p> : null}
<TechInfo
className="mt-2"
items={[
{ label: "格式", value: p.format.toUpperCase() },
{ label: "编码", value: encLabel(p.encoding) },
{ label: "大小", value: formatBytes(p.size, 1) },
]}
/>
</>
);
}
function SubtitleView({ p }: { p: Extract<FilePreview, { type: "subtitle" }> }) {
const graphic = p.format === "sup";
const count =
p.parsed > p.cues.length
? `共 ${p.parsed}${p.truncated ? "+" : ""} 条,显示前 ${p.cues.length} 条`
: `${p.cues.length} 条`;
return (
<>
<Meta items={[p.title, count]} />
{graphic ? <p className="mb-2 text-caption text-muted-foreground">PGS 图形字幕没有文字,只列时间轴。</p> : null}
{p.cues.length ? (
<ol className="max-h-[28rem] divide-y divide-border overflow-auto rounded-xl border border-border">
{p.cues.map((c, i) => (
<li key={i} className="flex gap-3 px-3 py-1.5 text-caption">
<span className="shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
{cueTime(c.start)}
{graphic ? ` → ${cueTime(c.end)}` : ""}
</span>
{graphic ? null : <span className="min-w-0 whitespace-pre-wrap break-words">{c.text}</span>}
</li>
))}
</ol>
) : (
<p className="text-caption text-muted-foreground">没有读到字幕条目。</p>
)}
{p.truncated ? <p className="mt-1 text-meta text-muted-foreground">只读了文件前 1 MB</p> : null}
<TechInfo
className="mt-2"
items={[
{ label: "格式", value: p.format.toUpperCase() },
{ label: "编码", value: encLabel(p.encoding) },
{ label: "大小", value: formatBytes(p.size, 1) },
]}
/>
</>
);
}
function TorrentView({ p }: { p: Extract<FilePreview, { type: "torrent" }> }) {
const prefix = `${p.name}/`;
return (
<div className="space-y-4">
<KvList
items={[
{ label: "名称", value: p.name, wide: true },
{ label: "总大小", value: `${formatBytes(p.totalSize, 2)} · ${p.fileCount} 个文件` },
{ label: "私有种子", value: p.private ? <StatusBadge tone="warning">私有</StatusBadge> : "否" },
...(p.comment ? [{ label: "注释", value: p.comment, wide: true }] : []),
]}
/>
<div>
<p className="mb-1 text-meta text-muted-foreground">
文件 {p.fileCount}
{p.fileCount > p.files.length ? `,显示前 ${p.files.length} 个` : ""}
</p>
<ul className="max-h-72 divide-y divide-border overflow-auto rounded-xl border border-border">
{p.files.map((f) => (
<li key={f.path} className="flex items-baseline gap-3 px-3 py-1.5 text-caption">
<span className="min-w-0 flex-1 font-mono text-meta break-all">
{f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path}
</span>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">{formatBytes(f.length, 1)}</span>
</li>
))}
</ul>
</div>
<TechInfo
items={[
{ label: "infohash", value: p.infohashV1 ?? p.hash, wide: true },
p.infohashV2 ? { label: "infohash v2", value: p.infohashV2, wide: true } : null,
{ label: "分块", value: p.pieceLength ? formatBytes(p.pieceLength, 0) : null },
{ label: "创建时间", value: p.createdAt ? formatDateTime(p.createdAt) : null },
{ label: "创建工具", value: p.createdBy },
{ label: ".torrent 大小", value: formatBytes(p.size, 1) },
]}
>
<div>
<p className="mb-1 text-meta text-muted-foreground">Tracker {p.trackers.length}(passkey 已隐藏)</p>
{p.trackers.length ? (
<ul className="space-y-0.5 font-mono text-meta break-all">
{p.trackers.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有 tracker,只能走 DHT。</p>
)}
</div>
</TechInfo>
</div>
);
}
function ImageView({
root,
entry,
p,
}: {
root: FileRoot;
entry: FileEntry;
p: Extract<FilePreview, { type: "image" }>;
}) {
const dims = p.width && p.height ? `${p.width} × ${p.height}` : null;
const meta = <Meta items={[dims, formatBytes(p.size, 1)]} />;
if (filesMockEnabled())
return (
<>
{meta}
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
</>
);
if (p.size > IMAGE_INLINE_MAX)
return (
<>
{meta}
<p className="text-caption text-muted-foreground">图片超过 20 MB,不在这里显示。</p>
</>
);
const src = fileDownloadUrl(root.id, entry.path, true);
return (
<>
{meta}
<a href={src} target="_blank" rel="noreferrer" className="block">
<img
src={src}
alt={entry.name}
loading="lazy"
decoding="async"
className="max-h-[28rem] rounded-xl border border-border object-contain"
/>
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口看原图
</span>
</a>
</>
);
}
/** 详情抽屉的「预览」节:文本/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 (
<DetailSection
title="预览"
actions={
filesMockEnabled() || entry.size > IMAGE_INLINE_MAX ? null : (
<a
className={buttonVariants({ size: "xs", variant: "outline" })}
href={fileDownloadUrl(root.id, entry.path)}
download={entry.name}
>
<Download />
下载
</a>
)
}
>
{q.isPending ? (
<LoadingState rows={3} />
) : q.error ? (
<p className="text-caption text-destructive">{filesErrorText(q.error, "预览失败")}</p>
) : q.data.type === "text" ? (
<TextView p={q.data} />
) : q.data.type === "subtitle" ? (
<SubtitleView p={q.data} />
) : q.data.type === "torrent" ? (
<TorrentView p={q.data} />
) : (
<ImageView root={root} entry={entry} p={q.data} />
)}
</DetailSection>
);
}
+82 -7
View File
@@ -5,6 +5,7 @@ import type {
FileEntry,
FileIdentifyItem,
FileKindTag,
FilePreview,
FileRef,
FileRoot,
FileStat,
@@ -238,6 +239,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
}),
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<unknown>
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<string> {
await delay(100);
if (path.endsWith(".nfo"))
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘 2</title>\n <year>2024</year>\n</movie>`;
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<string, unknown>): 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: `<?xml version="1.0" encoding="utf-8"?>\n<movie>\n <title>沙丘 2</title>\n <originaltitle>Dune: Part Two</originaltitle>\n <year>2024</year>\n <uniqueid type="tmdb" default="true">693134</uniqueid>\n <genre>科幻</genre>\n <runtime>166</runtime>\n</movie>`,
};
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} 文件`);
}
@@ -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}');
});
});
+36
View File
@@ -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<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(" "))
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<FileEntry, "type" | "ext">): 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;
}
}
+45 -35
View File
@@ -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 (
<>
<PageHeader
title="文件管理"
actions={
mobile ? undefined : (
<>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
<Link to="/console/files/trash" className={buttonVariants({ variant: "outline", size: "sm" })}>
<Trash2 />
回收站{trashCount && trashCount !== "0" ? ` ${trashCount}` : ""}
</Link>
</>
)
}
/>
<div className="flex min-w-0 gap-4">
{/* 桌面左栏:登记目录 + 目录树(可折叠、可拖宽) */}
{/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */}
<h1 className="sr-only">文件管理</h1>
<div className={cn("flex h-[calc(100dvh-3.5rem)] min-w-0", mobile && "flex-col p-3")}>
{/* 桌面左栏:贴着侧边栏的整高目录树(可折叠、可拖宽,宽度记在本机) */}
{!mobile ? (
<aside
className="pg-chrome relative shrink-0 self-start overflow-hidden rounded-2xl"
className="pg-chrome pg-chrome-side relative flex shrink-0 flex-col border-r"
style={{ width: treeOpen ? treeW : 44 }}
aria-label="登记目录与目录树"
>
{treeOpen ? (
<>
<div className="max-h-[calc(100dvh-11rem)] overflow-y-auto p-2.5">{tree}</div>
{/* 左侧留出全站侧栏折叠钮的位置 */}
<div className="flex h-11 shrink-0 items-center border-b border-[var(--shell-border)] pr-2 pl-6">
<span className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
登记目录
</span>
{filesMockEnabled() ? (
<StatusBadge tone="warning" className="ml-auto">
演示数据
</StatusBadge>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tree}</div>
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
<div
role="separator"
aria-orientation="vertical"
aria-label="调整目录树宽度"
title="拖动调整宽度,双击恢复默认"
aria-valuenow={treeW}
aria-valuemin={200}
aria-valuemax={440}
aria-valuemin={TREE_W_MIN}
aria-valuemax={TREE_W_MAX}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
if (e.key === "ArrowRight") setTreeW(treeW + 16);
}}
onDoubleClick={() => setTreeW(TREE_W_DEFAULT)}
onPointerDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startW = treeW;
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
@@ -925,25 +932,28 @@ export default function FilesPage() {
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", upH);
}}
className="absolute inset-y-0 right-0 w-1.5 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
className="absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
/>
</>
) : (
<TreeRail
roots={rootList}
current={rootId}
onSelect={go}
onExpand={() => setTreeOpenStored(true)}
trashCount={trashCount}
/>
<>
<div aria-hidden className="h-11 shrink-0 border-b border-[var(--shell-border)]" />
<TreeRail
roots={rootList}
current={rootId}
onSelect={go}
onExpand={() => setTreeOpenStored(true)}
trashCount={trashCount}
/>
</>
)}
</aside>
) : null}
<section
className={cn(
"pg-slab relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
mobile ? "h-[calc(100dvh-9.5rem)] min-h-[24rem]" : "h-[calc(100dvh-11rem)] min-h-[28rem]",
"pg-slab relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
!mobile && "m-3",
)}
aria-label="目录内容"
>
+5 -3
View File
@@ -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 (
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-clip bg-background">
@@ -245,8 +247,8 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
</header>
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
<main className="relative flex-1 overflow-x-clip px-4 pt-6 pb-24 md:px-6 md:pt-8">
<div key={location.pathname} className="mx-auto max-w-6xl motion-safe:animate-blur-in">
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-24 md:px-6 md:pt-8")}>
<div key={location.pathname} className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}>
<Outlet />
</div>
</main>
+37
View File
@@ -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 试听 | 低:库里音频多是音轨附件 | 浏览器 `<audio>` + HTTP Range;需给下载接口加 Range 与音频类型放行 | 大文件流式,不经内存 | S | 后做 |
| PDF | 很低:媒体库几乎没有 | `<iframe>` 内嵌(浏览器自带);pdf.js 约 1 MB 不值 | 内嵌需同源 + sandbox | S | 不做 |
| 压缩包列表 zip/rar/7z | 中:字幕包、原盘分卷 | zip 纯 JS 读中央目录即可;rar/7z 需 `unrar`/`7z` 子进程或 wasm(libarchive.js 约 1 MB) | 只读目录不解压,不会触发压缩炸弹;条目数设上限;文件名当纯文本 | zip S / rar·7z M | zip 后做,rar/7z 视需求 |
| .torrent | 高:核对资源内容、hash、是否私有 | 复用 `@umt/adapters` 的 `parseTorrent`;≤4 MB;回前 500 个文件与 tracker | tracker 里的 passkey 服务端打码 | S | **已做** |
| 视频规格 | 高 | 已有「探测」(ffprobe 摘要,缓存 24 小时) | — | — | 已有 |
| 视频关键帧截图条 | 中:确认片源、是否黑边/HDR 发灰 | 需 ffmpeg;每次 4–6 帧、限时、结果缓存到 DATA_DIR | 并发与 CPU 上限(4C);超时 SIGKILL | M | 后做,和缩略图一起决定是否加 ffmpeg |
| ISO/BDMV 结构识别 | 中:原盘目录显示主片、时长 | BDMV 目录读 `index.bdmv`/`MPLS` 找最长播放列表;ISO 需 UDF 解析 | 只读 | M(BDMV)/ L(ISO) | 后做,先做 BDMV 目录 |
## 已实现(本分支)
- `GET /files/preview?root&path`:返回 `text / subtitle / torrent / image` 四种结构,只回 JSON,不回原始内容;扩展名白名单与前端同表。上限:文本和字幕读前 1 MB,图片读头 512 KB,.torrent ≤4 MB,超过回 413。
- 下载接口同步放行文本类、.torrent、avif/gif;视频仍不经浏览器。
- 预览不写审计(只读、量大),下载仍写。
- 前端:详情抽屉「预览」节;格式、编码、hash、分块等收进「技术信息」。
## 需要决定
1. 是否在镜像里加 ffmpeg(+141 MB):决定缩略图、截图条两项。
2. 音频试听要给下载接口加 Range,是否放开音频类型。
+1 -1
View File
@@ -183,5 +183,5 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
- **SEARCH-3 后续(协调者截图复核)**:新筛选条下方仍保留旧的一行(大小/免费/H&R/排序/清除筛选),"免费"与「促销」维度重复、"清除全部"与"清除筛选"并存 → 并为一行:H&R 进分面,大小与排序放右侧,只留一个"清除";元信息行的耗时进「技术信息」。DEAI-2 合并后处理。
- **LIBV-1 后续(协调者截图复核)**:每集都挂一个"技术信息"开关太碎 → 点整行展开;"2 个版本"展开后第二个版本出现在第一个版本的技术信息里,应并列成同级行;"可升级:分辨率 1080p 不在允许列表 [2160p]"改人话("可换成 2160p");统一改用 DEAI-2 的 TechInfo 组件。
- **下载面板(DL-2,用户 2026-09-29)**:每个种子显示下载/上传速度(及总速度);默认只显示未完成的;已完成的单独一个视图(Tab 或分段)。
- **文件管理(FM-2,用户 2026-09-29)**:左侧目录树再往左靠,页面主体做大(文件管理页用宽布局);调研除视频播放外可集成的基础预览,先出调研结论。
- **文件管理(FM-2,用户 2026-09-29)**:左侧目录树再往左靠,页面主体做大(文件管理页用宽布局);调研除视频播放外可集成的基础预览,先出调研结论。→ 已做(分支 fm2):文件管理宽布局(目录树贴侧栏、可拖宽/折叠、手机抽屉);首批预览(文本/NFO 编码探测、字幕前 200 条、.torrent、图片尺寸);调研见 `docs/design/file-preview-research.md`。
- **Worker 分道(WK-1,用户 2026-09-29 讨论后认可,排在本轮测试之后)**:不再拆进程;IO Worker 内分三条道——文件(整理/硬链接/ffprobe/校验,并发 2)、网络(站点/RSS/TMDB/搜索,3–4,单站限流另计)、维护(清理/回填/巡检,1,空闲时跑),并发可在设置调。拆进程的触发条件:心跳事件循环延迟在整理期间持续 >200ms,或整理需要贴近存储部署到另一台机器。
+11 -1
View File
@@ -1540,10 +1540,20 @@ export const Api = {
path: "/files/download",
scope: "admin",
auth: "session",
summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;仅管理员控制台会话;审计)",
summary: "下载小文件(nfo/字幕/图片/文本/.torrent,≤20 MiB;流式;仅管理员控制台会话;审计)",
query: files.FilesDownloadQuery,
response: files.FilesDownloadResponse,
}),
preview: ep({
method: "get",
path: "/files/preview",
scope: "admin",
auth: "session",
summary:
"结构化预览(文本/NFO 前 1 MiB 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;仅管理员控制台会话)",
query: files.FilesPreviewQuery,
response: files.FilePreview,
}),
records: ep({
method: "get",
path: "/files/records",
+64
View File
@@ -728,6 +728,70 @@ export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
/** OpenAPI 描述用:实际响应为二进制流 */
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
// 预览(FM-2):GET /files/preview 返回结构化预览,仅管理员会话,路径守卫同下载。
// 文本/字幕最多读前 1 MiB 并探测编码(BOM / UTF-8 / UTF-16 / GB18030 / Big5);字幕解析前 200 条;
// .torrent ≤ 4 MiB 解析文件列表、tracker 与 infohash;图片只回尺寸类型信息,内容走 /files/download?inline=true。
export const FilesPreviewQuery = z.object({ root: FileRootId, path: FileRelPath });
export type FilesPreviewQuery = z.infer<typeof FilesPreviewQuery>;
export const SubtitleCue = z.object({
/** 毫秒 */
start: NonNegInt,
end: NonNegInt,
/** 去掉样式标签后的纯文本;图形字幕(sup)为空串 */
text: z.string(),
});
export type SubtitleCue = z.infer<typeof SubtitleCue>;
const PreviewBase = z.object({ size: NonNegInt });
export const FilePreview = z.discriminatedUnion("type", [
PreviewBase.extend({
type: z.literal("text"),
/** 小写扩展名(nfo/txt/log/md/json/xml/yaml…) */
format: z.string(),
/** 探测出的编码名(utf-8 / utf-16le / utf-16be / gb18030 / big5 / windows-1252) */
encoding: z.string(),
text: z.string(),
truncated: z.boolean(),
}),
PreviewBase.extend({
type: z.literal("subtitle"),
format: z.enum(["srt", "vtt", "ass", "ssa", "sup"]),
encoding: z.string().nullable(),
cues: z.array(SubtitleCue),
/** 读到的范围内共解析出多少条(可能大于 cues.length) */
parsed: NonNegInt,
/** 只读了文件前一部分 */
truncated: z.boolean(),
/** ASS/SSA 的 [Script Info] Title */
title: z.string().nullable(),
}),
PreviewBase.extend({
type: z.literal("torrent"),
name: z.string(),
hash: z.string(),
infohashV1: z.string().nullable(),
infohashV2: z.string().nullable(),
private: z.boolean(),
totalSize: NonNegInt,
fileCount: NonNegInt,
/** 最多 500 个 */
files: z.array(z.object({ path: z.string(), length: NonNegInt })),
trackers: z.array(z.string()),
comment: z.string().nullable(),
createdBy: z.string().nullable(),
createdAt: Timestamp.nullable(),
pieceLength: NonNegInt.nullable(),
}),
PreviewBase.extend({
type: z.literal("image"),
mime: z.string(),
width: NonNegInt.nullable(),
height: NonNegInt.nullable(),
}),
]);
export type FilePreview = z.infer<typeof FilePreview>;
// 整理记录回查(W5-A2,只读):整理页 `/console/organize?ids=` 预填;planIds 为 /files/move|copy|organize 的 planId
const csvIds = z