mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
wip
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownload } from "@/components/downloads/ActiveDownload";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
@@ -16,14 +17,14 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
const downloads: React.FC = () => {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const {
|
||||
clearProcess,
|
||||
process,
|
||||
readProcess,
|
||||
startBackgroundDownload,
|
||||
updateProcess,
|
||||
removeProcess,
|
||||
downloadedFiles,
|
||||
} = useDownload();
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||
[downloadedFiles]
|
||||
@@ -51,46 +52,48 @@ const downloads: React.FC = () => {
|
||||
>
|
||||
<View className="px-4 py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4">
|
||||
<View>
|
||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
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">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||
</View>
|
||||
{settings?.downloadMethod === "remux" && (
|
||||
<View>
|
||||
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
clearProcess();
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className="opacity-50">No items in queue</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className="opacity-50">No items in queue</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ActiveDownload />
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
{movies.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2">
|
||||
<Text className="text-2xl font-bold">Movies</Text>
|
||||
<Text className="text-lg font-bold">Movies</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||
</View>
|
||||
@@ -105,6 +108,11 @@ const downloads: React.FC = () => {
|
||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||
))}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className="flex ">
|
||||
<Text className="opacity-50">No downloaded items</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { process, startBackgroundDownload } = useDownload();
|
||||
const { processes, startBackgroundDownload } = useDownload();
|
||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] =
|
||||
@@ -188,6 +188,12 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
[]
|
||||
);
|
||||
|
||||
const process = useMemo(() => {
|
||||
if (!processes) return null;
|
||||
|
||||
return processes.find((process) => process.item.Id === item.Id);
|
||||
}, [processes, item.Id]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const ActiveDownload: React.FC<Props> = ({ ...props }) => {
|
||||
const router = useRouter();
|
||||
const { clearProcess, process } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
if (settings?.downloadMethod === "optimized") {
|
||||
await axios.delete(
|
||||
settings?.optimizedVersionsServerUrl + "cancel-job/" + process.id,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const tasks = await checkForExistingDownloads();
|
||||
for (const task of tasks) task.stop();
|
||||
clearProcess();
|
||||
} else {
|
||||
FFmpegKit.cancel();
|
||||
clearProcess();
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Download canceled");
|
||||
},
|
||||
onError: (e) => {
|
||||
console.log(e);
|
||||
toast.error("Failed to cancel download");
|
||||
clearProcess();
|
||||
},
|
||||
});
|
||||
|
||||
if (!process)
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
||||
<Text className="opacity-50">No active downloads</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
`}
|
||||
style={{
|
||||
width: process.progress
|
||||
? `${Math.max(5, process.progress)}%`
|
||||
: "5%",
|
||||
}}
|
||||
></View>
|
||||
<View className="p-4 flex flex-row items-center justify-between w-full">
|
||||
<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>
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs capitalize">{process.state}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => cancelJobMutation.mutate()}>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
101
components/downloads/ActiveDownloads.tsx
Normal file
101
components/downloads/ActiveDownloads.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
const router = useRouter();
|
||||
const { removeProcess, processes } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
try {
|
||||
if (settings?.downloadMethod === "optimized") {
|
||||
await axios.delete(
|
||||
settings?.optimizedVersionsServerUrl + "cancel-job/" + id,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${settings?.optimizedVersionsAuthHeader}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const tasks = await checkForExistingDownloads();
|
||||
for (const task of tasks) task.stop();
|
||||
} else {
|
||||
FFmpegKit.cancel();
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
removeProcess(id);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Download canceled");
|
||||
},
|
||||
onError: (e) => {
|
||||
console.log(e);
|
||||
toast.error("Failed to cancel download");
|
||||
},
|
||||
});
|
||||
|
||||
if (processes.length === 0)
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Active download</Text>
|
||||
<Text className="opacity-50">No active downloads</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
||||
<View className="space-y-2">
|
||||
{processes.map((p) => (
|
||||
<TouchableOpacity
|
||||
key={p.id}
|
||||
onPress={() => router.push(`/(auth)/items/page?id=${p.item.Id}`)}
|
||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
`}
|
||||
style={{
|
||||
width: p.progress ? `${Math.max(5, p.progress)}%` : "5%",
|
||||
}}
|
||||
></View>
|
||||
<View className="p-4 flex flex-row items-center justify-between w-full">
|
||||
<View>
|
||||
<Text className="font-semibold">{p.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{p.item.Type}</Text>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs">{p.progress.toFixed(0)}%</Text>
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs capitalize">{p.state}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => cancelJobMutation.mutate(p.id)}>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
|
||||
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{items.length}</Text>
|
||||
</View>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useRouter } from "expo-router";
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { process, updateProcess, clearProcess, saveDownloadedItemInfo } =
|
||||
const { clearProcesses, saveDownloadedItemInfo, addProcess, updateProcess } =
|
||||
useDownload();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
);
|
||||
|
||||
try {
|
||||
updateProcess({
|
||||
addProcess({
|
||||
id: item.Id,
|
||||
item,
|
||||
progress: 0,
|
||||
@@ -71,12 +71,9 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
updateProcess((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
progress: percentage,
|
||||
};
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
updateProcess(item.Id, {
|
||||
progress: percentage,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +111,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
resolve();
|
||||
}
|
||||
|
||||
clearProcess();
|
||||
clearProcesses();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
@@ -126,17 +123,17 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
clearProcess();
|
||||
clearProcesses();
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[output, item, clearProcess]
|
||||
[output, item, clearProcesses]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
clearProcess();
|
||||
}, [item.Name, clearProcess]);
|
||||
clearProcesses();
|
||||
}, [item.Name, clearProcesses]);
|
||||
|
||||
return { startRemuxing, cancelRemuxing };
|
||||
};
|
||||
|
||||
@@ -30,10 +30,16 @@ export type ProcessItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
progress: number;
|
||||
size?: number;
|
||||
state: "optimizing" | "downloading" | "done" | "error" | "canceled";
|
||||
state:
|
||||
| "optimizing"
|
||||
| "downloading"
|
||||
| "done"
|
||||
| "error"
|
||||
| "canceled"
|
||||
| "queued";
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "runningProcess";
|
||||
const STORAGE_KEY = "runningProcesses";
|
||||
|
||||
const DownloadContext = createContext<ReturnType<
|
||||
typeof useDownloadProvider
|
||||
@@ -41,7 +47,7 @@ const DownloadContext = createContext<ReturnType<
|
||||
|
||||
function useDownloadProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
const [process, setProcess] = useState<ProcessItem | null>(null);
|
||||
const [processes, setProcesses] = useState<ProcessItem[]>([]);
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const authHeader = useMemo(() => {
|
||||
@@ -59,178 +65,147 @@ function useDownloadProvider() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Load initial process state from AsyncStorage
|
||||
const loadInitialProcess = async () => {
|
||||
const storedProcess = await readProcess();
|
||||
setProcess(storedProcess);
|
||||
// Load initial processes state from AsyncStorage
|
||||
const loadInitialProcesses = async () => {
|
||||
const storedProcesses = await readProcesses();
|
||||
setProcesses(storedProcesses);
|
||||
};
|
||||
loadInitialProcess();
|
||||
loadInitialProcesses();
|
||||
}, []);
|
||||
|
||||
const clearProcess = useCallback(async () => {
|
||||
const clearProcesses = useCallback(async () => {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
setProcess(null);
|
||||
setProcesses([]);
|
||||
}, []);
|
||||
|
||||
const updateProcess = useCallback(
|
||||
async (
|
||||
itemOrUpdater:
|
||||
| ProcessItem
|
||||
| null
|
||||
| ((prevState: ProcessItem | null) => ProcessItem | null)
|
||||
) => {
|
||||
setProcess((prevProcess) => {
|
||||
let newState: ProcessItem | null;
|
||||
if (typeof itemOrUpdater === "function") {
|
||||
newState = itemOrUpdater(prevProcess);
|
||||
} else {
|
||||
newState = itemOrUpdater;
|
||||
}
|
||||
async (id: string, updater: Partial<ProcessItem>) => {
|
||||
setProcesses((prevProcesses) => {
|
||||
const newProcesses = prevProcesses.map((process) =>
|
||||
process.id === id ? { ...process, ...updater } : process
|
||||
);
|
||||
|
||||
if (newState === null) {
|
||||
AsyncStorage.removeItem(STORAGE_KEY);
|
||||
} else {
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
|
||||
}
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
|
||||
|
||||
return newState;
|
||||
return newProcesses;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const readProcess = useCallback(async (): Promise<ProcessItem | null> => {
|
||||
const item = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return item ? JSON.parse(item) : null;
|
||||
const addProcess = useCallback(async (item: ProcessItem) => {
|
||||
setProcesses((prevProcesses) => {
|
||||
const newProcesses = [...prevProcesses, item];
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
|
||||
return newProcesses;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(() => {
|
||||
if (!process?.item.Id) throw new Error("No item id");
|
||||
const removeProcess = useCallback(async (id: string) => {
|
||||
setProcesses((prevProcesses) => {
|
||||
const newProcesses = prevProcesses.filter((process) => process.id !== id);
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
|
||||
return newProcesses;
|
||||
});
|
||||
}, []);
|
||||
|
||||
download({
|
||||
id: process.id,
|
||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||
destination: `${directories.documents}/${process?.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
toast.info(`Download started for ${process.item.Name}`);
|
||||
updateProcess((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
state: "downloading",
|
||||
progress: 50,
|
||||
} as ProcessItem;
|
||||
});
|
||||
const readProcesses = useCallback(async (): Promise<ProcessItem[]> => {
|
||||
const items = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return items ? JSON.parse(items) : [];
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(
|
||||
(process: ProcessItem) => {
|
||||
if (!process?.item.Id) throw new Error("No item id");
|
||||
|
||||
download({
|
||||
id: process.id,
|
||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||
destination: `${directories.documents}/${process?.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
})
|
||||
.progress((data) => {
|
||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
updateProcess((prev) => {
|
||||
if (!prev) {
|
||||
console.warn("no prev");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
.begin(() => {
|
||||
toast.info(`Download started for ${process.item.Name}`);
|
||||
updateProcess(process.id, { state: "downloading" });
|
||||
})
|
||||
.progress((data) => {
|
||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
updateProcess(process.id, {
|
||||
state: "downloading",
|
||||
progress: percent,
|
||||
};
|
||||
});
|
||||
})
|
||||
.done(async () => {
|
||||
removeProcess(process.id);
|
||||
await saveDownloadedItemInfo(process.item);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
await refetch();
|
||||
completeHandler(process.id);
|
||||
toast.success(`Download completed for ${process.item.Name}`);
|
||||
})
|
||||
.error((error) => {
|
||||
updateProcess(process.id, { state: "error" });
|
||||
toast.error(`Download failed for ${process.item.Name}: ${error}`);
|
||||
});
|
||||
})
|
||||
.done(async () => {
|
||||
clearProcess();
|
||||
await saveDownloadedItemInfo(process.item);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["downloadedItems"],
|
||||
});
|
||||
await refetch();
|
||||
completeHandler(process.id);
|
||||
toast.success(`Download completed for ${process.item.Name}`);
|
||||
})
|
||||
.error((error) => {
|
||||
updateProcess((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
state: "error",
|
||||
};
|
||||
});
|
||||
toast.error(`Download failed for ${process.item.Name}: ${error}`);
|
||||
});
|
||||
}, [queryClient, process?.id, settings?.optimizedVersionsServerUrl]);
|
||||
},
|
||||
[queryClient, settings?.optimizedVersionsServerUrl]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
const checkJobStatusPeriodically = async () => {
|
||||
// console.log("checkJobStatusPeriodically ~");
|
||||
if (
|
||||
!process?.id ||
|
||||
!process.state ||
|
||||
!process.item.Id ||
|
||||
!settings?.optimizedVersionsServerUrl
|
||||
)
|
||||
return;
|
||||
if (process.state === "optimizing") {
|
||||
const job = await checkJobStatus(
|
||||
process.id,
|
||||
settings?.optimizedVersionsServerUrl,
|
||||
authHeader
|
||||
);
|
||||
if (!settings?.optimizedVersionsServerUrl) return;
|
||||
|
||||
if (!job) {
|
||||
clearProcess();
|
||||
return;
|
||||
}
|
||||
const updatedProcesses = await Promise.all(
|
||||
processes.map(async (process) => {
|
||||
if (!settings.optimizedVersionsServerUrl) return;
|
||||
if (process.state === "queued" || process.state === "optimizing") {
|
||||
const job = await checkJobStatus(
|
||||
process.id,
|
||||
settings.optimizedVersionsServerUrl,
|
||||
authHeader
|
||||
);
|
||||
|
||||
// Update the local process state with the state from the server.
|
||||
let newState: ProcessItem["state"] = "optimizing";
|
||||
if (job.status === "completed") {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
startDownload();
|
||||
return;
|
||||
} else if (job.status === "failed") {
|
||||
newState = "error";
|
||||
} else if (job.status === "cancelled") {
|
||||
newState = "canceled";
|
||||
}
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
updateProcess((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
state: newState,
|
||||
progress: job.progress,
|
||||
};
|
||||
});
|
||||
} else if (process.state === "downloading") {
|
||||
// Don't do anything, it's downloading locally
|
||||
return;
|
||||
} else if (["done", "canceled", "error"].includes(process.state)) {
|
||||
console.log("Job is done or failed or canceled");
|
||||
clearProcess();
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
}
|
||||
let newState: ProcessItem["state"] = process.state;
|
||||
if (job.status === "queued") {
|
||||
newState = "queued";
|
||||
} else if (job.status === "running") {
|
||||
newState = "optimizing";
|
||||
} else if (job.status === "completed") {
|
||||
startDownload(process);
|
||||
return null;
|
||||
} else if (job.status === "failed") {
|
||||
newState = "error";
|
||||
} else if (job.status === "cancelled") {
|
||||
newState = "canceled";
|
||||
}
|
||||
|
||||
return { ...process, state: newState, progress: job.progress };
|
||||
}
|
||||
return process;
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out null values (completed or cancelled jobs)
|
||||
const filteredProcesses = updatedProcesses.filter(
|
||||
(process) => process !== null
|
||||
) as ProcessItem[];
|
||||
|
||||
// Update the state with the filtered processes
|
||||
setProcesses(filteredProcesses);
|
||||
};
|
||||
|
||||
console.log("Starting interval check");
|
||||
const intervalId = setInterval(checkJobStatusPeriodically, 2000);
|
||||
|
||||
// Start checking immediately
|
||||
checkJobStatusPeriodically();
|
||||
|
||||
// Then check every 2 seconds
|
||||
intervalId = setInterval(checkJobStatusPeriodically, 2000);
|
||||
|
||||
// Clean up function
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [process?.id, settings?.optimizedVersionsServerUrl]);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [processes, settings?.optimizedVersionsServerUrl]);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (url: string, item: BaseItemDto) => {
|
||||
@@ -252,14 +227,14 @@ function useDownloadProvider() {
|
||||
|
||||
const { id } = response.data;
|
||||
|
||||
updateProcess({
|
||||
addProcess({
|
||||
id,
|
||||
item: item,
|
||||
progress: 0,
|
||||
state: "optimizing",
|
||||
state: "queued",
|
||||
});
|
||||
|
||||
toast.success(`Optimization started for ${item.Name}`, {
|
||||
toast.success(`Queued ${item.Name} for optimization`, {
|
||||
action: {
|
||||
label: "Go to download",
|
||||
onClick: () => {
|
||||
@@ -276,22 +251,16 @@ function useDownloadProvider() {
|
||||
[settings?.optimizedVersionsServerUrl]
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes all downloaded files and clears the download record.
|
||||
*/
|
||||
const deleteAllFiles = async (): Promise<void> => {
|
||||
try {
|
||||
// Get the base directory
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!baseDirectory) {
|
||||
throw new Error("Base directory not found");
|
||||
}
|
||||
|
||||
// Read the contents of the base directory
|
||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
|
||||
// Delete each item in the directory
|
||||
for (const item of dirContents) {
|
||||
const itemPath = `${baseDirectory}${item}`;
|
||||
const itemInfo = await FileSystem.getInfoAsync(itemPath);
|
||||
@@ -300,12 +269,10 @@ function useDownloadProvider() {
|
||||
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
||||
}
|
||||
}
|
||||
// Clear the downloadedItems in AsyncStorage
|
||||
await AsyncStorage.removeItem("downloadedItems");
|
||||
await AsyncStorage.removeItem("runningProcess");
|
||||
clearProcess();
|
||||
await AsyncStorage.removeItem("runningProcesses");
|
||||
clearProcesses();
|
||||
|
||||
// Invalidate the query to refresh the UI
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
|
||||
console.log(
|
||||
@@ -316,10 +283,6 @@ function useDownloadProvider() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a specific file and updates the download record.
|
||||
* @param id - The ID of the file to delete.
|
||||
*/
|
||||
const deleteFile = async (id: string): Promise<void> => {
|
||||
if (!id) {
|
||||
console.error("Invalid file ID");
|
||||
@@ -327,17 +290,14 @@ function useDownloadProvider() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the directory path
|
||||
const directory = FileSystem.documentDirectory;
|
||||
|
||||
if (!directory) {
|
||||
console.error("Document directory not found");
|
||||
return;
|
||||
}
|
||||
// Read the contents of the directory
|
||||
const dirContents = await FileSystem.readDirectoryAsync(directory);
|
||||
|
||||
// Find and delete the file with the matching ID (without extension)
|
||||
for (const item of dirContents) {
|
||||
const itemNameWithoutExtension = item.split(".")[0];
|
||||
if (itemNameWithoutExtension === id) {
|
||||
@@ -348,7 +308,6 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the item from AsyncStorage
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
let items = JSON.parse(downloadedItems);
|
||||
@@ -356,7 +315,6 @@ function useDownloadProvider() {
|
||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||
}
|
||||
|
||||
// Invalidate the query to refresh the UI
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
|
||||
console.log(
|
||||
@@ -370,10 +328,6 @@ function useDownloadProvider() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the list of downloaded files from AsyncStorage.
|
||||
* @returns An array of BaseItemDto objects representing downloaded files.
|
||||
*/
|
||||
async function getAllDownloadedItems(): Promise<BaseItemDto[]> {
|
||||
try {
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
@@ -411,19 +365,20 @@ function useDownloadProvider() {
|
||||
}
|
||||
|
||||
return {
|
||||
process,
|
||||
processes,
|
||||
updateProcess,
|
||||
startBackgroundDownload,
|
||||
clearProcess,
|
||||
readProcess,
|
||||
clearProcesses,
|
||||
readProcesses,
|
||||
downloadedFiles,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
saveDownloadedItemInfo,
|
||||
addProcess,
|
||||
removeProcess,
|
||||
};
|
||||
}
|
||||
|
||||
// Create the provider component
|
||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
const downloadProviderValue = useDownloadProvider();
|
||||
const queryClient = new QueryClient();
|
||||
@@ -435,7 +390,6 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Create a custom hook to use the download context
|
||||
export function useDownload() {
|
||||
const context = useContext(DownloadContext);
|
||||
if (context === null) {
|
||||
@@ -450,7 +404,7 @@ const checkJobStatus = async (
|
||||
authHeader?: string | null
|
||||
): Promise<{
|
||||
progress: number;
|
||||
status: "running" | "completed" | "failed" | "cancelled";
|
||||
status: "queued" | "running" | "completed" | "failed" | "cancelled";
|
||||
}> => {
|
||||
const statusResponse = await axios.get(`${baseUrl}job-status/${id}`, {
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user