- 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]>
1253 lines
43 KiB
TypeScript
1253 lines
43 KiB
TypeScript
// 文件管理页演示数据(仅开发构建 ?mock=1):内存文件树 + 与后端同形状的读写实现,
|
|
// 用来在后端联调前把交互走通。守卫/授权/幂等等语义以服务端为准,这里只做到“看起来对”。
|
|
import type {
|
|
FileDeleteCheck,
|
|
FileEntry,
|
|
FileIdentifyItem,
|
|
FileKindTag,
|
|
FilePreview,
|
|
FileRef,
|
|
FileRoot,
|
|
FileStat,
|
|
FilesListResponse,
|
|
FilesOrganizeGroup,
|
|
FilesTransferResult,
|
|
TrashItem,
|
|
} from "@umt/contracts";
|
|
import type { MediaSearchResult } from "~/api/files.ts";
|
|
import { ApiRequestError } from "~/api/parse.ts";
|
|
import type { FetcherArg } from "~/lib/api.ts";
|
|
|
|
type ItemRef = FileRef;
|
|
type FileKind = FileKindTag;
|
|
type FileListResponse = FilesListResponse;
|
|
type OrganizeGroup = FilesOrganizeGroup;
|
|
|
|
type Node = {
|
|
name: string;
|
|
dir: boolean;
|
|
size: number;
|
|
mtime: string;
|
|
children?: Node[];
|
|
meta?: Partial<Pick<FileEntry, "status" | "asset" | "download" | "media" | "escape">>;
|
|
/** 大目录:按需生成 n 个子项 */
|
|
lazyCount?: number;
|
|
};
|
|
|
|
const GB = 1024 ** 3;
|
|
const MB = 1024 ** 2;
|
|
const now = Date.now();
|
|
const ago = (h: number) => new Date(now - h * 3_600_000).toISOString();
|
|
|
|
const f = (name: string, size: number, h: number, meta?: Node["meta"]): Node => ({
|
|
name,
|
|
dir: false,
|
|
size: Math.round(size),
|
|
mtime: ago(h),
|
|
meta,
|
|
});
|
|
const d = (name: string, h: number, children: Node[], meta?: Node["meta"]): Node => ({
|
|
name,
|
|
dir: true,
|
|
size: 4096,
|
|
mtime: ago(h),
|
|
children,
|
|
meta,
|
|
});
|
|
|
|
const dune = (cu: string | null = null) => ({
|
|
assetId: "a-dune",
|
|
locationId: "l-dune",
|
|
mediaEntityId: "e-dune",
|
|
title: "沙丘 2",
|
|
year: 2024,
|
|
contentUnit: cu,
|
|
slotId: "s-dune-uhd",
|
|
slotStatus: "satisfied" as const,
|
|
isPrimary: true,
|
|
});
|
|
const tlou = (ep: number) => ({
|
|
assetId: `a-tlou-${ep}`,
|
|
locationId: `l-tlou-${ep}`,
|
|
mediaEntityId: "e-tlou",
|
|
title: "最后生还者",
|
|
year: 2023,
|
|
contentUnit: `S02E${String(ep).padStart(2, "0")}`,
|
|
slotId: "s-tlou-uhd",
|
|
slotStatus: "satisfied" as const,
|
|
isPrimary: true,
|
|
});
|
|
const uhdInfo: FileEntry["media"] = {
|
|
resolution: "2160p",
|
|
hdr: "DV/HDR10",
|
|
videoCodec: "HEVC",
|
|
audioTracks: 3,
|
|
durationSec: 9960,
|
|
};
|
|
|
|
function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
|
|
const roots: FileRoot[] = [
|
|
{
|
|
id: "lib:movies",
|
|
kind: "library",
|
|
name: "电影库",
|
|
hostPath: "/mnt/media/movies",
|
|
mediaKind: "movie",
|
|
writable: true,
|
|
health: "healthy",
|
|
checkedAt: ago(0.01),
|
|
healthReason: null,
|
|
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
|
|
pendingCount: null,
|
|
pendingPartial: false,
|
|
},
|
|
{
|
|
id: "lib:tv",
|
|
kind: "library",
|
|
name: "剧集库",
|
|
hostPath: "/mnt/media/tv",
|
|
mediaKind: "series",
|
|
writable: true,
|
|
health: "healthy",
|
|
checkedAt: ago(0.01),
|
|
healthReason: null,
|
|
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
|
|
pendingCount: null,
|
|
pendingPartial: false,
|
|
},
|
|
{
|
|
id: "lib:nas-old",
|
|
kind: "library",
|
|
name: "旧 NAS(只读登记)",
|
|
hostPath: "/mnt/nas-old",
|
|
mediaKind: "mixed",
|
|
writable: false,
|
|
health: "offline",
|
|
checkedAt: ago(3),
|
|
healthReason: "挂载点无响应(ENOTCONN)",
|
|
usage: null,
|
|
pendingCount: null,
|
|
pendingPartial: false,
|
|
},
|
|
{
|
|
id: "dl:qb-main:0",
|
|
kind: "download",
|
|
name: "qBittorrent · 下载",
|
|
hostPath: "/srv/downloads",
|
|
mediaKind: null,
|
|
writable: false,
|
|
health: "healthy",
|
|
checkedAt: ago(0.01),
|
|
healthReason: null,
|
|
usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB },
|
|
pendingCount: 12,
|
|
pendingPartial: false,
|
|
},
|
|
];
|
|
const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) =>
|
|
f(
|
|
`The Last of Us - S02E${String(ep).padStart(2, "0")} - 2160p WEB-DL.mkv`,
|
|
7.4 * GB + ep * 91 * MB,
|
|
400 - ep * 24,
|
|
{
|
|
status: "registered",
|
|
asset: tlou(ep),
|
|
media: uhdInfo,
|
|
},
|
|
),
|
|
);
|
|
const trees: Record<string, Node> = {
|
|
"lib:movies": d("", 1, [
|
|
d("Dune Part Two (2024)", 30, [
|
|
f("Dune Part Two (2024) - 2160p UHD BluRay.mkv", 71.2 * GB, 30, {
|
|
status: "registered",
|
|
asset: dune(),
|
|
media: uhdInfo,
|
|
}),
|
|
f("Dune Part Two (2024) - 2160p UHD BluRay.zh.srt", 96_000, 30, { status: "registered" }),
|
|
f("movie.nfo", 4_300, 30),
|
|
f("poster.jpg", 820_000, 30),
|
|
]),
|
|
d("Oppenheimer (2023)", 240, [
|
|
f("Oppenheimer (2023) - 1080p.mkv", 18.4 * GB, 240, {
|
|
status: "registered",
|
|
asset: {
|
|
...dune(),
|
|
assetId: "a-opp",
|
|
mediaEntityId: "e-opp",
|
|
title: "奥本海默",
|
|
year: 2023,
|
|
slotStatus: "upgrade_pending" as const,
|
|
},
|
|
}),
|
|
]),
|
|
d("_未归类", 12, [f("sample.mkv", 42 * MB, 12, { status: "unidentified" })]),
|
|
f(".DS_Store", 6_148, 900),
|
|
]),
|
|
"lib:tv": d("", 2, [
|
|
d("The Last of Us (2023)", 24, [
|
|
d("Season 01", 900, []),
|
|
d("Season 02", 24, tlouEps),
|
|
f("tvshow.nfo", 5_120, 24),
|
|
]),
|
|
d("Severance (2022)", 120, [d("Season 01", 1200, []), d("Season 02", 120, [])]),
|
|
]),
|
|
"dl:qb-main:0": d("", 0.5, [
|
|
d(
|
|
"Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR",
|
|
30,
|
|
[
|
|
f("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv", 71.2 * GB, 30, {
|
|
status: "seeding",
|
|
download: { taskId: "t-dune", status: "completed", progress: 1, hnr: "unsatisfied" },
|
|
}),
|
|
],
|
|
{ status: "seeding", download: { taskId: "t-dune", status: "completed", progress: 1, hnr: "unsatisfied" } },
|
|
),
|
|
d(
|
|
"The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
|
|
20,
|
|
[1, 2, 3, 4, 5, 6, 7].map((ep) =>
|
|
f(
|
|
`The.Last.of.Us.S02E${String(ep).padStart(2, "0")}.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
|
7.4 * GB,
|
|
20,
|
|
{
|
|
status: "seeding",
|
|
download: { taskId: "t-tlou", status: "completed", progress: 1, hnr: "satisfied" },
|
|
},
|
|
),
|
|
),
|
|
{ status: "seeding", download: { taskId: "t-tlou", status: "completed", progress: 1, hnr: "satisfied" } },
|
|
),
|
|
d("散文件", 3, [
|
|
f("Severance.S02E01.Hello.Ms.Cobel.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.1 * GB, 3, {
|
|
status: "unidentified",
|
|
}),
|
|
f("Severance.S02E02.Goodbye.Mrs.Selvig.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.3 * GB, 3, {
|
|
status: "unidentified",
|
|
}),
|
|
f("Severance.S02E03.Who.Is.Alive.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.0 * GB, 3, {
|
|
status: "unidentified",
|
|
}),
|
|
f("Severance.S02E01.zh.ass", 88_000, 3),
|
|
]),
|
|
f("Oppenheimer.2023.2160p.BluRay.x265.10bit.HDR-GRP.mkv", 31.8 * GB, 50, { status: "unidentified" }),
|
|
f("Nosferatu.2024.2160p.WEB-DL.mkv", 14.2 * GB, 0.2, {
|
|
status: "downloading",
|
|
download: { taskId: "t-nos", status: "downloading", progress: 0.42, hnr: "unknown" },
|
|
}),
|
|
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),
|
|
]),
|
|
};
|
|
return { roots, trees };
|
|
}
|
|
|
|
let state = makeRoots();
|
|
let trash: TrashItem[] = [
|
|
{
|
|
id: "tr-1",
|
|
rootId: "dl:qb-main:0",
|
|
originalPath: "Old.Movie.2019.1080p.mkv",
|
|
trashPath: "/srv/downloads/.umt-trash/20260926/1/Old.Movie.2019.1080p.mkv",
|
|
isDir: false,
|
|
size: Math.round(9.2 * GB),
|
|
deletedAt: ago(30),
|
|
expiresAt: new Date(now + 5.7 * 86_400_000).toISOString(),
|
|
actor: "w31-admin",
|
|
assetSnapshot: null,
|
|
},
|
|
{
|
|
id: "tr-2",
|
|
rootId: "lib:movies",
|
|
originalPath: "Tenet (2020)",
|
|
trashPath: "/mnt/media/movies/.umt-trash/20260921/2/Tenet (2020)",
|
|
isDir: true,
|
|
size: 22 * GB,
|
|
deletedAt: ago(150),
|
|
expiresAt: new Date(now + 0.8 * 86_400_000).toISOString(),
|
|
actor: "w31-admin",
|
|
assetSnapshot: { title: "信条", contentUnit: null },
|
|
},
|
|
];
|
|
|
|
export function resetMock() {
|
|
state = makeRoots();
|
|
}
|
|
|
|
const VIDEO = new Set(["mkv", "mp4", "m2ts", "ts", "avi", "mov", "webm"]);
|
|
const SUB = new Set(["srt", "ass", "ssa", "sup", "vtt"]);
|
|
const IMG = new Set(["jpg", "jpeg", "png", "webp"]);
|
|
|
|
function kindOf(name: string, dir: boolean): { ext: string | null; fileKind: FileKind } {
|
|
if (dir) return { ext: null, fileKind: "other" };
|
|
const m = /\.([A-Za-z0-9]{1,6})$/.exec(name);
|
|
const ext = m ? (m[1] as string).toLowerCase() : null;
|
|
if (!ext) return { ext, fileKind: "other" };
|
|
if (VIDEO.has(ext)) return { ext, fileKind: "video" };
|
|
if (SUB.has(ext)) return { ext, fileKind: "subtitle" };
|
|
if (IMG.has(ext)) return { ext, fileKind: "image" };
|
|
if (ext === "nfo") return { ext, fileKind: "nfo" };
|
|
if (["flac", "mka", "ac3", "dts"].includes(ext)) return { ext, fileKind: "audio" };
|
|
return { ext, fileKind: "other" };
|
|
}
|
|
|
|
function lazyChildren(n: Node): Node[] {
|
|
if (!n.lazyCount) return n.children ?? [];
|
|
if (n.children && n.children.length >= Math.min(n.lazyCount, 20_000)) return n.children;
|
|
const kids: Node[] = [];
|
|
for (let i = 1; i <= Math.min(n.lazyCount, 20_000); i++) {
|
|
kids.push(
|
|
f(`sample-${String(i).padStart(5, "0")}.mkv`, 30 * MB + (i % 97) * MB, 5 + (i % 300), { status: "unidentified" }),
|
|
);
|
|
}
|
|
n.children = kids;
|
|
return kids;
|
|
}
|
|
|
|
function rootOf(root: string): FileRoot {
|
|
const r = state.roots.find((x) => x.id === root);
|
|
if (!r) throw new ApiRequestError("not_found", "not_found:根不存在或未登记");
|
|
if (r.health === "offline")
|
|
throw new ApiRequestError("upstream_unavailable", `root_unavailable: ${r.healthReason ?? "挂载点无响应"}`);
|
|
return r;
|
|
}
|
|
|
|
function find(root: string, path: string): { node: Node; parent: Node | null } {
|
|
rootOf(root);
|
|
let node = state.trees[root];
|
|
if (!node) throw new ApiRequestError("not_found", "not_found:根不存在");
|
|
let parent: Node | null = null;
|
|
for (const seg of path.split("/").filter(Boolean)) {
|
|
if (seg === "..") throw new ApiRequestError("forbidden", "path_escape:路径越界");
|
|
const next = lazyChildren(node).find((c) => c.name === seg);
|
|
if (!next) throw new ApiRequestError("not_found", `not_found:不存在 ${path}`);
|
|
parent = node;
|
|
node = next;
|
|
}
|
|
return { node, parent };
|
|
}
|
|
|
|
const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
|
|
|
function toEntry(n: Node, path: string): FileEntry {
|
|
const { ext, fileKind } = kindOf(n.name, n.dir);
|
|
let status = n.meta?.status ?? "plain";
|
|
if (!n.meta?.status && fileKind === "video") status = "unidentified";
|
|
if (/\.(!qB|part|uhd-partial)$/.test(n.name)) status = "temp";
|
|
let dirSummary: FileEntry["dirSummary"] = null;
|
|
if (n.dir) {
|
|
const kids = n.lazyCount ? [] : (n.children ?? []);
|
|
const vids = kids.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video");
|
|
dirSummary = n.lazyCount
|
|
? { videoCount: 500, registeredCount: 0, partial: true }
|
|
: {
|
|
videoCount: vids.length,
|
|
registeredCount: vids.filter((v) => v.meta?.status === "registered").length,
|
|
partial: false,
|
|
};
|
|
}
|
|
return {
|
|
name: n.name,
|
|
path,
|
|
type: n.meta?.escape ? "symlink" : n.dir ? "dir" : "file",
|
|
size: n.dir ? 0 : n.size,
|
|
mtime: n.mtime,
|
|
ext,
|
|
fileKind: n.dir ? null : fileKind,
|
|
status,
|
|
escape: !!n.meta?.escape,
|
|
asset: n.meta?.asset ?? null,
|
|
download: n.meta?.download ?? null,
|
|
media: n.meta?.media ?? null,
|
|
dirSummary,
|
|
parsedHint: status === "unidentified" ? mockHint(n.name) : null,
|
|
};
|
|
}
|
|
|
|
/** mock 版 G7 parsedHint(真实后端用 parseReleasePath) */
|
|
function mockHint(name: string): string | null {
|
|
const p = parseName(name);
|
|
if (!p.title) return null;
|
|
const pad = (x: number) => String(x).padStart(2, "0");
|
|
const unit =
|
|
p.season != null
|
|
? `S${pad(p.season)}${p.episodes.length ? `E${pad(p.episodes[0] as number)}${p.episodes.length > 1 ? `-E${pad(p.episodes[p.episodes.length - 1] as number)}` : ""}` : ""}`
|
|
: null;
|
|
return [!unit && p.year ? `${p.title} (${p.year})` : p.title, unit, p.resolution].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
const STATUS_RANK: Record<string, number> = {
|
|
temp: 0,
|
|
downloading: 1,
|
|
registered: 2,
|
|
seeding: 3,
|
|
unidentified: 4,
|
|
plain: 5,
|
|
};
|
|
|
|
function globToRegex(q: string): RegExp {
|
|
if (!/[*?]/.test(q)) return new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
const src = q
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*/g, ".*")
|
|
.replace(/\?/g, ".");
|
|
return new RegExp(`^${src}$`, "i");
|
|
}
|
|
|
|
function list(params: Record<string, unknown>): FileListResponse {
|
|
const root = String(params.root ?? "");
|
|
const path = String(params.path ?? "");
|
|
const { node } = find(root, path);
|
|
if (!node.dir) throw new ApiRequestError("bad_request", "不是目录");
|
|
let kids = lazyChildren(node);
|
|
const truncated = (node.lazyCount ?? 0) > 20_000;
|
|
if (params.hidden !== "true") kids = kids.filter((k) => !k.name.startsWith("."));
|
|
let entries = kids.map((k) => toEntry(k, join(path, k.name)));
|
|
const q = String(params.q ?? "").trim();
|
|
if (q) {
|
|
const re = globToRegex(q);
|
|
entries = entries.filter((e) => re.test(e.name));
|
|
}
|
|
const filters = String(params.filter ?? "")
|
|
.split(",")
|
|
.filter(Boolean);
|
|
if (filters.length) {
|
|
entries = entries.filter((e) =>
|
|
filters.some((fl) =>
|
|
fl === "video"
|
|
? e.fileKind === "video"
|
|
: fl === "subtitle"
|
|
? e.fileKind === "subtitle"
|
|
: fl === "unregistered"
|
|
? e.status !== "registered"
|
|
: e.status === fl,
|
|
),
|
|
);
|
|
}
|
|
const sort = String(params.sort ?? "name");
|
|
const dir = params.order === "desc" ? -1 : 1;
|
|
const coll = new Intl.Collator("zh-CN", { numeric: true, sensitivity: "base" });
|
|
entries.sort((a, b) => {
|
|
const da = a.type === "dir" ? 0 : 1;
|
|
const db = b.type === "dir" ? 0 : 1;
|
|
if (da !== db) return da - db;
|
|
let c = 0;
|
|
if (sort === "size") c = a.size - b.size;
|
|
else if (sort === "mtime") c = (a.mtime ?? "").localeCompare(b.mtime ?? "");
|
|
else if (sort === "status") c = (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
|
|
else c = coll.compare(a.name, b.name);
|
|
return (c || coll.compare(a.name, b.name)) * dir;
|
|
});
|
|
const offset = Number(params.cursor ?? 0);
|
|
const limit = Number(params.limit ?? 200);
|
|
return {
|
|
root,
|
|
path,
|
|
entries: entries.slice(offset, offset + limit),
|
|
nextCursor: offset + limit < entries.length ? String(offset + limit) : null,
|
|
total: entries.length,
|
|
truncated,
|
|
stale: false,
|
|
scannedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function bindingsFor(root: string, path: string, node: Node) {
|
|
return node.dir && root.startsWith("lib:") && path && !path.includes("/")
|
|
? [
|
|
{
|
|
bindingId: `b-${node.name}`,
|
|
kind: "entity" as const,
|
|
mediaEntityId: "e-x",
|
|
title: node.name.replace(/ \(\d{4}\)$/, ""),
|
|
seasonNumber: null,
|
|
},
|
|
]
|
|
: [];
|
|
}
|
|
|
|
function stat(params: Record<string, unknown>): FileStat {
|
|
const root = String(params.root ?? "");
|
|
const path = String(params.path ?? "");
|
|
const r = rootOf(root);
|
|
const { node, parent } = find(root, path);
|
|
const entry = toEntry(node, path);
|
|
const stem = node.name.replace(/\.[^.]+$/, "");
|
|
const dirPath = path.split("/").slice(0, -1).join("/");
|
|
const sidecars = node.dir
|
|
? []
|
|
: (parent?.children ?? [])
|
|
.filter((c) => c !== node && c.name.startsWith(`${stem}.`))
|
|
.map((c) => join(dirPath, c.name));
|
|
return {
|
|
entry,
|
|
realPath: `${r.hostPath}/${path}`,
|
|
inode: 1_000_000 + path.length * 97,
|
|
device: 2051,
|
|
nlink: entry.status === "seeding" ? 2 : 1,
|
|
sharedInode: entry.status === "seeding",
|
|
sidecars,
|
|
bindings: bindingsFor(root, path, node),
|
|
};
|
|
}
|
|
|
|
function detach(ref: ItemRef): Node {
|
|
const { node, parent } = find(ref.root, ref.path);
|
|
if (!parent?.children) throw new ApiRequestError("forbidden", "不能操作根本身");
|
|
parent.children = parent.children.filter((c) => c !== node);
|
|
return node;
|
|
}
|
|
|
|
const clone = (n: Node): Node => ({ ...n, mtime: new Date().toISOString(), children: n.children?.map(clone) });
|
|
|
|
function walk(n: Node): Node[] {
|
|
return [n, ...(n.children ?? []).flatMap(walk)];
|
|
}
|
|
|
|
function deleteChecks(items: ItemRef[]): FileDeleteCheck[] {
|
|
return items.map((it) => {
|
|
const { node } = find(it.root, it.path);
|
|
const all = walk(node);
|
|
const dls = all
|
|
.filter((n) => n.meta?.download && (n.meta.status === "downloading" || n.meta.download.hnr !== "satisfied"))
|
|
.map((n) => n.meta?.download)
|
|
.filter((x): x is NonNullable<typeof x> => !!x)
|
|
.map((x) => ({ taskId: x.taskId, status: x.status, hnr: x.hnr }));
|
|
const downloads = [...new Map(dls.map((x) => [x.taskId, x])).values()];
|
|
const assets = all
|
|
.filter((n) => n.meta?.asset)
|
|
.map((n) => ({
|
|
assetId: n.meta?.asset?.assetId ?? "",
|
|
locationId: n.meta?.asset?.locationId ?? "",
|
|
mediaEntityId: n.meta?.asset?.mediaEntityId ?? "",
|
|
relativePath: n.name,
|
|
}));
|
|
const bindings = bindingsFor(it.root, it.path, node);
|
|
return {
|
|
root: it.root,
|
|
path: it.path,
|
|
isDir: node.dir,
|
|
size: all.reduce((s, n) => s + (n.dir ? 0 : n.size), 0),
|
|
blocked: !it.path
|
|
? "is_root"
|
|
: downloads.length
|
|
? "download_active"
|
|
: assets.length
|
|
? "needs_retire"
|
|
: bindings.length
|
|
? "needs_unbind"
|
|
: null,
|
|
downloads,
|
|
assets,
|
|
bindings,
|
|
sharedInode: node.meta?.status === "seeding",
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseName(name: string) {
|
|
const base = name.replace(/\.[^.]+$/, "");
|
|
const se = /S(\d{1,2})E(\d{1,3})(?:-?E(\d{1,3}))?/i.exec(base);
|
|
const yr = /[.( ](19\d{2}|20\d{2})[.) ]/.exec(`${base} `);
|
|
const res = /(2160p|1080p|720p)/i.exec(base);
|
|
const cut = Math.min(...[se?.index, yr?.index, res?.index].filter((x): x is number => x != null), base.length);
|
|
const title = base
|
|
.slice(0, cut)
|
|
.replace(/[._]+/g, " ")
|
|
.replace(/[-( ]+$/, "")
|
|
.trim();
|
|
const grp = /-([A-Za-z0-9]+)$/.exec(base);
|
|
const eps: number[] = [];
|
|
if (se) {
|
|
const a = Number(se[2]);
|
|
const b = se[3] ? Number(se[3]) : a;
|
|
for (let i = a; i <= b; i++) eps.push(i);
|
|
}
|
|
return {
|
|
title: title || null,
|
|
year: yr ? Number(yr[1]) : null,
|
|
season: se ? Number(se[1]) : null,
|
|
episodes: eps,
|
|
resolution: res ? (res[1] as string).toLowerCase() : null,
|
|
source: /bluray/i.test(base) ? "BluRay" : /web-?dl/i.test(base) ? "WEB-DL" : null,
|
|
releaseGroup: grp ? (grp[1] as string) : null,
|
|
mediaKind: se ? ("series" as const) : ("movie" as const),
|
|
};
|
|
}
|
|
|
|
const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [
|
|
{ match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" },
|
|
{ match: /dune/i, id: "e-dune", title: "沙丘 2", year: 2024, kind: "movie" },
|
|
{ match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" },
|
|
];
|
|
|
|
/** 作品选择器搜索:本地作品 + 一条 TMDB 候选(演示"将新建作品") */
|
|
export function mockMediaSearch(q: string): MediaSearchResult {
|
|
const t = q.trim().toLowerCase();
|
|
const local = KNOWN.filter((k) => k.title.includes(t) || k.match.test(t) || k.id.includes(t)).map((k) => ({
|
|
id: k.id,
|
|
title: k.title,
|
|
year: k.year,
|
|
kind: k.kind,
|
|
alias: null,
|
|
}));
|
|
const tmdb = t
|
|
? [
|
|
{
|
|
provider: "tmdb" as const,
|
|
entityType: "tv" as const,
|
|
externalId: "95396",
|
|
title: "人生切割术",
|
|
originalTitle: "Severance",
|
|
year: 2022,
|
|
overview: null,
|
|
popularity: null,
|
|
localEntityId: null,
|
|
},
|
|
]
|
|
: [];
|
|
return { local, tmdb, tmdbConfigured: true };
|
|
}
|
|
|
|
function identify(
|
|
items: ItemRef[],
|
|
minSize = 0,
|
|
): { items: FileIdentifyItem[]; truncated: boolean; excludedBySize: number } {
|
|
const out: FileIdentifyItem[] = [];
|
|
let excludedBySize = 0;
|
|
for (const it of items) {
|
|
const { node } = find(it.root, it.path);
|
|
const files = node.dir
|
|
? lazyChildren(node)
|
|
.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video")
|
|
.slice(0, 200)
|
|
.map((c) => ({ path: join(it.path, c.name), name: c.name, size: c.size, meta: c.meta }))
|
|
: [{ path: it.path, name: node.name, size: node.size, meta: node.meta }];
|
|
for (const fl of files) {
|
|
const parsed = parseName(fl.name);
|
|
const small = fl.size < minSize;
|
|
if (small) excludedBySize++;
|
|
const hit = small ? undefined : KNOWN.find((k) => k.match.test(parsed.title ?? ""));
|
|
const sev = !small && /severance/i.test(parsed.title ?? "");
|
|
const candidates = sev
|
|
? [
|
|
{
|
|
mediaEntityId: null,
|
|
provider: "tmdb",
|
|
externalId: "95396",
|
|
title: "人生切割术",
|
|
year: 2022,
|
|
kind: "series" as const,
|
|
level: "high" as const,
|
|
score: 0.9,
|
|
reasons: ["TMDB 标题命中", "季集结构一致"],
|
|
posterUrl: null,
|
|
},
|
|
]
|
|
: hit
|
|
? [
|
|
{
|
|
mediaEntityId: hit.id,
|
|
provider: "local",
|
|
externalId: null,
|
|
title: hit.title,
|
|
year: hit.year,
|
|
kind: hit.kind,
|
|
level: "high" as const,
|
|
score: 0.94,
|
|
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
|
|
posterUrl: null,
|
|
},
|
|
{
|
|
mediaEntityId: null,
|
|
provider: "tmdb",
|
|
externalId: "99999",
|
|
title: `${parsed.title}(同名旧作)`,
|
|
year: (parsed.year ?? hit.year) - 30,
|
|
kind: hit.kind,
|
|
level: "low" as const,
|
|
score: 0.31,
|
|
reasons: ["仅标题相似"],
|
|
posterUrl: null,
|
|
},
|
|
]
|
|
: [];
|
|
out.push({
|
|
root: it.root,
|
|
path: fl.path,
|
|
parsed,
|
|
candidates,
|
|
suggestion: { mediaEntityId: hit?.id ?? null, contentUnitId: null, contentUnit: null },
|
|
existing: { assetId: fl.meta?.asset?.assetId ?? null },
|
|
warnings: hit || sev || small ? [] : ["本地与 TMDB 都没有找到可信候选"],
|
|
size: fl.size,
|
|
skipped: small ? "below_min_size" : null,
|
|
records: /Oppenheimer/.test(fl.name)
|
|
? { count: 1, lastRecordId: "rec-old-1", lastStatus: "failed" as const }
|
|
: { count: 0, lastRecordId: null, lastStatus: null },
|
|
});
|
|
}
|
|
}
|
|
return { items: out, truncated: false, excludedBySize };
|
|
}
|
|
|
|
let seq = 0;
|
|
|
|
function doMove(body: {
|
|
items: ItemRef[];
|
|
target: ItemRef;
|
|
mode: "move" | "copy";
|
|
dryRun: boolean;
|
|
planId?: string;
|
|
}): FilesTransferResult {
|
|
const tRoot = rootOf(body.target.root);
|
|
const target = find(body.target.root, body.target.path).node;
|
|
if (!target.dir || !tRoot.writable)
|
|
throw new ApiRequestError("bad_request", "not_writable:目标必须是可写媒体库目录下的目录");
|
|
const pairs = body.items.map((it) => {
|
|
const { node } = find(it.root, it.path);
|
|
const name = node.name;
|
|
const exists = (target.children ?? []).some((c) => c.name === name);
|
|
const inside =
|
|
it.root === body.target.root &&
|
|
(`${body.target.path}/`.startsWith(`${it.path}/`) || it.path === body.target.path);
|
|
return {
|
|
source: it,
|
|
target: { root: body.target.root, path: join(body.target.path, name) },
|
|
size: walk(node).reduce((s, n) => s + (n.dir ? 0 : n.size), 0),
|
|
conflict: exists || inside,
|
|
registered: walk(node).some((n) => !!n.meta?.asset),
|
|
};
|
|
});
|
|
const hnrBlocks =
|
|
body.mode === "move"
|
|
? body.items.flatMap((it) =>
|
|
walk(find(it.root, it.path).node)
|
|
.filter((n) => n.meta?.download && n.meta.download.hnr !== "satisfied")
|
|
.map((n) => ({
|
|
taskId: n.meta?.download?.taskId ?? "",
|
|
path: it.path,
|
|
hnr: n.meta?.download?.hnr ?? "unknown",
|
|
status: n.meta?.download?.status ?? "completed",
|
|
})),
|
|
)
|
|
: [];
|
|
const totalBytes = pairs.reduce((s, p) => s + p.size, 0);
|
|
const res: FilesTransferResult = {
|
|
dryRun: body.dryRun,
|
|
planId: body.planId ?? `plan-${++seq}`,
|
|
planExpiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
|
|
mode: body.mode,
|
|
pairs,
|
|
totalBytes,
|
|
freeBytes: tRoot.usage?.freeBytes ?? null,
|
|
spaceOk: true,
|
|
hnrBlocks,
|
|
truncated: false,
|
|
executed: null,
|
|
};
|
|
if (body.dryRun) return res;
|
|
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active:含 H&R 未满足的做种文件,不能移动");
|
|
const succeeded: string[] = [];
|
|
const failed: { path: string; code: string; message: string }[] = [];
|
|
for (const p of pairs) {
|
|
if (p.conflict) {
|
|
failed.push({ path: p.source.path, code: "exists", message: "目标已存在同名项,不覆盖" });
|
|
continue;
|
|
}
|
|
const n = body.mode === "move" ? detach(p.source) : clone(find(p.source.root, p.source.path).node);
|
|
target.children = [...(target.children ?? []), n];
|
|
succeeded.push(p.source.path);
|
|
}
|
|
return { ...res, executed: { succeeded, failed, recordId: `rec-${++seq}` } };
|
|
}
|
|
|
|
function organize(body: {
|
|
items: {
|
|
root: string;
|
|
path: string;
|
|
mediaEntityId?: string;
|
|
external?: { externalId: string; kind: string };
|
|
season?: number;
|
|
episodes?: number[];
|
|
}[];
|
|
action: "copy" | "move";
|
|
dryRun: boolean;
|
|
onExisting?: "skip" | "conflict" | "replace_with_verify";
|
|
naming?: "template" | "keep";
|
|
minSizeBytes?: number;
|
|
}): { dryRun: boolean; groups: OrganizeGroup[]; recordIds: string[] } {
|
|
const byEntity = new Map<string, typeof body.items>();
|
|
for (const it of body.items) {
|
|
const id = it.mediaEntityId ?? (it.external ? "e-sev" : "?");
|
|
byEntity.set(id, [...(byEntity.get(id) ?? []), it]);
|
|
}
|
|
const groups: OrganizeGroup[] = [];
|
|
const onExisting = body.onExisting ?? "conflict";
|
|
for (const [id, items] of byEntity) {
|
|
const k =
|
|
KNOWN.find((x) => x.id === id) ??
|
|
(id === "e-sev" ? { title: "人生切割术", year: 2022, kind: "series" } : undefined);
|
|
const tv = k?.kind === "series";
|
|
const libRoot = tv ? "/mnt/media/tv" : "/mnt/media/movies";
|
|
const dirName = id === "e-tlou" ? "The Last of Us (2023)" : `${k?.title ?? id} (${k?.year ?? ""})`;
|
|
let conflict = false;
|
|
const files = items.flatMap((it) => {
|
|
const ext = it.path.split(".").pop();
|
|
const s = it.season != null ? `S${String(it.season).padStart(2, "0")}` : "";
|
|
const e = it.episodes?.length ? `E${String(it.episodes[0]).padStart(2, "0")}` : "";
|
|
const orig = it.path.split("/").pop() ?? "";
|
|
const name = body.naming === "keep" ? orig : tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`;
|
|
const dst = tv
|
|
? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}`
|
|
: `${libRoot}/${dirName}/${name}`;
|
|
const r = state.roots.find((x) => x.id === it.root);
|
|
const { node, parent } = find(it.root, it.path);
|
|
const exists = id === "e-dune";
|
|
if (exists && onExisting === "conflict") conflict = true;
|
|
const main = {
|
|
source: `${r?.hostPath}/${it.path}`,
|
|
target: dst,
|
|
kind: "main",
|
|
size: node.size,
|
|
action: exists
|
|
? onExisting === "skip"
|
|
? "skip"
|
|
: onExisting === "replace_with_verify"
|
|
? "replace"
|
|
: "hold"
|
|
: "transfer",
|
|
existing: exists
|
|
? onExisting === "skip"
|
|
? "skip"
|
|
: onExisting === "replace_with_verify"
|
|
? "replace"
|
|
: "conflict"
|
|
: null,
|
|
skipReason: exists && onExisting === "skip" ? "target_exists" : null,
|
|
} as OrganizeGroup["files"][number];
|
|
const stem = orig.replace(/\.[^.]+$/, "");
|
|
const dirPath = it.path.split("/").slice(0, -1).join("/");
|
|
const subs = (parent?.children ?? [])
|
|
.filter((c) => c !== node && !c.dir && c.name.startsWith(stem.slice(0, 16)) && /\.(srt|ass)$/.test(c.name))
|
|
.map((c) => ({
|
|
source: `${r?.hostPath}/${join(dirPath, c.name)}`,
|
|
target: dst.replace(/\.[^.]+$/, `.zh.${c.name.split(".").pop()}`),
|
|
kind: "subtitle",
|
|
size: c.size,
|
|
action: "transfer" as const,
|
|
existing: null,
|
|
skipReason: null,
|
|
}));
|
|
return [main, ...subs];
|
|
});
|
|
const segs = tv
|
|
? [
|
|
{ text: dirName, role: "root" as const, bound: id === "e-tlou" },
|
|
{ text: `Season ${String(items[0]?.season ?? 1).padStart(2, "0")}`, role: "season" as const, bound: false },
|
|
]
|
|
: [{ text: dirName, role: "root" as const, bound: id === "e-dune" }];
|
|
groups.push({
|
|
mediaEntityId: id,
|
|
contentUnitId: null,
|
|
planId: conflict ? null : `oplan-${id}`,
|
|
files,
|
|
excluded: [],
|
|
truncated: false,
|
|
recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
|
|
taskId: body.dryRun || conflict ? null : `task-${seq}`,
|
|
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
|
|
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按「交给待处理」整组不执行" } : null,
|
|
action: body.action,
|
|
entity: items.some((x) => x.external)
|
|
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
|
|
: null,
|
|
onExisting,
|
|
minSizeBytes: body.minSizeBytes,
|
|
naming: {
|
|
mode: body.naming ?? "template",
|
|
templateSource: body.naming === "keep" ? "override" : "global",
|
|
templateVersion: 2,
|
|
},
|
|
targetSegments: segs,
|
|
refreshTaskId: null,
|
|
});
|
|
}
|
|
return {
|
|
dryRun: body.dryRun,
|
|
groups,
|
|
recordIds: groups.map((g) => g.recordId).filter((x): x is string => !!x),
|
|
};
|
|
}
|
|
|
|
function tree(params: Record<string, unknown>) {
|
|
const root = String(params.root ?? "");
|
|
const path = String(params.path ?? "");
|
|
const { node } = find(root, path);
|
|
const dirs = lazyChildren(node)
|
|
.filter((c) => c.dir && !c.name.startsWith("."))
|
|
.map((c) => ({
|
|
name: c.name,
|
|
path: join(path, c.name),
|
|
hasChildren: !c.lazyCount && (c.children ?? []).some((x) => x.dir),
|
|
}));
|
|
const e = toEntry(node, path);
|
|
return {
|
|
root,
|
|
path,
|
|
name: node.name || rootOf(root).name,
|
|
parent: path ? path.split("/").slice(0, -1).join("/") : null,
|
|
dirs,
|
|
truncated: false,
|
|
summary: e.dirSummary ?? { videoCount: 0, registeredCount: 0, partial: false },
|
|
};
|
|
}
|
|
|
|
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
/** 与后端同路径同形状的内存实现 */
|
|
export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown> {
|
|
await delay(120 + Math.random() * 160);
|
|
const p = (arg.params ?? {}) as Record<string, unknown>;
|
|
// biome-ignore lint/suspicious/noExplicitAny: mock 请求体按端点解释
|
|
const b = (arg.body ?? {}) as any;
|
|
switch (path) {
|
|
case "/files/roots":
|
|
return { items: state.roots };
|
|
case "/files":
|
|
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))
|
|
throw new ApiRequestError("conflict", "exists:同名目录已存在");
|
|
node.children = [...(node.children ?? []), d(b.name, 0, [])];
|
|
return { root: b.root, path: join(b.path, b.name) };
|
|
}
|
|
case "/files/rename": {
|
|
const { node, parent } = find(b.root, b.path);
|
|
if (node.meta?.download)
|
|
throw new ApiRequestError("conflict", "download_active:做种中,改名会让下载器找不到文件");
|
|
if ((parent?.children ?? []).some((c) => c.name === b.newName && c !== node))
|
|
throw new ApiRequestError("conflict", "exists:目标名已存在,不覆盖");
|
|
const stem = node.name.replace(/\.[^.]+$/, "");
|
|
const newStem = String(b.newName).replace(/\.[^.]+$/, "");
|
|
const dirPath = String(b.path).split("/").slice(0, -1).join("/");
|
|
const side =
|
|
node.dir || !b.withSidecars
|
|
? []
|
|
: (parent?.children ?? []).filter((c) => c !== node && c.name.startsWith(`${stem}.`));
|
|
const renames = [
|
|
{ from: b.path, to: join(dirPath, b.newName), role: "main" as const },
|
|
...side.map((c) => ({
|
|
from: join(dirPath, c.name),
|
|
to: join(dirPath, newStem + c.name.slice(stem.length)),
|
|
role: "sidecar" as const,
|
|
})),
|
|
];
|
|
const assetLocations =
|
|
walk(node).filter((n) => n.meta?.asset).length + side.filter((c) => c.meta?.status === "registered").length;
|
|
const bindings = bindingsFor(b.root, b.path, node).map((x) => ({
|
|
oldBindingId: x.bindingId,
|
|
newBindingId: null,
|
|
}));
|
|
if (!b.dryRun) {
|
|
node.name = b.newName;
|
|
for (const c of side) c.name = newStem + c.name.slice(stem.length);
|
|
}
|
|
return { dryRun: !!b.dryRun, renames, assetLocations, bindings, recordId: b.dryRun ? null : `rec-${++seq}` };
|
|
}
|
|
case "/files/move":
|
|
return doMove(b);
|
|
case "/files/delete": {
|
|
const checks = deleteChecks(b.items).map((c) => ({
|
|
...c,
|
|
blocked:
|
|
c.blocked === "needs_retire" && b.retireAssets
|
|
? c.bindings.length && !b.unbind
|
|
? ("needs_unbind" as const)
|
|
: null
|
|
: c.blocked === "needs_unbind" && b.unbind
|
|
? null
|
|
: c.blocked,
|
|
}));
|
|
const base = {
|
|
dryRun: !!b.dryRun,
|
|
planId: b.dryRun ? `dplan-${++seq}` : (b.planId ?? null),
|
|
planExpiresAt: null,
|
|
mode: b.mode ?? "trash",
|
|
checks,
|
|
trashed: [],
|
|
deleted: [],
|
|
failed: [],
|
|
recordId: null,
|
|
};
|
|
if (b.dryRun) return base;
|
|
if (checks.some((c) => c.blocked)) throw new ApiRequestError("conflict", "download_active:有被引用项,不能删除");
|
|
const trashed: TrashItem[] = [];
|
|
for (const it of b.items as ItemRef[]) {
|
|
const n = detach(it);
|
|
const r = rootOf(it.root);
|
|
if (b.mode !== "permanent") {
|
|
const t: TrashItem = {
|
|
id: `tr-${++seq}`,
|
|
rootId: it.root,
|
|
originalPath: it.path,
|
|
trashPath: `${r.hostPath}/.umt-trash/20260927/${seq}/${it.path}`,
|
|
isDir: n.dir,
|
|
size: walk(n).reduce((s, x) => s + (x.dir ? 0 : x.size), 0),
|
|
deletedAt: new Date().toISOString(),
|
|
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
|
|
actor: "w31-admin",
|
|
assetSnapshot: n.meta?.asset ? { title: n.meta.asset.title, contentUnit: n.meta.asset.contentUnit } : null,
|
|
};
|
|
trashed.push(t);
|
|
trash.unshift(t);
|
|
}
|
|
}
|
|
return { ...base, trashed, deleted: (b.items as ItemRef[]).map((x) => x.path), recordId: `rec-${++seq}` };
|
|
}
|
|
case "/files/trash":
|
|
return { items: trash, nextCursor: null };
|
|
case "/files/trash/restore": {
|
|
const restored: { id: string; root: string; path: string }[] = [];
|
|
const failed: { id: string; code: string; message: string }[] = [];
|
|
for (const t of trash.filter((x) => b.ids.includes(x.id))) {
|
|
const name = t.originalPath.split("/").pop() ?? t.originalPath;
|
|
const parentPath = b.target ? b.target.path : t.originalPath.split("/").slice(0, -1).join("/");
|
|
const rootId = b.target ? b.target.root : t.rootId;
|
|
try {
|
|
const { node } = find(rootId, parentPath);
|
|
if ((node.children ?? []).some((c) => c.name === name)) {
|
|
failed.push({ id: t.id, code: "exists", message: `原位置已被占用:${t.originalPath}` });
|
|
continue;
|
|
}
|
|
node.children = [...(node.children ?? []), t.isDir ? d(name, 1, []) : f(name, t.size, 1)];
|
|
restored.push({ id: t.id, root: rootId, path: join(parentPath, name) });
|
|
} catch (e) {
|
|
failed.push({ id: t.id, code: "not_found", message: e instanceof Error ? e.message : "还原失败" });
|
|
}
|
|
}
|
|
const ok = new Set(restored.map((r) => r.id));
|
|
trash = trash.filter((x) => !ok.has(x.id));
|
|
return { restored, failed };
|
|
}
|
|
case "/files/trash/purge": {
|
|
const ids: string[] = b.ids ?? trash.filter((x) => Date.parse(x.expiresAt) <= Date.now()).map((x) => x.id);
|
|
trash = trash.filter((x) => !ids.includes(x.id));
|
|
return { purged: ids, failed: [] };
|
|
}
|
|
case "/files/identify":
|
|
await delay(500);
|
|
return identify(b.items, b.minSizeBytes ?? 0);
|
|
case "/files/tree":
|
|
return tree(p);
|
|
case "/files/suggest-name": {
|
|
const { node } = find(b.root, b.path);
|
|
const parsed = parseName(node.name);
|
|
const hit = node.meta?.asset ?? KNOWN.find((k) => k.match.test(parsed.title ?? ""));
|
|
if (!hit)
|
|
return {
|
|
name: null,
|
|
relativePath: null,
|
|
reason: "identity_uncertain",
|
|
mediaEntityId: null,
|
|
templateSource: null,
|
|
segments: [],
|
|
};
|
|
const title = "title" in hit ? hit.title : "";
|
|
const year = hit.year;
|
|
const ext = node.dir ? "" : `.${node.name.split(".").pop()}`;
|
|
const se =
|
|
parsed.season != null
|
|
? ` - S${String(parsed.season).padStart(2, "0")}E${String(parsed.episodes[0] ?? 1).padStart(2, "0")}`
|
|
: "";
|
|
const name = node.dir ? `${title} (${year})` : `${title} (${year})${se}${ext}`;
|
|
return {
|
|
name,
|
|
relativePath: `${title} (${year})/${name}`,
|
|
reason: null,
|
|
mediaEntityId: "mediaEntityId" in hit ? hit.mediaEntityId : hit.id,
|
|
templateSource: "global",
|
|
segments: node.dir
|
|
? [{ text: name, role: "root" }]
|
|
: [
|
|
{ text: `${title} (${year})`, role: "root" },
|
|
{ text: name, role: "file" },
|
|
],
|
|
};
|
|
}
|
|
case "/files/records":
|
|
return {
|
|
items: String(p.ids ?? "")
|
|
.split(",")
|
|
.filter(Boolean)
|
|
.map((id) => mockRecord(id)),
|
|
planRecords: {},
|
|
};
|
|
case "/files/record-sources":
|
|
return {
|
|
recordId: p.recordId,
|
|
mediaEntityId: "e-opp",
|
|
action: "copy",
|
|
status: "failed",
|
|
items: [{ root: "dl:qb-main:0", path: "Oppenheimer.2023.2160p.BluRay.x265.10bit.HDR-GRP.mkv" }],
|
|
unresolved: [],
|
|
};
|
|
case "/files/scrape-batch": {
|
|
const ids: string[] = b.mediaEntityIds ?? [];
|
|
return {
|
|
submitted: ids.map((id) => ({
|
|
mediaEntityId: id,
|
|
title: KNOWN.find((k) => k.id === id)?.title ?? id,
|
|
taskId: `run-${++seq}`,
|
|
lockedFields: ["title"],
|
|
})),
|
|
skipped: [],
|
|
};
|
|
}
|
|
case "/files/organize":
|
|
await delay(400);
|
|
return organize(b);
|
|
case "/files/scrape":
|
|
return {
|
|
mediaEntityId: b.mediaEntityId,
|
|
taskId: `run-${++seq}`,
|
|
lockedFields: ["title"],
|
|
nfo: b.writeNfo ? "not_implemented" : "not_requested",
|
|
images: b.images ? "not_implemented" : "not_requested",
|
|
};
|
|
case "/files/probe":
|
|
await delay(600);
|
|
return {
|
|
items: (b.items as ItemRef[]).map((it) => ({
|
|
...it,
|
|
status: "ok",
|
|
reason: null,
|
|
summary: uhdInfo,
|
|
container: "matroska",
|
|
subtitleTracks: 4,
|
|
savedToAsset: false,
|
|
cached: true,
|
|
})),
|
|
};
|
|
default:
|
|
throw new ApiRequestError("not_found", `mock 未实现:${path}`);
|
|
}
|
|
}
|
|
|
|
function mockRecord(id: string) {
|
|
const t = new Date().toISOString();
|
|
return {
|
|
id,
|
|
taskId: null,
|
|
downloadTaskId: null,
|
|
operationIds: [],
|
|
media: null,
|
|
trigger: "manual" as const,
|
|
origin: "manual" as const,
|
|
actor: { type: "admin" as const, id: "w31-admin" },
|
|
action: "copy" as const,
|
|
status: "succeeded" as const,
|
|
source: { view: "local" as const, path: null },
|
|
target: { view: "library" as const, path: null },
|
|
reason: null,
|
|
counts: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
|
files: null,
|
|
createdAt: t,
|
|
updatedAt: t,
|
|
startedAt: t,
|
|
finishedAt: t,
|
|
changeSeq: "1",
|
|
};
|
|
}
|
|
|
|
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} 文件`);
|
|
}
|