mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 19:48:28 +01:00
feat: download queue
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
133
app/_layout.tsx
133
app/_layout.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
14
providers/JobQueueProvider.tsx
Normal file
14
providers/JobQueueProvider.tsx
Normal 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
55
utils/atoms/queue.ts
Normal 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]);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user