From ad8bc954c1e072ce99dba93c2266bfd17fd7457e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 14 Aug 2024 13:30:43 +0200 Subject: [PATCH] feat: download queue --- app/(auth)/downloads.tsx | 114 ++++++++++++++++++---------- app/_layout.tsx | 133 +++++++++++++++++---------------- components/DownloadItem.tsx | 82 ++++++++++---------- hooks/useRemuxHlsToMp4.ts | 53 +++++++------ providers/JobQueueProvider.tsx | 14 ++++ utils/atoms/queue.ts | 55 ++++++++++++++ 6 files changed, 285 insertions(+), 166 deletions(-) create mode 100644 providers/JobQueueProvider.tsx create mode 100644 utils/atoms/queue.ts diff --git a/app/(auth)/downloads.tsx b/app/(auth)/downloads.tsx index d01df1d6..cfab6c42 100644 --- a/app/(auth)/downloads.tsx +++ b/app/(auth)/downloads.tsx @@ -17,9 +17,11 @@ import { router } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { FFmpegKit } from "ffmpeg-kit-react-native"; import * as FileSystem from "expo-file-system"; +import { queueAtom } from "@/utils/atoms/queue"; const downloads: React.FC = () => { const [process, setProcess] = useAtom(runningProcesses); + const [queue, setQueue] = useAtom(queueAtom); const { data: downloadedFiles, isLoading } = useQuery({ queryKey: ["downloaded_files", process?.item.Id], @@ -67,50 +69,84 @@ const downloads: React.FC = () => { return ( - - Active download - {process?.item ? ( - - 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" - > - - {process.item.Name} - {process.item.Type} - - - {process.progress.toFixed(0)}% - - {process.speed?.toFixed(2)}x + + + Queue + + {queue.map((q) => ( + 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" + > - ETA {eta} + {q.item.Name} + {q.item.Type} + + { + setQueue((prev) => prev.filter((i) => i.id !== q.id)); + }} + > + + + + ))} + + + {queue.length === 0 && ( + No items in queue + )} + + + + Active download + {process?.item ? ( + + 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" + > + + {process.item.Name} + + {process.item.Type} + + + + {process.progress.toFixed(0)}% + + + {process.speed?.toFixed(2)}x + + + ETA {eta} + - - { - FFmpegKit.cancel(); - setProcess(null); - }} - > - - - { + FFmpegKit.cancel(); + setProcess(null); + }} + > + + + - - ) : ( - No active downloads - )} + style={{ + width: process.progress + ? `${Math.max(5, process.progress)}%` + : "5%", + }} + > + + ) : ( + No active downloads + )} + {movies.length > 0 && ( diff --git a/app/_layout.tsx b/app/_layout.tsx index dde840d1..0258f5cd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -11,6 +11,9 @@ import * as ScreenOrientation from "expo-screen-orientation"; import { StatusBar } from "expo-status-bar"; import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar"; 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. SplashScreen.preventAutoHideAsync(); @@ -20,6 +23,8 @@ export const unstable_settings = { }; export default function RootLayout() { + useKeepAwake(); + const [loaded] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), }); @@ -75,69 +80,71 @@ export default function RootLayout() { return ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 51b01e2d..df7d8b92 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,18 +1,16 @@ +import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; 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 = { item: BaseItemDto; @@ -26,44 +24,30 @@ export const DownloadItem: React.FC = ({ const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [process] = useAtom(runningProcesses); + const [queue, setQueue] = useAtom(queueAtom); - const { downloadMedia, isDownloading, error, cancelDownload } = - useDownloadMedia(api, user?.Id); - - const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackUrl, item); + const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item); const { data: playbackInfo, isLoading } = useQuery({ queryKey: ["playbackInfo", item.Id], queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id), }); - const downloadFile = useCallback(async () => { - if (!playbackInfo) return; + const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({ + 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(false); - - useEffect(() => { - (async () => { const data: BaseItemDto[] = JSON.parse( (await AsyncStorage.getItem("downloaded_files")) || "[]", ); - if (data.find((d) => d.Id === item.Id)) setDownloaded(true); - })(); - }, [process]); + return data.some((d) => d.Id === item.Id); + }, + enabled: !!item.Id, + }); - if (isLoading) { + if (isLoading || isLoadingDownloaded) { return ( @@ -79,17 +63,7 @@ export const DownloadItem: React.FC = ({ ); } - if (process && process.item.Id !== item.Id!) { - return ( - {}}> - - - - - ); - } - - if (process) { + if (process && process?.item.Id === item.Id) { return ( { @@ -113,7 +87,23 @@ export const DownloadItem: React.FC = ({ ); - } else if (downloaded) { + } + + if (queue.some((i) => i.id === item.Id)) { + return ( + { + router.push("/downloads"); + }} + > + + + + + ); + } + + if (downloaded) { return ( { @@ -129,7 +119,13 @@ export const DownloadItem: React.FC = ({ return ( { - startRemuxing(); + queueActions.enqueue(queue, setQueue, { + id: item.Id!, + execute: async () => { + await startRemuxing(); + }, + item, + }); }} > diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index 8409041d..928cd93d 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -23,7 +23,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => { } 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 () => { writeToLog( @@ -54,28 +54,38 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => { ); }); - await FFmpegKit.executeAsync(command, async (session) => { - const returnCode = await session.getReturnCode(); + // Await the execution of the FFmpeg command and ensure that the callback is awaited properly. + await new Promise((resolve, reject) => { + FFmpegKit.executeAsync(command, async (session) => { + try { + const returnCode = await session.getReturnCode(); - if (returnCode.isValueSuccess()) { - await updateDownloadedFiles(item); - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`, - ); - } else if (returnCode.isValueError()) { - writeToLog( - "ERROR", - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, - ); - } else if (returnCode.isValueCancel()) { - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`, - ); - } + if (returnCode.isValueSuccess()) { + await updateDownloadedFiles(item); + writeToLog( + "INFO", + `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`, + ); + resolve(); + } else if (returnCode.isValueError()) { + writeToLog( + "ERROR", + `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`, + ); + reject(new Error("Remuxing failed")); // Reject the promise on error + } else if (returnCode.isValueCancel()) { + writeToLog( + "INFO", + `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`, + ); + resolve(); + } - setProgress(null); + setProgress(null); + } catch (error) { + reject(error); + } + }); }); } catch (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}`, ); setProgress(null); + throw error; // Re-throw the error to propagate it to the caller } }, [output, item, command, setProgress]); diff --git a/providers/JobQueueProvider.tsx b/providers/JobQueueProvider.tsx new file mode 100644 index 00000000..00358e48 --- /dev/null +++ b/providers/JobQueueProvider.tsx @@ -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 ( + {children} + ); +}; diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts new file mode 100644 index 00000000..968cada0 --- /dev/null +++ b/utils/atoms/queue.ts @@ -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; +} + +export const queueAtom = atom([]); +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]); +};