423 lines
14 KiB
TypeScript
423 lines
14 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,
|
|
ItemImagesResponse,
|
|
ItemMatchSearchRequest,
|
|
ItemMatchSearchResponse,
|
|
WatchDetail,
|
|
} from "@/api/types";
|
|
import { adminKeys, catalogKeys, episodeKeys, itemKeys, sectionKeys } from "./keys";
|
|
import { toast } from "sonner";
|
|
import { getCachedWatchedInvalidationKeys } from "@/pages/ItemDetail/watchedState";
|
|
import { invalidateMediaSurfaceQueries } from "./mediaSurfaceRefresh";
|
|
import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh";
|
|
|
|
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/${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/${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/${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/${contentId}/metadata`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
}),
|
|
onSuccess: (updatedItem) => {
|
|
queryClient.setQueryData(catalogKeys.itemDetail(contentId), updatedItem);
|
|
queryClient.invalidateQueries({ queryKey: ["items", "detail", contentId] });
|
|
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/${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(nextPlayed ? "Marked as watched" : "Marked as unwatched");
|
|
},
|
|
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/${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" | "type" | "series_id" | "season_number">;
|
|
|
|
export function useApplyItemMatch() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
item,
|
|
providerIds,
|
|
}: {
|
|
item: ApplyMatchItem;
|
|
providerIds: Record<string, string>;
|
|
}) => {
|
|
return api(`/admin/items/${item.content_id}/match/apply`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider_ids: providerIds }),
|
|
});
|
|
},
|
|
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");
|
|
},
|
|
});
|
|
}
|
|
|
|
// --- Image selector hooks ---
|
|
|
|
export function useItemImages(contentId: string | undefined, enabled = true) {
|
|
return useQuery({
|
|
queryKey: adminKeys.itemImages(contentId!),
|
|
queryFn: () => api<ItemImagesResponse>(`/admin/items/${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/${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");
|
|
},
|
|
});
|
|
}
|