diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index d4fc045a..9d2d3e50 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -2,26 +2,56 @@ import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { cancelJobById, JobStatus } from "@/utils/optimize-server"; +import { 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"; -import { useMutation } from "@tanstack/react-query"; -import axios from "axios"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; -import { useCallback } from "react"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import { + ActivityIndicator, + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; import { toast } from "sonner-native"; interface Props extends ViewProps {} export const ActiveDownloads: React.FC = ({ ...props }) => { + const { processes } = useDownload(); + if (processes?.length === 0) + return ( + + Active download + No active downloads + + ); + + return ( + + Active downloads + + {processes?.map((p) => ( + + ))} + + + ); +}; + +interface DownloadCardProps extends TouchableOpacityProps { + process: JobStatus; +} + +const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const router = useRouter(); - const { removeProcess, processes, setProcesses } = useDownload(); + const { removeProcess, setProcesses } = useDownload(); const [settings] = useSettings(); - const [api] = useAtom(apiAtom); + const queryClient = useQueryClient(); const cancelJobMutation = useMutation({ mutationFn: async (id: string) => { @@ -32,13 +62,14 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { const tasks = await checkForExistingDownloads(); for (const task of tasks) { if (task.id === id) { - await task.stop(); + task.stop(); } } } catch (e) { throw e; } finally { - removeProcess(id); + await removeProcess(id); + await queryClient.refetchQueries({ queryKey: ["jobs"] }); } } else { FFmpegKit.cancel(); @@ -54,69 +85,58 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { }, }); - const eta = useCallback( - (p: JobStatus) => { - if (!p.speed || !p.progress) return null; + const eta = (p: JobStatus) => { + if (!p.speed || !p.progress) return null; - const length = p?.item?.RunTimeTicks || 0; - const timeLeft = (length - length * (p.progress / 100)) / p.speed; - return formatTimeString(timeLeft, true); - }, - [process] - ); - - if (processes?.length === 0) - return ( - - Active download - No active downloads - - ); + const length = p?.item?.RunTimeTicks || 0; + const timeLeft = (length - length * (p.progress / 100)) / p.speed; + return formatTimeString(timeLeft, true); + }; return ( - - Active downloads - - {processes?.map((p) => ( - router.push(`/(auth)/items/page?id=${p.item.Id}`)} - className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" - > - - + router.push(`/(auth)/items/page?id=${process.item.Id}`)} + className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" + {...props} + > + + + + {process.item.Name} + {process.item.Type} + + {process.progress.toFixed(0)}% + {process.speed && ( + {process.speed?.toFixed(2)}x + )} + {eta(process) && ( - {p.item.Name} - {p.item.Type} - - {p.progress.toFixed(0)}% - {p.speed && ( - {p.speed?.toFixed(2)}x - )} - {eta(p) && ( - - ETA {eta(p)} - - )} - - - {p.status} - + ETA {eta(process)} - cancelJobMutation.mutate(p.id)}> - - - - - ))} + )} + + + {process.status} + + + cancelJobMutation.mutate(process.id)} + > + {cancelJobMutation.isPending ? ( + + ) : ( + + )} + - + ); }; diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 01b4af07..ba5fc807 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,10 +1,19 @@ import { useSettings } from "@/utils/atoms/settings"; +import { getOrSetDeviceId } from "@/utils/device"; +import { writeToLog } from "@/utils/log"; +import { + cancelAllJobs, + cancelJobById, + getAllJobsByDeviceId, + JobStatus, +} from "@/utils/optimize-server"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { checkForExistingDownloads, completeHandler, directories, download, + setConfig, } from "@kesha-antonov/react-native-background-downloader"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { @@ -14,8 +23,10 @@ import { useQueryClient, } from "@tanstack/react-query"; import axios from "axios"; +import * as BackgroundFetch from "expo-background-fetch"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; +import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; import React, { createContext, @@ -28,16 +39,6 @@ import React, { } 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"; -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"; @@ -108,12 +109,21 @@ function useDownloadProvider() { url, }); - setProcesses(jobs); + // Local downloading processes that are still valid + const downloadingProcesses = processes + .filter((p) => p.status === "downloading") + .filter((p) => jobs.some((j) => j.id === p.id)); + + const updatedProcesses = jobs.filter( + (j) => !downloadingProcesses.some((p) => p.id === j.id) + ); + + setProcesses([...updatedProcesses, ...downloadingProcesses]); return jobs; }, staleTime: 0, - refetchInterval: 1000 * 3, // 5 minutes + refetchInterval: 1000, enabled: settings?.downloadMethod === "optimized", }); @@ -123,11 +133,10 @@ function useDownloadProvider() { 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") { + // Check if the download is already in progress + const tasks = await checkForExistingDownloads(); + if (tasks.find((task) => task.id === job.id)) continue; await startDownload(job); continue; } @@ -199,32 +208,58 @@ function useDownloadProvider() { async (process: JobStatus) => { if (!process?.item.Id || !authHeader) throw new Error("No item id"); + setConfig({ + isLogsEnabled: true, + progressInterval: 500, + headers: { + Authorization: authHeader, + }, + }); + 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}`); + setProcesses((prev) => + prev.map((p) => + p.id === process.id + ? { + ...p, + speed: undefined, + status: "downloading", + progress: 0, + } + : p + ) + ); }) .progress((data) => { const percent = (data.bytesDownloaded / data.bytesTotal) * 100; - console.log("Progress ~", percent); + console.log("Download progress:", percent); + setProcesses((prev) => + prev.map((p) => + p.id === process.id + ? { + ...p, + speed: undefined, + status: "downloading", + progress: percent, + } + : p + ) + ); }) .done(async () => { - removeProcess(process.id); await saveDownloadedItemInfo(process.item); - await queryClient.invalidateQueries({ - queryKey: ["downloadedItems"], - }); - await refetch(); + removeProcess(process.id); completeHandler(process.id); toast.success(`Download completed for ${process.item.Name}`); }) - .error((error) => { + .error(async (error) => { + completeHandler(process.id); toast.error(`Download failed for ${process.item.Name}: ${error}`); writeToLog("ERROR", `Download failed for ${process.item.Name}`, { error, diff --git a/utils/optimize-server.ts b/utils/optimize-server.ts index 6fa08ec3..3db17037 100644 --- a/utils/optimize-server.ts +++ b/utils/optimize-server.ts @@ -10,7 +10,13 @@ interface IJobInput { export interface JobStatus { id: string; - status: "queued" | "running" | "completed" | "failed" | "cancelled"; + status: + | "queued" + | "optimizing" + | "completed" + | "failed" + | "cancelled" + | "downloading"; progress: number; outputPath: string; inputUrl: string;