- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
EntityWordsDryRun,
|
|
EntityWordsList,
|
|
EntityWordsSaveResult,
|
|
EntityWordsView,
|
|
EpisodeGroupList,
|
|
EpisodeGroupSetResult,
|
|
EpisodeMappingPreview,
|
|
type EpisodeMappingPreviewInput,
|
|
EpisodeStructureSeasons,
|
|
GlobalWordsDryRun,
|
|
GlobalWordsSaveResult,
|
|
IdentifyWordsBlock,
|
|
type WordsTryInput,
|
|
WordsTryResult,
|
|
} from "@umt/contracts";
|
|
import { api } from "~/lib/api.ts";
|
|
import { decisionKeys } from "./decisions.ts";
|
|
import { getParsed, libraryKeys, parseOrThrow } from "./library.ts";
|
|
|
|
// 识别词(全局 / 作品,原文整块读写;保存前试算,作品保存后服务端按作品重算)、集数映射预览(纯读)、剧集组。
|
|
|
|
export const episodeMappingKeys = {
|
|
all: ["episode-mapping"] as const,
|
|
words: (entityId: string | null) => ["episode-mapping", "words", entityId ?? "global"] as const,
|
|
wordsList: (q: string) => ["episode-mapping", "words-list", q] as const,
|
|
dryRun: (entityId: string | null, text: string) =>
|
|
["episode-mapping", "dry-run", entityId ?? "global", text] as const,
|
|
tryLine: (input: WordsTryInput) => ["episode-mapping", "try", input] as const,
|
|
preview: (entityId: string, input: EpisodeMappingPreviewInput) =>
|
|
["episode-mapping", "preview", entityId, input] as const,
|
|
groups: (entityId: string) => ["episode-mapping", "groups", entityId] as const,
|
|
structure: (entityId: string, groupId: string | null) =>
|
|
["episode-mapping", "structure", entityId, groupId ?? "default"] as const,
|
|
};
|
|
|
|
// ── 识别词 ──
|
|
|
|
export function useGlobalWords() {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.words(null),
|
|
queryFn: ({ signal }) => getParsed(IdentifyWordsBlock, "/identify-words/global", undefined, signal),
|
|
// 保存后后台重算有进度:进行中时每 3 秒刷一次
|
|
refetchInterval: (q) => {
|
|
const s = q.state.data?.reresolve?.status;
|
|
return s === "queued" || s === "running" ? 3_000 : false;
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useEntityWords(entityId: string | null | undefined) {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.words(entityId ?? ""),
|
|
enabled: !!entityId,
|
|
queryFn: ({ signal }) =>
|
|
getParsed(EntityWordsView, `/media-entities/${entityId}/identify-words`, undefined, signal),
|
|
});
|
|
}
|
|
|
|
export function useEntityWordsList(q: string) {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.wordsList(q),
|
|
placeholderData: keepPreviousData,
|
|
queryFn: ({ signal }) => getParsed(EntityWordsList, "/identify-words/entities", { q: q || undefined }, signal),
|
|
});
|
|
}
|
|
|
|
/** 试算(纯读):text 为 null 时不发 */
|
|
export function useWordsDryRun(entityId: string | null, text: string | null) {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.dryRun(entityId, text ?? ""),
|
|
enabled: text !== null,
|
|
placeholderData: keepPreviousData,
|
|
queryFn: async () =>
|
|
entityId
|
|
? parseOrThrow(
|
|
EntityWordsDryRun,
|
|
await api.post<unknown>(`/media-entities/${entityId}/identify-words/dry-run`, { text }),
|
|
"/media-entities/{id}/identify-words/dry-run",
|
|
)
|
|
: parseOrThrow(
|
|
GlobalWordsDryRun,
|
|
await api.post<unknown>("/identify-words/global/dry-run", { text }),
|
|
"/identify-words/global/dry-run",
|
|
),
|
|
});
|
|
}
|
|
|
|
/** 一行识别词的实时示例(纯读) */
|
|
export function useWordsTry(input: WordsTryInput | null) {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.tryLine(input ?? { scope: "global", line: "" }),
|
|
enabled: !!input?.line.trim(),
|
|
placeholderData: keepPreviousData,
|
|
staleTime: 60_000,
|
|
queryFn: async () =>
|
|
parseOrThrow(WordsTryResult, await api.post<unknown>("/identify-words/try", input), "/identify-words/try"),
|
|
});
|
|
}
|
|
|
|
/** 保存识别词:成功后刷新识别词、预览、事项与作品详情(季集可能变了) */
|
|
export function useSaveWords(entityId: string | null) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (body: { text: string; expectedVersion: number }) =>
|
|
entityId
|
|
? parseOrThrow(
|
|
EntityWordsSaveResult,
|
|
await api.put<unknown>(`/media-entities/${entityId}/identify-words`, body),
|
|
"/media-entities/{id}/identify-words",
|
|
)
|
|
: parseOrThrow(
|
|
GlobalWordsSaveResult,
|
|
await api.put<unknown>("/identify-words/global", body),
|
|
"/identify-words/global",
|
|
),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
|
|
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
|
if (entityId) {
|
|
void qc.invalidateQueries({ queryKey: libraryKeys.detail(entityId) });
|
|
void qc.invalidateQueries({ queryKey: libraryKeys.entity(entityId) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useEpisodeMappingPreview(
|
|
entityId: string | null | undefined,
|
|
input: EpisodeMappingPreviewInput,
|
|
enabled = true,
|
|
) {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.preview(entityId ?? "", input),
|
|
enabled: !!entityId && enabled,
|
|
placeholderData: keepPreviousData,
|
|
queryFn: async () =>
|
|
parseOrThrow(
|
|
EpisodeMappingPreview,
|
|
await api.post<unknown>(`/media-entities/${entityId}/episode-mapping/preview`, input),
|
|
"/media-entities/{id}/episode-mapping/preview",
|
|
),
|
|
});
|
|
}
|
|
|
|
// ── 剧集组(季集结构) ──
|
|
|
|
/** 作品可选的 TMDB 剧集组与当前选择 */
|
|
export function useEpisodeGroups(entityId: string | undefined, enabled = true) {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.groups(entityId ?? ""),
|
|
enabled: !!entityId && enabled,
|
|
staleTime: 5 * 60_000,
|
|
queryFn: ({ signal }) =>
|
|
getParsed(EpisodeGroupList, `/media-entities/${entityId}/episode-groups`, undefined, signal),
|
|
});
|
|
}
|
|
|
|
/** 某套季集结构(null = TMDB 默认季)的季卡片与每集,带本地已入库 */
|
|
export function useEpisodeStructure(entityId: string | undefined, groupId: string | null, enabled = true) {
|
|
return useQuery({
|
|
queryKey: episodeMappingKeys.structure(entityId ?? "", groupId),
|
|
enabled: !!entityId && enabled,
|
|
placeholderData: keepPreviousData,
|
|
queryFn: ({ signal }) =>
|
|
getParsed(
|
|
EpisodeStructureSeasons,
|
|
`/media-entities/${entityId}/episode-structure`,
|
|
groupId ? { groupId } : undefined,
|
|
signal,
|
|
),
|
|
});
|
|
}
|
|
|
|
/** 切换作品的季集结构:成功后刷新剧集组、预览、规则、待处理与作品详情(季集都变了) */
|
|
export function useSetEpisodeGroup(entityId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (groupId: string | null) =>
|
|
parseOrThrow(
|
|
EpisodeGroupSetResult,
|
|
await api.put<unknown>(`/media-entities/${entityId}/episode-group`, { groupId }),
|
|
"/media-entities/{id}/episode-group",
|
|
),
|
|
onSuccess: () => {
|
|
void qc.invalidateQueries({ queryKey: episodeMappingKeys.all });
|
|
void qc.invalidateQueries({ queryKey: decisionKeys.all });
|
|
void qc.invalidateQueries({ queryKey: libraryKeys.all });
|
|
void qc.invalidateQueries({ queryKey: ["subscriptions"] });
|
|
},
|
|
});
|
|
}
|