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
@@ -5,6 +5,7 @@ import type {
|
||||
FileEntry,
|
||||
FileIdentifyItem,
|
||||
FileKindTag,
|
||||
FilePreview,
|
||||
FileRef,
|
||||
FileRoot,
|
||||
FileStat,
|
||||
@@ -238,6 +239,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
|
||||
}),
|
||||
f("Nosferatu.2024.2160p.WEB-DL.mkv.!qB", 2.1 * GB, 0.1, { status: "temp" }),
|
||||
f("README.txt", 1_024, 700),
|
||||
f("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX-FraMeSToR.torrent", 186_000, 30),
|
||||
f("escape-link", 0, 700, { escape: true }),
|
||||
{ name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 },
|
||||
f(".hidden-note", 64, 700),
|
||||
@@ -922,6 +924,8 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
||||
return list(p);
|
||||
case "/files/stat":
|
||||
return stat(p);
|
||||
case "/files/preview":
|
||||
return mockFilePreview(p);
|
||||
case "/files/mkdir": {
|
||||
const { node } = find(b.root, b.path);
|
||||
if ((node.children ?? []).some((c) => c.name === b.name))
|
||||
@@ -1167,11 +1171,82 @@ function mockRecord(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function mockPreview(root: string, path: string): Promise<string> {
|
||||
await delay(100);
|
||||
if (path.endsWith(".nfo"))
|
||||
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘 2</title>\n <year>2024</year>\n</movie>`;
|
||||
if (/\.(srt|ass)$/.test(path))
|
||||
return "1\n00:00:01,000 --> 00:00:04,000\n 厄拉科斯…\n\n2\n00:00:05,000 --> 00:00:07,500\n 一切都从这里开始。";
|
||||
return `(${root}:${path})`;
|
||||
function mockFilePreview(p: Record<string, unknown>): FilePreview {
|
||||
const path = String(p.path ?? "");
|
||||
const { node } = find(String(p.root ?? ""), path);
|
||||
const size = node.size;
|
||||
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(path)?.[1]?.toLowerCase() ?? "";
|
||||
if (ext === "nfo")
|
||||
return {
|
||||
type: "text",
|
||||
size,
|
||||
format: "nfo",
|
||||
encoding: "utf-8",
|
||||
truncated: false,
|
||||
text: `<?xml version="1.0" encoding="utf-8"?>\n<movie>\n <title>沙丘 2</title>\n <originaltitle>Dune: Part Two</originaltitle>\n <year>2024</year>\n <uniqueid type="tmdb" default="true">693134</uniqueid>\n <genre>科幻</genre>\n <runtime>166</runtime>\n</movie>`,
|
||||
};
|
||||
if (ext === "txt")
|
||||
return {
|
||||
type: "text",
|
||||
size,
|
||||
format: "txt",
|
||||
encoding: "gb18030",
|
||||
truncated: false,
|
||||
text: ["本目录是 qBittorrent 的默认保存路径。", "做种中的文件不要改名或移动。"].join("\n"),
|
||||
};
|
||||
if (ext === "srt" || ext === "ass") {
|
||||
const lines = [
|
||||
"厄拉科斯。",
|
||||
"一切都从这里开始。",
|
||||
"香料必须流动。",
|
||||
"我看见了未来。",
|
||||
"沙虫来了!",
|
||||
"保罗·厄崔迪。",
|
||||
];
|
||||
const cues = Array.from({ length: 24 }, (_, i) => ({
|
||||
start: 62_000 + i * 3_400,
|
||||
end: 64_500 + i * 3_400,
|
||||
text: lines[i % lines.length] as string,
|
||||
}));
|
||||
return {
|
||||
type: "subtitle",
|
||||
size,
|
||||
format: ext,
|
||||
encoding: ext === "ass" ? "utf-16le" : "utf-8",
|
||||
cues,
|
||||
parsed: 1_342,
|
||||
truncated: false,
|
||||
title: ext === "ass" ? "Severance S02E01 简中" : null,
|
||||
};
|
||||
}
|
||||
if (ext === "torrent")
|
||||
return {
|
||||
type: "torrent",
|
||||
size,
|
||||
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR",
|
||||
hash: "3f786850e387550fdab836ed7e6dc881de23001b",
|
||||
infohashV1: "3f786850e387550fdab836ed7e6dc881de23001b",
|
||||
infohashV2: null,
|
||||
private: true,
|
||||
totalSize: Math.round(71.2 * GB + 96_000),
|
||||
fileCount: 2,
|
||||
files: [
|
||||
{
|
||||
path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
|
||||
length: Math.round(71.2 * GB),
|
||||
},
|
||||
{
|
||||
path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/chs.srt",
|
||||
length: 96_000,
|
||||
},
|
||||
],
|
||||
trackers: ["https://tracker.example.org/announce.php?passkey=…"],
|
||||
comment: null,
|
||||
createdBy: "mktorrent 1.1",
|
||||
createdAt: ago(40),
|
||||
pieceLength: 16 * MB,
|
||||
};
|
||||
if (IMG.has(ext))
|
||||
return { type: "image", size, mime: `image/${ext === "jpg" ? "jpeg" : ext}`, width: 2000, height: 3000 };
|
||||
throw new ApiRequestError("forbidden", `forbidden:不支持预览 .${ext} 文件`);
|
||||
}
|
||||
Reference in new issue
Block a user