Files
UHD_Media_Tool/apps/web/app/api/files.ts
T

408 lines
15 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,
FilePreview,
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,
FilesScrapeRecordsResult,
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,
FilesScrapeNeedsChoice,
FilesScrapeResult,
FilesSuggestNameResult,
FilesTransferResult,
FilesTreeResult,
IdentifyCompletedPayload,
NamingTemplateSource,
ScrapeRecord,
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}`;
}
/** 结构化预览(FM-2):文本/NFO 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;服务端只读文件头 */
export function useFilePreview(root: string | null, path: string | null, enabled: boolean) {
return useQuery({
queryKey: ["files", "preview", root, path],
enabled: enabled && !!root && !!path,
retry: false,
staleTime: 30_000,
queryFn: ({ signal }) => call(FilePreview, "/files/preview", { params: { root, path }, signal }),
});
}
/** 整理记录回查: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,
});
}
/** 刮削记录回查:排队 / 进行中时每 2 秒刷新 */
export function useScrapeRecords(ids: string[]) {
return useQuery({
queryKey: ["files", "scrape-records", ids.join(",")],
enabled: ids.length > 0,
retry: false,
queryFn: ({ signal }) =>
call(FilesScrapeRecordsResult, "/files/scrape-records", { params: { ids: ids.join(",") }, signal }),
refetchInterval: (query) =>
query.state.data?.records.some((r) => r.status === "queued" || r.status === "running") ? 2_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,
});
/** 刮削(SCR-2):任意目录 / 视频文件就地写 NFO 与图片;认不准的项在 needsChoice 里,选定后再提交 */
export const useScrape = () => useFilesMutation((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 });