Files
UHD_Media_Tool/apps/web/app/api/files.ts
T
rootandClaude Opus 5.5 abc69886ea feat(contracts,server,web): files G6–G8 fields and identify S-7 contract
- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the
  unidentified filter, recursive, cached 5 min, invalidated by files.changed)
- G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web
  subline uses it and no longer parses names itself
- G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and
  catalog.lookup, local entities from metadata.posterPath); thumbnails in the
  identify panel
- S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending,
  MediaServerInventory.partial, EventType identify.completed with
  IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard
  merge remote results by root + path, buffering events that beat the response

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

430 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
cursorPage,
type FileEntry,
type FileRef,
FileRootList,
FileStat,
type FilesDeleteRequest,
FilesDeleteResult,
type FilesEntityFromCandidateRequest,
FilesEntityFromCandidateResult,
type FilesIdentifyRequest,
FilesIdentifyResult,
FilesListResponse,
type FilesMkdirRequest,
FilesMkdirResult,
type FilesMoveRequest,
type FilesOrganizeRequest,
FilesOrganizeResult,
type FilesProbeRequest,
FilesProbeResult,
FilesRecordSourcesResult,
FilesRecordsResult,
type FilesRenameRequest,
FilesRenameResult,
type FilesScrapeBatchRequest,
FilesScrapeBatchResult,
type FilesScrapeRequest,
FilesScrapeResult,
type FilesSuggestNameRequest,
FilesSuggestNameResult,
FilesTransferResult,
FilesTrashList,
type FilesTrashPurgeRequest,
FilesTrashPurgeResult,
type FilesTrashRestoreRequest,
FilesTrashRestoreResult,
FilesTreeResult,
LibraryItem,
SearchResponse,
type TmdbCandidate,
} from "@umt/contracts";
import type { z } from "zod";
import type { FetcherArg } from "~/lib/api.ts";
import { ApiRequestError, apiParsed } from "./parse.ts";
// 文件管理(W5-A:/files*;规格 docs/plan/06-file-manager-spec.md §2,形状取 contracts/files.ts)。
// ?mock=1 只在开发构建生效:不连后端,用 components/files/mock.ts 的内存文件树演示交互。
export type {
FileDeleteCheck,
FileEntry,
FileIdentifyCandidate,
FileIdentifyItem,
FileParsed,
FileRef,
FileRoot,
FileStatus,
FilesIdentifyResult,
FilesOnExisting,
FilesOrganizeGroup,
FilesOrganizeItem,
FilesOrganizeRequest,
FilesScrapeBatchResult,
FilesSuggestNameResult,
FilesTransferResult,
FilesTreeResult,
IdentifyCompletedPayload,
NamingTemplateSource,
TmdbCandidate,
TrashItem,
} from "@umt/contracts";
// ─── mock 开关 ───────────────────────────────────────────────────
const MOCK_KEY = "umt-files-mock";
/** 纯函数:开发构建才允许;URL ?mock=1 打开并记在会话里,?mock=0 关闭 */
export function resolveFilesMock(o: {
dev: boolean;
search: string;
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem"> | null;
}) {
if (!o.dev) return false;
const p = new URLSearchParams(o.search);
try {
if (!o.storage) return p.get("mock") === "1";
if (p.get("mock") === "1") o.storage.setItem(MOCK_KEY, "1");
if (p.get("mock") === "0") o.storage.removeItem(MOCK_KEY);
return o.storage.getItem(MOCK_KEY) === "1";
} catch {
return p.get("mock") === "1";
}
}
export function filesMockEnabled(): boolean {
if (typeof window === "undefined") return false;
let storage: Storage | null = null;
try {
storage = window.sessionStorage;
} catch {
storage = null;
}
return resolveFilesMock({ dev: import.meta.env.DEV, search: window.location.search, storage });
}
let mockModule: Promise<typeof import("~/components/files/mock.ts")> | null = null;
const loadMock = () => {
mockModule ??= import("~/components/files/mock.ts");
return mockModule;
};
/** 统一出口:mock 时走内存实现,否则走后端并按合同校验;失败抛 ApiRequestError */
async function call<S extends z.ZodType>(schema: S, path: string, arg: FetcherArg = {}): Promise<z.infer<S>> {
if (filesMockEnabled()) {
const m = await loadMock();
return (await m.mockFiles(path, arg)) as z.infer<S>;
}
return apiParsed(schema, `/api/v1${path}`, arg);
}
const post = <S extends z.ZodType>(schema: S, path: string, body: unknown) =>
call(schema, path, { method: "POST", body });
// ─── 错误 ────────────────────────────────────────────────────────
/** 文件专有原因放在 message 前缀(如 "root_unavailable: …");取出来分流文案 */
export function filesErrorReason(err: unknown): string | null {
if (!(err instanceof ApiRequestError)) return null;
const m = /^([a-z_]+):/.exec(err.message);
return m ? (m[1] as string) : null;
}
/** 根离线 → 显式离线态(不当作空目录) */
export const isRootOffline = (err: unknown) =>
filesErrorReason(err) === "root_unavailable" ||
(err instanceof ApiRequestError && err.code === "upstream_unavailable");
/** 去掉原因前缀,给人看的部分 */
export function filesErrorText(err: unknown, fallback = "操作失败"): string {
if (!(err instanceof Error) || !err.message) return fallback;
return err.message.replace(/^[a-z_]+:\s*/, "");
}
// ─── 查询 ────────────────────────────────────────────────────────
export const filesKeys = {
all: ["files"] as const,
roots: ["files", "roots"] as const,
list: (q: ListQuery) => ["files", "list", q.root, q.path, q] as const,
tree: (root: string, path: string) => ["files", "tree", root, path] as const,
stat: (root: string, path: string) => ["files", "stat", root, path] as const,
trash: ["files", "trash"] as const,
records: (ids: string[], planIds: string[]) => ["files", "records", ids.join(","), planIds.join(",")] as const,
};
export function useFileRoots() {
return useQuery({
queryKey: filesKeys.roots,
queryFn: ({ signal }) => call(FileRootList, "/files/roots", { signal }).then((r) => r.items),
staleTime: 30_000,
});
}
export type ListQuery = {
root: string;
path: string;
sort?: "name" | "mtime" | "size" | "status";
order?: "asc" | "desc";
q?: string;
filter?: string;
hidden?: boolean;
};
export const PAGE_SIZE = 200;
export function useFileList(q: ListQuery | null) {
return useInfiniteQuery({
queryKey: filesKeys.list(q ?? { root: "", path: "" }),
enabled: !!q?.root,
initialPageParam: undefined as string | undefined,
placeholderData: (prev, prevQuery) =>
// 同一目录内换排序/过滤保留旧数据防闪;换目录不保留(避免把上一个目录的内容当成当前目录)
prevQuery && prevQuery.queryKey[2] === q?.root && prevQuery.queryKey[3] === q?.path
? keepPreviousData(prev)
: undefined,
retry: (n, err) => !(err instanceof ApiRequestError) && n < 2,
queryFn: ({ pageParam, signal }) =>
call(FilesListResponse, "/files", {
params: {
root: q?.root,
path: q?.path ?? "",
sort: q?.sort,
order: q?.order,
q: q?.q || undefined,
filter: q?.filter || undefined,
hidden: q?.hidden ? "true" : undefined,
cursor: pageParam,
limit: PAGE_SIZE,
},
signal,
}),
getNextPageParam: (last: FilesListResponse) => last.nextCursor ?? undefined,
});
}
/** 目录树懒加载:GET /files/tree(子目录 + 是否还有下级 + 本目录视频/已登记计数 + 上一级) */
export function useDirChildren(root: string, path: string, enabled: boolean) {
return useQuery({
queryKey: filesKeys.tree(root, path),
enabled: enabled && !!root,
staleTime: 30_000,
retry: false,
queryFn: ({ signal }) => call(FilesTreeResult, "/files/tree", { params: { root, path }, signal }),
});
}
export function useFileStat(root: string | null, path: string | null) {
return useQuery({
queryKey: filesKeys.stat(root ?? "", path ?? ""),
enabled: !!root && path != null,
retry: false,
queryFn: ({ signal }) => call(FileStat, "/files/stat", { params: { root, path }, signal }),
});
}
export function useTrash(enabled = true) {
return useInfiniteQuery({
queryKey: filesKeys.trash,
enabled,
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) =>
call(FilesTrashList, "/files/trash", { params: { cursor: pageParam, limit: 100 }, signal }),
getNextPageParam: (last: FilesTrashList) => last.nextCursor ?? undefined,
});
}
/** 非媒体小文件(NFO/字幕/图片 ≤ 20 MB)下载与预览地址 */
export function fileDownloadUrl(root: string, path: string, inline = false): string {
const qs = new URLSearchParams({ root, path });
if (inline) qs.set("inline", "true");
return `/api/v1/files/download?${qs}`;
}
/** 文本预览最多读前 256 KB(规格 §9) */
export const TEXT_PREVIEW_BYTES = 256 * 1024;
export type TextPreview = { text: string; truncated: boolean };
export function useTextPreview(root: string | null, path: string | null, enabled: boolean) {
return useQuery({
queryKey: ["files", "preview", root, path],
enabled: enabled && !!root && !!path,
retry: false,
queryFn: async ({ signal }): Promise<TextPreview> => {
if (filesMockEnabled())
return { text: await (await loadMock()).mockPreview(root ?? "", path ?? ""), truncated: false };
const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal });
if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`);
return readTextHead(res, TEXT_PREVIEW_BYTES);
},
});
}
/** 只读响应体前 max 字节(流式,读够就取消),按 UTF-8 解码 */
async function readTextHead(res: Response, max: number): Promise<TextPreview> {
const reader = res.body?.getReader();
if (!reader) {
const t = await res.text();
return { text: t.slice(0, max), truncated: t.length > max };
}
const chunks: Uint8Array[] = [];
let n = 0;
let truncated = false;
while (n < max) {
const { done, value } = await reader.read();
if (done || !value) break;
chunks.push(value);
n += value.length;
}
if (n >= max) {
truncated = n > max || !(await reader.read()).done;
await reader.cancel().catch(() => {});
}
const buf = new Uint8Array(Math.min(n, max));
let off = 0;
for (const c of chunks) {
const part = c.subarray(0, Math.min(c.length, buf.length - off));
buf.set(part, off);
off += part.length;
if (off >= buf.length) break;
}
return { text: new TextDecoder("utf-8", { fatal: false }).decode(buf), truncated };
}
/** 整理记录回查:ids 或 planIds(执行后映射到记录);执行中每 3 秒刷新 */
export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enabled = true) {
const ids = q.ids ?? [];
const planIds = q.planIds ?? [];
return useQuery({
queryKey: filesKeys.records(ids, planIds),
enabled: enabled && (ids.length > 0 || planIds.length > 0),
retry: false,
queryFn: ({ signal }) =>
call(FilesRecordsResult, "/files/records", {
params: { ids: ids.join(",") || undefined, planIds: planIds.join(",") || undefined },
signal,
}),
refetchInterval: (query) =>
query.state.data?.items.some((r) => r.status === "queued" || r.status === "running") ? 3_000 : false,
});
}
/** 整理记录 → 源 FileRef(整理记录页"重新整理") */
export function useRecordSources(recordId: string | null) {
return useQuery({
queryKey: ["files", "record-sources", recordId ?? ""],
enabled: !!recordId,
retry: false,
queryFn: ({ signal }) => call(FilesRecordSourcesResult, "/files/record-sources", { params: { recordId }, signal }),
});
}
/** 作品选择器里的本地作品 */
export type LocalEntityHit = { id: string; title: string; year: number | null; kind: string; alias: string | null };
export type MediaSearchResult = {
local: LocalEntityHit[];
tmdb: TmdbCandidate[];
tmdbConfigured: boolean;
};
/**
* 作品选择器:本地作品(/library 子串匹配 ∪ /search 别名匹配)+ TMDB 候选(/search?scope=media)。
* 两路并行,任一失败只少一组;mock 时只查内存数据。
*/
export function useMediaSearch(q: string) {
const t = q.trim();
return useQuery({
queryKey: ["files", "media-search", t],
enabled: t.length > 0,
staleTime: 60_000,
placeholderData: keepPreviousData,
retry: false,
queryFn: async ({ signal }): Promise<MediaSearchResult> => {
if (filesMockEnabled()) {
const m = await loadMock();
return m.mockMediaSearch(t);
}
const [lib, search] = await Promise.allSettled([
apiParsed(cursorPage(LibraryItem), "/api/v1/library", { params: { q: t, limit: 10 }, signal }),
apiParsed(SearchResponse, "/api/v1/search", { params: { q: t, scope: "media", limit: 8 }, signal }),
]);
if (lib.status === "rejected" && search.status === "rejected") throw lib.reason;
const local = new Map<string, LocalEntityHit>();
if (lib.status === "fulfilled")
for (const i of lib.value.items)
local.set(i.entity.id, {
id: i.entity.id,
title: i.entity.title,
year: i.entity.year,
kind: i.entity.kind,
alias: null,
});
const media = search.status === "fulfilled" ? search.value.media : null;
for (const h of media?.local.items ?? [])
if (!local.has(h.entity.id))
local.set(h.entity.id, {
id: h.entity.id,
title: h.entity.title,
year: h.entity.year,
kind: h.entity.kind,
alias: h.matchedAlias && h.matchedAlias !== h.entity.title ? h.matchedAlias : null,
});
return {
local: [...local.values()],
tmdb: media?.tmdb.items ?? [],
tmdbConfigured: media?.tmdb.configured ?? false,
};
},
});
}
// ─── 变更 ────────────────────────────────────────────────────────
function useFilesMutation<B, T>(fn: (b: B) => Promise<T>, opts: { invalidate?: (b: B) => boolean } = {}) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: (_d, b) => {
if (opts.invalidate?.(b) ?? true) void qc.invalidateQueries({ queryKey: filesKeys.all });
},
});
}
const notDry = (b: { dryRun?: boolean }) => b.dryRun === false;
export const useMkdir = () => useFilesMutation((b: FilesMkdirRequest) => post(FilesMkdirResult, "/files/mkdir", b));
export const useRename = () =>
useFilesMutation((b: FilesRenameRequest) => post(FilesRenameResult, "/files/rename", b), { invalidate: notDry });
export const useMove = () =>
useFilesMutation((b: FilesMoveRequest) => post(FilesTransferResult, "/files/move", b), { invalidate: notDry });
export const useDelete = () =>
useFilesMutation((b: FilesDeleteRequest) => post(FilesDeleteResult, "/files/delete", b), { invalidate: notDry });
export const useRestore = () =>
useFilesMutation((b: FilesTrashRestoreRequest) => post(FilesTrashRestoreResult, "/files/trash/restore", b));
export const usePurge = () =>
useFilesMutation((b: FilesTrashPurgeRequest) => post(FilesTrashPurgeResult, "/files/trash/purge", b));
export const useIdentify = () =>
useMutation({ mutationFn: (b: FilesIdentifyRequest) => post(FilesIdentifyResult, "/files/identify", b) });
export const useOrganizeFiles = () =>
useFilesMutation((b: FilesOrganizeRequest) => post(FilesOrganizeResult, "/files/organize", b), {
invalidate: notDry,
});
export const useScrape = () =>
useMutation({ mutationFn: (b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b) });
export const useProbe = () => useFilesMutation((b: FilesProbeRequest) => post(FilesProbeResult, "/files/probe", b));
export const useScrapeBatch = () =>
useMutation({ mutationFn: (b: FilesScrapeBatchRequest) => post(FilesScrapeBatchResult, "/files/scrape-batch", b) });
export const useSuggestName = () =>
useMutation({ mutationFn: (b: FilesSuggestNameRequest) => post(FilesSuggestNameResult, "/files/suggest-name", b) });
export const useEntityFromCandidate = () =>
useMutation({
mutationFn: (b: FilesEntityFromCandidateRequest) =>
post(FilesEntityFromCandidateResult, "/files/entity-from-candidate", b),
});
/** 行的唯一键:根 + 相对路径 */
export const refKey = (r: FileRef) => `${r.root}\u0000${r.path}`;
export const entryRef = (root: string, e: Pick<FileEntry, "path">): FileRef => ({ root, path: e.path });