Files
UHD_Media_Tool/apps/server/test/file-manager/browse.test.ts
T
rootandClaude Opus 5.5 3c17fa3356 fix: 文件与作品页的低优先级问题(FIX-7)
- 文件列表「主文件」只认视频扩展名(字幕自成资产时位置 is_primary 也为真)
- 行内悬浮操作只在鼠标悬浮时出现,选中行不再常驻遮挡大小/时间
- 种子详情按识别状态说人话,解析结果 JSON 跟随技术信息默认收起
- 作品页空收录版本写「没设收录要求」,版本行写「库里有 N 个版本」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:00:03 +08:00

198 lines
10 KiB
TypeScript

import { mkdir, rename, symlink, utimes, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createFmHarness,
type FmHarness,
pgAvailable,
READ_KEY,
seedAsset,
seedDownload,
seedEntity,
} from "./harness.ts";
// 读接口 + 路径守卫:只列登记根、路径逃逸(.. / URL 编码 / 符号链接 / 根外绝对路径)、离线 503、截断与分页、行内状态。
const skip = !(await pgAvailable());
const q = (o: Record<string, string>) => new URLSearchParams(o).toString();
describe.skipIf(skip)("files 浏览与守卫", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("browse", { options: { listMaxEntries: 300 } });
await mkdir(join(h.lib, "Movie (2020)"));
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv"), "video");
await writeFile(join(h.lib, "Movie (2020)", "Other.2021.mkv"), "video2");
await writeFile(join(h.lib, "Movie (2020)", ".hidden"), "x");
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv.uhd-partial"), "x");
await symlink(h.outside, join(h.lib, "escape-dir"));
await symlink(join(h.outside, "secret.txt"), join(h.lib, "escape-file.txt"));
await symlink(join(h.lib, "Movie (2020)"), join(h.lib, "inner-link"));
await mkdir(join(h.dl, "Show.S01.1080p"));
await writeFile(join(h.dl, "Show.S01.1080p", "Show.S01E01.mkv"), "ep1");
const e = await seedEntity(h.db, { kind: "movie", title: "Movie", year: 2020 });
await seedAsset(h.db, h, { entityId: e, rel: "Movie (2020)/Movie.2020.1080p.mkv" });
await seedDownload(h.db, h, { name: "Show.S01.1080p", status: "downloading" });
// 根为 "/" 的配置永不出现
await h.db
.insertInto("library_root")
.values({ name: "危险", media_kind: "mixed", local_path: "/", management_mode: "managed" })
.execute();
});
afterAll(async () => h?.close());
it("GET /files/roots 只列登记根,带健康与可写", async () => {
const r = await h.api("GET", "/files/roots");
expect(r.status).toBe(200);
const items = r.body.data.items;
expect(items.map((x: { id: string }) => x.id).sort()).toEqual([h.dlRoot, h.libRoot].sort());
const lib = items.find((x: { id: string }) => x.id === h.libRoot);
expect(lib).toMatchObject({ kind: "library", health: "healthy", writable: true });
const dl = items.find((x: { id: string }) => x.id === h.dlRoot);
expect(dl).toMatchObject({ kind: "download", writable: false, mediaKind: null });
// API Key 只读 scope 可读
expect((await h.api("GET", "/files/roots", undefined, READ_KEY)).status).toBe(200);
});
it("列目录:行内业务状态、隐藏文件、符号链接逃逸标记", async () => {
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)" })}`);
expect(r.status).toBe(200);
const byName = Object.fromEntries(r.body.data.entries.map((e: { name: string }) => [e.name, e]));
expect(Object.keys(byName).sort()).toEqual(["Movie.2020.1080p.mkv", "Other.2021.mkv"]);
expect(byName["Movie.2020.1080p.mkv"]).toMatchObject({ status: "registered", fileKind: "video" });
expect(byName["Movie.2020.1080p.mkv"].asset).toMatchObject({ title: "Movie", year: 2020 });
expect(byName["Other.2021.mkv"].status).toBe("unidentified");
const all = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", hidden: "true" })}`);
const names = all.body.data.entries.map((e: { name: string }) => e.name);
expect(names).toContain(".hidden");
const partial = all.body.data.entries.find((e: { name: string }) => e.name.endsWith(".uhd-partial"));
expect(partial.status).toBe("temp");
const root = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
const esc = root.body.data.entries.find((e: { name: string }) => e.name === "escape-dir");
expect(esc).toMatchObject({ type: "symlink", escape: true });
const inner = root.body.data.entries.find((e: { name: string }) => e.name === "inner-link");
expect(inner.escape).toBe(false);
const dir = root.body.data.entries.find((e: { name: string }) => e.name === "Movie (2020)");
expect(dir.dirSummary).toMatchObject({ videoCount: 2, registeredCount: 1 });
});
it("主文件只认视频:整理登记成独立资产的字幕(is_primary = true)不标主文件(FIX-7)", async () => {
await mkdir(join(h.lib, "Sub (2014)"));
await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.mkv"), "video");
await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.chi.zh-cn.srt"), "1\n");
const e = await seedEntity(h.db, { kind: "movie", title: "Sub", year: 2014 });
const v = await seedAsset(h.db, h, { entityId: e, rel: "Sub (2014)/Sub (2014) - 1080p.mkv" });
const srt = await seedAsset(h.db, h, {
entityId: e,
rel: "Sub (2014)/Sub (2014) - 1080p.chi.zh-cn.srt",
kind: "subtitle",
});
// 与 organize.ts 一致:附属文件的资产位置也是该资产的主位置,并挂在视频位置下
await h.db
.updateTable("asset_location")
.set({ is_primary: true, sidecar_of_location_id: v.locationId })
.where("id", "=", srt.locationId)
.execute();
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Sub (2014)" })}`);
const byName = Object.fromEntries(r.body.data.entries.map((x: { name: string }) => [x.name, x]));
expect(byName["Sub (2014) - 1080p.mkv"].asset).toMatchObject({ isPrimary: true });
expect(byName["Sub (2014) - 1080p.chi.zh-cn.srt"].asset).toMatchObject({ isPrimary: false });
});
it("下载根:纳管下载中的内容标 downloading", async () => {
const r = await h.api("GET", `/files?${q({ root: h.dlRoot })}`);
const e = r.body.data.entries.find((x: { name: string }) => x.name === "Show.S01.1080p");
expect(e.status).toBe("downloading");
expect(e.download.status).toBe("downloading");
const inner = await h.api("GET", `/files?${q({ root: h.dlRoot, path: "Show.S01.1080p" })}`);
expect(inner.body.data.entries[0].status).toBe("downloading");
});
it("路径逃逸一律拒绝:..、URL 编码、根外绝对路径、符号链接(目录与文件)、未登记根", async () => {
const bad = [
`/files?${q({ root: h.libRoot, path: ".." })}`,
`/files?${q({ root: h.libRoot, path: "Movie (2020)/../.." })}`,
`/files?root=${h.libRoot}&path=..%2F..%2Fetc`,
`/files?${q({ root: h.libRoot, path: "/etc" })}`,
`/files?${q({ root: h.libRoot, path: "escape-dir" })}`,
`/files/stat?${q({ root: h.libRoot, path: "escape-dir/secret.txt" })}`,
`/files/stat?${q({ root: h.libRoot, path: "escape-file.txt" })}`,
];
for (const p of bad) {
const r = await h.api("GET", p);
expect([400, 422], p).toContain(r.status);
expect(JSON.stringify(r.body)).not.toContain("top secret");
}
const unknown = await h.api("GET", `/files?${q({ root: "lib:00000000-0000-4000-8000-000000000000" })}`);
expect(unknown.status).toBe(404);
// 根内符号链接(目标仍在根内)可以浏览
const ok = await h.api("GET", `/files?${q({ root: h.libRoot, path: "inner-link" })}`);
expect(ok.status).toBe(200);
// 写操作同样受守卫
const mk = await h.api("POST", "/files/mkdir", {
idempotencyKey: "k-escape-0001",
root: h.libRoot,
path: "escape-dir",
name: "x",
});
expect(mk.status).toBe(400);
});
it("根离线(哨兵缺失)→ 503 root_unavailable,不返回空列表", async () => {
await rename(join(h.lib, ".uhd-root"), join(h.lib, ".uhd-root.bak"));
try {
const r = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
expect(r.status).toBe(503);
expect(r.body.error.message).toMatch(/^root_unavailable:/);
const roots = await h.api("GET", "/files/roots");
expect(roots.body.data.items.find((x: { id: string }) => x.id === h.libRoot).health).toBe("offline");
} finally {
await rename(join(h.lib, ".uhd-root.bak"), join(h.lib, ".uhd-root"));
}
});
it("大目录截断 + 分页稳定 + 目录变化 stale + 过滤", async () => {
const big = join(h.lib, "big");
await mkdir(big);
for (let i = 0; i < 400; i++) await writeFile(join(big, `f${String(i).padStart(4, "0")}.txt`), "");
const r1 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100" })}`);
expect(r1.body.data).toMatchObject({ truncated: true, total: 300, stale: false });
expect(r1.body.data.entries).toHaveLength(100);
const seen = new Set(r1.body.data.entries.map((e: { name: string }) => e.name));
let cursor = r1.body.data.nextCursor;
while (cursor) {
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100", cursor })}`);
for (const e of r.body.data.entries) {
expect(seen.has(e.name)).toBe(false);
seen.add(e.name);
}
cursor = r.body.data.nextCursor;
}
expect(seen.size).toBe(300);
// 自然排序(截断取的是目录读取顺序的前 N 项,排序在其内进行)
const page1 = r1.body.data.entries.map((e: { name: string }) => e.name);
expect(page1).toEqual([...page1].sort());
// 目录 mtime 变化 → stale
await writeFile(join(big, "zz-new.txt"), "");
await utimes(big, new Date(), new Date(Date.now() + 5000));
const r2 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", cursor: r1.body.data.nextCursor })}`);
expect(r2.body.data.stale).toBe(true);
// 过滤 + glob
const g = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", q: "movie*.mkv" })}`);
expect(g.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Movie.2020.1080p.mkv"]);
const f = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", filter: "unidentified" })}`);
expect(f.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Other.2021.mkv"]);
});
it("GET /files/stat:inode/nlink/sidecars", async () => {
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.chs.srt"), "sub");
const r = await h.api("GET", `/files/stat?${q({ root: h.libRoot, path: "Movie (2020)/Movie.2020.1080p.mkv" })}`);
expect(r.status).toBe(200);
expect(r.body.data.sidecars).toEqual(["Movie (2020)/Movie.2020.1080p.chs.srt"]);
expect(r.body.data.nlink).toBe(1);
expect(r.body.data.sharedInode).toBe(false);
});
});