diff options
author | Liang Yi <[email protected]> | 2022-01-22 21:35:11 +0800 |
---|---|---|
committer | GitHub <[email protected]> | 2022-01-22 21:35:11 +0800 |
commit | d8d2300980ca69a4ae6511cb49a6dc548c0da793 (patch) | |
tree | 23f2f136c495b4064f43a0c4148391c46b9fa997 /frontend/src/apis | |
parent | 6b82a734e2bc597b219472774c0ec58038630c65 (diff) | |
download | bazarr-d8d2300980ca69a4ae6511cb49a6dc548c0da793.tar.gz bazarr-d8d2300980ca69a4ae6511cb49a6dc548c0da793.zip |
Add React-Query to improve network and cache performancev1.0.3-beta.15
Diffstat (limited to 'frontend/src/apis')
27 files changed, 1032 insertions, 77 deletions
diff --git a/frontend/src/apis/hooks.ts b/frontend/src/apis/hooks.ts deleted file mode 100644 index 084efb63f..000000000 --- a/frontend/src/apis/hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useCallback, useRef, useState } from "react"; - -type Request = (...args: any[]) => Promise<any>; -type Return<T extends Request> = PromiseType<ReturnType<T>>; - -export function useAsyncRequest<F extends Request>( - request: F -): [Async.Item<Return<F>>, (...args: Parameters<F>) => void] { - const [state, setState] = useState<Async.Item<Return<F>>>({ - state: "uninitialized", - content: null, - error: null, - }); - - const requestRef = useRef(request); - - const update = useCallback( - (...args: Parameters<F>) => { - setState((s) => ({ ...s, state: "loading" })); - requestRef - .current(...args) - .then((res) => - setState({ state: "succeeded", content: res, error: null }) - ) - .catch((error) => setState((s) => ({ ...s, state: "failed", error }))); - }, - [requestRef] - ); - - return [state, update]; -} diff --git a/frontend/src/apis/hooks/episodes.ts b/frontend/src/apis/hooks/episodes.ts new file mode 100644 index 000000000..d67d2d194 --- /dev/null +++ b/frontend/src/apis/hooks/episodes.ts @@ -0,0 +1,115 @@ +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "react-query"; +import { usePaginationQuery } from "../queries/hooks"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => { + episodes.forEach((item) => { + client.setQueryData( + [ + QueryKeys.Series, + item.sonarrSeriesId, + QueryKeys.Episodes, + item.sonarrEpisodeId, + ], + item + ); + }); +}; + +export function useEpisodesByIds(ids: number[]) { + const client = useQueryClient(); + return useQuery( + [QueryKeys.Series, QueryKeys.Episodes, ids], + () => api.episodes.byEpisodeId(ids), + { + onSuccess: (data) => { + cacheEpisodes(client, data); + }, + } + ); +} + +export function useEpisodesBySeriesId(id: number) { + const client = useQueryClient(); + return useQuery( + [QueryKeys.Series, id, QueryKeys.Episodes, QueryKeys.All], + () => api.episodes.bySeriesId([id]), + { + onSuccess: (data) => { + cacheEpisodes(client, data); + }, + } + ); +} + +export function useEpisodeWantedPagination() { + return usePaginationQuery([QueryKeys.Series, QueryKeys.Wanted], (param) => + api.episodes.wanted(param) + ); +} + +export function useEpisodeBlacklist() { + return useQuery( + [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist], + () => api.episodes.blacklist() + ); +} + +export function useEpisodeAddBlacklist() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist], + (param: { + seriesId: number; + episodeId: number; + form: FormType.AddBlacklist; + }) => { + const { seriesId, episodeId, form } = param; + return api.episodes.addBlacklist(seriesId, episodeId, form); + }, + { + onSuccess: (_, { seriesId, episodeId }) => { + client.invalidateQueries([QueryKeys.Series, QueryKeys.Blacklist]); + client.invalidateQueries([QueryKeys.Series, seriesId]); + }, + } + ); +} + +export function useEpisodeDeleteBlacklist() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist], + (param: { all?: boolean; form?: FormType.DeleteBlacklist }) => + api.episodes.deleteBlacklist(param.all, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Series, QueryKeys.Blacklist]); + }, + } + ); +} + +export function useEpisodeHistoryPagination() { + return usePaginationQuery( + [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.History], + (param) => api.episodes.history(param) + ); +} + +export function useEpisodeHistory(episodeId?: number) { + return useQuery( + [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.History, episodeId], + () => { + if (episodeId) { + return api.episodes.historyBy(episodeId); + } + } + ); +} diff --git a/frontend/src/apis/hooks/histories.ts b/frontend/src/apis/hooks/histories.ts new file mode 100644 index 000000000..53a7340ba --- /dev/null +++ b/frontend/src/apis/hooks/histories.ts @@ -0,0 +1,21 @@ +import { useQuery } from "react-query"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +export function useHistoryStats( + time: History.TimeFrameOptions, + action: History.ActionOptions | null, + provider: System.Provider | null, + language: Language.Info | null +) { + return useQuery( + [QueryKeys.System, QueryKeys.History, { time, action, provider, language }], + () => + api.history.stats( + time, + action ?? undefined, + provider?.name, + language?.code2 + ) + ); +} diff --git a/frontend/src/apis/hooks/index.ts b/frontend/src/apis/hooks/index.ts new file mode 100644 index 000000000..34b794592 --- /dev/null +++ b/frontend/src/apis/hooks/index.ts @@ -0,0 +1,9 @@ +export * from "./episodes"; +export * from "./histories"; +export * from "./languages"; +export * from "./movies"; +export * from "./providers"; +export * from "./series"; +export * from "./status"; +export * from "./subtitles"; +export * from "./system"; diff --git a/frontend/src/apis/hooks/languages.ts b/frontend/src/apis/hooks/languages.ts new file mode 100644 index 000000000..d26c46f87 --- /dev/null +++ b/frontend/src/apis/hooks/languages.ts @@ -0,0 +1,23 @@ +import { useQuery } from "react-query"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +export function useLanguages(history?: boolean) { + return useQuery( + [QueryKeys.System, QueryKeys.Languages, history ?? false], + () => api.system.languages(history), + { + staleTime: Infinity, + } + ); +} + +export function useLanguageProfiles() { + return useQuery( + [QueryKeys.System, QueryKeys.LanguagesProfiles], + () => api.system.languagesProfileList(), + { + staleTime: Infinity, + } + ); +} diff --git a/frontend/src/apis/hooks/movies.ts b/frontend/src/apis/hooks/movies.ts new file mode 100644 index 000000000..541e00217 --- /dev/null +++ b/frontend/src/apis/hooks/movies.ts @@ -0,0 +1,138 @@ +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "react-query"; +import { usePaginationQuery } from "../queries/hooks"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +const cacheMovies = (client: QueryClient, movies: Item.Movie[]) => { + movies.forEach((item) => { + client.setQueryData([QueryKeys.Movies, item.radarrId], item); + }); +}; + +export function useMoviesByIds(ids: number[]) { + const client = useQueryClient(); + return useQuery([QueryKeys.Movies, ...ids], () => api.movies.movies(ids), { + onSuccess: (data) => { + cacheMovies(client, data); + }, + }); +} + +export function useMovieById(id: number) { + return useQuery([QueryKeys.Movies, id], async () => { + const response = await api.movies.movies([id]); + return response.length > 0 ? response[0] : undefined; + }); +} + +export function useMovies() { + const client = useQueryClient(); + return useQuery( + [QueryKeys.Movies, QueryKeys.All], + () => api.movies.movies(), + { + enabled: false, + onSuccess: (data) => { + cacheMovies(client, data); + }, + } + ); +} + +export function useMoviesPagination() { + return usePaginationQuery([QueryKeys.Movies], (param) => + api.movies.moviesBy(param) + ); +} + +export function useMovieModification() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Movies], + (form: FormType.ModifyItem) => api.movies.modify(form), + { + onSuccess: (_, form) => { + form.id.forEach((v) => { + client.invalidateQueries([QueryKeys.Movies, v]); + }); + // TODO: query less + client.invalidateQueries([QueryKeys.Movies]); + }, + } + ); +} + +export function useMovieAction() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Actions, QueryKeys.Movies], + (form: FormType.MoviesAction) => api.movies.action(form), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.Movies]); + }, + } + ); +} + +export function useMovieWantedPagination() { + return usePaginationQuery([QueryKeys.Movies, QueryKeys.Wanted], (param) => + api.movies.wanted(param) + ); +} + +export function useMovieBlacklist() { + return useQuery([QueryKeys.Movies, QueryKeys.Blacklist], () => + api.movies.blacklist() + ); +} + +export function useMovieAddBlacklist() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Movies, QueryKeys.Blacklist], + (param: { id: number; form: FormType.AddBlacklist }) => { + const { id, form } = param; + return api.movies.addBlacklist(id, form); + }, + { + onSuccess: (_, { id }) => { + client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]); + client.invalidateQueries([QueryKeys.Movies, id]); + }, + } + ); +} + +export function useMovieDeleteBlacklist() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Movies, QueryKeys.Blacklist], + (param: { all?: boolean; form?: FormType.DeleteBlacklist }) => + api.movies.deleteBlacklist(param.all, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Movies, QueryKeys.Blacklist]); + }, + } + ); +} + +export function useMovieHistoryPagination() { + return usePaginationQuery([QueryKeys.Movies, QueryKeys.History], (param) => + api.movies.history(param) + ); +} + +export function useMovieHistory(radarrId?: number) { + return useQuery([QueryKeys.Movies, QueryKeys.History, radarrId], () => { + if (radarrId) { + return api.movies.historyBy(radarrId); + } + }); +} diff --git a/frontend/src/apis/hooks/providers.ts b/frontend/src/apis/hooks/providers.ts new file mode 100644 index 000000000..f1daf9f37 --- /dev/null +++ b/frontend/src/apis/hooks/providers.ts @@ -0,0 +1,99 @@ +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +export function useSystemProviders(history?: boolean) { + return useQuery( + [QueryKeys.System, QueryKeys.Providers, history ?? false], + () => api.providers.providers(history) + ); +} + +export function useMoviesProvider(radarrId?: number) { + return useQuery( + [QueryKeys.System, QueryKeys.Providers, QueryKeys.Movies, radarrId], + () => { + if (radarrId) { + return api.providers.movies(radarrId); + } + }, + { + staleTime: Infinity, + } + ); +} + +export function useEpisodesProvider(episodeId?: number) { + return useQuery( + [QueryKeys.System, QueryKeys.Providers, QueryKeys.Episodes, episodeId], + () => { + if (episodeId) { + return api.providers.episodes(episodeId); + } + }, + { + staleTime: Infinity, + } + ); +} + +export function useResetProvider() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Providers], + () => api.providers.reset(), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Providers]); + }, + } + ); +} + +export function useDownloadEpisodeSubtitles() { + const client = useQueryClient(); + + return useMutation( + [ + QueryKeys.System, + QueryKeys.Providers, + QueryKeys.Subtitles, + QueryKeys.Episodes, + ], + (param: { + seriesId: number; + episodeId: number; + form: FormType.ManualDownload; + }) => + api.providers.downloadEpisodeSubtitle( + param.seriesId, + param.episodeId, + param.form + ), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Series, param.seriesId]); + }, + } + ); +} + +export function useDownloadMovieSubtitles() { + const client = useQueryClient(); + + return useMutation( + [ + QueryKeys.System, + QueryKeys.Providers, + QueryKeys.Subtitles, + QueryKeys.Movies, + ], + (param: { radarrId: number; form: FormType.ManualDownload }) => + api.providers.downloadMovieSubtitle(param.radarrId, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Movies, param.radarrId]); + }, + } + ); +} diff --git a/frontend/src/apis/hooks/series.ts b/frontend/src/apis/hooks/series.ts new file mode 100644 index 000000000..4de9f5c1b --- /dev/null +++ b/frontend/src/apis/hooks/series.ts @@ -0,0 +1,80 @@ +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "react-query"; +import { usePaginationQuery } from "../queries/hooks"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +function cacheSeries(client: QueryClient, series: Item.Series[]) { + series.forEach((item) => { + client.setQueryData([QueryKeys.Series, item.sonarrSeriesId], item); + }); +} + +export function useSeriesByIds(ids: number[]) { + const client = useQueryClient(); + return useQuery([QueryKeys.Series, ...ids], () => api.series.series(ids), { + onSuccess: (data) => { + cacheSeries(client, data); + }, + }); +} + +export function useSeriesById(id: number) { + return useQuery([QueryKeys.Series, id], async () => { + const response = await api.series.series([id]); + return response.length > 0 ? response[0] : undefined; + }); +} + +export function useSeries() { + const client = useQueryClient(); + return useQuery( + [QueryKeys.Series, QueryKeys.All], + () => api.series.series(), + { + enabled: false, + onSuccess: (data) => { + cacheSeries(client, data); + }, + } + ); +} + +export function useSeriesPagination() { + return usePaginationQuery([QueryKeys.Series], (param) => + api.series.seriesBy(param) + ); +} + +export function useSeriesModification() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Series], + (form: FormType.ModifyItem) => api.series.modify(form), + { + onSuccess: (_, form) => { + form.id.forEach((v) => { + client.invalidateQueries([QueryKeys.Series, v]); + }); + client.invalidateQueries([QueryKeys.Series]); + }, + } + ); +} + +export function useSeriesAction() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.Actions, QueryKeys.Series], + (form: FormType.SeriesAction) => api.series.action(form), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.Series]); + }, + } + ); +} diff --git a/frontend/src/apis/hooks/status.ts b/frontend/src/apis/hooks/status.ts new file mode 100644 index 000000000..46a73cfda --- /dev/null +++ b/frontend/src/apis/hooks/status.ts @@ -0,0 +1,18 @@ +import { useIsMutating } from "react-query"; +import { QueryKeys } from "../queries/keys"; + +export function useIsAnyActionRunning() { + return useIsMutating([QueryKeys.Actions]) > 0; +} + +export function useIsMovieActionRunning() { + return useIsMutating([QueryKeys.Actions, QueryKeys.Movies]) > 0; +} + +export function useIsSeriesActionRunning() { + return useIsMutating([QueryKeys.Actions, QueryKeys.Series]) > 0; +} + +export function useIsAnyMutationRunning() { + return useIsMutating() > 0; +} diff --git a/frontend/src/apis/hooks/subtitles.ts b/frontend/src/apis/hooks/subtitles.ts new file mode 100644 index 000000000..5080daeb7 --- /dev/null +++ b/frontend/src/apis/hooks/subtitles.ts @@ -0,0 +1,119 @@ +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +export function useSubtitleAction() { + const client = useQueryClient(); + interface Param { + action: string; + form: FormType.ModifySubtitle; + } + return useMutation( + [QueryKeys.Subtitles], + (param: Param) => api.subtitles.modify(param.action, param.form), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.History]); + }, + } + ); +} + +export function useEpisodeSubtitleModification() { + const client = useQueryClient(); + + interface Param<T> { + seriesId: number; + episodeId: number; + form: T; + } + + const download = useMutation( + [QueryKeys.Subtitles, QueryKeys.Episodes], + (param: Param<FormType.Subtitle>) => + api.episodes.downloadSubtitles( + param.seriesId, + param.episodeId, + param.form + ), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Series, param.seriesId]); + }, + } + ); + + const remove = useMutation( + [QueryKeys.Subtitles, QueryKeys.Episodes], + (param: Param<FormType.DeleteSubtitle>) => + api.episodes.deleteSubtitles(param.seriesId, param.episodeId, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Series, param.seriesId]); + }, + } + ); + + const upload = useMutation( + [QueryKeys.Subtitles, QueryKeys.Episodes], + (param: Param<FormType.UploadSubtitle>) => + api.episodes.uploadSubtitles(param.seriesId, param.episodeId, param.form), + { + onSuccess: (_, { seriesId }) => { + client.invalidateQueries([QueryKeys.Series, seriesId]); + }, + } + ); + + return { download, remove, upload }; +} + +export function useMovieSubtitleModification() { + const client = useQueryClient(); + + interface Param<T> { + radarrId: number; + form: T; + } + + const download = useMutation( + [QueryKeys.Subtitles, QueryKeys.Movies], + (param: Param<FormType.Subtitle>) => + api.movies.downloadSubtitles(param.radarrId, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Movies, param.radarrId]); + }, + } + ); + + const remove = useMutation( + [QueryKeys.Subtitles, QueryKeys.Movies], + (param: Param<FormType.DeleteSubtitle>) => + api.movies.deleteSubtitles(param.radarrId, param.form), + { + onSuccess: (_, param) => { + client.invalidateQueries([QueryKeys.Movies, param.radarrId]); + }, + } + ); + + const upload = useMutation( + [QueryKeys.Subtitles, QueryKeys.Movies], + (param: Param<FormType.UploadSubtitle>) => + api.movies.uploadSubtitles(param.radarrId, param.form), + { + onSuccess: (_, { radarrId }) => { + client.invalidateQueries([QueryKeys.Movies, radarrId]); + }, + } + ); + + return { download, remove, upload }; +} + +export function useSubtitleInfos(names: string[]) { + return useQuery([QueryKeys.Subtitles, QueryKeys.Infos, names], () => + api.subtitles.info(names) + ); +} diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts new file mode 100644 index 000000000..f096806b8 --- /dev/null +++ b/frontend/src/apis/hooks/system.ts @@ -0,0 +1,188 @@ +import { useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { setUnauthenticated } from "../../@redux/actions"; +import store from "../../@redux/store"; +import { QueryKeys } from "../queries/keys"; +import api from "../raw"; + +export function useBadges() { + return useQuery([QueryKeys.System, QueryKeys.Badges], () => api.badges.all()); +} + +export function useFileSystem( + type: "bazarr" | "sonarr" | "radarr", + path: string, + enabled: boolean +) { + return useQuery( + [QueryKeys.FileSystem, type, path], + () => { + if (type === "bazarr") { + return api.files.bazarr(path); + } else if (type === "radarr") { + return api.files.radarr(path); + } else if (type === "sonarr") { + return api.files.sonarr(path); + } + }, + { + enabled, + } + ); +} + +export function useSystemSettings() { + return useQuery( + [QueryKeys.System, QueryKeys.Settings], + () => api.system.settings(), + { + staleTime: Infinity, + } + ); +} + +export function useSettingsMutation() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Settings], + (data: LooseObject) => api.system.updateSettings(data), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.System]); + }, + } + ); +} + +export function useServerSearch(query: string, enabled: boolean) { + return useQuery( + [QueryKeys.System, QueryKeys.Search, query], + () => api.system.search(query), + { + enabled, + } + ); +} + +export function useSystemLogs() { + return useQuery([QueryKeys.System, QueryKeys.Logs], () => api.system.logs(), { + refetchOnWindowFocus: "always", + refetchInterval: 1000 * 60, + staleTime: 1000, + }); +} + +export function useDeleteLogs() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Logs], + () => api.system.deleteLogs(), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Logs]); + }, + } + ); +} + +export function useSystemTasks() { + return useQuery( + [QueryKeys.System, QueryKeys.Tasks], + () => api.system.tasks(), + { + refetchOnWindowFocus: "always", + refetchInterval: 1000 * 60, + staleTime: 1000 * 10, + } + ); +} + +export function useRunTask() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Tasks], + (id: string) => api.system.runTask(id), + { + onSuccess: () => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]); + }, + } + ); +} + +export function useSystemStatus() { + return useQuery([QueryKeys.System, "status"], () => api.system.status()); +} + +export function useSystemHealth() { + return useQuery([QueryKeys.System, "health"], () => api.system.health()); +} + +export function useSystemReleases() { + return useQuery([QueryKeys.System, "releases"], () => api.system.releases()); +} + +export function useSystem() { + const client = useQueryClient(); + const { mutate: logout, isLoading: isLoggingOut } = useMutation( + [QueryKeys.System, QueryKeys.Actions], + () => api.system.logout(), + { + onSuccess: () => { + store.dispatch(setUnauthenticated()); + client.clear(); + }, + } + ); + + const { mutate: login, isLoading: isLoggingIn } = useMutation( + [QueryKeys.System, QueryKeys.Actions], + (param: { username: string; password: string }) => + api.system.login(param.username, param.password), + { + onSuccess: () => { + window.location.reload(); + }, + } + ); + + const { mutate: shutdown, isLoading: isShuttingDown } = useMutation( + [QueryKeys.System, QueryKeys.Actions], + () => api.system.shutdown(), + { + onSuccess: () => { + client.clear(); + }, + } + ); + + const { mutate: restart, isLoading: isRestarting } = useMutation( + [QueryKeys.System, QueryKeys.Actions], + () => api.system.restart(), + { + onSuccess: () => { + client.clear(); + }, + } + ); + + return useMemo( + () => ({ + logout, + shutdown, + restart, + login, + isWorking: isLoggingOut || isShuttingDown || isRestarting || isLoggingIn, + }), + [ + isLoggingIn, + isLoggingOut, + isRestarting, + isShuttingDown, + login, + logout, + restart, + shutdown, + ] + ); +} diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/queries/client.ts index 3efc0aba1..2263874bc 100644 --- a/frontend/src/apis/index.ts +++ b/frontend/src/apis/queries/client.ts @@ -1,8 +1,8 @@ import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios"; -import { siteRedirectToAuth } from "../@redux/actions"; -import { AppDispatch } from "../@redux/store"; -import { Environment, isProdEnv } from "../utilities"; -class Api { +import { setUnauthenticated } from "../../@redux/actions"; +import { AppDispatch } from "../../@redux/store"; +import { Environment, isProdEnv } from "../../utilities"; +class BazarrClient { axios!: AxiosInstance; source!: CancelTokenSource; dispatch!: AppDispatch; @@ -57,7 +57,7 @@ class Api { handleError(code: number) { switch (code) { case 401: - this.dispatch(siteRedirectToAuth()); + this.dispatch(setUnauthenticated()); break; case 500: break; @@ -67,15 +67,4 @@ class Api { } } -export default new Api(); -export { default as BadgesApi } from "./badges"; -export { default as EpisodesApi } from "./episodes"; -export { default as FilesApi } from "./files"; -export { default as HistoryApi } from "./history"; -export * from "./hooks"; -export { default as MoviesApi } from "./movies"; -export { default as ProvidersApi } from "./providers"; -export { default as SeriesApi } from "./series"; -export { default as SubtitlesApi } from "./subtitles"; -export { default as SystemApi } from "./system"; -export { default as UtilsApi } from "./utils"; +export default new BazarrClient(); diff --git a/frontend/src/apis/queries/hooks.ts b/frontend/src/apis/queries/hooks.ts new file mode 100644 index 000000000..b8cc52c9c --- /dev/null +++ b/frontend/src/apis/queries/hooks.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from "react"; +import { + QueryKey, + useQuery, + useQueryClient, + UseQueryResult, +} from "react-query"; +import { GetItemId } from "utilities"; +import { usePageSize } from "utilities/storage"; +import { QueryKeys } from "./keys"; + +export type UsePaginationQueryResult<T extends object> = UseQueryResult< + DataWrapperWithTotal<T> +> & { + controls: { + previousPage: () => void; + nextPage: () => void; + gotoPage: (index: number) => void; + }; + paginationStatus: { + totalCount: number; + pageSize: number; + pageCount: number; + page: number; + canPrevious: boolean; + canNext: boolean; + }; +}; + +export function usePaginationQuery< + TObject extends object = object, + TQueryKey extends QueryKey = QueryKey +>( + queryKey: TQueryKey, + queryFn: RangeQuery<TObject> +): UsePaginationQueryResult<TObject> { + const client = useQueryClient(); + + const [page, setIndex] = useState(0); + const [pageSize] = usePageSize(); + + const start = page * pageSize; + + const results = useQuery( + [...queryKey, QueryKeys.Range, { start, size: pageSize }], + () => { + const param: Parameter.Range = { + start, + length: pageSize, + }; + return queryFn(param); + }, + { + onSuccess: ({ data }) => { + data.forEach((item) => { + const id = GetItemId(item); + if (id) { + client.setQueryData([...queryKey, id], item); + } + }); + }, + } + ); + + const { data } = results; + + const totalCount = data?.total ?? 0; + const pageCount = Math.ceil(totalCount / pageSize); + + const previousPage = useCallback(() => { + setIndex((index) => Math.max(0, index - 1)); + }, []); + + const nextPage = useCallback(() => { + if (pageCount > 0) { + setIndex((index) => Math.min(pageCount - 1, index + 1)); + } + }, [pageCount]); + + const gotoPage = useCallback( + (idx: number) => { + if (idx >= 0 && idx < pageCount) { + setIndex(idx); + } + }, + [pageCount] + ); + + // Reset page index if we out of bound + useEffect(() => { + if (pageCount === 0) return; + + if (page >= pageCount) { + setIndex(pageCount - 1); + } else if (page < 0) { + setIndex(0); + } + }, [page, pageCount]); + + return { + ...results, + paginationStatus: { + totalCount, + pageCount, + pageSize, + page, + canPrevious: page > 0, + canNext: page < pageCount - 1, + }, + controls: { + gotoPage, + previousPage, + nextPage, + }, + }; +} diff --git a/frontend/src/apis/queries/index.ts b/frontend/src/apis/queries/index.ts new file mode 100644 index 000000000..a1a17ffd9 --- /dev/null +++ b/frontend/src/apis/queries/index.ts @@ -0,0 +1,14 @@ +import { QueryClient } from "react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + staleTime: 1000 * 60, + keepPreviousData: true, + }, + }, +}); + +export default queryClient; diff --git a/frontend/src/apis/queries/keys.ts b/frontend/src/apis/queries/keys.ts new file mode 100644 index 000000000..cfdd44133 --- /dev/null +++ b/frontend/src/apis/queries/keys.ts @@ -0,0 +1,23 @@ +export enum QueryKeys { + Movies = "movies", + Episodes = "episodes", + Series = "series", + Badges = "badges", + FileSystem = "file-system", + System = "system", + Settings = "settings", + Subtitles = "subtitles", + Providers = "providers", + Languages = "languages", + LanguagesProfiles = "languages-profiles", + Blacklist = "blacklist", + Search = "search", + Actions = "actions", + Tasks = "tasks", + Logs = "logs", + Infos = "infos", + History = "history", + Wanted = "wanted", + Range = "range", + All = "all", +} diff --git a/frontend/src/apis/badges.ts b/frontend/src/apis/raw/badges.ts index 0021dede6..0021dede6 100644 --- a/frontend/src/apis/badges.ts +++ b/frontend/src/apis/raw/badges.ts diff --git a/frontend/src/apis/base.ts b/frontend/src/apis/raw/base.ts index 3a0ab4bb9..2c514adf9 100644 --- a/frontend/src/apis/base.ts +++ b/frontend/src/apis/raw/base.ts @@ -1,5 +1,5 @@ import { AxiosResponse } from "axios"; -import apis from "."; +import client from "../queries/client"; class BaseApi { prefix: string; @@ -31,7 +31,7 @@ class BaseApi { } protected async get<T = unknown>(path: string, params?: any) { - const response = await apis.axios.get<T>(this.prefix + path, { params }); + const response = await client.axios.get<T>(this.prefix + path, { params }); return response.data; } @@ -41,7 +41,7 @@ class BaseApi { params?: any ): Promise<AxiosResponse<T>> { const form = this.createFormdata(formdata); - return apis.axios.post(this.prefix + path, form, { params }); + return client.axios.post(this.prefix + path, form, { params }); } protected patch<T = void>( @@ -50,7 +50,7 @@ class BaseApi { params?: any ): Promise<AxiosResponse<T>> { const form = this.createFormdata(formdata); - return apis.axios.patch(this.prefix + path, form, { params }); + return client.axios.patch(this.prefix + path, form, { params }); } protected delete<T = void>( @@ -59,7 +59,7 @@ class BaseApi { params?: any ): Promise<AxiosResponse<T>> { const form = this.createFormdata(formdata); - return apis.axios.delete(this.prefix + path, { params, data: form }); + return client.axios.delete(this.prefix + path, { params, data: form }); } } diff --git a/frontend/src/apis/episodes.ts b/frontend/src/apis/raw/episodes.ts index 345954b32..2075fb8e6 100644 --- a/frontend/src/apis/episodes.ts +++ b/frontend/src/apis/raw/episodes.ts @@ -20,7 +20,7 @@ class EpisodeApi extends BaseApi { } async wanted(params: Parameter.Range) { - const response = await this.get<AsyncDataWrapper<Wanted.Episode>>( + const response = await this.get<DataWrapperWithTotal<Wanted.Episode>>( "/wanted", params ); @@ -28,7 +28,7 @@ class EpisodeApi extends BaseApi { } async wantedBy(episodeid: number[]) { - const response = await this.get<AsyncDataWrapper<Wanted.Episode>>( + const response = await this.get<DataWrapperWithTotal<Wanted.Episode>>( "/wanted", { episodeid } ); @@ -36,7 +36,7 @@ class EpisodeApi extends BaseApi { } async history(params: Parameter.Range) { - const response = await this.get<AsyncDataWrapper<History.Episode>>( + const response = await this.get<DataWrapperWithTotal<History.Episode>>( "/history", params ); @@ -44,11 +44,11 @@ class EpisodeApi extends BaseApi { } async historyBy(episodeid: number) { - const response = await this.get<AsyncDataWrapper<History.Episode>>( + const response = await this.get<DataWrapperWithTotal<History.Episode>>( "/history", { episodeid } ); - return response; + return response.data; } async downloadSubtitles( diff --git a/frontend/src/apis/files.ts b/frontend/src/apis/raw/files.ts index 88913ac08..88913ac08 100644 --- a/frontend/src/apis/files.ts +++ b/frontend/src/apis/raw/files.ts diff --git a/frontend/src/apis/history.ts b/frontend/src/apis/raw/history.ts index d26d89d8b..c1226cd7f 100644 --- a/frontend/src/apis/history.ts +++ b/frontend/src/apis/raw/history.ts @@ -6,13 +6,13 @@ class HistoryApi extends BaseApi { } async stats( - timeframe?: History.TimeframeOptions, + timeFrame?: History.TimeFrameOptions, action?: History.ActionOptions, provider?: string, language?: Language.CodeType ) { const response = await this.get<History.Stat>("/stats", { - timeframe, + timeFrame, action, provider, language, diff --git a/frontend/src/apis/raw/index.ts b/frontend/src/apis/raw/index.ts new file mode 100644 index 000000000..5283f0f2c --- /dev/null +++ b/frontend/src/apis/raw/index.ts @@ -0,0 +1,25 @@ +import badges from "./badges"; +import episodes from "./episodes"; +import files from "./files"; +import history from "./history"; +import movies from "./movies"; +import providers from "./providers"; +import series from "./series"; +import subtitles from "./subtitles"; +import system from "./system"; +import utils from "./utils"; + +const api = { + badges, + episodes, + files, + movies, + series, + providers, + history, + subtitles, + system, + utils, +}; + +export default api; diff --git a/frontend/src/apis/movies.ts b/frontend/src/apis/raw/movies.ts index 8e94712cb..b8690fdcc 100644 --- a/frontend/src/apis/movies.ts +++ b/frontend/src/apis/raw/movies.ts @@ -21,14 +21,17 @@ class MovieApi extends BaseApi { } async movies(radarrid?: number[]) { - const response = await this.get<AsyncDataWrapper<Item.Movie>>("", { + const response = await this.get<DataWrapperWithTotal<Item.Movie>>("", { radarrid, }); - return response; + return response.data; } async moviesBy(params: Parameter.Range) { - const response = await this.get<AsyncDataWrapper<Item.Movie>>("", params); + const response = await this.get<DataWrapperWithTotal<Item.Movie>>( + "", + params + ); return response; } @@ -37,7 +40,7 @@ class MovieApi extends BaseApi { } async wanted(params: Parameter.Range) { - const response = await this.get<AsyncDataWrapper<Wanted.Movie>>( + const response = await this.get<DataWrapperWithTotal<Wanted.Movie>>( "/wanted", params ); @@ -45,14 +48,17 @@ class MovieApi extends BaseApi { } async wantedBy(radarrid: number[]) { - const response = await this.get<AsyncDataWrapper<Wanted.Movie>>("/wanted", { - radarrid, - }); + const response = await this.get<DataWrapperWithTotal<Wanted.Movie>>( + "/wanted", + { + radarrid, + } + ); return response; } async history(params: Parameter.Range) { - const response = await this.get<AsyncDataWrapper<History.Movie>>( + const response = await this.get<DataWrapperWithTotal<History.Movie>>( "/history", params ); @@ -60,11 +66,11 @@ class MovieApi extends BaseApi { } async historyBy(radarrid: number) { - const response = await this.get<AsyncDataWrapper<History.Movie>>( + const response = await this.get<DataWrapperWithTotal<History.Movie>>( "/history", { radarrid } ); - return response; + return response.data; } async action(action: FormType.MoviesAction) { diff --git a/frontend/src/apis/providers.ts b/frontend/src/apis/raw/providers.ts index cfbb2dbc5..cfbb2dbc5 100644 --- a/frontend/src/apis/providers.ts +++ b/frontend/src/apis/raw/providers.ts diff --git a/frontend/src/apis/series.ts b/frontend/src/apis/raw/series.ts index 976104003..d94b108df 100644 --- a/frontend/src/apis/series.ts +++ b/frontend/src/apis/raw/series.ts @@ -6,14 +6,17 @@ class SeriesApi extends BaseApi { } async series(seriesid?: number[]) { - const response = await this.get<AsyncDataWrapper<Item.Series>>("", { + const response = await this.get<DataWrapperWithTotal<Item.Series>>("", { seriesid, }); - return response; + return response.data; } async seriesBy(params: Parameter.Range) { - const response = await this.get<AsyncDataWrapper<Item.Series>>("", params); + const response = await this.get<DataWrapperWithTotal<Item.Series>>( + "", + params + ); return response; } diff --git a/frontend/src/apis/subtitles.ts b/frontend/src/apis/raw/subtitles.ts index abfe96ba6..abfe96ba6 100644 --- a/frontend/src/apis/subtitles.ts +++ b/frontend/src/apis/raw/subtitles.ts diff --git a/frontend/src/apis/system.ts b/frontend/src/apis/raw/system.ts index d21200c28..c473f076d 100644 --- a/frontend/src/apis/system.ts +++ b/frontend/src/apis/raw/system.ts @@ -30,7 +30,7 @@ class SystemApi extends BaseApi { return response; } - async setSettings(data: object) { + async updateSettings(data: object) { await this.post("/settings", data); } diff --git a/frontend/src/apis/utils.ts b/frontend/src/apis/raw/utils.ts index d2adddb26..d553c8f7f 100644 --- a/frontend/src/apis/utils.ts +++ b/frontend/src/apis/raw/utils.ts @@ -1,4 +1,4 @@ -import apis from "."; +import client from "../queries/client"; type UrlTestResponse = | { @@ -13,7 +13,7 @@ type UrlTestResponse = class RequestUtils { async urlTest(protocol: string, url: string, params?: any) { try { - const result = await apis.axios.get<UrlTestResponse>( + const result = await client.axios.get<UrlTestResponse>( `../test/${protocol}/${url}api/system/status`, { params } ); @@ -24,7 +24,7 @@ class RequestUtils { throw new Error("Cannot get response, fallback to v3 api"); } } catch (e) { - const result = await apis.axios.get<UrlTestResponse>( + const result = await client.axios.get<UrlTestResponse>( `../test/${protocol}/${url}api/v3/system/status`, { params } ); |