feat: download queue

This commit is contained in:
Fredrik Burmester
2024-08-14 13:30:43 +02:00
parent f87824ec58
commit ad8bc954c1
6 changed files with 285 additions and 166 deletions

View File

@@ -17,9 +17,11 @@ import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { FFmpegKit } from "ffmpeg-kit-react-native"; import { FFmpegKit } from "ffmpeg-kit-react-native";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { queueAtom } from "@/utils/atoms/queue";
const downloads: React.FC = () => { const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses); const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { data: downloadedFiles, isLoading } = useQuery({ const { data: downloadedFiles, isLoading } = useQuery({
queryKey: ["downloaded_files", process?.item.Id], queryKey: ["downloaded_files", process?.item.Id],
@@ -67,50 +69,84 @@ const downloads: React.FC = () => {
return ( return (
<ScrollView> <ScrollView>
<View className="px-4 py-4"> <View className="px-4 py-4">
<View className="mb-4"> <View className="mb-4 flex flex-col space-y-4">
<Text className="text-2xl font-bold mb-2">Active download</Text> <View>
{process?.item ? ( <Text className="text-2xl font-bold mb-2">Queue</Text>
<TouchableOpacity <View className="flex flex-col space-y-2">
onPress={() => {queue.map((q) => (
router.push(`/(auth)/items/${process.item.Id}/page`) <TouchableOpacity
} onPress={() => router.push(`/(auth)/items/${q.item.Id}/page`)}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
> >
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
<View> <View>
<Text className="text-xs">ETA {eta}</Text> <Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
setQueue((prev) => prev.filter((i) => i.id !== q.id));
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
<View>
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/${process.item.Id}/page`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.Type}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<Text className="text-xs">
{process.speed?.toFixed(2)}x
</Text>
<View>
<Text className="text-xs">ETA {eta}</Text>
</View>
</View> </View>
</View> </View>
</View> <TouchableOpacity
<TouchableOpacity onPress={() => {
onPress={() => { FFmpegKit.cancel();
FFmpegKit.cancel(); setProcess(null);
setProcess(null); }}
}} >
> <Ionicons name="close" size={24} color="red" />
<Ionicons name="close" size={24} color="red" /> </TouchableOpacity>
</TouchableOpacity> <View
<View className={`
className={`
absolute bottom-0 left-0 h-1 bg-purple-600 absolute bottom-0 left-0 h-1 bg-purple-600
`} `}
style={{ style={{
width: process.progress width: process.progress
? `${Math.max(5, process.progress)}%` ? `${Math.max(5, process.progress)}%`
: "5%", : "5%",
}} }}
></View> ></View>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<Text className="opacity-50">No active downloads</Text> <Text className="opacity-50">No active downloads</Text>
)} )}
</View>
</View> </View>
{movies.length > 0 && ( {movies.length > 0 && (
<View className="mb-4"> <View className="mb-4">

View File

@@ -11,6 +11,9 @@ import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar"; import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { useJobProcessor } from "@/utils/atoms/queue";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { useKeepAwake } from "expo-keep-awake";
// Prevent the splash screen from auto-hiding before asset loading is complete. // Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
@@ -20,6 +23,8 @@ export const unstable_settings = {
}; };
export default function RootLayout() { export default function RootLayout() {
useKeepAwake();
const [loaded] = useFonts({ const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
}); });
@@ -75,69 +80,71 @@ export default function RootLayout() {
return ( return (
<QueryClientProvider client={queryClientRef.current}> <QueryClientProvider client={queryClientRef.current}>
<JotaiProvider> <JotaiProvider>
<ActionSheetProvider> <JobQueueProvider>
<JellyfinProvider> <ActionSheetProvider>
<StatusBar style="light" backgroundColor="#000" /> <JellyfinProvider>
<ThemeProvider value={DarkTheme}> <StatusBar style="light" backgroundColor="#000" />
<Stack> <ThemeProvider value={DarkTheme}>
<Stack.Screen <Stack>
name="(auth)/(tabs)" <Stack.Screen
options={{ name="(auth)/(tabs)"
headerShown: false, options={{
title: "Home", headerShown: false,
}} title: "Home",
/> }}
<Stack.Screen />
name="(auth)/settings" <Stack.Screen
options={{ name="(auth)/settings"
headerShown: true, options={{
title: "Settings", headerShown: true,
headerStyle: { backgroundColor: "black" }, title: "Settings",
headerShadowVisible: false, headerStyle: { backgroundColor: "black" },
}} headerShadowVisible: false,
/> }}
<Stack.Screen />
name="(auth)/downloads" <Stack.Screen
options={{ name="(auth)/downloads"
headerShown: true, options={{
title: "Downloads", headerShown: true,
headerStyle: { backgroundColor: "black" }, title: "Downloads",
headerShadowVisible: false, headerStyle: { backgroundColor: "black" },
}} headerShadowVisible: false,
/> }}
<Stack.Screen />
name="(auth)/items/[id]/page" <Stack.Screen
options={{ name="(auth)/items/[id]/page"
title: "", options={{
headerShown: false, title: "",
}} headerShown: false,
/> }}
<Stack.Screen />
name="(auth)/collections/[collection]/page" <Stack.Screen
options={{ name="(auth)/collections/[collection]/page"
title: "", options={{
headerShown: true, title: "",
headerStyle: { backgroundColor: "black" }, headerShown: true,
headerShadowVisible: false, headerStyle: { backgroundColor: "black" },
}} headerShadowVisible: false,
/> }}
<Stack.Screen />
name="(auth)/series/[id]/page" <Stack.Screen
options={{ name="(auth)/series/[id]/page"
title: "", options={{
headerShown: false, title: "",
}} headerShown: false,
/> }}
<Stack.Screen />
name="login" <Stack.Screen
options={{ headerShown: false, title: "Login" }} name="login"
/> options={{ headerShown: false, title: "Login" }}
<Stack.Screen name="+not-found" /> />
</Stack> <Stack.Screen name="+not-found" />
<CurrentlyPlayingBar /> </Stack>
</ThemeProvider> <CurrentlyPlayingBar />
</JellyfinProvider> </ThemeProvider>
</ActionSheetProvider> </JellyfinProvider>
</ActionSheetProvider>
</JobQueueProvider>
</JotaiProvider> </JotaiProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -1,18 +1,16 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads"; import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { Text } from "./common/Text";
import { useDownloadMedia } from "@/hooks/useDownloadMedia";
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
type DownloadProps = { type DownloadProps = {
item: BaseItemDto; item: BaseItemDto;
@@ -26,44 +24,30 @@ export const DownloadItem: React.FC<DownloadProps> = ({
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses); const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { downloadMedia, isDownloading, error, cancelDownload } = const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
useDownloadMedia(api, user?.Id);
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
const { data: playbackInfo, isLoading } = useQuery({ const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id], queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id), queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
}); });
const downloadFile = useCallback(async () => { const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
if (!playbackInfo) return; queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
const source = playbackInfo.MediaSources?.[0];
if (source?.SupportsDirectPlay && item.CanDownload) {
downloadMedia(item);
} else {
throw new Error(
"Direct play not supported thus the file cannot be downloaded",
);
}
}, [item, user, playbackInfo]);
const [downloaded, setDownloaded] = useState<boolean>(false);
useEffect(() => {
(async () => {
const data: BaseItemDto[] = JSON.parse( const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]", (await AsyncStorage.getItem("downloaded_files")) || "[]",
); );
if (data.find((d) => d.Id === item.Id)) setDownloaded(true); return data.some((d) => d.Id === item.Id);
})(); },
}, [process]); enabled: !!item.Id,
});
if (isLoading) { if (isLoading || isLoadingDownloaded) {
return ( return (
<View className="rounded h-10 aspect-square flex items-center justify-center"> <View className="rounded h-10 aspect-square flex items-center justify-center">
<ActivityIndicator size={"small"} color={"white"} /> <ActivityIndicator size={"small"} color={"white"} />
@@ -79,17 +63,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
); );
} }
if (process && process.item.Id !== item.Id!) { if (process && process?.item.Id === item.Id) {
return (
<TouchableOpacity onPress={() => {}}>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
if (process) {
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -113,7 +87,23 @@ export const DownloadItem: React.FC<DownloadProps> = ({
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
} else if (downloaded) { }
if (queue.some((i) => i.id === item.Id)) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="hourglass" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
if (downloaded) {
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@@ -129,7 +119,13 @@ export const DownloadItem: React.FC<DownloadProps> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
startRemuxing(); queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await startRemuxing();
},
item,
});
}} }}
> >
<View className="rounded h-10 aspect-square flex items-center justify-center"> <View className="rounded h-10 aspect-square flex items-center justify-center">

View File

@@ -23,7 +23,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
} }
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`; const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => { const startRemuxing = useCallback(async () => {
writeToLog( writeToLog(
@@ -54,28 +54,38 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
); );
}); });
await FFmpegKit.executeAsync(command, async (session) => { // Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
const returnCode = await session.getReturnCode(); await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) { if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item); await updateDownloadedFiles(item);
writeToLog( writeToLog(
"INFO", "INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`, `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
); );
} else if (returnCode.isValueError()) { resolve();
writeToLog( } else if (returnCode.isValueError()) {
"ERROR", writeToLog(
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, "ERROR",
); `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
} else if (returnCode.isValueCancel()) { );
writeToLog( reject(new Error("Remuxing failed")); // Reject the promise on error
"INFO", } else if (returnCode.isValueCancel()) {
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`, writeToLog(
); "INFO",
} `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
resolve();
}
setProgress(null); setProgress(null);
} catch (error) {
reject(error);
}
});
}); });
} catch (error) { } catch (error) {
console.error("Failed to remux:", error); console.error("Failed to remux:", error);
@@ -84,6 +94,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
); );
setProgress(null); setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
} }
}, [output, item, command, setProgress]); }, [output, item, command, setProgress]);

View File

@@ -0,0 +1,14 @@
import React, { createContext } from "react";
import { useJobProcessor } from "@/utils/atoms/queue";
const JobQueueContext = createContext(null);
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
useJobProcessor();
return (
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
);
};

55
utils/atoms/queue.ts Normal file
View File

@@ -0,0 +1,55 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
export interface Job {
id: string;
item: BaseItemDto;
execute: () => void | Promise<void>;
}
export const queueAtom = atom<Job[]>([]);
export const isProcessingAtom = atom(false);
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
const updatedQueue = [...queue, job];
console.info("Enqueueing job", job, updatedQueue);
setQueue(updatedQueue);
},
processJob: async (
queue: Job[],
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
) => {
const [job, ...rest] = queue;
setQueue(rest);
console.info("Processing job", job);
setProcessing(true);
await job.execute();
console.info("Job done", job);
setProcessing(false);
},
clear: (
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
) => {
setQueue([]);
setProcessing(false);
},
};
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
useEffect(() => {
console.info("Queue changed", queue, isProcessing);
if (queue.length > 0 && !isProcessing) {
console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setProcessing);
}
}, [queue, isProcessing, setQueue, setProcessing]);
};