93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { api } from "@/api/client";
|
|
import type { ItemDetail } from "@/api/types";
|
|
import { useMetadataAIStatus } from "@/hooks/queries/items";
|
|
|
|
// Give up shimmering after this long; the original text stays and the
|
|
// server-side cooldown keeps a failing endpoint from being re-hit per view.
|
|
const TRANSLATE_TIMEOUT_MS = 45_000;
|
|
const POLL_INTERVAL_MS = 2_000;
|
|
|
|
/**
|
|
* Viewer-facing on-demand description translation for the detail page.
|
|
*
|
|
* When the detail response carries `pending_translation_language` (the
|
|
* description is not in this profile's language and no localization exists),
|
|
* the server's `metadata_ai.on_view` mode decides the UX:
|
|
* - "auto": fire the translation on view (once per item+language) and pulse
|
|
* the description until the refetched detail comes back localized.
|
|
* - "button": expose a translate trigger for the chip under the overview.
|
|
* Completion is observed as the flag clearing on refetch — the job's first
|
|
* batch translates the item's own overview, so this lands in seconds.
|
|
*/
|
|
export function useOnViewTranslation(item: ItemDetail | undefined) {
|
|
const queryClient = useQueryClient();
|
|
const { data: status } = useMetadataAIStatus();
|
|
const mode = status?.on_view ?? "off";
|
|
|
|
const contentId = item?.content_id ?? "";
|
|
const pendingLanguage = item?.pending_translation_language ?? "";
|
|
const seriesId = item?.series_id;
|
|
const seasonNumber = item?.season_number;
|
|
|
|
const [translating, setTranslating] = useState(false);
|
|
// Tracks which item+language we already fired for, so auto mode triggers
|
|
// once per view rather than re-firing on every refetch while polling.
|
|
const firedForRef = useRef("");
|
|
|
|
const trigger = useCallback(() => {
|
|
if (!contentId || !pendingLanguage) return;
|
|
const key = `${contentId}:${pendingLanguage}`;
|
|
if (firedForRef.current === key) return;
|
|
firedForRef.current = key;
|
|
setTranslating(true);
|
|
api(`/items/${encodeURIComponent(contentId)}/translate-description`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ target_language: pendingLanguage }),
|
|
}).catch(() => {
|
|
setTranslating(false);
|
|
});
|
|
}, [contentId, pendingLanguage]);
|
|
|
|
// Auto mode: translate on view.
|
|
useEffect(() => {
|
|
if (mode === "auto" && pendingLanguage) trigger();
|
|
}, [mode, pendingLanguage, trigger]);
|
|
|
|
// While translating, poll the detail; the localized overview replaces the
|
|
// text and clears the pending flag, which ends the shimmer below.
|
|
useEffect(() => {
|
|
if (!translating || !contentId) return;
|
|
const startedAt = Date.now();
|
|
const timer = setInterval(() => {
|
|
if (Date.now() - startedAt > TRANSLATE_TIMEOUT_MS) {
|
|
setTranslating(false);
|
|
return;
|
|
}
|
|
// Prefix invalidation covers the per-library detail key variants
|
|
// (["catalog", "items", id, "detail", <libraryId|"default">]).
|
|
void queryClient.invalidateQueries({ queryKey: ["catalog", "items", contentId] });
|
|
if (seriesId && typeof seasonNumber === "number") {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "series", seriesId, "seasons", seasonNumber],
|
|
});
|
|
}
|
|
}, POLL_INTERVAL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [translating, contentId, queryClient, seasonNumber, seriesId]);
|
|
|
|
// The refetched detail no longer reports a missing language: done.
|
|
useEffect(() => {
|
|
if (translating && !pendingLanguage) setTranslating(false);
|
|
}, [translating, pendingLanguage]);
|
|
|
|
return {
|
|
/** Pulse the description text. */
|
|
translating,
|
|
/** Render the explicit translate chip (button mode only). */
|
|
onTranslate: mode === "button" && pendingLanguage && !translating ? trigger : undefined,
|
|
};
|
|
}
|