import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AcquisitionSlot, cursorPage, type EpisodeTotalSet, MediaEntity, Subscription, type SubscriptionBatch, SubscriptionBatchResult, SubscriptionCalendar, SubscriptionCoverage, type SubscriptionCreate, type SubscriptionGroup, type SubscriptionMediaKind, type SubscriptionOrigin, SubscriptionOverviewPage, SubscriptionProgress, SubscriptionResetResult, type SubscriptionSort, type SubscriptionStatus, type SubscriptionUpdate, } from "@umt/contracts"; import { z } from "zod"; import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; // 订阅:单次收录 / 持续追踪,仅本季 / 接续后续季(REQ §6.5) export const subscriptionKeys = { all: ["subscriptions"] as const, list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const, overview: (f: OverviewFilters) => ["subscriptions", "overview", f] as const, detail: (id: string) => ["subscriptions", "detail", id] as const, coverage: (id: string) => ["subscriptions", "coverage", id] as const, calendar: (from: string, to: string, kind?: SubscriptionMediaKind) => ["subscriptions", "calendar", from, to, kind ?? ""] as const, }; export type SubscriptionFilters = { status?: SubscriptionStatus; mediaEntityId?: string; mediaKind?: SubscriptionMediaKind; /** 标题 / 原名 / 别名 / 未建档检索词 */ q?: string; }; const Page = cursorPage(Subscription); export function useSubscriptions(filters: SubscriptionFilters) { return useInfiniteQuery({ queryKey: subscriptionKeys.list(filters), initialPageParam: undefined as string | undefined, queryFn: ({ pageParam, signal }) => getParsed(Page, "/subscriptions", { ...filters, cursor: pageParam, limit: 50 }, signal), getNextPageParam: (last) => last.nextCursor ?? undefined, placeholderData: keepPreviousData, }); } export function useCreateSubscription() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: SubscriptionCreate) => api.post("/subscriptions", body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")), onSuccess: () => { void qc.invalidateQueries({ queryKey: subscriptionKeys.all }); void qc.invalidateQueries({ queryKey: ["slots"] }); void qc.invalidateQueries({ queryKey: ["library"] }); }, }); } export function useUpdateSubscription() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, ...body }: SubscriptionUpdate & { id: string }) => api.patch(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")), onSuccess: () => { void qc.invalidateQueries({ queryKey: subscriptionKeys.all }); // best_version 写到收录版本的升级策略 void qc.invalidateQueries({ queryKey: ["slots"] }); }, }); } /** 订阅行上显示作品标题(旧 ID 自动跳转);按 ID 缓存 */ export function useEntityTitle(id: string | null | undefined) { return useQuery({ queryKey: ["library", "entity-row", id ?? ""], enabled: !!id, staleTime: 5 * 60_000, queryFn: ({ signal }) => getParsed(MediaEntity, `/media-entities/${id}`, undefined, signal), }); } /** 订阅进度:季总集数账本 + 每集状态(已覆盖/在途/缺失/疑似缺集)+ Slot 在途与延后(W5-C) */ export function useSubscriptionProgress(id: string | null | undefined, enabled = true) { return useQuery({ queryKey: ["subscriptions", "progress", id ?? ""], enabled: !!id && enabled, queryFn: ({ signal }) => getParsed(SubscriptionProgress, `/subscriptions/${id}/progress`, undefined, signal), }); } /** 手动主动搜索:按标题/别名在已开启站点搜索,结果走同一识别评估链 */ export function useSearchSubscription() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => api.post<{ runId: string; deduplicated: boolean }>(`/subscriptions/${id}/search`), onSuccess: () => void qc.invalidateQueries({ queryKey: ["subscriptions", "overview"] }), }); } /** 手动设置季总集数(不锁定时只升;锁定后元数据刷新不再改写) */ export function useSetEpisodeTotal() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, ...body }: EpisodeTotalSet & { id: string }) => api .post(`/subscriptions/${id}/episode-total`, body) .then((d) => parseOrThrow(SubscriptionProgress, d, "/subscriptions/episode-total")), onSuccess: (_d, v) => { void qc.invalidateQueries({ queryKey: ["subscriptions", "progress", v.id] }); void qc.invalidateQueries({ queryKey: subscriptionKeys.coverage(v.id) }); void qc.invalidateQueries({ queryKey: ["subscriptions", "overview"] }); }, }); } // ── W9-E 总览 / 覆盖矩阵 / 动作(docs/runbook/subscriptions.md) ── export type OverviewFilters = { mediaKind?: SubscriptionMediaKind; group?: SubscriptionGroup; sort?: SubscriptionSort; order?: "asc" | "desc"; siteId?: string; ruleGroupId?: string; origin?: SubscriptionOrigin; upgrading?: "true" | "false"; q?: string; }; /** 总览:条目带分组/进度/版本徽标;counts 是当前筛选(不含 group)下各分组数量 */ export function useSubscriptionOverview(filters: OverviewFilters) { return useInfiniteQuery({ queryKey: subscriptionKeys.overview(filters), initialPageParam: undefined as string | undefined, queryFn: ({ pageParam, signal }) => getParsed(SubscriptionOverviewPage, "/subscriptions", { ...filters, cursor: pageParam, limit: 100 }, signal), getNextPageParam: (last) => last.nextCursor ?? undefined, placeholderData: keepPreviousData, }); } export function useSubscription(id: string | undefined) { return useQuery({ queryKey: subscriptionKeys.detail(id ?? ""), enabled: !!id, queryFn: ({ signal }) => getParsed(Subscription, `/subscriptions/${id}`, undefined, signal), }); } /** 单条总览条目(详情页页头:进度/徽标/自动补全来源);按作品筛选后取同 id */ export function useSubscriptionOverviewItem(sub: Subscription | undefined) { return useQuery({ queryKey: ["subscriptions", "overview-item", sub?.id ?? "", sub?.updatedAt ?? ""], enabled: !!sub, queryFn: async ({ signal }) => { const page = await getParsed( SubscriptionOverviewPage, "/subscriptions", { ...(sub!.mediaEntityId ? { mediaEntityId: sub!.mediaEntityId } : { q: sub!.titleQuery ?? undefined }), ...(sub!.status === "cancelled" ? { status: "cancelled" } : {}), limit: 100, }, signal, ); return page.items.find((x) => x.id === sub!.id) ?? null; }, }); } /** 覆盖矩阵;refresh() 跳过服务端 30s 缓存 */ export function useSubscriptionCoverage(id: string | undefined) { return useQuery({ queryKey: subscriptionKeys.coverage(id ?? ""), enabled: !!id, queryFn: ({ signal }) => getParsed(SubscriptionCoverage, `/subscriptions/${id}/coverage`, undefined, signal), }); } export function useRefreshCoverage() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => getParsed(SubscriptionCoverage, `/subscriptions/${id}/coverage`, { refresh: "true" }), onSuccess: (d, id) => qc.setQueryData(subscriptionKeys.coverage(id), d), }); } function useInvalidateSubs() { const qc = useQueryClient(); return () => { void qc.invalidateQueries({ queryKey: subscriptionKeys.all }); void qc.invalidateQueries({ queryKey: ["slots"] }); }; } export type SubscriptionAction = "pause" | "resume" | "resubscribe"; /** 暂停 / 恢复 / 重新订阅 */ export function useSubscriptionAction() { const done = useInvalidateSubs(); return useMutation({ mutationFn: ({ id, action }: { id: string; action: SubscriptionAction }) => api .post(`/subscriptions/${id}/${action}`) .then((d) => parseOrThrow(Subscription, d, `/subscriptions/${action}`)), onSuccess: done, }); } /** 批量:search / pause / resume / delete(删除 = 取消,写审计);逐条返回结果 */ export function useSubscriptionBatch() { const done = useInvalidateSubs(); return useMutation({ mutationFn: (body: SubscriptionBatch) => api .post("/subscriptions/batch", body) .then((d) => parseOrThrow(SubscriptionBatchResult, d, "/subscriptions/batch")), onSuccess: done, }); } /** 手动补全某版本:不受自动补全开关与补全预算限制 */ export function useFillSlot() { const done = useInvalidateSubs(); return useMutation({ mutationFn: ({ id, slotId, unitKeys }: { id: string; slotId: string; unitKeys?: string[] }) => api.post<{ runId: string; deduplicated: boolean }>( `/subscriptions/${id}/slots/${slotId}/fill`, unitKeys?.length ? { unitKeys } : {}, ), onSuccess: done, }); } /** 收录版本排序:第一个成为主版本 */ export function useSetSlotOrder() { const done = useInvalidateSubs(); return useMutation({ mutationFn: ({ id, slotIds }: { id: string; slotIds: string[] }) => api .put(`/subscriptions/${id}/slot-order`, { slotIds }) .then((d) => parseOrThrow(z.array(AcquisitionSlot), d, "/subscriptions/slot-order")), onSuccess: done, }); } /** 重置进度:取消未提交/下载已不在的计划,清延后,重评;仍在下载的保留,不删资产 */ export function useResetSubscription() { const done = useInvalidateSubs(); return useMutation({ mutationFn: (id: string) => api .post(`/subscriptions/${id}/reset`) .then((d) => parseOrThrow(SubscriptionResetResult, d, "/subscriptions/reset")), onSuccess: done, }); } /** 日历:from / to 为 YYYY-MM-DD,跨度不超过 62 天 */ export function useSubscriptionCalendar(from: string, to: string, mediaKind?: SubscriptionMediaKind) { return useQuery({ queryKey: subscriptionKeys.calendar(from, to, mediaKind), queryFn: ({ signal }) => getParsed(SubscriptionCalendar, "/subscriptions/calendar", { from, to, mediaKind }, signal), placeholderData: keepPreviousData, }); } /** 409 重复订阅:消息里带已有订阅 id */ export function duplicateSubscriptionId(message: string): string | null { const m = /已有订阅[::]\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(message); return m?.[1] ?? null; }