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; 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 | null>(null); function useDownloadProvider() { const queryClient = useQueryClient(); const [processes, setProcesses] = useState([]); 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(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) => { 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 => { 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 => { 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 => { 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 { 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 ( {children} ); } 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; };