feat(files): wide file manager layout and basic previews (FM-2)
- Files page uses a wide layout (route handle layout:"wide"): no max width, full-height directory tree docked next to the sidebar, resizable (200-520px, remembered, double-click resets) and collapsible; mobile keeps the tree in a drawer. - GET /files/preview (admin console session only, same path guards as download): text/NFO with encoding detection (BOM/UTF-16/UTF-8/GB18030/Big5), subtitles first 200 cues (SRT/VTT/ASS/SSA, PGS timings), .torrent file list/trackers (passkey masked)/infohash, image dimensions. Reads at most 1 MiB of text; .torrent capped at 4 MiB. - Download also allows text files, .torrent and avif/gif. - Research notes in docs/design/file-preview-research.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3add4bab41
commit
289e8b297a
20 files changed
+1294
-159
No files matched your search
@@ -4,6 +4,7 @@ import type { Principal } from "../../http/auth.ts";
|
|||||||
import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts";
|
import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts";
|
||||||
import { audit } from "./ledger.ts";
|
import { audit } from "./ledger.ts";
|
||||||
import { kindOfName } from "./list.ts";
|
import { kindOfName } from "./list.ts";
|
||||||
|
import { previewKindOf } from "./preview-parse.ts";
|
||||||
|
|
||||||
// GET /files/download(W5-A2):仅管理员会话;只下载 NFO/字幕/图片小文件(视频不经浏览器,规格 §1 边界);
|
// GET /files/download(W5-A2):仅管理员会话;只下载 NFO/字幕/图片小文件(视频不经浏览器,规格 §1 边界);
|
||||||
// 路径守卫同其它接口(realpath 在根内、符号链接目标也须在根内,打开前再复核一次);
|
// 路径守卫同其它接口(realpath 在根内、符号链接目标也须在根内,打开前再复核一次);
|
||||||
@@ -19,6 +20,7 @@ const MIME: Record<string, string> = {
|
|||||||
webp: "image/webp",
|
webp: "image/webp",
|
||||||
bmp: "image/bmp",
|
bmp: "image/bmp",
|
||||||
avif: "image/avif",
|
avif: "image/avif",
|
||||||
|
torrent: "application/x-bittorrent",
|
||||||
nfo: "text/plain; charset=utf-8",
|
nfo: "text/plain; charset=utf-8",
|
||||||
xml: "text/plain; charset=utf-8",
|
xml: "text/plain; charset=utf-8",
|
||||||
srt: "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", "只能下载文件,不能下载目录");
|
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
|
||||||
const name = basename(r.abs);
|
const name = basename(r.abs);
|
||||||
const kind = kindOfName(name);
|
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 real = await recheck(r.abs, r.rootReal);
|
||||||
const file = await Deno.open(real, { read: true });
|
const file = await Deno.open(real, { read: true });
|
||||||
let size: number;
|
let size: number;
|
||||||
@@ -67,7 +72,7 @@ export async function downloadFile(
|
|||||||
}
|
}
|
||||||
const ext = extname(name).replace(/^\./, "").toLowerCase();
|
const ext = extname(name).replace(/^\./, "").toLowerCase();
|
||||||
// SVG 可带脚本:不内联、不给 image/svg+xml
|
// 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 });
|
await audit(d, principal, "download", { root: q.root, path: r.rel, size, inline });
|
||||||
return new Response(file.readable, {
|
return new Response(file.readable, {
|
||||||
status: 200,
|
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 { listDir, listRoots, statEntry } from "./list.ts";
|
||||||
import { mkdirIn, renameEntry } from "./mutate.ts";
|
import { mkdirIn, renameEntry } from "./mutate.ts";
|
||||||
import { organizeFiles } from "./organize.ts";
|
import { organizeFiles } from "./organize.ts";
|
||||||
|
import { previewFile } from "./preview.ts";
|
||||||
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
|
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
|
||||||
import { findRecords } from "./records.ts";
|
import { findRecords } from "./records.ts";
|
||||||
import { suggestName } from "./suggest-name.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.probe, async (_c, { body }) => probeFiles(d, body));
|
||||||
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
|
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.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
|
// W9-H 文件管理 v2
|
||||||
mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body));
|
mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body));
|
||||||
mount(app, F.scrapeBatch, async (c, { body }) =>
|
mount(app, F.scrapeBatch, async (c, { body }) =>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe("Api 注册表鉴权标注", () => {
|
|||||||
"PATCH /auth/passkeys/{id}",
|
"PATCH /auth/passkeys/{id}",
|
||||||
"DELETE /auth/passkeys/{id}",
|
"DELETE /auth/passkeys/{id}",
|
||||||
"GET /files/download",
|
"GET /files/download",
|
||||||
|
"GET /files/preview",
|
||||||
"PATCH /approvals/{id}",
|
"PATCH /approvals/{id}",
|
||||||
"POST /approvals",
|
"POST /approvals",
|
||||||
"POST /auth/password",
|
"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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
// 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。
|
// 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。
|
||||||
const UNIT = [
|
const UNIT = [
|
||||||
"test/api-registry.test.ts",
|
"test/api-registry.test.ts",
|
||||||
|
"test/file-manager/preview-parse.test.ts",
|
||||||
"test/auth.test.ts",
|
"test/auth.test.ts",
|
||||||
"test/health.test.ts",
|
"test/health.test.ts",
|
||||||
"test/sources-identity/identity-differential.test.ts",
|
"test/sources-identity/identity-differential.test.ts",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClie
|
|||||||
import {
|
import {
|
||||||
cursorPage,
|
cursorPage,
|
||||||
type FileEntry,
|
type FileEntry,
|
||||||
|
FilePreview,
|
||||||
type FileRef,
|
type FileRef,
|
||||||
FileRootList,
|
FileRootList,
|
||||||
FileStat,
|
FileStat,
|
||||||
@@ -241,57 +242,17 @@ export function fileDownloadUrl(root: string, path: string, inline = false): str
|
|||||||
return `/api/v1/files/download?${qs}`;
|
return `/api/v1/files/download?${qs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文本预览最多读前 256 KB(规格 §9) */
|
/** 结构化预览(FM-2):文本/NFO 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;服务端只读文件头 */
|
||||||
export const TEXT_PREVIEW_BYTES = 256 * 1024;
|
export function useFilePreview(root: string | null, path: string | null, enabled: boolean) {
|
||||||
|
|
||||||
export type TextPreview = { text: string; truncated: boolean };
|
|
||||||
|
|
||||||
export function useTextPreview(root: string | null, path: string | null, enabled: boolean) {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["files", "preview", root, path],
|
queryKey: ["files", "preview", root, path],
|
||||||
enabled: enabled && !!root && !!path,
|
enabled: enabled && !!root && !!path,
|
||||||
retry: false,
|
retry: false,
|
||||||
queryFn: async ({ signal }): Promise<TextPreview> => {
|
staleTime: 30_000,
|
||||||
if (filesMockEnabled())
|
queryFn: ({ signal }) => call(FilePreview, "/files/preview", { params: { root, path }, signal }),
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只读响应体前 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 秒刷新 */
|
/** 整理记录回查:ids 或 planIds(执行后映射到记录);执行中每 3 秒刷新 */
|
||||||
export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enabled = true) {
|
export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enabled = true) {
|
||||||
const ids = q.ids ?? [];
|
const ids = q.ids ?? [];
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { Download, ExternalLink, LoaderCircle, ScanSearch } from "lucide-react";
|
import { LoaderCircle, ScanSearch } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { type FileEntry, type FileRoot, filesErrorText, useFileStat, useProbe } from "~/api/files.ts";
|
||||||
type FileEntry,
|
|
||||||
type FileRoot,
|
|
||||||
fileDownloadUrl,
|
|
||||||
filesErrorText,
|
|
||||||
filesMockEnabled,
|
|
||||||
useFileStat,
|
|
||||||
useProbe,
|
|
||||||
useTextPreview,
|
|
||||||
} from "~/api/files.ts";
|
|
||||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts";
|
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts";
|
||||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||||
import { LoadingState } from "~/components/console/empty-state.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 { Timeline } from "~/components/console/timeline.tsx";
|
||||||
import { SLOT_STATUS } from "~/components/library/labels.ts";
|
import { SLOT_STATUS } from "~/components/library/labels.ts";
|
||||||
import type { WizardEntity } from "~/components/organize-wizard/model.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 { downloadLabel } from "~/lib/labels.ts";
|
||||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
import { EntryStatus } from "./file-list.tsx";
|
import { EntryStatus } from "./file-list.tsx";
|
||||||
|
import { FilePreviewSection } from "./file-preview.tsx";
|
||||||
import { IdentifyPanel } from "./identify-panel.tsx";
|
import { IdentifyPanel } from "./identify-panel.tsx";
|
||||||
import { baseName } from "./model.ts";
|
import { baseName, previewKindOf } from "./model.ts";
|
||||||
|
|
||||||
const PREVIEW_MAX = 20 * 1024 * 1024;
|
|
||||||
|
|
||||||
const ACTION_LABEL: Record<string, string> = {
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
copy: "复制",
|
copy: "复制",
|
||||||
@@ -78,7 +68,7 @@ function RecordsSection({ entityId }: { entityId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
|
/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与预览 */
|
||||||
export function FileDetail({
|
export function FileDetail({
|
||||||
root,
|
root,
|
||||||
entry,
|
entry,
|
||||||
@@ -99,11 +89,7 @@ export function FileDetail({
|
|||||||
const stat = useFileStat(root.id, entry.path);
|
const stat = useFileStat(root.id, entry.path);
|
||||||
const probe = useProbe();
|
const probe = useProbe();
|
||||||
const e = stat.data?.entry ?? entry;
|
const e = stat.data?.entry ?? entry;
|
||||||
const previewable =
|
const pk = previewKindOf(e);
|
||||||
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 media = probe.data?.items[0]?.summary ?? e.media;
|
const media = probe.data?.items[0]?.summary ?? e.media;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,6 +99,7 @@ export function FileDetail({
|
|||||||
title={e.name}
|
title={e.name}
|
||||||
description={<EntryStatus e={e} />}
|
description={<EntryStatus e={e} />}
|
||||||
footer={actions}
|
footer={actions}
|
||||||
|
width={pk && pk !== "image" ? "lg" : "md"}
|
||||||
>
|
>
|
||||||
<DetailSection title="文件">
|
<DetailSection title="文件">
|
||||||
<KvList
|
<KvList
|
||||||
@@ -292,51 +279,7 @@ export function FileDetail({
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{previewable ? (
|
<FilePreviewSection root={root} entry={e} />
|
||||||
<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}
|
|
||||||
</DetailSheet>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
FileEntry,
|
FileEntry,
|
||||||
FileIdentifyItem,
|
FileIdentifyItem,
|
||||||
FileKindTag,
|
FileKindTag,
|
||||||
|
FilePreview,
|
||||||
FileRef,
|
FileRef,
|
||||||
FileRoot,
|
FileRoot,
|
||||||
FileStat,
|
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("Nosferatu.2024.2160p.WEB-DL.mkv.!qB", 2.1 * GB, 0.1, { status: "temp" }),
|
||||||
f("README.txt", 1_024, 700),
|
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 }),
|
f("escape-link", 0, 700, { escape: true }),
|
||||||
{ name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 },
|
{ name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 },
|
||||||
f(".hidden-note", 64, 700),
|
f(".hidden-note", 64, 700),
|
||||||
@@ -922,6 +924,8 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
|||||||
return list(p);
|
return list(p);
|
||||||
case "/files/stat":
|
case "/files/stat":
|
||||||
return stat(p);
|
return stat(p);
|
||||||
|
case "/files/preview":
|
||||||
|
return mockFilePreview(p);
|
||||||
case "/files/mkdir": {
|
case "/files/mkdir": {
|
||||||
const { node } = find(b.root, b.path);
|
const { node } = find(b.root, b.path);
|
||||||
if ((node.children ?? []).some((c) => c.name === b.name))
|
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> {
|
function mockFilePreview(p: Record<string, unknown>): FilePreview {
|
||||||
await delay(100);
|
const path = String(p.path ?? "");
|
||||||
if (path.endsWith(".nfo"))
|
const { node } = find(String(p.root ?? ""), path);
|
||||||
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘 2</title>\n <year>2024</year>\n</movie>`;
|
const size = node.size;
|
||||||
if (/\.(srt|ass)$/.test(path))
|
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(path)?.[1]?.toLowerCase() ?? "";
|
||||||
return "1\n00:00:01,000 --> 00:00:04,000\n 厄拉科斯…\n\n2\n00:00:05,000 --> 00:00:07,500\n 一切都从这里开始。";
|
if (ext === "nfo")
|
||||||
return `(${root}:${path})`;
|
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,
|
applyIdentifyCompleted,
|
||||||
clickSelect,
|
clickSelect,
|
||||||
collapseCrumbs,
|
collapseCrumbs,
|
||||||
|
cueTime,
|
||||||
deleteBlockText,
|
deleteBlockText,
|
||||||
emptySelection,
|
emptySelection,
|
||||||
entryStatusView,
|
entryStatusView,
|
||||||
@@ -18,6 +19,8 @@ import {
|
|||||||
opApplies,
|
opApplies,
|
||||||
opDisabledReason,
|
opDisabledReason,
|
||||||
parseEpisodes,
|
parseEpisodes,
|
||||||
|
prettyJson,
|
||||||
|
previewKindOf,
|
||||||
pruneSelection,
|
pruneSelection,
|
||||||
selectAll,
|
selectAll,
|
||||||
shiftEpisodes,
|
shiftEpisodes,
|
||||||
@@ -329,3 +332,20 @@ describe("识别远端回填(S-7)", () => {
|
|||||||
expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]);
|
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}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -519,3 +519,39 @@ export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyComple
|
|||||||
items: r.items.map((it) => by.get(identifyKey(it)) ?? it),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
useTrash,
|
useTrash,
|
||||||
} from "~/api/files.ts";
|
} from "~/api/files.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
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 { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -88,7 +87,7 @@ import { ShortcutsDialog } from "~/components/files/shortcuts-dialog.tsx";
|
|||||||
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
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 { Input } from "~/components/ui/input.tsx";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
@@ -113,7 +112,13 @@ type Dialog =
|
|||||||
| { kind: "wizard"; source: OrganizeSource }
|
| { kind: "wizard"; source: OrganizeSource }
|
||||||
| { kind: "shortcuts" };
|
| { kind: "shortcuts" };
|
||||||
|
|
||||||
|
/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */
|
||||||
|
export const handle = { layout: "wide" };
|
||||||
|
|
||||||
const TREE_W_KEY = "umt-files-tree-w";
|
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 TREE_OPEN_KEY = "umt-files-tree-open";
|
||||||
const LAST_LOC_KEY = "umt-files-last";
|
const LAST_LOC_KEY = "umt-files-last";
|
||||||
const FILES_CHANGED = "files.changed";
|
const FILES_CHANGED = "files.changed";
|
||||||
@@ -150,13 +155,13 @@ function writeStore(key: string, v: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useTreeWidth() {
|
function useTreeWidth() {
|
||||||
const [w, setW] = useState(260);
|
const [w, setW] = useState(TREE_W_DEFAULT);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = Number(readStore(TREE_W_KEY));
|
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 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);
|
setW(c);
|
||||||
writeStore(TREE_W_KEY, String(c));
|
writeStore(TREE_W_KEY, String(c));
|
||||||
};
|
};
|
||||||
@@ -876,45 +881,47 @@ export default function FilesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
{/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */}
|
||||||
title="文件管理"
|
<h1 className="sr-only">文件管理</h1>
|
||||||
actions={
|
<div className={cn("flex h-[calc(100dvh-3.5rem)] min-w-0", mobile && "flex-col p-3")}>
|
||||||
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">
|
|
||||||
{/* 桌面左栏:登记目录 + 目录树(可折叠、可拖宽) */}
|
|
||||||
{!mobile ? (
|
{!mobile ? (
|
||||||
<aside
|
<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 }}
|
style={{ width: treeOpen ? treeW : 44 }}
|
||||||
aria-label="登记目录与目录树"
|
aria-label="登记目录与目录树"
|
||||||
>
|
>
|
||||||
{treeOpen ? (
|
{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 不能获焦 */}
|
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-label="调整目录树宽度"
|
aria-label="调整目录树宽度"
|
||||||
|
title="拖动调整宽度,双击恢复默认"
|
||||||
aria-valuenow={treeW}
|
aria-valuenow={treeW}
|
||||||
aria-valuemin={200}
|
aria-valuemin={TREE_W_MIN}
|
||||||
aria-valuemax={440}
|
aria-valuemax={TREE_W_MAX}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||||||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={() => setTreeW(TREE_W_DEFAULT)}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const startW = treeW;
|
const startW = treeW;
|
||||||
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
||||||
@@ -925,25 +932,28 @@ export default function FilesPage() {
|
|||||||
window.addEventListener("pointermove", move);
|
window.addEventListener("pointermove", move);
|
||||||
window.addEventListener("pointerup", upH);
|
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}
|
<div aria-hidden className="h-11 shrink-0 border-b border-[var(--shell-border)]" />
|
||||||
current={rootId}
|
<TreeRail
|
||||||
onSelect={go}
|
roots={rootList}
|
||||||
onExpand={() => setTreeOpenStored(true)}
|
current={rootId}
|
||||||
trashCount={trashCount}
|
onSelect={go}
|
||||||
/>
|
onExpand={() => setTreeOpenStored(true)}
|
||||||
|
trashCount={trashCount}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
"pg-slab relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
|
"pg-slab relative flex min-h-[24rem] 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]",
|
!mobile && "m-3",
|
||||||
)}
|
)}
|
||||||
aria-label="目录内容"
|
aria-label="目录内容"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { SessionInfo } from "@umt/contracts";
|
import type { SessionInfo } from "@umt/contracts";
|
||||||
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "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 { useSession } from "~/api/auth.ts";
|
||||||
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
||||||
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
||||||
@@ -176,6 +176,8 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
|
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
|
||||||
|
// 路由 handle.layout === "wide"(文件管理):内容区不限最大宽度、不留外边距,页面自己排版
|
||||||
|
const wide = useMatches().some((m) => (m.handle as { layout?: string } | undefined)?.layout === "wide");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-clip bg-background">
|
<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>
|
</header>
|
||||||
|
|
||||||
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
||||||
<main className="relative flex-1 overflow-x-clip px-4 pt-6 pb-24 md:px-6 md:pt-8">
|
<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="mx-auto max-w-6xl motion-safe:animate-blur-in">
|
<div key={location.pathname} className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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,是否放开音频类型。
|
||||||
@@ -183,5 +183,5 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
|
|||||||
- **SEARCH-3 后续(协调者截图复核)**:新筛选条下方仍保留旧的一行(大小/免费/H&R/排序/清除筛选),"免费"与「促销」维度重复、"清除全部"与"清除筛选"并存 → 并为一行:H&R 进分面,大小与排序放右侧,只留一个"清除";元信息行的耗时进「技术信息」。DEAI-2 合并后处理。
|
- **SEARCH-3 后续(协调者截图复核)**:新筛选条下方仍保留旧的一行(大小/免费/H&R/排序/清除筛选),"免费"与「促销」维度重复、"清除全部"与"清除筛选"并存 → 并为一行:H&R 进分面,大小与排序放右侧,只留一个"清除";元信息行的耗时进「技术信息」。DEAI-2 合并后处理。
|
||||||
- **LIBV-1 后续(协调者截图复核)**:每集都挂一个"技术信息"开关太碎 → 点整行展开;"2 个版本"展开后第二个版本出现在第一个版本的技术信息里,应并列成同级行;"可升级:分辨率 1080p 不在允许列表 [2160p]"改人话("可换成 2160p");统一改用 DEAI-2 的 TechInfo 组件。
|
- **LIBV-1 后续(协调者截图复核)**:每集都挂一个"技术信息"开关太碎 → 点整行展开;"2 个版本"展开后第二个版本出现在第一个版本的技术信息里,应并列成同级行;"可升级:分辨率 1080p 不在允许列表 [2160p]"改人话("可换成 2160p");统一改用 DEAI-2 的 TechInfo 组件。
|
||||||
- **下载面板(DL-2,用户 2026-09-29)**:每个种子显示下载/上传速度(及总速度);默认只显示未完成的;已完成的单独一个视图(Tab 或分段)。
|
- **下载面板(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,或整理需要贴近存储部署到另一台机器。
|
- **Worker 分道(WK-1,用户 2026-09-29 讨论后认可,排在本轮测试之后)**:不再拆进程;IO Worker 内分三条道——文件(整理/硬链接/ffprobe/校验,并发 2)、网络(站点/RSS/TMDB/搜索,3–4,单站限流另计)、维护(清理/回填/巡检,1,空闲时跑),并发可在设置调。拆进程的触发条件:心跳事件循环延迟在整理期间持续 >200ms,或整理需要贴近存储部署到另一台机器。
|
||||||
@@ -1540,10 +1540,20 @@ export const Api = {
|
|||||||
path: "/files/download",
|
path: "/files/download",
|
||||||
scope: "admin",
|
scope: "admin",
|
||||||
auth: "session",
|
auth: "session",
|
||||||
summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;仅管理员控制台会话;审计)",
|
summary: "下载小文件(nfo/字幕/图片/文本/.torrent,≤20 MiB;流式;仅管理员控制台会话;审计)",
|
||||||
query: files.FilesDownloadQuery,
|
query: files.FilesDownloadQuery,
|
||||||
response: files.FilesDownloadResponse,
|
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({
|
records: ep({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/files/records",
|
path: "/files/records",
|
||||||
|
|||||||
@@ -728,6 +728,70 @@ export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
|
|||||||
/** OpenAPI 描述用:实际响应为二进制流 */
|
/** OpenAPI 描述用:实际响应为二进制流 */
|
||||||
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
|
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
|
// 整理记录回查(W5-A2,只读):整理页 `/console/organize?ids=` 预填;planIds 为 /files/move|copy|organize 的 planId
|
||||||
|
|
||||||
const csvIds = z
|
const csvIds = z
|
||||||
|
|||||||
Reference in new issue
Block a user