mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-10-29 17:52:41 -04:00
309 lines
8.9 KiB
TypeScript
309 lines
8.9 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import RNBackgroundDownloader, {
|
|
type DownloadTask,
|
|
} from "@kesha-antonov/react-native-background-downloader";
|
|
import {
|
|
type Account,
|
|
type Episode,
|
|
EpisodeP,
|
|
type Movie,
|
|
MovieP,
|
|
type QueryIdentifier,
|
|
type WatchInfo,
|
|
WatchInfoP,
|
|
queryFn,
|
|
toQueryKey,
|
|
} from "@kyoo/models";
|
|
import { getCurrentAccount, storage } from "@kyoo/models/src/account-internal";
|
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
|
import { deleteAsync } from "expo-file-system";
|
|
import type { useRouter } from "expo-router";
|
|
import { type PrimitiveAtom, atom, useSetAtom, useStore } from "jotai";
|
|
import { type ReactNode, useEffect } from "react";
|
|
import { ToastAndroid } from "react-native";
|
|
import { z } from "zod";
|
|
import { Player } from "../../../../src/ui/player";
|
|
|
|
type Router = ReturnType<typeof useRouter>;
|
|
|
|
export type State = {
|
|
status: "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED" | "PENDING";
|
|
progress: number | null;
|
|
size: number;
|
|
availableSize: number;
|
|
error?: string;
|
|
pause: (() => void) | null;
|
|
resume: (() => void) | null;
|
|
remove: () => void;
|
|
play: (router: Router) => void;
|
|
retry: (() => void) | null;
|
|
};
|
|
|
|
export const downloadAtom = atom<
|
|
{
|
|
data: Episode | Movie;
|
|
info: WatchInfo;
|
|
path: string;
|
|
state: PrimitiveAtom<State>;
|
|
}[]
|
|
>([]);
|
|
|
|
const query = <T,>(query: QueryIdentifier<T>, info: Account): Promise<T> =>
|
|
queryFn(
|
|
{
|
|
queryKey: toQueryKey(query),
|
|
signal: undefined as any,
|
|
meta: undefined,
|
|
apiUrl: info.apiUrl,
|
|
// use current user and current apiUrl to download this meta.
|
|
},
|
|
query.parser,
|
|
info.token.access_token,
|
|
);
|
|
|
|
const setupDownloadTask = (
|
|
state: { data: Episode | Movie; info: WatchInfo; path: string },
|
|
task: DownloadTask,
|
|
store: ReturnType<typeof useStore>,
|
|
queryClient: QueryClient,
|
|
stateAtom?: PrimitiveAtom<State>,
|
|
) => {
|
|
if (!stateAtom) stateAtom = atom({} as State);
|
|
store.set(stateAtom, {
|
|
status: task.state,
|
|
progress: task.bytesTotal ? (task.bytesDownloaded / task.bytesTotal) * 100 : null,
|
|
size: task.bytesTotal,
|
|
availableSize: task.bytesDownloaded,
|
|
pause: () => {
|
|
task.pause();
|
|
store.set(stateAtom!, (x) => ({ ...x, state: "PAUSED" }));
|
|
},
|
|
resume: () => {
|
|
task.resume();
|
|
store.set(stateAtom!, (x) => ({ ...x, state: "DOWNLOADING" }));
|
|
},
|
|
remove: () => {
|
|
task.stop();
|
|
store.set(downloadAtom, (x) => x.filter((y) => y.data.id !== task.id));
|
|
},
|
|
play: () => {
|
|
ToastAndroid.show("The file has not finished downloading", ToastAndroid.LONG);
|
|
},
|
|
retry: () => {
|
|
const [newTask, path] = download(
|
|
{
|
|
type: state.data.kind,
|
|
id: state.data.id,
|
|
slug: state.data.slug,
|
|
extension: state.info.extension,
|
|
},
|
|
getCurrentAccount()!,
|
|
);
|
|
setupDownloadTask({ ...state, path }, newTask, store, queryClient, stateAtom);
|
|
},
|
|
});
|
|
|
|
// we use the store instead of the onMount because we want to update the state to cache it even if it was not
|
|
// used during this launch of the app.
|
|
const update = updater(store, stateAtom);
|
|
|
|
task
|
|
.begin(({ expectedBytes }) => update((x) => ({ ...x, size: expectedBytes })))
|
|
.progress(({ bytesDownloaded, bytesTotal }) => {
|
|
update((x) => ({
|
|
...x,
|
|
progress: Math.round((bytesDownloaded / bytesTotal) * 100),
|
|
size: bytesTotal,
|
|
availableSize: bytesDownloaded,
|
|
status: "DOWNLOADING",
|
|
}));
|
|
})
|
|
.done(() => {
|
|
update((x) => ({ ...x, progress: 100, status: "DONE", play: playFn(state, queryClient) }));
|
|
RNBackgroundDownloader.completeHandler(task.id);
|
|
})
|
|
.error(({ error }) => {
|
|
update((x) => ({ ...x, status: "FAILED", error }));
|
|
console.error(`Error downloading ${state.data.slug}`, error);
|
|
ToastAndroid.show(`Error downloading ${state.data.slug}`, ToastAndroid.LONG);
|
|
});
|
|
|
|
return { data: state.data, info: state.info, path: state.path, state: stateAtom };
|
|
};
|
|
|
|
const updater = (
|
|
store: ReturnType<typeof useStore>,
|
|
atom: PrimitiveAtom<State>,
|
|
): ((f: (old: State) => State) => void) => {
|
|
return (f) => {
|
|
// if it lags, we could only store progress info on status change and not on every change.
|
|
store.set(atom, f);
|
|
|
|
const downloads = store.get(downloadAtom);
|
|
storage.set(
|
|
"downloads",
|
|
JSON.stringify(
|
|
downloads.map((d) => ({
|
|
data: d.data,
|
|
info: d.info,
|
|
path: d.path,
|
|
state: store.get(d.state),
|
|
})),
|
|
),
|
|
);
|
|
};
|
|
};
|
|
|
|
const download = (
|
|
{
|
|
type,
|
|
id,
|
|
slug,
|
|
extension,
|
|
}: { slug: string; id: string; type: "episode" | "movie"; extension: string },
|
|
account: Account,
|
|
) => {
|
|
// TODO: support custom paths
|
|
const path = `${RNBackgroundDownloader.directories.documents}/${slug}-${id}.${extension}`;
|
|
const task = RNBackgroundDownloader.download({
|
|
id: id,
|
|
// TODO: support variant qualities
|
|
url: `${account.apiUrl}/${type}/${slug}/direct`,
|
|
destination: path,
|
|
headers: {
|
|
Authorization: account.token.access_token,
|
|
},
|
|
isNotificationVisible: true,
|
|
// TODO: Implement only wifi
|
|
// network: Network.ALL,
|
|
});
|
|
console.log("Starting download", path);
|
|
return [task, path] as const;
|
|
};
|
|
|
|
export const useDownloader = () => {
|
|
const setDownloads = useSetAtom(downloadAtom);
|
|
const store = useStore();
|
|
const queryClient = useQueryClient();
|
|
|
|
return async (type: "episode" | "movie", slug: string) => {
|
|
try {
|
|
const account = getCurrentAccount()!;
|
|
const [data, info] = await Promise.all([
|
|
query(Player.query(type, slug), account),
|
|
query(Player.infoQuery(type, slug), account),
|
|
]);
|
|
|
|
if (store.get(downloadAtom).find((x) => x.data.id === data.id)) {
|
|
ToastAndroid.show(`${slug} is already downloaded, skipping`, ToastAndroid.LONG);
|
|
return;
|
|
}
|
|
|
|
const [task, path] = download(
|
|
{ type, slug, id: data.id, extension: info.extension },
|
|
account,
|
|
);
|
|
setDownloads((x) => [
|
|
...x,
|
|
setupDownloadTask({ data, info, path }, task, store, queryClient),
|
|
]);
|
|
} catch (e) {
|
|
console.error("download error", e);
|
|
ToastAndroid.show(`Error downloading ${slug}`, ToastAndroid.LONG);
|
|
}
|
|
};
|
|
};
|
|
|
|
const playFn =
|
|
(dl: { data: Episode | Movie; info: WatchInfo; path: string }, queryClient: QueryClient) =>
|
|
(router: Router) => {
|
|
dl.data.links.direct = dl.path;
|
|
dl.data.links.hls = null;
|
|
queryClient.setQueryData(toQueryKey(Player.query(dl.data.kind, dl.data.slug)), dl.data);
|
|
queryClient.setQueryData(toQueryKey(Player.infoQuery(dl.data.kind, dl.data.slug)), dl.info);
|
|
router.push(
|
|
dl.data.kind === "episode"
|
|
? { pathname: "/watch/[slug]", params: { slug: dl.data.slug } }
|
|
: { pathname: "/movie/[slug]/watch", params: { slug: dl.data.slug } },
|
|
);
|
|
};
|
|
|
|
export const DownloadProvider = ({ children }: { children: ReactNode }) => {
|
|
const store = useStore();
|
|
const queryClient = useQueryClient();
|
|
|
|
useEffect(() => {
|
|
async function run() {
|
|
if (store.get(downloadAtom).length) return;
|
|
|
|
const tasks = await RNBackgroundDownloader.checkForExistingDownloads();
|
|
const dls: { data: Episode | Movie; info: WatchInfo; path: string; state: State }[] =
|
|
JSON.parse(storage.getString("downloads") ?? "[]");
|
|
const downloads = dls.map((dl) => {
|
|
const t = tasks.find((x) => x.id === dl.data.id);
|
|
if (t) return setupDownloadTask(dl, t, store, queryClient);
|
|
|
|
const stateAtom = atom({
|
|
status: dl.state.status === "DONE" ? "DONE" : "FAILED",
|
|
progress: dl.state.progress,
|
|
size: dl.state.size,
|
|
availableSize: dl.state.availableSize,
|
|
pause: null,
|
|
resume: null,
|
|
play: playFn(dl, queryClient),
|
|
remove: () => {
|
|
deleteAsync(dl.path);
|
|
store.set(downloadAtom, (x) => x.filter((y) => y.data.id !== dl.data.id));
|
|
},
|
|
retry: () => {
|
|
const [newTask, path] = download(
|
|
{
|
|
type: dl.data.kind,
|
|
id: dl.data.id,
|
|
slug: dl.data.slug,
|
|
extension: dl.info.extension,
|
|
},
|
|
getCurrentAccount()!,
|
|
);
|
|
setupDownloadTask({ ...dl, path }, newTask, store, queryClient, stateAtom);
|
|
},
|
|
} as State);
|
|
return {
|
|
data: z.union([EpisodeP, MovieP]).parse(dl.data),
|
|
info: WatchInfoP.parse(dl.info),
|
|
path: dl.path,
|
|
state: stateAtom,
|
|
};
|
|
});
|
|
store.set(downloadAtom, downloads);
|
|
|
|
for (const t of tasks) {
|
|
if (!downloads.find((x) => x.data.id === t.id)) t.stop();
|
|
}
|
|
RNBackgroundDownloader.ensureDownloadsAreRunning();
|
|
}
|
|
run();
|
|
}, [store, queryClient]);
|
|
|
|
return children;
|
|
};
|