// 文件管理页演示数据(仅开发构建 ?mock=1):内存文件树 + 与后端同形状的读写实现, // 用来在后端联调前把交互走通。守卫/授权/幂等等语义以服务端为准,这里只做到“看起来对”。 import type { FileDeleteCheck, FileEntry, FileIdentifyItem, FileKindTag, FilePreview, FileRef, FileRoot, FileStat, FilesListResponse, FilesOrganizeGroup, FilesTransferResult, ScrapeRecord, 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>; /** 大目录:按需生成 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 } { 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, storage: null, }, { 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, storage: null, }, { 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, storage: null, }, { 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, storage: null, }, ]; 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 = { "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 = { 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): 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): 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 => !!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; // SCR-2 刮削:认得出的(KNOWN)直接写,认不出的给两个 TMDB 候选 const SCRAPES = new Map(); function mockScrape(b: { items: { root: string; path: string; mediaEntityId?: string; external?: { externalId: string } }[]; mode?: "missing" | "overwrite" | "update"; }) { const records: ScrapeRecord[] = []; const needsChoice: unknown[] = []; for (const it of b.items) { const known = KNOWN.find((k) => k.match.test(it.path) || k.id === it.mediaEntityId); if (!known && !it.external) { needsChoice.push({ root: it.root, path: it.path, title: it.path.split("/").pop() ?? it.path, warnings: [], candidates: [1972, 2002].map((year, i) => ({ mediaEntityId: null, provider: "tmdb", externalId: String(593 + i), title: it.path.split("/").pop() ?? it.path, year, kind: "movie", level: "medium", score: 5 - i, reasons: ["title:exact"], posterUrl: null, })), }); continue; } const stem = it.path.replace(/\.[^./]+$/, ""); const now = new Date().toISOString(); const files = [ { path: `${stem}.nfo`, kind: "nfo" as const, role: "movie_nfo", action: "written" as const, reason: null }, { path: `${it.path}/poster.jpg`, kind: "image" as const, role: "poster", action: "written" as const, reason: null, }, ]; const rec: ScrapeRecord = { id: `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`, root: it.root, path: it.path, status: "succeeded", trigger: "manual", mode: b.mode ?? "missing", match: { mediaEntityId: known?.id ?? null, provider: "tmdb", externalId: it.external?.externalId ?? "0", kind: known?.kind === "series" ? "tv" : "movie", title: known?.title ?? it.path, year: known?.year ?? null, source: it.external || it.mediaEntityId ? "chosen" : known ? "auto" : "chosen", }, written: files.length, skipped: 0, failed: 0, reason: null, files, organizationRecordId: null, videos: null, taskId: `run-${seq}`, requestedAt: now, startedAt: now, finishedAt: now, }; SCRAPES.set(rec.id, rec); records.push(rec); } return { records, needsChoice, skipped: [], pending: false, entities: [] }; } 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" | "in_place"; dryRun: boolean; onExisting?: "skip" | "conflict" | "replace_with_verify"; naming?: "template" | "keep"; minSizeBytes?: number; }): { dryRun: boolean; groups: OrganizeGroup[]; recordIds: string[] } { const byEntity = new Map(); 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 = body.action === "in_place" ? `${state.roots.find((x) => x.id === it.root)?.hostPath}/${it.path}` : 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 = body.action !== "in_place" && 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: body.action === "in_place" ? `${r?.hostPath}/${join(dirPath, c.name)}` : 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) { 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 { await delay(120 + Math.random() * 160); const p = (arg.params ?? {}) as Record; // 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": await delay(500); return mockScrape(b); case "/files/scrape-records": return { records: String(p.ids ?? "") .split(",") .map((id) => SCRAPES.get(id)) .filter(Boolean), }; 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): 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: `\n\n 沙丘 2\n Dune: Part Two\n 2024\n 693134\n 科幻\n 166\n`, }; 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} 文件`); }