From 41a23d3437a511b791d17963b366186fddbfe557 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sat, 28 Sep 2024 15:45:00 +0200 Subject: [PATCH] wip --- app/(auth)/(tabs)/(home)/downloads.tsx | 92 +---- app/(auth)/(tabs)/(home)/settings.tsx | 6 +- app/(auth)/(tabs)/_layout.tsx | 1 + app/_layout.tsx | 124 ++++--- components/DownloadItem.tsx | 72 ++-- components/downloads/ActiveDownload.tsx | 83 +++++ components/downloads/EpisodeCard.tsx | 4 +- components/downloads/MovieCard.tsx | 4 +- components/settings/SettingToggles.tsx | 111 ++++-- hooks/useDownloadM3U8Files.ts | 222 ------------ hooks/useDownloadMedia.ts | 117 ------- hooks/useDownloadedFileOpener.ts | 83 +---- hooks/useFiles.ts | 96 ----- hooks/useRemuxHlsToMp4.ts | 76 ++-- providers/DownloadProvider.tsx | 442 ++++++++++++++++++++++++ utils/atoms/downloads.ts | 10 - utils/atoms/queue.ts | 45 ++- utils/atoms/settings.ts | 1 + 18 files changed, 806 insertions(+), 783 deletions(-) create mode 100644 components/downloads/ActiveDownload.tsx delete mode 100644 hooks/useDownloadM3U8Files.ts delete mode 100644 hooks/useDownloadMedia.ts delete mode 100644 hooks/useFiles.ts create mode 100644 providers/DownloadProvider.tsx diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index 30b67bbf..20429bd4 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -1,30 +1,28 @@ import { Text } from "@/components/common/Text"; +import { ActiveDownload } from "@/components/downloads/ActiveDownload"; import { MovieCard } from "@/components/downloads/MovieCard"; import { SeriesCard } from "@/components/downloads/SeriesCard"; -import { Loader } from "@/components/Loader"; -import { getAllDownloadedItems } from "@/hooks/useDownloadM3U8Files"; -import { runningProcesses } from "@/utils/atoms/downloads"; +import { useDownload } from "@/providers/DownloadProvider"; import { queueAtom } from "@/utils/atoms/queue"; import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { router } from "expo-router"; -import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; import { useEffect, useMemo } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; const downloads: React.FC = () => { - const [process, setProcess] = useAtom(runningProcesses); const [queue, setQueue] = useAtom(queueAtom); - - const { data: downloadedFiles, isLoading } = useQuery({ - queryKey: ["downloaded_files", process?.item.Id], - queryFn: getAllDownloadedItems, - staleTime: 0, - }); + const { + clearProcess, + process, + readProcess, + startBackgroundDownload, + updateProcess, + downloadedFiles, + } = useDownload(); const movies = useMemo( () => downloadedFiles?.filter((f) => f.Type === "Movie") || [], @@ -96,14 +94,6 @@ const downloads: React.FC = () => { const insets = useSafeAreaInsets(); - if (isLoading) { - return ( - - - - ); - } - return ( { { - setQueue((prev) => prev.filter((i) => i.id !== q.id)); + clearProcess(); + setQueue(async (prev) => { + if (!prev) return []; + return [...(await prev).filter((i) => i.id !== q.id)]; + }); }} > @@ -144,49 +138,7 @@ const downloads: React.FC = () => { )} - - Active download - {process?.item ? ( - - router.push(`/(auth)/items/page?id=${process.item.Id}`) - } - className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" - > - - {process.item.Name} - - {process.item.Type} - - - - {process.progress.toFixed(0)}% - - - - { - FFmpegKit.cancel(); - setProcess(null); - }} - > - - - - - ) : ( - No active downloads - )} - + {movies.length > 0 && ( @@ -212,15 +164,3 @@ const downloads: React.FC = () => { }; export default downloads; - -/* - * Format a number (Date.getTime) to a human readable string ex. 2m 34s - * @param {number} num - The number to format - * - * @returns {string} - The formatted string - */ -const formatNumber = (num: number) => { - const minutes = Math.floor(num / 60000); - const seconds = ((num % 60000) / 1000).toFixed(0); - return `${minutes}m ${seconds}s`; -}; diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 75d74306..834ba569 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -2,22 +2,20 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; import { SettingToggles } from "@/components/settings/SettingToggles"; -import { useFiles } from "@/hooks/useFiles"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { clearLogs, readFromLog } from "@/utils/log"; -import { Ionicons } from "@expo/vector-icons"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useAtom } from "jotai"; import { Alert, ScrollView, View } from "react-native"; -import { red } from "react-native-reanimated/lib/typescript/reanimated2/Colors"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { toast } from "sonner-native"; export default function settings() { const { logout } = useJellyfin(); - const { deleteAllFiles } = useFiles(); + const { deleteAllFiles } = useDownload(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index b8772e68..4dc8d799 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -1,5 +1,6 @@ import { TabBarIcon } from "@/components/navigation/TabBarIcon"; import { Colors } from "@/constants/Colors"; +import { useCheckRunningJobs } from "@/hooks/useCheckRunningJobs"; import { BlurView } from "expo-blur"; import * as NavigationBar from "expo-navigation-bar"; import { Tabs } from "expo-router"; diff --git a/app/_layout.tsx b/app/_layout.tsx index b942db93..b638b9c8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -14,12 +14,15 @@ import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; import { Provider as JotaiProvider, useAtom } from "jotai"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import * as Linking from "expo-linking"; import { orientationAtom } from "@/utils/atoms/orientation"; import { Toaster } from "sonner-native"; +import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader"; +import { AppState } from "react-native"; +import { DownloadProvider } from "@/providers/DownloadProvider"; SplashScreen.preventAutoHideAsync(); @@ -74,6 +77,25 @@ function Layout() { ); }, [settings]); + const appState = useRef(AppState.currentState); + + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextAppState) => { + if ( + appState.current.match(/inactive|background/) && + nextAppState === "active" + ) { + checkForExistingDownloads(); + } + }); + + checkForExistingDownloads(); + + return () => { + subscription.remove(); + }; + }, []); + useEffect(() => { const subscription = ScreenOrientation.addOrientationChangeListener( (event) => { @@ -101,57 +123,59 @@ function Layout() { - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - + + + + + + diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 652b8131..e34ccb94 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,6 +1,6 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { runningProcesses } from "@/utils/atoms/downloads"; import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { useSettings } from "@/utils/atoms/settings"; import ios from "@/utils/profiles/ios"; @@ -17,8 +17,6 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -31,8 +29,6 @@ import { Loader } from "./Loader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; -import { useDownloadM3U8Files } from "@/hooks/useDownloadM3U8Files"; -import * as FileSystem from "expo-file-system"; interface DownloadProps extends ViewProps { item: BaseItemDto; @@ -41,12 +37,10 @@ interface DownloadProps extends ViewProps { export const DownloadItem: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [process] = useAtom(runningProcesses); const [queue, setQueue] = useAtom(queueAtom); const [settings] = useSettings(); - // const { startRemuxing } = useRemuxHlsToMp4(item); - - const { startBackgroundDownload } = useDownloadM3U8Files(item); + const { process, startBackgroundDownload } = useDownload(); + const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item); const [selectedMediaSource, setSelectedMediaSource] = useState(null); @@ -157,7 +151,14 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { if (!url) throw new Error("No url"); - return await startBackgroundDownload(url); + if ( + settings?.optimizedVersionsServerUrl && + settings.optimizedVersionsServerUrl.length > 0 + ) { + return await startBackgroundDownload(url, item); + } else { + return await startRemuxing(url); + } }, [ api, item, @@ -172,42 +173,13 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { /** * Check if item is downloaded */ - const { data: downloaded, isFetching } = useQuery({ - queryKey: ["downloaded", item.Id], - queryFn: async () => { - if (!item.Id) { - return false; - } + const { downloadedFiles } = useDownload(); - try { - // Check if the item exists in AsyncStorage - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - const items: BaseItemDto[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - const isInStorage = items.some( - (storedItem) => storedItem.Id === item.Id - ); + const isDownloaded = useMemo(() => { + if (!downloadedFiles) return false; - if (!isInStorage) { - return false; - } - - // Check if the directory and m3u8 file exist - const directoryPath = `${FileSystem.documentDirectory}${item.Id}`; - const m3u8FilePath = `${directoryPath}/local.m3u8`; - - const dirInfo = await FileSystem.getInfoAsync(directoryPath); - const fileInfo = await FileSystem.getInfoAsync(m3u8FilePath); - - return dirInfo.exists && fileInfo.exists; - } catch (error) { - console.error("Error checking download status:", error); - return false; - } - }, - enabled: !!item.Id, - }); + return downloadedFiles.some((file) => file.Id === item.Id); + }, [downloadedFiles, item.Id]); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -225,9 +197,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center" {...props} > - {isFetching ? ( - - ) : process && process?.item.Id === item.Id ? ( + {process && process?.item.Id === item.Id ? ( { router.push("/downloads"); @@ -255,7 +225,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { > - ) : downloaded ? ( + ) : isDownloaded ? ( { router.push("/downloads"); @@ -315,9 +285,13 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { className="mt-auto" onPress={() => { if (userCanDownload === true) { + if (!item.Id) { + Alert.alert("Error", "Item ID is undefined."); + return; + } closeModal(); queueActions.enqueue(queue, setQueue, { - id: item.Id!, + id: item.Id, execute: async () => { await initiateDownload(); }, diff --git a/components/downloads/ActiveDownload.tsx b/components/downloads/ActiveDownload.tsx new file mode 100644 index 00000000..f08e00f0 --- /dev/null +++ b/components/downloads/ActiveDownload.tsx @@ -0,0 +1,83 @@ +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"; + +interface Props extends ViewProps {} + +export const ActiveDownload: React.FC = ({ ...props }) => { + const router = useRouter(); + const { clearProcess, process } = useDownload(); + const [settings] = useSettings(); + + const cancelJobMutation = useMutation({ + mutationFn: async (id: string) => { + if (!process) return; + + await axios.delete(settings?.optimizedVersionsServerUrl + id); + const tasks = await checkForExistingDownloads(); + for (const task of tasks) task.stop(); + clearProcess(); + }, + onSuccess: () => { + toast.success("Download cancelled"); + }, + onError: () => { + toast.error("Failed to cancel download"); + }, + }); + + if (!process) + return ( + + Active download + No active downloads + + ); + + return ( + + Active download + router.push(`/(auth)/items/page?id=${process.item.Id}`)} + className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" + > + + + + {process.item.Name} + {process.item.Id} + {process.item.Type} + + {process.progress.toFixed(0)}% + + + {process.state} + + + cancelJobMutation.mutate(process.id)} + > + + + + + + ); +}; diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 8b344279..a303caed 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -5,8 +5,8 @@ import { TouchableOpacity } from "react-native"; import * as ContextMenu from "zeego/context-menu"; import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; -import { useFiles } from "@/hooks/useFiles"; import { Text } from "../common/Text"; +import { useDownload } from "@/providers/DownloadProvider"; interface EpisodeCardProps { item: BaseItemDto; @@ -18,7 +18,7 @@ interface EpisodeCardProps { * @returns {React.ReactElement} The rendered EpisodeCard component. */ export const EpisodeCard: React.FC = ({ item }) => { - const { deleteFile } = useFiles(); + const { deleteFile } = useDownload(); const { openFile } = useFileOpener(); const handleOpenFile = useCallback(() => { diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx index 16808bbf..69eeae4f 100644 --- a/components/downloads/MovieCard.tsx +++ b/components/downloads/MovieCard.tsx @@ -4,11 +4,11 @@ import React, { useCallback } from "react"; import { TouchableOpacity, View } from "react-native"; import * as ContextMenu from "zeego/context-menu"; -import { useFiles } from "@/hooks/useFiles"; import { runtimeTicksToMinutes } from "@/utils/time"; import { Text } from "../common/Text"; import { useFileOpener } from "@/hooks/useDownloadedFileOpener"; +import { useDownload } from "@/providers/DownloadProvider"; interface MovieCardProps { item: BaseItemDto; @@ -20,7 +20,7 @@ interface MovieCardProps { * @returns {React.ReactElement} The rendered MovieCard component. */ export const MovieCard: React.FC = ({ item }) => { - const { deleteFile } = useFiles(); + const { deleteFile } = useDownload(); const { openFile } = useFileOpener(); const handleOpenFile = useCallback(() => { diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index a4b17367..6eef150c 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -33,6 +33,8 @@ export const SettingToggles: React.FC = ({ ...props }) => { const [user] = useAtom(userAtom); const [marlinUrl, setMarlinUrl] = useState(""); + const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = + useState(""); const queryClient = useQueryClient(); @@ -308,9 +310,9 @@ export const SettingToggles: React.FC = ({ ...props }) => { Device profile @@ -362,6 +364,7 @@ export const SettingToggles: React.FC = ({ ...props }) => { + = ({ ...props }) => { {settings.searchEngine === "Marlin" && ( - <> - - - setMarlinUrl(text)} - /> - - + + + setMarlinUrl(text)} + /> + + + {settings.marlinServerUrl && ( - {settings.marlinServerUrl} + Current: {settings.marlinServerUrl} - + )} )} + + + Optimized versions server + + Set the URL for the optimized versions server for downloads. + + + + + setOptimizedVersionsServerUrl(text)} + /> + + + + + {settings.optimizedVersionsServerUrl && ( + + Current: {settings.optimizedVersionsServerUrl} + + )} + diff --git a/hooks/useDownloadM3U8Files.ts b/hooks/useDownloadM3U8Files.ts deleted file mode 100644 index f96995a0..00000000 --- a/hooks/useDownloadM3U8Files.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { apiAtom } from "@/providers/JellyfinProvider"; -import { runningProcesses } from "@/utils/atoms/downloads"; -import { writeToLog } from "@/utils/log"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { download } from "@kesha-antonov/react-native-background-downloader"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQueryClient } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native"; -import { useAtom } from "jotai"; -import { useCallback, useEffect, useState } from "react"; -import { toast } from "sonner-native"; - -export const useDownloadM3U8Files = (item: BaseItemDto) => { - const [_, setProgress] = useAtom(runningProcesses); - const queryClient = useQueryClient(); - const [api] = useAtom(apiAtom); - - const [totalSegments, setTotalSegments] = useState(0); - const [downloadedSegments, setDownloadedSegments] = useState([]); - - if (!item.Id || !item.Name) { - throw new Error("Item must have an Id and Name"); - } - - const startBackgroundDownload = useCallback( - async (url: string) => { - if (!api) { - throw new Error("API is not defined"); - } - - toast.success("Download started", { invert: true }); - writeToLog("INFO", `Starting download for item ${item.Name}`); - setProgress({ - startTime: new Date(), - item, - progress: 0, - }); - - try { - const directoryPath = `${FileSystem.documentDirectory}${item.Id}`; - await FileSystem.makeDirectoryAsync(directoryPath, { - intermediates: true, - }); - - const m3u8Content = await FileSystem.downloadAsync( - url, - `${directoryPath}/original.m3u8` - ); - - if (m3u8Content.status !== 200) { - throw new Error("Failed to download m3u8 file"); - } - - const m3u8Text = await FileSystem.readAsStringAsync(m3u8Content.uri); - const segments = await fetchSegmentInfo( - m3u8Text, - api.basePath, - item.Id! - ); - - setTotalSegments(segments.length); - - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]; - const segmentUrl = `${api.basePath}/videos/${item.Id}/${segment.path}`; - const destination = `${directoryPath}/${i}.ts`; - - download({ - id: `${item.Id}_segment_${i}`, - url: segmentUrl, - destination: destination, - }).done(() => { - setDownloadedSegments((prev) => [...prev, i]); - }); - } - - await createLocalM3U8File(segments, directoryPath); - await saveDownloadedItemInfo(item); - - writeToLog("INFO", `Download completed for item: ${item.Name}`); - await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - await queryClient.invalidateQueries({ queryKey: ["downloaded"] }); - } catch (error) { - console.error("Failed to download:", error); - writeToLog("ERROR", `Download failed for item: ${item.Name}`); - setProgress(null); - throw error; - } - }, - [item, queryClient, api] - ); - - useEffect(() => { - if (totalSegments === 0) return; - - console.log("[0]", downloadedSegments.length, totalSegments); - - const progress = (downloadedSegments.length / totalSegments) * 100; - setProgress((prev) => ({ - ...prev!, - progress, - })); - if (progress > 99) { - setProgress(null); - } - }, [downloadedSegments, totalSegments]); - - return { startBackgroundDownload }; -}; - -interface Segment { - duration: number; - path: string; -} - -async function fetchSegmentInfo( - masterM3U8Content: string, - baseUrl: string, - itemId: string -): Promise { - const lines = masterM3U8Content.split("\n"); - const mainPlaylistLine = lines.find((line) => line.startsWith("main.m3u8")); - - if (!mainPlaylistLine) { - throw new Error("Main playlist URL not found in the master M3U8"); - } - - const url = `${baseUrl}/videos/${itemId}/${mainPlaylistLine}`; - const response = await fetch(url); - const mainPlaylistContent = await response.text(); - - const segments: Segment[] = []; - const mainPlaylistLines = mainPlaylistContent.split("\n"); - - for (let i = 0; i < mainPlaylistLines.length; i++) { - if (mainPlaylistLines[i].startsWith("#EXTINF:")) { - const durationMatch = mainPlaylistLines[i].match( - /#EXTINF:(\d+(?:\.\d+)?)/ - ); - const duration = durationMatch ? parseFloat(durationMatch[1]) : 0; - const path = mainPlaylistLines[i + 1]; - - if (path) { - segments.push({ duration, path }); - } - - i++; - } - } - - return segments; -} - -async function createLocalM3U8File(segments: Segment[], directoryPath: string) { - let localM3U8Content = "#EXTM3U\n#EXT-X-VERSION:3\n"; - localM3U8Content += `#EXT-X-TARGETDURATION:${Math.ceil( - Math.max(...segments.map((s) => s.duration)) - )}\n`; - localM3U8Content += "#EXT-X-MEDIA-SEQUENCE:0\n"; - - segments.forEach((segment, index) => { - console.log(segment.path.split(".")[1]); - localM3U8Content += `#EXTINF:${segment.duration.toFixed(3)},\n`; - localM3U8Content += `${directoryPath}/${index}.ts\n`; - }); - - localM3U8Content += "#EXT-X-ENDLIST\n"; - - const localM3U8Path = `${directoryPath}/local.m3u8`; - await FileSystem.writeAsStringAsync(localM3U8Path, localM3U8Content); -} - -export 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)); - } catch (error) { - console.error("Failed to save downloaded item information:", error); - } -} - -export async function deleteDownloadedItem(itemId: string) { - try { - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - let items: BaseItemDto[] = downloadedItems - ? JSON.parse(downloadedItems) - : []; - items = items.filter((item) => item.Id !== itemId); - await AsyncStorage.setItem("downloadedItems", JSON.stringify(items)); - - const directoryPath = `${FileSystem.documentDirectory}${itemId}`; - await FileSystem.deleteAsync(directoryPath, { idempotent: true }); - } catch (error) { - console.error("Failed to delete downloaded item:", error); - } -} - -export 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 []; - } -} diff --git a/hooks/useDownloadMedia.ts b/hooks/useDownloadMedia.ts deleted file mode 100644 index 61351e2d..00000000 --- a/hooks/useDownloadMedia.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { useAtom } from "jotai"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as FileSystem from "expo-file-system"; -import { Api } from "@jellyfin/sdk"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { runningProcesses } from "@/utils/atoms/downloads"; - -/** - * Custom hook for downloading media using the Jellyfin API. - * - * @param api - The Jellyfin API instance - * @param userId - The user ID - * @returns An object with download-related functions and state - */ -export const useDownloadMedia = (api: Api | null, userId?: string | null) => { - const [isDownloading, setIsDownloading] = useState(false); - const [error, setError] = useState(null); - const [_, setProgress] = useAtom(runningProcesses); - const downloadResumableRef = useRef( - null, - ); - - const downloadMedia = useCallback( - async (item: BaseItemDto | null): Promise => { - if (!item?.Id || !api || !userId) { - setError("Invalid item or API"); - return false; - } - - setIsDownloading(true); - setError(null); - setProgress({ item, progress: 0 }); - - try { - const filename = item.Id; - const fileUri = `${FileSystem.documentDirectory}${filename}`; - const url = `${api.basePath}/Items/${item.Id}/File`; - - downloadResumableRef.current = FileSystem.createDownloadResumable( - url, - fileUri, - { - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - }, - (downloadProgress) => { - const currentProgress = - downloadProgress.totalBytesWritten / - downloadProgress.totalBytesExpectedToWrite; - setProgress({ item, progress: currentProgress * 100 }); - }, - ); - - const res = await downloadResumableRef.current.downloadAsync(); - - if (!res?.uri) { - throw new Error("Download failed: No URI returned"); - } - - await updateDownloadedFiles(item); - - setIsDownloading(false); - setProgress(null); - return true; - } catch (error) { - console.error("Error downloading media:", error); - setError("Failed to download media"); - setIsDownloading(false); - setProgress(null); - return false; - } - }, - [api, userId, setProgress], - ); - - const cancelDownload = useCallback(async (): Promise => { - if (!downloadResumableRef.current) return; - - try { - await downloadResumableRef.current.pauseAsync(); - setIsDownloading(false); - setError("Download cancelled"); - setProgress(null); - downloadResumableRef.current = null; - } catch (error) { - console.error("Error cancelling download:", error); - setError("Failed to cancel download"); - } - }, [setProgress]); - - return { downloadMedia, isDownloading, error, cancelDownload }; -}; - -/** - * Updates the list of downloaded files in AsyncStorage. - * - * @param item - The item to add to the downloaded files list - */ -async function updateDownloadedFiles(item: BaseItemDto): Promise { - try { - const currentFiles: BaseItemDto[] = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) ?? "[]", - ); - const updatedFiles = [ - ...currentFiles.filter((file) => file.Id !== item.Id), - item, - ]; - await AsyncStorage.setItem( - "downloaded_files", - JSON.stringify(updatedFiles), - ); - } catch (error) { - console.error("Error updating downloaded files:", error); - } -} diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts index a2e38943..09aecd26 100644 --- a/hooks/useDownloadedFileOpener.ts +++ b/hooks/useDownloadedFileOpener.ts @@ -1,11 +1,10 @@ // hooks/useFileOpener.ts -import { useCallback } from "react"; -import { useRouter } from "expo-router"; -import * as FileSystem from "expo-file-system"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { usePlayback } from "@/providers/PlaybackProvider"; -import { FFmpegKit, ReturnCode } from "ffmpeg-kit-react-native"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import * as FileSystem from "expo-file-system"; +import { useRouter } from "expo-router"; +import { useCallback } from "react"; export const useFileOpener = () => { const router = useRouter(); @@ -13,77 +12,17 @@ export const useFileOpener = () => { const openFile = useCallback( async (item: BaseItemDto) => { - const m3u8File = `${FileSystem.documentDirectory}${item.Id}/local.m3u8`; - const outputFile = `${FileSystem.documentDirectory}${item.Id}/output.mp4`; + const directory = FileSystem.documentDirectory; + const url = `${directory}/${item.Id}.mp4`; - console.log("Checking for output file:", outputFile); - - const outputFileInfo = await FileSystem.getInfoAsync(outputFile); - - if (outputFileInfo.exists) { - console.log("Output MP4 file already exists. Playing directly."); - startDownloadedFilePlayback({ - item, - url: outputFile, - }); - router.push("/play"); - return; - } - - console.log("Output MP4 file does not exist. Converting from M3U8."); - - const m3u8FileInfo = await FileSystem.getInfoAsync(m3u8File); - - if (!m3u8FileInfo.exists) { - console.warn("m3u8 file does not exist:", m3u8File); - return; - } - - const conversionSuccess = await convertM3U8ToMP4(m3u8File, outputFile); - - if (conversionSuccess) { - startDownloadedFilePlayback({ - item, - url: outputFile, - }); - router.push("/play"); - } else { - console.error("Failed to convert M3U8 to MP4"); - // Handle conversion failure (e.g., show an error message to the user) - } + startDownloadedFilePlayback({ + item, + url, + }); + router.push("/play"); }, [startDownloadedFilePlayback] ); return { openFile }; }; - -export async function convertM3U8ToMP4( - inputM3U8: string, - outputMP4: string -): Promise { - console.log("Converting M3U8 to MP4"); - console.log("Input M3U8:", inputM3U8); - console.log("Output MP4:", outputMP4); - - try { - const command = `-i ${inputM3U8} -c copy ${outputMP4}`; - console.log("Executing FFmpeg command:", command); - - const session = await FFmpegKit.execute(command); - const returnCode = await session.getReturnCode(); - - if (ReturnCode.isSuccess(returnCode)) { - console.log("Conversion completed successfully"); - return true; - } else { - console.error("Conversion failed. Return code:", returnCode); - const output = await session.getOutput(); - console.error("FFmpeg output:", output); - return false; - } - } catch (error) { - console.error("Error during conversion:", error); - return false; - } -} diff --git a/hooks/useFiles.ts b/hooks/useFiles.ts deleted file mode 100644 index 018ffcd6..00000000 --- a/hooks/useFiles.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQueryClient } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; - -/** - * Custom hook for managing downloaded files. - * @returns An object with functions to delete individual files and all files. - */ -export const useFiles = () => { - const queryClient = useQueryClient(); - - /** - * Deletes all downloaded files and clears the download record. - */ - const deleteAllFiles = async (): Promise => { - try { - // Get all downloaded items - const downloadedItems = await AsyncStorage.getItem("downloadedItems"); - if (downloadedItems) { - const items = JSON.parse(downloadedItems); - - // Delete each item's folder - for (const item of items) { - const folderPath = `${FileSystem.documentDirectory}${item.Id}`; - await FileSystem.deleteAsync(folderPath, { idempotent: true }); - } - } - - // Clear the downloadedItems in AsyncStorage - await AsyncStorage.removeItem("downloadedItems"); - - // Invalidate the query to refresh the UI - queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - - console.log( - "Successfully deleted all downloaded files and cleared AsyncStorage" - ); - } catch (error) { - console.error("Failed to delete all files:", error); - } - }; - - /** - * Deletes a specific file and updates the download record. - * @param id - The ID of the file to delete. - */ - const deleteFile = async (id: string): Promise => { - if (!id) { - console.error("Invalid file ID"); - return; - } - - try { - // Delete the entire folder - const folderPath = `${FileSystem.documentDirectory}${id}`; - await FileSystem.deleteAsync(folderPath, { idempotent: true }); - - // Remove the item from AsyncStorage - 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)); - } - - // Invalidate the query to refresh the UI - queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - - console.log( - `Successfully deleted folder and AsyncStorage entry for ID ${id}` - ); - } catch (error) { - console.error( - `Failed to delete folder and AsyncStorage entry for ID ${id}:`, - error - ); - } - }; - - return { deleteFile, deleteAllFiles }; -}; - -/** - * Retrieves the list of downloaded files from AsyncStorage. - * @returns An array of BaseItemDto objects representing downloaded files. - */ -async function getDownloadedFiles(): Promise { - try { - const filesJson = await AsyncStorage.getItem("downloaded_files"); - return filesJson ? JSON.parse(filesJson) : []; - } catch (error) { - console.error("Failed to retrieve downloaded files:", error); - return []; - } -} diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index be78aed1..0a41bb14 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -4,10 +4,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import * as FileSystem from "expo-file-system"; import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { runningProcesses } from "@/utils/atoms/downloads"; import { writeToLog } from "@/utils/log"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner-native"; +import { useDownload } from "@/providers/DownloadProvider"; /** * Custom hook for remuxing HLS to MP4 using FFmpeg. @@ -17,8 +17,9 @@ import { toast } from "sonner-native"; * @returns An object with remuxing-related functions */ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { - const [_, setProgress] = useAtom(runningProcesses); const queryClient = useQueryClient(); + const { process, updateProcess, clearProcess, saveDownloadedItemInfo } = + useDownload(); if (!item.Id || !item.Name) { writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments"); @@ -29,9 +30,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const startRemuxing = useCallback( async (url: string) => { - toast.success("Download started", { - invert: true, - }); + toast.success("Download started"); const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; @@ -41,7 +40,12 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { ); try { - setProgress({ item, progress: 0, startTime: new Date(), speed: 0 }); + updateProcess({ + id: item.Id!, + item, + progress: 0, + state: "downloading", + }); FFmpegKitConfig.enableStatisticsCallback((statistics) => { const videoLength = @@ -56,11 +60,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { ? Math.floor((processedFrames / totalFrames) * 100) : 0; - setProgress((prev) => - prev?.item.Id === item.Id! - ? { ...prev, progress: percentage, speed } - : prev - ); + updateProcess((prev) => { + if (!prev) return null; + return { + ...prev, + progress: percentage, + }; + }); }); // Await the execution of the FFmpeg command and ensure that the callback is awaited properly. @@ -70,19 +76,25 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { const returnCode = await session.getReturnCode(); if (returnCode.isValueSuccess()) { - await updateDownloadedFiles(item); + await saveDownloadedItemInfo(item); + toast.success("Download completed"); writeToLog( "INFO", `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}` ); + await queryClient.invalidateQueries({ + queryKey: ["downloadedItems"], + }); 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}` @@ -90,63 +102,33 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => { resolve(); } - setProgress(null); + clearProcess(); } catch (error) { reject(error); } }); }); - - await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] }); - await queryClient.invalidateQueries({ queryKey: ["downloaded"] }); } catch (error) { console.error("Failed to remux:", error); writeToLog( "ERROR", `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}` ); - setProgress(null); + clearProcess(); throw error; // Re-throw the error to propagate it to the caller } }, - [output, item, setProgress] + [output, item, clearProcess] ); const cancelRemuxing = useCallback(() => { FFmpegKit.cancel(); - setProgress(null); + clearProcess(); writeToLog( "INFO", `useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}` ); - }, [item.Name, setProgress]); + }, [item.Name, clearProcess]); return { startRemuxing, cancelRemuxing }; }; - -/** - * Updates the list of downloaded files in AsyncStorage. - * - * @param item - The item to add to the downloaded files list - */ -async function updateDownloadedFiles(item: BaseItemDto): Promise { - try { - const currentFiles: BaseItemDto[] = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) || "[]" - ); - const updatedFiles = [ - ...currentFiles.filter((i) => i.Id !== item.Id), - item, - ]; - await AsyncStorage.setItem( - "downloaded_files", - JSON.stringify(updatedFiles) - ); - } catch (error) { - console.error("Error updating downloaded files:", error); - writeToLog( - "ERROR", - `Failed to update downloaded files for item: ${item.Name}` - ); - } -} diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx new file mode 100644 index 00000000..9718b941 --- /dev/null +++ b/providers/DownloadProvider.tsx @@ -0,0 +1,442 @@ +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 React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { toast } from "sonner-native"; + +export type ProcessItem = { + id: string; + item: Partial; + progress: number; + size?: number; + state: "optimizing" | "downloading" | "done" | "error" | "canceled"; +}; + +const STORAGE_KEY = "runningProcess"; + +const DownloadContext = createContext | null>(null); + +function useDownloadProvider() { + const queryClient = useQueryClient(); + const [process, setProcess] = useState(null); + const [settings] = useSettings(); + + const { + data: downloadedFiles, + isLoading, + refetch, + } = useQuery({ + queryKey: ["downloadedItems"], + queryFn: getAllDownloadedItems, + staleTime: 0, + }); + + useEffect(() => { + // Load initial process state from AsyncStorage + const loadInitialProcess = async () => { + const storedProcess = await readProcess(); + setProcess(storedProcess); + }; + loadInitialProcess(); + }, []); + + const clearProcess = useCallback(async () => { + await AsyncStorage.removeItem(STORAGE_KEY); + setProcess(null); + }, []); + + 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; + } + + if (newState === null) { + AsyncStorage.removeItem(STORAGE_KEY); + } else { + AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + } + + return newState; + }); + }, + [] + ); + + const readProcess = useCallback(async (): Promise => { + const item = await AsyncStorage.getItem(STORAGE_KEY); + return item ? JSON.parse(item) : null; + }, []); + + const startDownload = useCallback(() => { + 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`, + }) + .begin(() => { + updateProcess((prev) => { + if (!prev) return null; + return { + ...prev, + state: "downloading", + progress: 50, + } as ProcessItem; + }); + }) + .progress((data) => { + const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + updateProcess((prev) => { + if (!prev) { + console.warn("no prev"); + return null; + } + return { + ...prev, + state: "downloading", + progress: percent, + }; + }); + }) + .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]); + + 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 + ); + + if (!job) { + clearProcess(); + return; + } + + // console.log("Job ~", job); + + // 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"; + } + + 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); + } + }; + + console.log("Starting interval check"); + + // 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]); + + const startBackgroundDownload = useCallback( + async (url: string, item: BaseItemDto) => { + try { + const response = await axios.post( + settings?.optimizedVersionsServerUrl + "optimize-version", + { url }, + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (response.status !== 201) { + throw new Error("Failed to start optimization job"); + } + + const { id } = response.data; + + updateProcess({ + id, + item: item, + progress: 0, + state: "optimizing", + }); + + toast.success(`Optimization job started for ${item.Name}`); + } catch (error) { + console.error("Error in startBackgroundDownload:", error); + toast.error(`Failed to start download for ${item.Name}`); + } + }, + [settings?.optimizedVersionsServerUrl] + ); + + /** + * Deletes all downloaded files and clears the download record. + */ + const deleteAllFiles = async (): Promise => { + 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); + + if (itemInfo.exists) { + await FileSystem.deleteAsync(itemPath, { idempotent: true }); + } + } + // Clear the downloadedItems in AsyncStorage + await AsyncStorage.removeItem("downloadedItems"); + await AsyncStorage.removeItem("runningProcess"); + clearProcess(); + + // Invalidate the query to refresh the UI + 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); + } + }; + + /** + * Deletes a specific file and updates the download record. + * @param id - The ID of the file to delete. + */ + const deleteFile = async (id: string): Promise => { + if (!id) { + console.error("Invalid file ID"); + return; + } + + 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) { + const filePath = `${directory}${item}`; + await FileSystem.deleteAsync(filePath, { idempotent: true }); + console.log(`Successfully deleted file: ${item}`); + break; + } + } + + // Remove the item from AsyncStorage + 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)); + } + + // Invalidate the query to refresh the UI + 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 + ); + } + }; + + /** + * Retrieves the list of downloaded files from AsyncStorage. + * @returns An array of BaseItemDto objects representing downloaded files. + */ + 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)); + } catch (error) { + console.error("Failed to save downloaded item information:", error); + } + } + + return { + process, + updateProcess, + startBackgroundDownload, + clearProcess, + readProcess, + downloadedFiles, + deleteAllFiles, + deleteFile, + saveDownloadedItemInfo, + }; +} + +// Create the provider component +export function DownloadProvider({ children }: { children: React.ReactNode }) { + const downloadProviderValue = useDownloadProvider(); + const queryClient = new QueryClient(); + + return ( + + {children} + + ); +} + +// Create a custom hook to use the download context +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 +): Promise<{ + progress: number; + status: "running" | "completed" | "failed" | "cancelled"; +}> => { + const statusResponse = await axios.get(`${baseUrl}job-status/${id}`); + + if (statusResponse.status !== 200) { + throw new Error("Failed to fetch job status"); + } + + const json = statusResponse.data; + return json; +}; diff --git a/utils/atoms/downloads.ts b/utils/atoms/downloads.ts index 1fc47d18..e69de29b 100644 --- a/utils/atoms/downloads.ts +++ b/utils/atoms/downloads.ts @@ -1,10 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { atom } from "jotai"; - -export type ProcessItem = { - item: BaseItemDto; - progress: number; - startTime?: Date; -}; - -export const runningProcesses = atom(null); diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 09bccb37..de6a7336 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -1,5 +1,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { atom, useAtom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; import { useEffect } from "react"; export interface Job { @@ -8,8 +10,31 @@ export interface Job { execute: () => void | Promise; } -export const queueAtom = atom([]); -export const isProcessingAtom = atom(false); +export const runningAtom = atomWithStorage("queueRunning", false, { + getItem: async (key) => { + const value = await AsyncStorage.getItem(key); + return value ? JSON.parse(value) : false; + }, + setItem: async (key, value) => { + await AsyncStorage.setItem(key, JSON.stringify(value)); + }, + removeItem: async (key) => { + await AsyncStorage.removeItem(key); + }, +}); + +export const queueAtom = atomWithStorage("queueJobs", [], { + getItem: async (key) => { + const value = await AsyncStorage.getItem(key); + return value ? JSON.parse(value) : []; + }, + setItem: async (key, value) => { + await AsyncStorage.setItem(key, JSON.stringify(value)); + }, + removeItem: async (key) => { + await AsyncStorage.removeItem(key); + }, +}); export const queueActions = { enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => { @@ -20,7 +45,7 @@ export const queueActions = { processJob: async ( queue: Job[], setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void, + setProcessing: (processing: boolean) => void ) => { const [job, ...rest] = queue; setQueue(rest); @@ -28,13 +53,17 @@ export const queueActions = { console.info("Processing job", job); setProcessing(true); + + // Excute the function assiociated with the job. await job.execute(); + console.info("Job done", job); + setProcessing(false); }, clear: ( setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void, + setProcessing: (processing: boolean) => void ) => { setQueue([]); setProcessing(false); @@ -43,12 +72,12 @@ export const queueActions = { export const useJobProcessor = () => { const [queue, setQueue] = useAtom(queueAtom); - const [isProcessing, setProcessing] = useAtom(isProcessingAtom); + const [running, setRunning] = useAtom(runningAtom); useEffect(() => { - if (queue.length > 0 && !isProcessing) { + if (queue.length > 0 && !running) { console.info("Processing queue", queue); - queueActions.processJob(queue, setQueue, setProcessing); + queueActions.processJob(queue, setQueue, setRunning); } - }, [queue, isProcessing, setQueue, setProcessing]); + }, [queue, running, setQueue, setRunning]); }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index d4d8356a..9618efa4 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -72,6 +72,7 @@ type Settings = { defaultVideoOrientation: ScreenOrientation.OrientationLock; forwardSkipTime: number; rewindSkipTime: number; + optimizedVersionsServerUrl?: string | null; }; /** *