- 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]>
430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
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 });
|