* feat(matching): split wrongly merged versions, reattribute watch state, anchor group keys on provider tags
Wrong merges (two titles normalizing to the same title+year key) stacked
different films as fake "versions" of one item with no in-app repair, and
explicit {tmdb-…}/[imdb-…] folder tags could not prevent it because the
content-group key ignored provider IDs entirely. Merges also silently
orphaned all per-user watch state.
- Anchor group keys on structured provider tags: same tag always groups,
different tags can never merge; untagged files keep title+year keys.
- media_identity_overrides: path-scoped (root/file) forced identities applied
during group inference, so admin splits survive rescans.
- internal/catalog/reattribute: shared user-state mover — exact moves for
file-linked rows, evidence-based user_watch_history classification via the
playback session log, newest-wins progress conflicts; wired into
rebindItemToExistingItem to stop merge orphaning (with S/E episode mapping).
- POST /admin/items/{id}/split (dry-run = full transaction + rollback, so
previews are exact), POST /admin/items/{id}/merge, GET /admin/items/{id}/files.
- Web admin: Split Versions dialog (files by folder → candidate search →
preview → split), Resolve link from ambiguous-roots diagnostics.
Part of #318
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reattribute): classify history before moving session log; cover managed downloads and series-scoped preferences
Review findings on #319, all reproduced against a migrated scratch database:
- moveFileSubset re-pointed playback_history_admin before the history
evidence query ran, erasing exactly the evidence proving a profile's plays
were all on moved files — their history stayed behind as ambiguous.
History classification now runs first; the pre-fix code demonstrably fails
TestRun_HistoryEvidenceClassification.
- Managed offline downloads (downloads.content_id/episode_id) were not
remapped on split or merge, stranding rows on the old id. Now moved per
file on splits and swept per id pair on merges/episode re-anchoring.
- Series merges left user_audio_preferences, user_subtitle_preferences,
user_series_playback_preferences (series_id-keyed) and the denormalized
user_home_item_dismissals.series_id behind. All four now move, mirroring
the provider-merge remap.
All five reattribute DB tests now verified green against PostgreSQL, with
new coverage for managed downloads, subtitle preferences, and dismissal
series ids.
Part of #318
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
560 lines
19 KiB
TypeScript
560 lines
19 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "@/api/client";
|
|
import { useRealtimeEvents } from "@/components/realtimeEventsContext";
|
|
import type {
|
|
AdminJob,
|
|
ApplyItemImageRequest,
|
|
ApplyItemImageResponse,
|
|
ItemDetail,
|
|
ItemFilesResponse,
|
|
ItemImagesResponse,
|
|
ItemMatchSearchRequest,
|
|
ItemMatchSearchResponse,
|
|
ItemSplitRequest,
|
|
ItemSplitResponse,
|
|
WatchDetail,
|
|
} from "@/api/types";
|
|
import { adminKeys, catalogKeys, episodeKeys, itemKeys, sectionKeys } from "./keys";
|
|
import { toast } from "sonner";
|
|
import {
|
|
getCachedWatchedInvalidationKeys,
|
|
getWatchedToastMessage,
|
|
} from "@/pages/ItemDetail/watchedState";
|
|
import { invalidateMediaSurfaceQueries } from "./mediaSurfaceRefresh";
|
|
import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh";
|
|
|
|
function itemPathID(id: string): string {
|
|
return encodeURIComponent(id);
|
|
}
|
|
|
|
export async function fetchWatchDetail(
|
|
id: string,
|
|
fileId?: number,
|
|
libraryId?: number,
|
|
options?: RequestInit,
|
|
): Promise<WatchDetail> {
|
|
const searchParams = new URLSearchParams();
|
|
if (fileId != null) searchParams.set("fileId", String(fileId));
|
|
if (libraryId != null) searchParams.set("library_id", String(libraryId));
|
|
const query = searchParams.toString();
|
|
return api<WatchDetail>(`/watch/${itemPathID(id)}${query ? `?${query}` : ""}`, options);
|
|
}
|
|
|
|
export function useWatchDetail(id: string | undefined, fileId?: number, libraryId?: number) {
|
|
return useQuery({
|
|
queryKey: itemKeys.watchDetail(id!, fileId, libraryId),
|
|
queryFn: () => fetchWatchDetail(id!, fileId, libraryId),
|
|
enabled: !!id,
|
|
staleTime: 0,
|
|
});
|
|
}
|
|
|
|
type RefreshMutationItem = Pick<ItemDetail, "content_id" | "type" | "series_id" | "season_number">;
|
|
export type RefreshItemMetadataMode = "quick" | "complete";
|
|
|
|
interface RefreshItemMetadataVariables {
|
|
item: RefreshMutationItem;
|
|
mode: RefreshItemMetadataMode;
|
|
onReplaced?: (contentID: string) => void | Promise<void>;
|
|
}
|
|
|
|
interface ItemRefreshJobResult {
|
|
requested_content_id?: string;
|
|
refresh_content_id?: string;
|
|
detail_content_id?: string;
|
|
scan_path?: string;
|
|
matched_files?: number;
|
|
scan_result?: {
|
|
new?: number;
|
|
};
|
|
}
|
|
|
|
export function useRefreshItemMetadata() {
|
|
const queryClient = useQueryClient();
|
|
const { awaitAdminJob } = useRealtimeEvents();
|
|
return useMutation({
|
|
mutationFn: async ({ item, mode }: RefreshItemMetadataVariables) => {
|
|
const job = await api<AdminJob>(
|
|
`/admin/items/${itemPathID(item.content_id)}/refresh-metadata`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ mode }),
|
|
},
|
|
);
|
|
const completed = await awaitAdminJob(job.id);
|
|
return { job: completed };
|
|
},
|
|
onSuccess: async ({ job }, { item, mode, onReplaced }) => {
|
|
const result = (job.result_payload ?? {}) as ItemRefreshJobResult;
|
|
const refreshContentID = result.refresh_content_id;
|
|
const detailContentID = result.detail_content_id;
|
|
const newFiles = result.scan_result?.new ?? 0;
|
|
|
|
if (mode === "complete") {
|
|
toast.success("Complete refresh finished");
|
|
} else if (newFiles > 0) {
|
|
toast.success(
|
|
`Metadata refreshed. Found ${newFiles} new file version${newFiles === 1 ? "" : "s"}`,
|
|
);
|
|
} else {
|
|
toast.success("Metadata refreshed");
|
|
}
|
|
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", item.content_id] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", item.content_id, "detail"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: ["items", "watchDetail", item.content_id] }),
|
|
]);
|
|
|
|
if (refreshContentID && refreshContentID !== item.content_id) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", refreshContentID] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", refreshContentID, "detail"],
|
|
}),
|
|
]);
|
|
}
|
|
if (
|
|
detailContentID &&
|
|
detailContentID !== item.content_id &&
|
|
detailContentID !== refreshContentID
|
|
) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", detailContentID] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", detailContentID, "detail"],
|
|
}),
|
|
]);
|
|
}
|
|
|
|
if (item.type === "series") {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.seasons(item.content_id) }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "series", item.content_id, "seasons"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.all }),
|
|
]);
|
|
} else if ((item.type === "season" || item.type === "episode") && item.series_id) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", item.series_id] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", item.series_id, "detail"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.seasons(item.series_id) }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "series", item.series_id, "seasons"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.all }),
|
|
]);
|
|
if (item.season_number != null) {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: episodeKeys.bySeason(item.series_id, item.season_number),
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: episodeKeys.seasonDetail(item.series_id, item.season_number),
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: catalogKeys.seasonEpisodes(item.series_id, item.season_number),
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: catalogKeys.seasonDetail(item.series_id, item.season_number),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (detailContentID && detailContentID !== item.content_id && onReplaced) {
|
|
await onReplaced(detailContentID);
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Refresh failed");
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface RedetectEpisodeIntroResponse {
|
|
status: string;
|
|
}
|
|
|
|
export async function redetectEpisodeIntro(
|
|
episodeId: string,
|
|
): Promise<RedetectEpisodeIntroResponse> {
|
|
return api<RedetectEpisodeIntroResponse>(`/admin/items/${itemPathID(episodeId)}/redetect-intro`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function useRedetectEpisodeIntro() {
|
|
return useMutation({
|
|
mutationFn: redetectEpisodeIntro,
|
|
onSuccess: (response) => {
|
|
toast.success(
|
|
response.status === "already_running"
|
|
? "Re-detection already running"
|
|
: "Re-detection started",
|
|
);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error instanceof Error ? error.message : "Failed to start re-detection");
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface UpdateItemMetadataRequest {
|
|
title?: string;
|
|
sort_title?: string;
|
|
original_title?: string;
|
|
overview?: string;
|
|
tagline?: string;
|
|
content_rating?: string;
|
|
year?: number;
|
|
runtime?: number;
|
|
genres?: string[];
|
|
studios?: string[];
|
|
networks?: string[];
|
|
countries?: string[];
|
|
release_date?: string | null;
|
|
first_air_date?: string | null;
|
|
last_air_date?: string | null;
|
|
air_time?: string | null;
|
|
air_timezone?: string | null;
|
|
air_date?: string | null;
|
|
status?: string;
|
|
rating_imdb?: number | null;
|
|
rating_tmdb?: number | null;
|
|
rating_rt_critic?: number | null;
|
|
rating_rt_audience?: number | null;
|
|
imdb_id?: string;
|
|
tmdb_id?: string;
|
|
tvdb_id?: string;
|
|
season_number?: number;
|
|
episode_number?: number;
|
|
locked_fields?: number[];
|
|
}
|
|
|
|
export function useUpdateItemMetadata(contentId: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (data: UpdateItemMetadataRequest) =>
|
|
api<ItemDetail>(`/admin/items/${itemPathID(contentId)}/metadata`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
onSuccess: () => {
|
|
void invalidateMediaSurfaceQueries(queryClient, { itemId: contentId }).then(() => {
|
|
bumpHomeRefreshSignal(queryClient);
|
|
});
|
|
toast.success("Metadata saved");
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Failed to save metadata");
|
|
},
|
|
});
|
|
}
|
|
|
|
type WatchedMutationItem = Pick<
|
|
ItemDetail,
|
|
"content_id" | "type" | "series_id" | "season_number" | "user_data"
|
|
>;
|
|
|
|
export function useWatchedStateMutation(item: WatchedMutationItem) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: (nextPlayed: boolean) =>
|
|
api(`/watched/${itemPathID(item.content_id)}`, {
|
|
method: nextPlayed ? "POST" : "DELETE",
|
|
}),
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Failed to update watched state");
|
|
},
|
|
onSuccess: (_data, nextPlayed) => {
|
|
toast.success(getWatchedToastMessage(item, nextPlayed));
|
|
},
|
|
onSettled: async () => {
|
|
await invalidateMediaSurfaceQueries(queryClient, {
|
|
itemId: item.content_id,
|
|
watchedKeys: getCachedWatchedInvalidationKeys(queryClient, item),
|
|
});
|
|
bumpHomeRefreshSignal(queryClient);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useSearchItemMatchCandidates(contentId: string) {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: (params: ItemMatchSearchRequest) =>
|
|
api<ItemMatchSearchResponse>(`/admin/items/${itemPathID(contentId)}/match/search`, {
|
|
method: "POST",
|
|
body: JSON.stringify(params),
|
|
}),
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Match search failed");
|
|
},
|
|
meta: { queryClient },
|
|
});
|
|
}
|
|
|
|
type ApplyMatchItem = Pick<ItemDetail, "content_id" | "series_id" | "season_number"> & {
|
|
type: string;
|
|
library_id?: number;
|
|
};
|
|
|
|
export function useApplyItemMatch() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
item,
|
|
providerIds,
|
|
}: {
|
|
item: ApplyMatchItem;
|
|
providerIds: Record<string, string>;
|
|
}) => {
|
|
return api(`/admin/items/${itemPathID(item.content_id)}/match/apply`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
provider_ids: providerIds,
|
|
library_id: item.library_id,
|
|
}),
|
|
});
|
|
},
|
|
onSuccess: async (_, { item }) => {
|
|
toast.success("Match applied successfully");
|
|
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", item.content_id] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", item.content_id, "detail"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: ["items", "watchDetail", item.content_id] }),
|
|
queryClient.invalidateQueries({ queryKey: adminKeys.staleMediaIDs() }),
|
|
queryClient.invalidateQueries({ queryKey: adminKeys.unmatchedItems() }),
|
|
]);
|
|
|
|
if (item.type === "series") {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.seasons(item.content_id) }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "series", item.content_id, "seasons"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.all }),
|
|
]);
|
|
} else if ((item.type === "season" || item.type === "episode") && item.series_id) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", item.series_id] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", item.series_id, "detail"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.seasons(item.series_id) }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "series", item.series_id, "seasons"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.all }),
|
|
]);
|
|
if (item.season_number != null) {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: episodeKeys.bySeason(item.series_id, item.season_number),
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: episodeKeys.seasonDetail(item.series_id, item.season_number),
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: catalogKeys.seasonEpisodes(item.series_id, item.season_number),
|
|
});
|
|
await queryClient.invalidateQueries({
|
|
queryKey: catalogKeys.seasonDetail(item.series_id, item.season_number),
|
|
});
|
|
}
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Failed to apply match");
|
|
},
|
|
});
|
|
}
|
|
|
|
// --- Split/merge hooks ---
|
|
|
|
export function useItemFiles(contentId: string | undefined) {
|
|
return useQuery({
|
|
queryKey: ["items", "files", contentId],
|
|
queryFn: () => api<ItemFilesResponse>(`/admin/items/${itemPathID(contentId ?? "")}/files`),
|
|
enabled: Boolean(contentId),
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useSplitItem() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: ({ contentId, request }: { contentId: string; request: ItemSplitRequest }) =>
|
|
api<ItemSplitResponse>(`/admin/items/${itemPathID(contentId)}/split`, {
|
|
method: "POST",
|
|
body: JSON.stringify(request),
|
|
}),
|
|
onSuccess: async (result, { contentId }) => {
|
|
if (result.dry_run) return;
|
|
toast.success(
|
|
`Moved ${result.files_moved} file${result.files_moved === 1 ? "" : "s"} to a separate item`,
|
|
);
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", contentId] }),
|
|
queryClient.invalidateQueries({ queryKey: ["catalog", "items", contentId, "detail"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["items", "watchDetail", contentId] }),
|
|
queryClient.invalidateQueries({ queryKey: ["items", "files", contentId] }),
|
|
queryClient.invalidateQueries({ queryKey: adminKeys.unmatchedItems() }),
|
|
]);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Failed to split item");
|
|
},
|
|
});
|
|
}
|
|
|
|
// --- Image selector hooks ---
|
|
|
|
export function useItemImages(contentId: string | undefined, enabled = true) {
|
|
return useQuery({
|
|
queryKey: adminKeys.itemImages(contentId!),
|
|
queryFn: () => api<ItemImagesResponse>(`/admin/items/${itemPathID(contentId!)}/images`),
|
|
enabled: !!contentId && enabled,
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
}
|
|
|
|
type ApplyImageItem = Pick<ItemDetail, "content_id" | "type" | "series_id" | "season_number">;
|
|
|
|
export function useApplyItemImage() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
item,
|
|
request,
|
|
}: {
|
|
item: ApplyImageItem;
|
|
request: ApplyItemImageRequest;
|
|
}) =>
|
|
api<ApplyItemImageResponse>(`/admin/items/${itemPathID(item.content_id)}/images/apply`, {
|
|
method: "POST",
|
|
body: JSON.stringify(request),
|
|
}),
|
|
onSuccess: async (_, { item }) => {
|
|
toast.success("Image applied successfully");
|
|
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: adminKeys.itemImages(item.content_id) }),
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", item.content_id] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", item.content_id, "detail"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: ["items", "watchDetail", item.content_id] }),
|
|
queryClient.invalidateQueries({ queryKey: catalogKeys.all }),
|
|
queryClient.invalidateQueries({ queryKey: sectionKeys.all }),
|
|
]);
|
|
|
|
// Cascade for seasons/episodes to parent series.
|
|
if ((item.type === "season" || item.type === "episode") && item.series_id) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", item.series_id] }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "items", item.series_id, "detail"],
|
|
}),
|
|
queryClient.invalidateQueries({ queryKey: episodeKeys.seasons(item.series_id) }),
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["catalog", "series", item.series_id, "seasons"],
|
|
}),
|
|
]);
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Failed to apply image");
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Metadata AI translation (descriptions into the localization tables)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface MetadataTranslationJob {
|
|
id: number;
|
|
target_kind: string;
|
|
content_id: string;
|
|
include_children: boolean;
|
|
source_language: string;
|
|
target_language: string;
|
|
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
|
progress: number;
|
|
progress_message: string;
|
|
fields_done: number;
|
|
fields_total: number;
|
|
force: boolean;
|
|
error_message?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
/** Whether the server has metadata AI translation configured, and the
|
|
* viewer-facing on-view mode. */
|
|
export function useMetadataAIStatus(enabled = true) {
|
|
return useQuery({
|
|
queryKey: ["metadata-ai", "status"],
|
|
queryFn: () =>
|
|
api<{ enabled: boolean; on_view?: "off" | "button" | "auto" }>("/metadata/ai/status"),
|
|
staleTime: 5 * 60 * 1000,
|
|
enabled,
|
|
});
|
|
}
|
|
|
|
export interface TranslateItemMetadataRequest {
|
|
target_language: string;
|
|
include_children?: boolean;
|
|
force?: boolean;
|
|
}
|
|
|
|
export function useTranslateItemMetadata(contentId: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (body: TranslateItemMetadataRequest) =>
|
|
api<{ job: MetadataTranslationJob }>(
|
|
`/admin/items/${itemPathID(contentId)}/metadata-translation`,
|
|
{ method: "POST", body: JSON.stringify(body) },
|
|
),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: ["metadata-translation-jobs", contentId],
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "Failed to start translation");
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Recent translation jobs for an item. Polls while a job is active so the
|
|
* metadata editor can show live progress without a websocket.
|
|
*/
|
|
export function useMetadataTranslationJobs(contentId: string, enabled: boolean) {
|
|
return useQuery({
|
|
queryKey: ["metadata-translation-jobs", contentId],
|
|
queryFn: () =>
|
|
api<{ jobs: MetadataTranslationJob[] }>(
|
|
`/admin/items/${itemPathID(contentId)}/metadata-translation/jobs`,
|
|
),
|
|
enabled,
|
|
refetchInterval: (query) => {
|
|
const jobs = query.state.data?.jobs ?? [];
|
|
return jobs.some((j) => j.status === "pending" || j.status === "running") ? 1500 : false;
|
|
},
|
|
});
|
|
}
|