mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
wip
This commit is contained in:
@@ -16,12 +16,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const {
|
||||
startBackgroundDownload,
|
||||
updateProcess,
|
||||
removeProcess,
|
||||
downloadedFiles,
|
||||
} = useDownload();
|
||||
const { removeProcess, downloadedFiles } = useDownload();
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
);
|
||||
|
||||
let url: string | undefined = undefined;
|
||||
let fileExtension: string | undefined | null = "mp4";
|
||||
|
||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
||||
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
||||
@@ -146,12 +147,14 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
}
|
||||
} else if (mediaSource.TranscodingUrl) {
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
fileExtension = mediaSource.TranscodingContainer;
|
||||
}
|
||||
|
||||
if (!url) throw new Error("No url");
|
||||
if (!fileExtension) throw new Error("No file extension");
|
||||
|
||||
if (settings?.downloadMethod === "optimized") {
|
||||
return await startBackgroundDownload(url, item);
|
||||
return await startBackgroundDownload(url, item, fileExtension);
|
||||
} else {
|
||||
return await startRemuxing(url);
|
||||
}
|
||||
@@ -192,7 +195,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const process = useMemo(() => {
|
||||
if (!processes) return null;
|
||||
|
||||
return processes.find((process) => process.item.Id === item.Id);
|
||||
return processes.find((process) => process?.item?.Id === item.Id);
|
||||
}, [processes, item.Id]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ProcessItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { cancelJobById, JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
||||
@@ -18,7 +19,7 @@ interface Props extends ViewProps {}
|
||||
|
||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
const router = useRouter();
|
||||
const { removeProcess, processes } = useDownload();
|
||||
const { removeProcess, processes, setProcesses } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
@@ -26,25 +27,22 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
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: api?.accessToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (settings?.downloadMethod === "optimized") {
|
||||
try {
|
||||
const tasks = await checkForExistingDownloads();
|
||||
for (const task of tasks) task.stop();
|
||||
} else {
|
||||
FFmpegKit.cancel();
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) {
|
||||
await task.stop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
removeProcess(id);
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
removeProcess(id);
|
||||
} else {
|
||||
FFmpegKit.cancel();
|
||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -52,12 +50,12 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
},
|
||||
onError: (e) => {
|
||||
console.log(e);
|
||||
toast.error("Failed to cancel download on the server");
|
||||
toast.error("Could not cancel download");
|
||||
},
|
||||
});
|
||||
|
||||
const eta = useCallback(
|
||||
(p: ProcessItem) => {
|
||||
(p: JobStatus) => {
|
||||
if (!p.speed || !p.progress) return null;
|
||||
|
||||
const length = p?.item?.RunTimeTicks || 0;
|
||||
@@ -67,7 +65,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
[process]
|
||||
);
|
||||
|
||||
if (processes.length === 0)
|
||||
if (processes?.length === 0)
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Active download</Text>
|
||||
@@ -79,7 +77,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
<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) => (
|
||||
{processes?.map((p) => (
|
||||
<TouchableOpacity
|
||||
key={p.id}
|
||||
onPress={() => router.push(`/(auth)/items/page?id=${p.item.Id}`)}
|
||||
@@ -109,7 +107,7 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs capitalize">{p.state}</Text>
|
||||
<Text className="text-xs capitalize">{p.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => cancelJobMutation.mutate(p.id)}>
|
||||
|
||||
@@ -24,11 +24,13 @@ import { Button } from "../Button";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { opacity } from "react-native-reanimated/lib/typescript/reanimated2/Colors";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const { setProcesses } = useDownload();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -495,7 +497,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: "remux" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
setProcesses([]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
||||
@@ -504,6 +506,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: "optimized" });
|
||||
setProcesses([]);
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -13,13 +13,40 @@ export const useFileOpener = () => {
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
const directory = FileSystem.documentDirectory;
|
||||
const url = `${directory}/${item.Id}.mp4`;
|
||||
|
||||
startDownloadedFilePlayback({
|
||||
item,
|
||||
url,
|
||||
});
|
||||
router.push("/play");
|
||||
if (!directory) {
|
||||
throw new Error("Document directory is not available");
|
||||
}
|
||||
|
||||
if (!item.Id) {
|
||||
throw new Error("Item ID is not available");
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await FileSystem.readDirectoryAsync(directory);
|
||||
for (let f of files) {
|
||||
console.log(f);
|
||||
}
|
||||
const path = item.Id!;
|
||||
const matchingFile = files.find((file) => file.startsWith(path));
|
||||
|
||||
if (!matchingFile) {
|
||||
throw new Error(`No file found for item ${path}`);
|
||||
}
|
||||
|
||||
const url = `${directory}${matchingFile}`;
|
||||
|
||||
console.log("Opening " + url);
|
||||
|
||||
startDownloadedFilePlayback({
|
||||
item,
|
||||
url,
|
||||
});
|
||||
router.push("/play");
|
||||
} catch (error) {
|
||||
console.error("Error opening file:", error);
|
||||
// Handle the error appropriately, e.g., show an error message to the user
|
||||
}
|
||||
},
|
||||
[startDownloadedFilePlayback]
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useRouter } from "expo-router";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -19,8 +20,7 @@ import { useRouter } from "expo-router";
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { clearProcesses, saveDownloadedItemInfo, addProcess, updateProcess } =
|
||||
useDownload();
|
||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||
const router = useRouter();
|
||||
|
||||
if (!item.Id || !item.Name) {
|
||||
@@ -52,12 +52,20 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
);
|
||||
|
||||
try {
|
||||
addProcess({
|
||||
id: item.Id,
|
||||
item,
|
||||
progress: 0,
|
||||
state: "downloading",
|
||||
});
|
||||
setProcesses((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: "",
|
||||
deviceId: "",
|
||||
inputUrl: "",
|
||||
item,
|
||||
itemId: item.Id,
|
||||
outputPath: "",
|
||||
progress: 0,
|
||||
status: "running",
|
||||
timestamp: new Date(),
|
||||
} as JobStatus,
|
||||
]);
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
@@ -73,9 +81,17 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
: 0;
|
||||
|
||||
if (!item.Id) throw new Error("Item is undefined");
|
||||
updateProcess(item.Id, {
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
setProcesses((prev) => {
|
||||
return prev.map((process) => {
|
||||
if (process.itemId === item.Id) {
|
||||
return {
|
||||
...process,
|
||||
progress: percentage,
|
||||
speed: Math.max(speed, 0),
|
||||
};
|
||||
}
|
||||
return process;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,14 +114,12 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
});
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
toast.success("Download failed");
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
toast.success("Download canceled");
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
|
||||
@@ -113,7 +127,9 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
resolve();
|
||||
}
|
||||
|
||||
clearProcesses();
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
@@ -125,17 +141,21 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
clearProcesses();
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[output, item, clearProcesses]
|
||||
[output, item]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
clearProcesses();
|
||||
}, [item.Name, clearProcesses]);
|
||||
setProcesses((prev) => {
|
||||
return prev.filter((process) => process.itemId !== item.Id);
|
||||
});
|
||||
}, [item.Name]);
|
||||
|
||||
return { startRemuxing, cancelRemuxing };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
checkForExistingDownloads,
|
||||
completeHandler,
|
||||
directories,
|
||||
download,
|
||||
@@ -22,27 +23,21 @@ import React, {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom } from "./JellyfinProvider";
|
||||
import * as BackgroundFetch from "expo-background-fetch";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
|
||||
export type ProcessItem = {
|
||||
id: string;
|
||||
item: Partial<BaseItemDto>;
|
||||
progress: number;
|
||||
size?: number;
|
||||
speed?: number;
|
||||
state:
|
||||
| "optimizing"
|
||||
| "downloading"
|
||||
| "done"
|
||||
| "error"
|
||||
| "canceled"
|
||||
| "queued";
|
||||
};
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import {
|
||||
cancelAllJobs,
|
||||
cancelJobById,
|
||||
getAllJobsByDeviceId,
|
||||
JobStatus,
|
||||
} from "@/utils/optimize-server";
|
||||
|
||||
export const BACKGROUND_FETCH_TASK = "background-fetch";
|
||||
|
||||
@@ -77,7 +72,6 @@ const DownloadContext = createContext<ReturnType<
|
||||
|
||||
function useDownloadProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
const [processes, setProcesses] = useState<ProcessItem[]>([]);
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -92,115 +86,133 @@ function useDownloadProvider() {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Check background task status
|
||||
checkStatusAsync();
|
||||
const [processes, setProcesses] = useState<JobStatus[]>([]);
|
||||
|
||||
// Load initial processes state from AsyncStorage
|
||||
const loadInitialProcesses = async () => {
|
||||
const storedProcesses = await readProcesses();
|
||||
setProcesses(storedProcesses);
|
||||
useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: async () => {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (
|
||||
settings?.downloadMethod !== "optimized" ||
|
||||
!url ||
|
||||
!deviceId ||
|
||||
!authHeader
|
||||
)
|
||||
return [];
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader,
|
||||
url,
|
||||
});
|
||||
|
||||
setProcesses(jobs);
|
||||
|
||||
return jobs;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: 1000 * 3, // 5 minutes
|
||||
enabled: settings?.downloadMethod === "optimized",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkIfShouldStartDownload = async () => {
|
||||
if (!processes) return;
|
||||
for (let i = 0; i < processes.length; i++) {
|
||||
const job = processes[i];
|
||||
|
||||
// Check if the download is already in progress
|
||||
const tasks = await checkForExistingDownloads();
|
||||
if (tasks.find((task) => task.id === job.id)) continue;
|
||||
|
||||
if (job.status === "completed") {
|
||||
await startDownload(job);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
loadInitialProcesses();
|
||||
}, []);
|
||||
|
||||
checkIfShouldStartDownload();
|
||||
}, [processes]);
|
||||
|
||||
/********************
|
||||
* Background task
|
||||
*******************/
|
||||
const [isRegistered, setIsRegistered] = useState(false);
|
||||
const [status, setStatus] =
|
||||
useState<BackgroundFetch.BackgroundFetchStatus | null>(null);
|
||||
// useEffect(() => {
|
||||
// // Check background task status
|
||||
// checkStatusAsync();
|
||||
// }, []);
|
||||
|
||||
const checkStatusAsync = async () => {
|
||||
const status = await BackgroundFetch.getStatusAsync();
|
||||
const isRegistered = await TaskManager.isTaskRegisteredAsync(
|
||||
BACKGROUND_FETCH_TASK
|
||||
);
|
||||
setStatus(status);
|
||||
setIsRegistered(isRegistered);
|
||||
// const [isRegistered, setIsRegistered] = useState(false);
|
||||
// const [status, setStatus] =
|
||||
// useState<BackgroundFetch.BackgroundFetchStatus | null>(null);
|
||||
|
||||
console.log("Background fetch status:", status);
|
||||
console.log("Background fetch task registered:", isRegistered);
|
||||
};
|
||||
// const checkStatusAsync = async () => {
|
||||
// const status = await BackgroundFetch.getStatusAsync();
|
||||
// const isRegistered = await TaskManager.isTaskRegisteredAsync(
|
||||
// BACKGROUND_FETCH_TASK
|
||||
// );
|
||||
// setStatus(status);
|
||||
// setIsRegistered(isRegistered);
|
||||
|
||||
const toggleFetchTask = async () => {
|
||||
if (isRegistered) {
|
||||
console.log("Unregistering background fetch task");
|
||||
await unregisterBackgroundFetchAsync();
|
||||
} else {
|
||||
console.log("Registering background fetch task");
|
||||
await registerBackgroundFetchAsync();
|
||||
}
|
||||
// console.log("Background fetch status:", status);
|
||||
// console.log("Background fetch task registered:", isRegistered);
|
||||
// };
|
||||
|
||||
checkStatusAsync();
|
||||
};
|
||||
// const toggleFetchTask = async () => {
|
||||
// if (isRegistered) {
|
||||
// console.log("Unregistering background fetch task");
|
||||
// await unregisterBackgroundFetchAsync();
|
||||
// } else {
|
||||
// console.log("Registering background fetch task");
|
||||
// await registerBackgroundFetchAsync();
|
||||
// }
|
||||
|
||||
// checkStatusAsync();
|
||||
// };
|
||||
/**********************
|
||||
**********************
|
||||
*********************/
|
||||
|
||||
const clearProcesses = useCallback(async () => {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
setProcesses([]);
|
||||
}, []);
|
||||
const removeProcess = useCallback(
|
||||
async (id: string) => {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
|
||||
return;
|
||||
|
||||
const updateProcess = useCallback(
|
||||
async (id: string, updater: Partial<ProcessItem>) => {
|
||||
setProcesses((prevProcesses) => {
|
||||
const newProcesses = prevProcesses.map((process) =>
|
||||
process.id === id ? { ...process, ...updater } : process
|
||||
);
|
||||
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
|
||||
|
||||
return newProcesses;
|
||||
});
|
||||
try {
|
||||
await cancelJobById({
|
||||
authHeader,
|
||||
id,
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
const addProcess = useCallback(async (item: ProcessItem) => {
|
||||
setProcesses((prevProcesses) => {
|
||||
const newProcesses = [...prevProcesses, item];
|
||||
AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newProcesses));
|
||||
return newProcesses;
|
||||
});
|
||||
}, []);
|
||||
|
||||
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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const readProcesses = useCallback(async (): Promise<ProcessItem[]> => {
|
||||
const items = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
return items ? JSON.parse(items) : [];
|
||||
}, []);
|
||||
|
||||
const startDownload = useCallback(
|
||||
(process: ProcessItem) => {
|
||||
async (process: JobStatus) => {
|
||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||
|
||||
download({
|
||||
id: process.id,
|
||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||
destination: `${directories.documents}/${process?.item.Id}.mp4`,
|
||||
destination: `${directories.documents}/${process.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
toast.info(`Download started for ${process.item.Name}`);
|
||||
updateProcess(process.id, { state: "downloading", progress: 0 });
|
||||
})
|
||||
.progress((data) => {
|
||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
updateProcess(process.id, {
|
||||
state: "downloading",
|
||||
progress: percent,
|
||||
});
|
||||
console.log("Progress ~", percent);
|
||||
})
|
||||
.done(async () => {
|
||||
removeProcess(process.id);
|
||||
@@ -213,112 +225,22 @@ function useDownloadProvider() {
|
||||
toast.success(`Download completed for ${process.item.Name}`);
|
||||
})
|
||||
.error((error) => {
|
||||
updateProcess(process.id, { state: "error" });
|
||||
toast.error(`Download failed for ${process.item.Name}: ${error}`);
|
||||
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
||||
error,
|
||||
});
|
||||
});
|
||||
},
|
||||
[queryClient, settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkJobStatusPeriodically = async () => {
|
||||
if (!settings?.optimizedVersionsServerUrl || !authHeader) return;
|
||||
|
||||
const updatedProcesses = await Promise.all(
|
||||
processes.map(async (process) => {
|
||||
if (!settings.optimizedVersionsServerUrl) return process;
|
||||
if (process.state === "queued" || process.state === "optimizing") {
|
||||
try {
|
||||
const job = await checkJobStatus(
|
||||
process.id,
|
||||
settings.optimizedVersionsServerUrl,
|
||||
authHeader
|
||||
);
|
||||
|
||||
if (!job) {
|
||||
return process;
|
||||
}
|
||||
|
||||
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 {
|
||||
...process,
|
||||
progress: 100,
|
||||
speed: 0,
|
||||
};
|
||||
} else if (job.status === "failed") {
|
||||
newState = "error";
|
||||
} else if (job.status === "cancelled") {
|
||||
newState = "canceled";
|
||||
}
|
||||
|
||||
return {
|
||||
...process,
|
||||
state: newState,
|
||||
progress: job.progress,
|
||||
speed: job.speed,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && !error.response) {
|
||||
// Network error occurred (server might be down)
|
||||
console.error("Network error occurred:", error.message);
|
||||
toast.error(
|
||||
"Network error: Unable to connect to optimization server"
|
||||
);
|
||||
return {
|
||||
...process,
|
||||
state: "error",
|
||||
errorMessage:
|
||||
"Network error: Unable to connect to optimization server",
|
||||
};
|
||||
} else {
|
||||
// Other types of errors
|
||||
console.error("Error checking job status:", error);
|
||||
toast.error(
|
||||
"An unexpected error occurred while checking job status"
|
||||
);
|
||||
return {
|
||||
...process,
|
||||
state: "error",
|
||||
errorMessage: "An unexpected error occurred",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return process;
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out null values (completed jobs)
|
||||
const filteredProcesses = updatedProcesses.filter(
|
||||
(process) => process !== null
|
||||
) as ProcessItem[];
|
||||
|
||||
// Update the state with the filtered processes
|
||||
setProcesses(filteredProcesses);
|
||||
};
|
||||
|
||||
const intervalId = setInterval(checkJobStatusPeriodically, 2000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [
|
||||
processes,
|
||||
settings?.optimizedVersionsServerUrl,
|
||||
authHeader,
|
||||
startDownload,
|
||||
]);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (url: string, item: BaseItemDto) => {
|
||||
async (url: string, item: BaseItemDto, fileExtension: string) => {
|
||||
try {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
const response = await axios.post(
|
||||
settings?.optimizedVersionsServerUrl + "optimize-version",
|
||||
{ url },
|
||||
{ url, fileExtension, deviceId, itemId: item.Id, item },
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -331,15 +253,6 @@ function useDownloadProvider() {
|
||||
throw new Error("Failed to start optimization job");
|
||||
}
|
||||
|
||||
const { id } = response.data;
|
||||
|
||||
addProcess({
|
||||
id,
|
||||
item: item,
|
||||
progress: 0,
|
||||
state: "queued",
|
||||
});
|
||||
|
||||
toast.success(`Queued ${item.Name} for optimization`, {
|
||||
action: {
|
||||
label: "Go to download",
|
||||
@@ -400,14 +313,19 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
await AsyncStorage.removeItem("downloadedItems");
|
||||
await AsyncStorage.removeItem("runningProcesses");
|
||||
clearProcesses();
|
||||
|
||||
if (!authHeader) throw new Error("No auth header");
|
||||
if (!settings?.optimizedVersionsServerUrl)
|
||||
throw new Error("No server url");
|
||||
cancelAllJobs({
|
||||
authHeader,
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
deviceId: await getOrSetDeviceId(),
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
|
||||
console.log(
|
||||
"Successfully deleted all files and folders in the directory and cleared AsyncStorage"
|
||||
);
|
||||
toast.success("All files and folders deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all files and folders:", error);
|
||||
}
|
||||
@@ -496,16 +414,13 @@ function useDownloadProvider() {
|
||||
|
||||
return {
|
||||
processes,
|
||||
updateProcess,
|
||||
startBackgroundDownload,
|
||||
clearProcesses,
|
||||
readProcesses,
|
||||
downloadedFiles,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
saveDownloadedItemInfo,
|
||||
addProcess,
|
||||
removeProcess,
|
||||
setProcesses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -527,25 +442,3 @@ export function useDownload() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
const checkJobStatus = async (
|
||||
id: string,
|
||||
baseUrl: string,
|
||||
authHeader: string
|
||||
): Promise<{
|
||||
progress: number;
|
||||
status: "queued" | "running" | "completed" | "failed" | "cancelled";
|
||||
speed?: string;
|
||||
}> => {
|
||||
const statusResponse = await axios.get(`${baseUrl}job-status/${id}`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if (statusResponse.status !== 200) {
|
||||
throw new Error("Failed to fetch job status");
|
||||
}
|
||||
|
||||
const json = statusResponse.data;
|
||||
return json;
|
||||
};
|
||||
|
||||
@@ -109,5 +109,12 @@ export const getStreamUrl = async ({
|
||||
|
||||
if (!url) throw new Error("No url");
|
||||
|
||||
console.log(
|
||||
mediaSource.VideoType,
|
||||
mediaSource.Container,
|
||||
mediaSource.TranscodingContainer,
|
||||
mediaSource.TranscodingSubProtocol
|
||||
);
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
100
utils/optimize-server.ts
Normal file
100
utils/optimize-server.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { itemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import axios from "axios";
|
||||
|
||||
interface IJobInput {
|
||||
deviceId: string;
|
||||
authHeader: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface JobStatus {
|
||||
id: string;
|
||||
status: "queued" | "running" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
outputPath: string;
|
||||
inputUrl: string;
|
||||
deviceId: string;
|
||||
itemId: string;
|
||||
item: Partial<BaseItemDto>;
|
||||
speed?: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all jobs for a specific device.
|
||||
*
|
||||
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
|
||||
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
|
||||
* @param {string} params.authHeader - The authorization header for the API request.
|
||||
* @param {string} params.url - The base URL for the API endpoint.
|
||||
*
|
||||
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
|
||||
*
|
||||
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
|
||||
*/
|
||||
export async function getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader,
|
||||
url,
|
||||
}: IJobInput): Promise<JobStatus[]> {
|
||||
const statusResponse = await axios.get(`${url}all-jobs`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
params: {
|
||||
deviceId,
|
||||
},
|
||||
});
|
||||
if (statusResponse.status !== 200) {
|
||||
throw new Error("Failed to fetch job status");
|
||||
}
|
||||
|
||||
return statusResponse.data;
|
||||
}
|
||||
|
||||
interface ICancelJob {
|
||||
authHeader: string;
|
||||
url: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export async function cancelJobById({
|
||||
authHeader,
|
||||
url,
|
||||
id,
|
||||
}: ICancelJob): Promise<boolean> {
|
||||
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if (statusResponse.status !== 200) {
|
||||
throw new Error("Failed to cancel process");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
|
||||
try {
|
||||
await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader,
|
||||
url,
|
||||
}).then((jobs) => {
|
||||
jobs.forEach((job) => {
|
||||
cancelJobById({
|
||||
authHeader,
|
||||
url,
|
||||
id: job.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user