2023-12-19 14:25:38 +01:00

222 lines
6.0 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 {
download,
completeHandler,
directories,
DownloadTask,
checkForExistingDownloads,
ensureDownloadsAreRunning,
} from "@kesha-antonov/react-native-background-downloader";
import { deleteAsync } from "expo-file-system";
import {
Account,
Episode,
Movie,
QueryIdentifier,
WatchInfo,
queryFn,
toQueryKey,
} from "@kyoo/models";
import { Player } from "../player";
import { atom, useSetAtom, PrimitiveAtom, useStore } from "jotai";
import { getCurrentAccount, storage } from "@kyoo/models/src/account-internal";
import { useEffect } from "react";
type State = {
status: "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED";
progress: number;
size: number;
availableSize: number;
error?: string;
pause: (() => void) | null;
resume: (() => void) | null;
remove: () => void;
play: () => void;
};
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>,
) => {
const stateAtom = atom({
status: task.state,
progress: task.percent * 100,
size: task.totalBytes,
availableSize: task.bytesWritten,
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: () => {
// TODO: set useQuery cache
// TODO: move to the play page.
},
} as State);
// 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((percent, availableSize, size) =>
update((x) => ({ ...x, percent, size, availableSize, status: "DOWNLOADING" })),
)
.done(() => {
update((x) => ({ ...x, percent: 100, status: "DONE" }));
// apparently this is needed for ios /shrug i'm totaly gona forget this
// if i ever implement ios so keeping this here
completeHandler(task.id);
})
.error((error) => update((x) => ({ ...x, status: "FAILED", error })));
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),
})),
),
);
};
};
export const useDownloader = () => {
const setDownloads = useSetAtom(downloadAtom);
const store = useStore();
return async (type: "episode" | "movie", slug: string) => {
const account = getCurrentAccount()!;
const [data, info] = await Promise.all([
query(Player.query(type, slug), account),
query(Player.infoQuery(type, slug), account),
]);
// TODO: support custom paths
const path = `${directories.documents}/${slug}-${data.id}.${info.extension}`;
const task = download({
id: data.id,
// TODO: support variant qualities
url: `${account.apiUrl}/api/video/${type}/${slug}/direct`,
destination: path,
headers: {
Authorization: account.token.access_token,
},
// TODO: Implement only wifi
// network: Network.ALL,
});
setDownloads((x) => [...x, setupDownloadTask({ data, info, path }, task, store)]);
};
};
export const DownloadProvider = () => {
const store = useStore();
useEffect(() => {
async function run() {
if (store.get(downloadAtom).length) return;
const tasks = await 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);
return {
data: dl.data,
info: dl.info,
path: dl.path,
state: 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: () => {
// TODO: setup this
},
remove: () => {
deleteAsync(dl.path);
store.set(downloadAtom, (x) => x.filter((y) => y.data.id !== dl.data.id));
},
} as State),
};
});
store.set(downloadAtom, downloads);
for (const t of tasks) {
if (!downloads.find((x) => x.data.id === t.id)) t.stop();
}
ensureDownloadsAreRunning();
}
run();
}, [store]);
};