Add logic to download episodes and movies

This commit is contained in:
Zoe Roux 2023-12-15 19:31:12 +01:00
parent b51c77dfa7
commit 3a2e5f5eb1
7 changed files with 226 additions and 27 deletions

View File

@ -17,6 +17,7 @@
"@formatjs/intl-displaynames": "^6.6.4",
"@formatjs/intl-locale": "^3.4.3",
"@gorhom/portal": "^1.0.14",
"@kesha-antonov/react-native-background-downloader": "^2.10.0",
"@kyoo/ui": "workspace:^",
"@material-symbols/svg-400": "^0.14.1",
"@shopify/flash-list": "1.4.3",

View File

@ -22,7 +22,7 @@ import { z } from "zod";
import { Account, AccountP } from "./accounts";
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
export const storage = new MMKV();
const readAccounts = () => {
const acc = storage.getString("accounts");

View File

@ -40,7 +40,7 @@ export let kyooApiUrl = kyooUrl;
export const queryFn = async <Data,>(
context:
| (QueryFunctionContext & { timeout?: number })
| (QueryFunctionContext & { timeout?: number, apiUrl?: string })
| {
path: (string | false | undefined | null)[];
body?: object;
@ -52,7 +52,6 @@ export const queryFn = async <Data,>(
type?: z.ZodType<Data>,
token?: string | null,
): Promise<Data> => {
// @ts-ignore
const url = context.apiUrl ?? (Platform.OS === "web" ? kyooUrl : getCurrentAccount()!.apiUrl);
kyooApiUrl = url;
@ -166,7 +165,7 @@ export type QueryPage<Props = {}, Items = unknown> = ComponentType<
randomItems?: Items[];
};
const toQueryKey = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
export const toQueryKey = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
if (query.params) {
return [
...query.path,

View File

@ -14,6 +14,7 @@
"typescript": "^5.3.2"
},
"peerDependencies": {
"@kesha-antonov/react-native-background-downloader": "*",
"@material-symbols/svg-400": "*",
"@shopify/flash-list": "^1.3.1",
"@tanstack/react-query": "*",
@ -26,5 +27,13 @@
"react-native-reanimated": "*",
"react-native-svg": "*",
"yoshiki": "*"
},
"optionalDependencies": {
"@kesha-antonov/react-native-background-downloader": "^2.10.0"
},
"peerDependenciesMeta": {
"@kesha-antonov/react-native-background-downloader": {
"optional": true
}
}
}

View File

@ -0,0 +1,165 @@
/*
* 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,
Network,
ensureDownloadsAreRunning,
} from "@kesha-antonov/react-native-background-downloader";
import {
Account,
Episode,
Movie,
QueryIdentifier,
WatchInfo,
queryFn,
toQueryKey,
} from "@kyoo/models";
import { Player } from "../player";
import { atom, useSetAtom, useAtom, Atom, PrimitiveAtom, useStore } from "jotai";
import { getCurrentAccount, storage } from "@kyoo/models/src/account-internal";
import { useContext, useEffect } from "react";
type State = {
status: "DOWNLOADING" | "PAUSED" | "DONE" | "FAILED" | "STOPPED";
progress: number;
size: number;
availableSize: number;
error?: string;
pause: () => void;
resume: () => void;
stop: () => 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 listenToTask = (
task: DownloadTask,
atom: PrimitiveAtom<State>,
atomStore: ReturnType<typeof useStore>,
) => {
task
.begin(({ expectedBytes }) => atomStore.set(atom, (x) => ({ ...x, size: expectedBytes })))
.progress((percent, availableSize, size) =>
atomStore.set(atom, (x) => ({ ...x, percent, size, availableSize })),
)
.done(() => {
atomStore.set(atom, (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) => atomStore.set(atom, (x) => ({ ...x, status: "FAILED", error })));
};
export const useDownloader = () => {
const setDownloads = useSetAtom(downloadAtom);
const atomStore = 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,
});
const state = atom({
status: task.state,
progress: task.percent * 100,
size: task.totalBytes,
availableSize: task.bytesWritten,
pause: () => task.pause(),
resume: () => task.resume(),
stop: () => {
task.stop();
setDownloads((x) => x.filter((y) => y.data.id !== task.id));
},
play: () => {
// TODO: set useQuery cache
// TODO: move to the play page.
},
});
// 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.
listenToTask(task, state, atomStore);
setDownloads((x) => [...x, { data, info, path, state }]);
};
};
export const DownloadProvider = () => {
const store = useStore();
useEffect(() => {
async function run() {
const tasks = await checkForExistingDownloads();
const downloads = store.get(downloadAtom);
for (const t of tasks) {
const downAtom = downloads.find((x) => x.data.id === t.id);
if (!downAtom) {
t.stop();
continue;
}
listenToTask(t, downAtom.state, store);
}
ensureDownloadsAreRunning();
}
run();
}, [store]);
};

View File

@ -24,7 +24,6 @@ import {
Movie,
MovieP,
QueryIdentifier,
QueryPage,
WatchInfo,
WatchInfoP,
useFetch,
@ -46,24 +45,6 @@ import { WatchStatusObserver } from "./watch-status-observer";
type Item = (Movie & { type: "movie" }) | (Episode & { type: "episode" });
const query = (type: string, slug: string): QueryIdentifier<Item> =>
type === "episode"
? {
path: ["episode", slug],
params: {
fields: ["nextEpisode", "previousEpisode", "show"],
},
parser: EpisodeP.transform((x) => ({ ...x, type: "episode" })),
}
: {
path: ["movie", slug],
parser: MovieP.transform((x) => ({ ...x, type: "movie" })),
};
const infoQuery = (type: string, slug: string): QueryIdentifier<WatchInfo> => ({
path: ["video", type, slug, "info"],
parser: WatchInfoP,
});
const mapData = (
data: Item | undefined,
info: WatchInfo | undefined,
@ -86,14 +67,14 @@ const mapData = (
};
};
export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({ slug, type }) => {
export const Player = ({ slug, type }: { slug: string; type: "episode" | "movie" }) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const router = useRouter();
const [playbackError, setPlaybackError] = useState<string | undefined>(undefined);
const { data, error } = useFetch(query(type, slug));
const { data: info, error: infoError } = useFetch(infoQuery(type, slug));
const { data, error } = useFetch(Player.query(type, slug));
const { data: info, error: infoError } = useFetch(Player.infoQuery(type, slug));
const previous =
data && data.type === "episode" && data.previousEpisode
? `/watch/${data.previousEpisode.slug}`
@ -181,4 +162,27 @@ export const Player: QueryPage<{ slug: string; type: "episode" | "movie" }> = ({
);
};
Player.getFetchUrls = ({ slug, type }) => [query(type, slug), infoQuery(type, slug)];
Player.query = (type: "episode" | "movie", slug: string): QueryIdentifier<Item> =>
type === "episode"
? {
path: ["episode", slug],
params: {
fields: ["nextEpisode", "previousEpisode", "show"],
},
parser: EpisodeP.transform((x) => ({ ...x, type: "episode" })),
}
: {
path: ["movie", slug],
parser: MovieP.transform((x) => ({ ...x, type: "movie" })),
};
Player.infoQuery = (type: "episode" | "movie", slug: string): QueryIdentifier<WatchInfo> => ({
path: ["video", type, slug, "info"],
parser: WatchInfoP,
});
// if more queries are needed, dont forget to update download.tsx to cache those.
Player.getFetchUrls = ({ slug, type }: { slug: string; type: "episode" | "movie" }) => [
Player.query(type, slug),
Player.infoQuery(type, slug),
];

View File

@ -2857,6 +2857,15 @@ __metadata:
languageName: node
linkType: hard
"@kesha-antonov/react-native-background-downloader@npm:^2.10.0":
version: 2.10.0
resolution: "@kesha-antonov/react-native-background-downloader@npm:2.10.0"
peerDependencies:
react-native: ">=0.57.0"
checksum: f56e4bf5f28b07d8b9e776ce3bdc6ed715ef3d9a95d3ec67b4852dc2992c51283550b5406106454557654a1c2503e920ec62d895aa6240aae97ba4f029df8a32
languageName: node
linkType: hard
"@kyoo/models@workspace:^, @kyoo/models@workspace:packages/models":
version: 0.0.0-use.local
resolution: "@kyoo/models@workspace:packages/models"
@ -2869,6 +2878,9 @@ __metadata:
"@tanstack/react-query": "*"
react: "*"
react-native: "*"
dependenciesMeta:
"@kesha-antonov/react-native-background-downloader":
optional: true
peerDependenciesMeta:
react-native-web:
optional: true
@ -2935,6 +2947,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@kyoo/ui@workspace:packages/ui"
dependencies:
"@kesha-antonov/react-native-background-downloader": ^2.10.0
"@kyoo/models": "workspace:^"
"@kyoo/primitives": "workspace:^"
"@shopify/flash-list": ^1.6.3
@ -2942,6 +2955,7 @@ __metadata:
react-native-uuid: ^2.0.1
typescript: ^5.3.2
peerDependencies:
"@kesha-antonov/react-native-background-downloader": "*"
"@material-symbols/svg-400": "*"
"@shopify/flash-list": ^1.3.1
"@tanstack/react-query": "*"
@ -2954,6 +2968,12 @@ __metadata:
react-native-reanimated: "*"
react-native-svg: "*"
yoshiki: "*"
dependenciesMeta:
"@kesha-antonov/react-native-background-downloader":
optional: true
peerDependenciesMeta:
"@kesha-antonov/react-native-background-downloader":
optional: true
languageName: unknown
linkType: soft
@ -11269,6 +11289,7 @@ __metadata:
"@formatjs/intl-displaynames": ^6.6.4
"@formatjs/intl-locale": ^3.4.3
"@gorhom/portal": ^1.0.14
"@kesha-antonov/react-native-background-downloader": ^2.10.0
"@kyoo/ui": "workspace:^"
"@material-symbols/svg-400": ^0.14.1
"@shopify/flash-list": 1.4.3