mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-13 16:02:55 +01:00
552 lines
16 KiB
TypeScript
552 lines
16 KiB
TypeScript
import { useSettings } from "@/utils/atoms/settings";
|
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
import {
|
|
completeHandler,
|
|
directories,
|
|
download,
|
|
} from "@kesha-antonov/react-native-background-downloader";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import {
|
|
QueryClient,
|
|
QueryClientProvider,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import axios from "axios";
|
|
import * as FileSystem from "expo-file-system";
|
|
import { useRouter } from "expo-router";
|
|
import { useAtom } from "jotai";
|
|
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
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";
|
|
};
|
|
|
|
export const BACKGROUND_FETCH_TASK = "background-fetch";
|
|
|
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|
const now = Date.now();
|
|
|
|
console.log(
|
|
`Got background fetch call at date: ${new Date(now).toISOString()}`
|
|
);
|
|
|
|
// Be sure to return the successful result type!
|
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
|
});
|
|
|
|
const STORAGE_KEY = "runningProcesses";
|
|
|
|
export async function registerBackgroundFetchAsync() {
|
|
return BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
|
|
minimumInterval: 60 * 15, // 1 minutes
|
|
stopOnTerminate: false, // android only,
|
|
startOnBoot: true, // android only
|
|
});
|
|
}
|
|
|
|
export async function unregisterBackgroundFetchAsync() {
|
|
return BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
|
|
}
|
|
|
|
const DownloadContext = createContext<ReturnType<
|
|
typeof useDownloadProvider
|
|
> | null>(null);
|
|
|
|
function useDownloadProvider() {
|
|
const queryClient = useQueryClient();
|
|
const [processes, setProcesses] = useState<ProcessItem[]>([]);
|
|
const [settings] = useSettings();
|
|
const router = useRouter();
|
|
const [api] = useAtom(apiAtom);
|
|
|
|
const authHeader = useMemo(() => {
|
|
return api?.accessToken;
|
|
}, [api]);
|
|
|
|
const { data: downloadedFiles, refetch } = useQuery({
|
|
queryKey: ["downloadedItems"],
|
|
queryFn: getAllDownloadedItems,
|
|
staleTime: 0,
|
|
});
|
|
|
|
useEffect(() => {
|
|
// Check background task status
|
|
checkStatusAsync();
|
|
|
|
// Load initial processes state from AsyncStorage
|
|
const loadInitialProcesses = async () => {
|
|
const storedProcesses = await readProcesses();
|
|
setProcesses(storedProcesses);
|
|
};
|
|
loadInitialProcesses();
|
|
}, []);
|
|
|
|
/********************
|
|
* Background task
|
|
*******************/
|
|
const [isRegistered, setIsRegistered] = useState(false);
|
|
const [status, setStatus] =
|
|
useState<BackgroundFetch.BackgroundFetchStatus | null>(null);
|
|
|
|
const checkStatusAsync = async () => {
|
|
const status = await BackgroundFetch.getStatusAsync();
|
|
const isRegistered = await TaskManager.isTaskRegisteredAsync(
|
|
BACKGROUND_FETCH_TASK
|
|
);
|
|
setStatus(status);
|
|
setIsRegistered(isRegistered);
|
|
|
|
console.log("Background fetch status:", status);
|
|
console.log("Background fetch task registered:", isRegistered);
|
|
};
|
|
|
|
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 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;
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
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) => {
|
|
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`,
|
|
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,
|
|
});
|
|
})
|
|
.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}`);
|
|
});
|
|
},
|
|
[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) => {
|
|
try {
|
|
const response = await axios.post(
|
|
settings?.optimizedVersionsServerUrl + "optimize-version",
|
|
{ url },
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: authHeader,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (response.status !== 201) {
|
|
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",
|
|
onClick: () => {
|
|
router.push("/downloads");
|
|
toast.dismiss();
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Error in startBackgroundDownload:", error);
|
|
if (axios.isAxiosError(error)) {
|
|
console.error("Axios error details:", {
|
|
message: error.message,
|
|
response: error.response?.data,
|
|
status: error.response?.status,
|
|
headers: error.response?.headers,
|
|
});
|
|
toast.error(
|
|
`Failed to start download for ${item.Name}: ${error.message}`
|
|
);
|
|
if (error.response) {
|
|
toast.error(
|
|
`Server responded with status ${error.response.status}`
|
|
);
|
|
} else if (error.request) {
|
|
toast.error("No response received from server");
|
|
} else {
|
|
toast.error("Error setting up the request");
|
|
}
|
|
} else {
|
|
console.error("Non-Axios error:", error);
|
|
toast.error(
|
|
`Failed to start download for ${item.Name}: Unexpected error`
|
|
);
|
|
}
|
|
}
|
|
},
|
|
[settings?.optimizedVersionsServerUrl, authHeader]
|
|
);
|
|
|
|
const deleteAllFiles = async (): Promise<void> => {
|
|
try {
|
|
const baseDirectory = FileSystem.documentDirectory;
|
|
|
|
if (!baseDirectory) {
|
|
throw new Error("Base directory not found");
|
|
}
|
|
|
|
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
|
|
|
for (const item of dirContents) {
|
|
const itemPath = `${baseDirectory}${item}`;
|
|
const itemInfo = await FileSystem.getInfoAsync(itemPath);
|
|
|
|
if (itemInfo.exists) {
|
|
await FileSystem.deleteAsync(itemPath, { idempotent: true });
|
|
}
|
|
}
|
|
await AsyncStorage.removeItem("downloadedItems");
|
|
await AsyncStorage.removeItem("runningProcesses");
|
|
clearProcesses();
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
|
|
|
console.log(
|
|
"Successfully deleted all files and folders in the directory and cleared AsyncStorage"
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to delete all files and folders:", error);
|
|
}
|
|
};
|
|
|
|
const deleteFile = async (id: string): Promise<void> => {
|
|
if (!id) {
|
|
console.error("Invalid file ID");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const directory = FileSystem.documentDirectory;
|
|
|
|
if (!directory) {
|
|
console.error("Document directory not found");
|
|
return;
|
|
}
|
|
const dirContents = await FileSystem.readDirectoryAsync(directory);
|
|
|
|
for (const item of dirContents) {
|
|
const itemNameWithoutExtension = item.split(".")[0];
|
|
if (itemNameWithoutExtension === id) {
|
|
const filePath = `${directory}${item}`;
|
|
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
|
console.log(`Successfully deleted file: ${item}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
|
if (downloadedItems) {
|
|
let items = JSON.parse(downloadedItems);
|
|
items = items.filter((item: any) => item.Id !== id);
|
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
|
}
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
|
|
|
console.log(
|
|
`Successfully deleted file and AsyncStorage entry for ID ${id}`
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to delete file and AsyncStorage entry for ID ${id}:`,
|
|
error
|
|
);
|
|
}
|
|
};
|
|
|
|
async function getAllDownloadedItems(): Promise<BaseItemDto[]> {
|
|
try {
|
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
|
if (downloadedItems) {
|
|
return JSON.parse(downloadedItems) as BaseItemDto[];
|
|
} else {
|
|
return [];
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to retrieve downloaded items:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
|
try {
|
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
|
let items: BaseItemDto[] = downloadedItems
|
|
? JSON.parse(downloadedItems)
|
|
: [];
|
|
|
|
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
|
if (existingItemIndex !== -1) {
|
|
items[existingItemIndex] = item;
|
|
} else {
|
|
items.push(item);
|
|
}
|
|
|
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
|
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
|
refetch();
|
|
} catch (error) {
|
|
console.error("Failed to save downloaded item information:", error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
processes,
|
|
updateProcess,
|
|
startBackgroundDownload,
|
|
clearProcesses,
|
|
readProcesses,
|
|
downloadedFiles,
|
|
deleteAllFiles,
|
|
deleteFile,
|
|
saveDownloadedItemInfo,
|
|
addProcess,
|
|
removeProcess,
|
|
};
|
|
}
|
|
|
|
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|
const downloadProviderValue = useDownloadProvider();
|
|
const queryClient = new QueryClient();
|
|
|
|
return (
|
|
<DownloadContext.Provider value={downloadProviderValue}>
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
</DownloadContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useDownload() {
|
|
const context = useContext(DownloadContext);
|
|
if (context === null) {
|
|
throw new Error("useDownload must be used within a DownloadProvider");
|
|
}
|
|
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;
|
|
};
|