import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { cursorPage, type EntityIdentitySet, type EntityMetadataField, LibraryDetail, LibraryItem, type LibraryListQuery, LibraryRoot, LibraryScrapeResult, MediaEntity, MediaEntityDetail, type MediaKind, type ScrapeMode, type UnitMetadataField, } from "@umt/contracts"; import type { z } from "zod"; import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts"; // 媒体库:列表 / 详情 / 作品详情(内容单元、别名、字段来源)与作品级操作。 // 所有响应都按 contracts 的 zod schema 校验,结构不符按错误处理(不静默渲染半截数据)。 /** 取 data 并用 zod 校验;不符合 contracts 时抛 ApiRequestError("internal") */ export async function getParsed( schema: S, path: string, params?: QueryParams, signal?: AbortSignal, ): Promise> { return parseOrThrow(schema, await api.get(path, params, signal), path); } export function parseOrThrow(schema: S, data: unknown, path: string): z.infer { const r = schema.safeParse(data); if (!r.success) { const first = r.error.issues[0]; console.warn(`[api] ${path} 响应结构不符`, r.error.issues); throw new ApiRequestError( "internal", `接口 ${path} 返回的数据结构不符(${first?.path.join(".") || "根"}: ${first?.message ?? "未知"})`, ); } return r.data; } export const libraryKeys = { all: ["library"] as const, list: (q: LibraryFilters) => ["library", "list", q] as const, detail: (id: string) => ["library", "detail", id] as const, entity: (id: string) => ["library", "entity", id] as const, roots: ["library", "roots"] as const, search: (q: string) => ["library", "search", q] as const, }; export type LibraryFilters = { kind?: MediaKind; q?: string; filter?: LibraryListQuery["filter"] }; const LibraryPage = cursorPage(LibraryItem); export type LibraryPage = z.infer; /** 媒体库列表:游标分页(加载更多) */ export function useLibraryList(filters: LibraryFilters) { return useInfiniteQuery({ queryKey: libraryKeys.list(filters), initialPageParam: undefined as string | undefined, queryFn: ({ pageParam, signal }) => getParsed(LibraryPage, "/library", { ...filters, cursor: pageParam, limit: 30 }, signal), getNextPageParam: (last) => last.nextCursor ?? undefined, placeholderData: keepPreviousData, }); } export function useLibraryDetail(id: string | undefined) { return useQuery({ queryKey: libraryKeys.detail(id ?? ""), enabled: !!id, queryFn: ({ signal }) => getParsed(LibraryDetail, `/library/${id}`, undefined, signal), // 刮削排队 / 进行中时每 3 秒取一次,拿到结果就停 refetchInterval: (q) => { const s = q.state.data?.scrape?.status; return s === "queued" || s === "running" ? 3_000 : false; }, }); } /** 刮削:对作品目录就地写 NFO 与图片(后台任务,SCR-2 与文件管理「刮削」同一实现);mode 缺省按设置 */ export function useScrapeEntity(id: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (v: { mode?: ScrapeMode } = {}) => parseOrThrow(LibraryScrapeResult, await api.post(`/library/${id}/scrape`, v), "/library/{id}/scrape"), onSuccess: () => void qc.invalidateQueries({ queryKey: libraryKeys.detail(id) }), }); } /** 作品详情(内容单元、别名、字段级元数据与覆盖),旧 ID 自动跳转 */ export function useEntityDetail(id: string | undefined) { return useQuery({ queryKey: libraryKeys.entity(id ?? ""), enabled: !!id, queryFn: ({ signal }) => getParsed(MediaEntityDetail, `/media-entities/${id}/detail`, undefined, signal), }); } const RootPage = cursorPage(LibraryRoot); /** 库根(把 asset_location 的 libraryRootId 翻成名称与本地路径) */ export function useLibraryRoots() { return useQuery({ queryKey: libraryKeys.roots, queryFn: ({ signal }) => getParsed(RootPage, "/library-roots", { limit: 200 }, signal), staleTime: 5 * 60_000, }); } const EntityPage = cursorPage(MediaEntity); /** 作品检索(订阅作品选择、合并目标选择) */ export function useEntitySearch(q: string, opts: { enabled?: boolean; kind?: MediaKind } = {}) { const term = q.trim(); return useQuery({ queryKey: [...libraryKeys.search(term), opts.kind ?? ""], enabled: opts.enabled ?? true, queryFn: ({ signal }) => getParsed(EntityPage, "/media-entities", { q: term || undefined, kind: opts.kind, limit: 20 }, signal), placeholderData: keepPreviousData, }); } function useInvalidateEntity(id: string) { const qc = useQueryClient(); return () => { void qc.invalidateQueries({ queryKey: libraryKeys.detail(id) }); void qc.invalidateQueries({ queryKey: libraryKeys.entity(id) }); void qc.invalidateQueries({ queryKey: ["library", "list"] }); void qc.invalidateQueries({ queryKey: ["identity"] }); }; } /** 改外部映射:旧映射退役(留修订),新映射 accepted + 人工锁 */ export function useSetEntityIdentity(id: string) { const done = useInvalidateEntity(id); return useMutation({ mutationFn: (body: EntityIdentitySet) => api.post(`/media-entities/${id}/identities`, body), onSuccess: done, }); } /** 字段覆盖并锁定 */ export function useLockField(id: string) { const done = useInvalidateEntity(id); return useMutation({ mutationFn: (v: { field: EntityMetadataField; value: unknown; reason?: string }) => api.put(`/media-entities/${id}/fields/${v.field}`, { value: v.value, lock: true, reason: v.reason }), onSuccess: done, }); } /** 取消字段覆盖与锁定 */ export function useUnlockField(id: string) { const done = useInvalidateEntity(id); return useMutation({ mutationFn: (field: EntityMetadataField) => api.del(`/media-entities/${id}/fields/${field}`), onSuccess: done, }); } /** * 刷新元数据(字段级;用户锁优先)。full = 拉全部季的每集信息。刷新是后台任务: * 提交后隔几秒再取一次详情,拿到任务写回的结果。 */ export function useRefreshMetadata(id: string) { const done = useInvalidateEntity(id); return useMutation({ mutationFn: (opts?: { full?: boolean }) => api.post(`/media-entities/${id}/refresh${opts?.full ? "?full=1" : ""}`), onSuccess: () => { done(); for (const ms of [3_000, 8_000, 20_000]) setTimeout(done, ms); }, }); } /** 季 / 集字段覆盖并锁定(MD-1) */ export function useLockUnitField(entityId: string) { const done = useInvalidateEntity(entityId); return useMutation({ mutationFn: (v: { unitId: string; field: UnitMetadataField; value: unknown; reason?: string }) => api.put(`/content-units/${v.unitId}/fields/${v.field}`, { value: v.value, lock: true, reason: v.reason, }), onSuccess: done, }); } /** 取消季 / 集字段覆盖与锁定 */ export function useUnlockUnitField(entityId: string) { const done = useInvalidateEntity(entityId); return useMutation({ mutationFn: (v: { unitId: string; field: UnitMetadataField }) => api.del(`/content-units/${v.unitId}/fields/${v.field}`), onSuccess: done, }); } /** 合并到另一作品(保留旧 ID 跳转) */ export function useMergeEntity(id: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (v: { intoId: string; reason?: string }) => api.post(`/media-entities/${id}/merge`, v), onSuccess: () => void qc.invalidateQueries({ queryKey: libraryKeys.all }), }); }