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 { 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 }) =>
|
||||
|
||||
Reference in new issue
Block a user