/* * Kyoo - A portable and vast media library solution. * Copyright (c) Kyoo. * * See AUTHORS.md and LICENSE file in the project root for full license information. * * Kyoo is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * any later version. * * Kyoo is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kyoo. If not, see . */ import { ComponentType, ReactElement } from "react"; import { dehydrate, QueryClient, QueryFunctionContext, useInfiniteQuery, useQuery, } from "@tanstack/react-query"; import { z } from "zod"; import { KyooErrors } from "./kyoo-errors"; import { Page, Paged } from "./page"; import { Platform } from "react-native"; import { getToken } from "./login"; import { getCurrentAccount } from "./account-internal"; const kyooUrl = typeof window === "undefined" ? process.env.KYOO_URL ?? "http://localhost:5000" : "/api"; // The url of kyoo, set after each query (used by the image parser). export let kyooApiUrl = kyooUrl; export const queryFn = async ( context: { apiUrl?: string; authenticated?: boolean; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; } & ( | QueryFunctionContext | ({ path: (string | false | undefined | null)[]; body?: object; formData?: FormData; plainText?: boolean; } & Partial) ), type?: Parser, token?: string | null, ): Promise> => { const url = context.apiUrl ?? (Platform.OS === "web" ? kyooUrl : getCurrentAccount()!.apiUrl); kyooApiUrl = url; if (token === undefined && context.authenticated !== false) token = await getToken(); const path = [url] .concat( "path" in context ? (context.path.filter((x) => x) as string[]) : "pageParam" in context && context.pageParam ? [context.pageParam as string] : (context.queryKey.filter((x) => x) as string[]), ) .join("/") .replace("/?", "?"); let resp; try { resp = await fetch(path, { method: context.method, body: "body" in context && context.body ? JSON.stringify(context.body) : "formData" in context && context.formData ? context.formData : undefined, headers: { ...(token ? { Authorization: token } : {}), ...("body" in context ? { "Content-Type": "application/json" } : {}), }, signal: context.signal, }); } catch (e) { if (typeof e === "object" && e && "name" in e && e.name === "AbortError") throw { errors: ["Aborted"] } as KyooErrors; console.log("Fetch error", e, path); throw { errors: ["Could not reach Kyoo's server."] } as KyooErrors; } if (resp.status === 404) { throw { errors: ["Resource not found."] } as KyooErrors; } if (!resp.ok) { const error = await resp.text(); let data; try { data = JSON.parse(error); } catch (e) { data = { errors: [error] } as KyooErrors; } console.log( `Invalid response (${ "method" in context && context.method ? context.method : "GET" } ${path}):`, data, resp.status, ); throw data as KyooErrors; } if (resp.status === 204) return null; if ("plainText" in context && context.plainText) return (await resp.text()) as unknown; let data; try { data = await resp.json(); } catch (e) { console.error("Invalid json from kyoo", e); throw { errors: ["Invalid response from kyoo"] }; } if (!type) return data; const parsed = await type.safeParseAsync(data); if (!parsed.success) { console.log("Path: ", path, " Response: ", resp.status, " Parse error: ", parsed.error); throw { errors: [ "Invalid response from kyoo. Possible version mismatch between the server and the application.", ], } as KyooErrors; } return parsed.data; }; export type MutationParam = { params?: Record; body?: object; path: string[]; method: "POST" | "DELETE"; }; export const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { // 5min staleTime: 300_000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, mutations: { mutationFn: (({ method, path, body, params }: MutationParam) => { return queryFn({ method, path: toQueryKey({ path, params }), body, }); }) as any, }, }, }); export type QueryIdentifier = { parser: z.ZodType; path: (string | undefined)[]; params?: { [query: string]: boolean | number | string | string[] | undefined }; infinite?: boolean | { value: true; map?: (x: any[]) => Ret[] }; placeholderData?: T | (() => T); enabled?: boolean; options?: Partial[0]>; }; export type QueryPage = ComponentType< Props & { randomItems: Items[] } > & { getFetchUrls?: (route: { [key: string]: string }, randomItems: Items[]) => QueryIdentifier[]; getLayout?: | QueryPage<{ page: ReactElement }> | { Layout: QueryPage<{ page: ReactElement }>; props: object }; requiredPermissions?: string[]; randomItems?: Items[]; }; export const toQueryKey = (query: { path: (string | undefined)[]; params?: { [query: string]: boolean | number | string | string[] | undefined }; }) => { if (query.params) { return [ ...query.path, "?" + Object.entries(query.params) .filter(([_, v]) => v !== undefined) .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) .join("&"), ]; } else { return query.path; } }; export const useFetch = (query: QueryIdentifier) => { return useQuery({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn({ ...ctx, ...query.options }, query.parser), placeholderData: query.placeholderData as any, enabled: query.enabled, }); }; export const useInfiniteFetch = (query: QueryIdentifier) => { const ret = useInfiniteQuery, KyooErrors>({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn({ ...ctx, ...query.options }, Paged(query.parser)), getNextPageParam: (page: Page) => page?.next || undefined, initialPageParam: undefined, placeholderData: query.placeholderData as any, enabled: query.enabled, }); const items = ret.data?.pages.flatMap((x) => x.items); return { ...ret, items: items && typeof query.infinite === "object" && query.infinite.map ? query.infinite.map(items) : (items as unknown as Ret[] | undefined), }; }; export const fetchQuery = async (queries: QueryIdentifier[], authToken?: string | null) => { // we can't put this check in a function because we want build time optimizations // see https://github.com/vercel/next.js/issues/5354 for details if (typeof window !== "undefined") return {}; const client = createQueryClient(); await Promise.all( queries.map((query) => { if (query.infinite) { return client.prefetchInfiniteQuery({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn(ctx, Paged(query.parser), authToken), initialPageParam: undefined, }); } else { return client.prefetchQuery({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn(ctx, query.parser, authToken), }); } }), ); return dehydrate(client); };