/* * 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, QueryKey, 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"; const kyooUrl = Platform.OS !== "web" ? process.env.PUBLIC_BACK_URL : typeof window === "undefined" ? process.env.KYOO_URL ?? "http://localhost:5000" : "/api"; export let kyooApiUrl: string | null = kyooUrl || null; export const setApiUrl = (apiUrl: string) => { kyooApiUrl = apiUrl; }; export const queryFn = async ( context: | QueryFunctionContext | { path: (string | false | undefined | null)[]; body?: object; method: "GET" | "POST" | "DELETE"; authenticated?: boolean; apiUrl?: string; abortSignal?: AbortSignal; }, type?: z.ZodType, token?: string | null, ): Promise => { // @ts-ignore let url: string | null = context.apiUrl ?? kyooApiUrl; if (!url) console.error("Kyoo's url is not defined."); kyooApiUrl = url; // @ts-ignore if (!token && 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 as string] : (context.queryKey.filter((x, i) => x && i) as string[]), ) .join("/") .replace("/?", "?"); let resp; try { resp = await fetch(path, { // @ts-ignore method: context.method, // @ts-ignore body: context.body ? JSON.stringify(context.body) : undefined, headers: { ...(token ? { Authorization: token } : {}), ...("body" in context ? { "Content-Type": "application/json" } : {}), }, signal: "abortSignal" in context ? context.abortSignal : undefined, }); } catch (e) { console.log("Fetch error", e); 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 the method is DELETE, 204 NoContent is returned from kyoo. // @ts-ignore if (context.method === "DELETE") return undefined; let data; try { data = await resp.json(); } catch (e) { console.error("Invald json from kyoo", e); throw { errors: ["Invalid repsonse 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 const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, }, }); 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[] }; /** * A custom get next function if the infinite query is not a page. */ getNext?: (item: unknown) => string | undefined; }; export type QueryPage = ComponentType< Props & { randomItems: Items[] } > & { getFetchUrls?: (route: { [key: string]: string }) => QueryIdentifier[]; getLayout?: | QueryPage<{ page: ReactElement }> | { Layout: QueryPage<{ page: ReactElement }>; props: object }; randomItems?: Items[]; }; const toQueryKey = (query: QueryIdentifier) => { const prefix = Platform.OS !== "web" ? [kyooApiUrl] : [""]; if (query.params) { return [ ...prefix, ...query.path, "?" + Object.entries(query.params) .filter(([_, v]) => v !== undefined) .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) .join("&"), ]; } else { return [...prefix, ...query.path]; } }; export const useFetch = (query: QueryIdentifier) => { return useQuery({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn(ctx, query.parser), }); }; export const useInfiniteFetch = (query: QueryIdentifier) => { if (query.getNext) { // eslint-disable-next-line react-hooks/rules-of-hooks const ret = useInfiniteQuery({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn(ctx, z.array(query.parser)), getNextPageParam: query.getNext, initialPageParam: undefined, }); return { ...ret, items: ret.data?.pages.flatMap((x) => x) as unknown as Ret[] | undefined }; } // eslint-disable-next-line react-hooks/rules-of-hooks const ret = useInfiniteQuery, KyooErrors>({ queryKey: toQueryKey(query), queryFn: (ctx) => queryFn(ctx, Paged(query.parser)), getNextPageParam: (page: Page) => page?.next || undefined, initialPageParam: undefined, }); 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); };