diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 6aed50a9..281ed07e 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -1,39 +1,103 @@ import { Text } from "@/components/common/Text"; +import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types"; import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; import { useRouter } from "expo-router"; import { useEffect } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; -export default function index() { - const { downloadedFiles, getDownloadedItem, activeDownloads } = - useNativeDownloads(); +const PROGRESSBAR_HEIGHT = 10; + +const formatETA = (seconds: number): string => { + const pad = (n: number) => n.toString().padStart(2, "0"); + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; +}; + +const getETA = (download: DownloadInfo): string | null => { + console.log("getETA", download); + if ( + !download.startTime || + !download.bytesDownloaded || + !download.bytesTotal + ) { + console.log(download); + return null; + } + + const elapsed = Date.now() / 100 - download.startTime; // seconds + + console.log("Elapsed (s):", Number(download.startTime), Date.now(), elapsed); + + if (elapsed <= 0 || download.bytesDownloaded <= 0) return null; + + const speed = download.bytesDownloaded / elapsed; // bytes per second + const remainingBytes = download.bytesTotal - download.bytesDownloaded; + + if (speed <= 0) return null; + + const secondsLeft = remainingBytes / speed; + + return formatETA(secondsLeft); +}; + +export default function Index() { + const { downloadedFiles, activeDownloads } = useNativeDownloads(); const router = useRouter(); const goToVideo = (item: any) => { - console.log(item); // @ts-expect-error router.push("/player/direct-player?offline=true&itemId=" + item.id); }; - useEffect(() => { - console.log(activeDownloads); - }, [activeDownloads]); - return ( - {activeDownloads.map((i) => ( - - {i.id} - - ))} + {activeDownloads.map((i) => { + const progress = + i.bytesTotal && i.bytesDownloaded + ? i.bytesDownloaded / i.bytesTotal + : 0; + const eta = getETA(i); + return ( + + {i.metadata?.item?.Name} + {i.state === "PENDING" ? ( + + ) : i.state === "DOWNLOADING" ? ( + + {i.bytesDownloaded} / {i.bytesTotal} + + ) : null} + + + + {eta ? ETA: {eta} : Calculating...} + + ); + })} {downloadedFiles.map((i) => ( goToVideo(i)} className="bg-neutral-800 p-4 rounded-lg" > - {i.metadata.item.Name} - {i.metadata.item.Type} + {i.metadata.item?.Name} + {i.metadata.item?.Type} ))} diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index a794d145..01e11220 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -4,6 +4,7 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { Colors } from "@/constants/Colors"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { @@ -11,7 +12,7 @@ import { useSplashScreenVisible, } from "@/providers/SplashScreenProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { Ionicons } from "@expo/vector-icons"; +import { Feather, Ionicons } from "@expo/vector-icons"; import { Api } from "@jellyfin/sdk"; import { BaseItemDto, @@ -26,7 +27,11 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import NetInfo from "@react-native-community/netinfo"; import { QueryFunction, useQuery } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; +import { + useNavigation, + useNavigationContainerRef, + useRouter, +} from "expo-router"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -34,6 +39,7 @@ import { ActivityIndicator, RefreshControl, ScrollView, + TouchableOpacity, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -83,6 +89,23 @@ export default function index() { setLoadingRetry(false); }, []); + const navigation = useNavigation(); + + useEffect(() => { + navigation.setOptions({ + headerLeft: () => ( + { + router.push("/(auth)/downloads"); + }} + className="p-2" + > + + + ), + }); + }, []); + useEffect(() => { const unsubscribe = NetInfo.addEventListener((state) => { if (state.isConnected == false || state.isInternetReachable === false) diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 5828c88f..f286adf6 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -21,9 +21,6 @@ import React, { lazy, useEffect } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { storage } from "@/utils/mmkv"; -const DownloadSettings = lazy( - () => import("@/components/settings/DownloadSettings") -); export default function settings() { const router = useRouter(); @@ -72,8 +69,6 @@ export default function settings() { - {!Platform.isTV && } - diff --git a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx b/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx deleted file mode 100644 index 988651f0..00000000 --- a/app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Text } from "@/components/common/Text"; -import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getOrSetDeviceId } from "@/utils/device"; -import { getStatistics } from "@/utils/optimize-server"; -import { useMutation } from "@tanstack/react-query"; -import { useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -import { ActivityIndicator, TouchableOpacity, View } from "react-native"; -import { toast } from "sonner-native"; -import { useTranslation } from "react-i18next"; -import DisabledSetting from "@/components/settings/DisabledSetting"; - -export default function page() { - const navigation = useNavigation(); - - const { t } = useTranslation(); - - const [api] = useAtom(apiAtom); - const [settings, updateSettings, pluginSettings] = useSettings(); - - const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] = - useState(settings?.optimizedVersionsServerUrl || ""); - - const saveMutation = useMutation({ - mutationFn: async (newVal: string) => { - if (newVal.length === 0 || !newVal.startsWith("http")) { - toast.error(t("home.settings.toasts.invalid_url")); - return; - } - - const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/"; - - updateSettings({ - optimizedVersionsServerUrl: updatedUrl, - }); - - return await getStatistics({ - url: settings?.optimizedVersionsServerUrl, - authHeader: api?.accessToken, - deviceId: getOrSetDeviceId(), - }); - }, - onSuccess: (data) => { - if (data) { - toast.success(t("home.settings.toasts.connected")); - } else { - toast.error(t("home.settings.toasts.could_not_connect")); - } - }, - onError: () => { - toast.error(t("home.settings.toasts.could_not_connect")); - }, - }); - - const onSave = (newVal: string) => { - saveMutation.mutate(newVal); - }; - - useEffect(() => { - if (!pluginSettings?.optimizedVersionsServerUrl?.locked) { - navigation.setOptions({ - title: t("home.settings.downloads.optimized_server"), - headerRight: () => - saveMutation.isPending ? ( - - ) : ( - onSave(optimizedVersionsServerUrl)}> - {t("home.settings.downloads.save_button")} - - ), - }); - } - }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]); - - return ( - - - - ); -} diff --git a/app/_layout.tsx b/app/_layout.tsx index 1cd6ac89..7f12fb8d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,53 +1,38 @@ import "@/augmentations"; -import { Platform } from "react-native"; -import { Text } from "@/components/common/Text"; import i18n from "@/i18n"; -import { DownloadProvider } from "@/providers/DownloadProvider"; -import { - getOrSetDeviceId, - getTokenFromStorage, - JellyfinProvider, -} from "@/providers/JellyfinProvider"; -import { JobQueueProvider } from "@/providers/JobQueueProvider"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; +import { JellyfinProvider } from "@/providers/JellyfinProvider"; +import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { SplashScreenProvider, useSplashScreenLoading, } from "@/providers/SplashScreenProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider"; -import { Settings, useSettings } from "@/utils/atoms/settings"; -import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks"; +import { useSettings } from "@/utils/atoms/settings"; import { LogProvider, writeToLog } from "@/utils/log"; import { storage } from "@/utils/mmkv"; -import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -const BackGroundDownloader = !Platform.isTV - ? require("@kesha-antonov/react-native-background-downloader") - : null; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -const BackgroundFetch = !Platform.isTV - ? require("expo-background-fetch") - : null; -import * as FileSystem from "expo-file-system"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; -const Notifications = !Platform.isTV ? require("expo-notifications") : null; -import { router, Stack } from "expo-router"; -import * as ScreenOrientation from "@/packages/expo-screen-orientation"; -const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; import { getLocales } from "expo-localization"; +import { router, Stack } from "expo-router"; import { Provider as JotaiProvider } from "jotai"; import { useEffect, useRef } from "react"; import { I18nextProvider, useTranslation } from "react-i18next"; -import { Appearance, AppState } from "react-native"; +import { Appearance, AppState, Platform } from "react-native"; import { SystemBars } from "react-native-edge-to-edge"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { Toaster } from "sonner-native"; -import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider"; +const BackGroundDownloader = !Platform.isTV + ? require("@kesha-antonov/react-native-background-downloader") + : null; +const Notifications = !Platform.isTV ? require("expo-notifications") : null; if (!Platform.isTV) { Notifications.setNotificationHandler({ @@ -94,102 +79,6 @@ function useNotificationObserver() { }, []); } -if (!Platform.isTV) { - TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { - console.log("TaskManager ~ trigger"); - - const now = Date.now(); - - const settingsData = storage.getString("settings"); - - if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; - - const settings: Partial = JSON.parse(settingsData); - const url = settings?.optimizedVersionsServerUrl; - - if (!settings?.autoDownload || !url) - return BackgroundFetch.BackgroundFetchResult.NoData; - - const token = getTokenFromStorage(); - const deviceId = getOrSetDeviceId(); - const baseDirectory = FileSystem.documentDirectory; - - if (!token || !deviceId || !baseDirectory) - return BackgroundFetch.BackgroundFetchResult.NoData; - - const jobs = await getAllJobsByDeviceId({ - deviceId, - authHeader: token, - url, - }); - - console.log("TaskManager ~ Active jobs: ", jobs.length); - - for (let job of jobs) { - if (job.status === "completed") { - const downloadUrl = url + "download/" + job.id; - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - - if (tasks.find((task: { id: string }) => task.id === job.id)) { - console.log("TaskManager ~ Download already in progress: ", job.id); - continue; - } - - BackGroundDownloader.download({ - id: job.id, - url: downloadUrl, - destination: `${baseDirectory}${job.item.Id}.mp4`, - headers: { - Authorization: token, - }, - }) - .begin(() => { - console.log("TaskManager ~ Download started: ", job.id); - }) - .done(() => { - console.log("TaskManager ~ Download completed: ", job.id); - saveDownloadedItemInfo(job.item); - BackGroundDownloader.completeHandler(job.id); - cancelJobById({ - authHeader: token, - id: job.id, - url: url, - }); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download completed", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - }) - .error((error: any) => { - console.log("TaskManager ~ Download error: ", job.id, error); - BackGroundDownloader.completeHandler(job.id); - Notifications.scheduleNotificationAsync({ - content: { - title: job.item.Name, - body: "Download failed", - data: { - url: `/downloads`, - }, - }, - trigger: null, - }); - }); - } - } - - console.log(`Auto download started: ${new Date(now).toISOString()}`); - - // Be sure to return the successful result type! - return BackgroundFetch.BackgroundFetchResult.NewData; - }); -} - const checkAndRequestPermissions = async () => { try { const hasAskedBefore = storage.getString( @@ -316,64 +205,62 @@ function Layout() { return ( - - - - - - - - - + null, + }} + /> + + + + + + + + + + + ); } diff --git a/components/settings/OptimizedServerForm.tsx b/components/settings/OptimizedServerForm.tsx deleted file mode 100644 index 35910f04..00000000 --- a/components/settings/OptimizedServerForm.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { TextInput, View, Linking } from "react-native"; -import { Text } from "../common/Text"; -import { useTranslation } from "react-i18next"; - -interface Props { - value: string; - onChangeValue: (value: string) => void; -} - -export const OptimizedServerForm: React.FC = ({ - value, - onChangeValue, -}) => { - const handleOpenLink = () => { - Linking.openURL("https://github.com/streamyfin/optimized-versions-server"); - }; - - const { t } = useTranslation(); - - return ( - - - - {t("home.settings.downloads.url")} - onChangeValue(text)} - /> - - - - {t("home.settings.downloads.optimized_version_hint")}{" "} - - {t("home.settings.downloads.read_more_about_optimized_server")} - - - - ); -}; diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index 8a47945c..a23ba6c3 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -5,7 +5,7 @@ public class HlsDownloaderModule: Module { var activeDownloads: [Int: ( task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any], - startTime: Date + startTime: Double )] = [:] public func definition() -> ModuleDefinition { @@ -15,8 +15,9 @@ public class HlsDownloaderModule: Module { Function("downloadHLSAsset") { (providedId: String, url: String, metadata: [String: Any]?) -> Void in + let startTime = Date().timeIntervalSince1970 print( - "Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata))" + "Starting download - ID: \(providedId), URL: \(url), Metadata: \(String(describing: metadata)), StartTime: \(startTime)" ) guard let assetURL = URL(string: url) else { @@ -27,6 +28,7 @@ public class HlsDownloaderModule: Module { "error": "Invalid URL", "state": "FAILED", "metadata": metadata ?? [:], + "startTime": startTime, ]) return } @@ -36,6 +38,7 @@ public class HlsDownloaderModule: Module { withIdentifier: "com.example.hlsdownload") let delegate = HLSDownloadDelegate(module: self) delegate.providedId = providedId + delegate.startTime = startTime let downloadSession = AVAssetDownloadURLSession( configuration: configuration, assetDownloadDelegate: delegate, @@ -47,7 +50,7 @@ public class HlsDownloaderModule: Module { asset: asset, assetTitle: providedId, assetArtworkData: nil, - options: nil + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: startTime] ) else { self.sendEvent( @@ -57,12 +60,13 @@ public class HlsDownloaderModule: Module { "error": "Failed to create download task", "state": "FAILED", "metadata": metadata ?? [:], + "startTime": startTime, ]) return } delegate.taskIdentifier = task.taskIdentifier - self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], Date()) + self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime) self.sendEvent( "onProgress", [ @@ -70,7 +74,7 @@ public class HlsDownloaderModule: Module { "progress": 0.0, "state": "PENDING", "metadata": metadata ?? [:], - "startTime": Date().timeIntervalSince1970, + "startTime": startTime, ]) task.resume() @@ -95,7 +99,7 @@ public class HlsDownloaderModule: Module { "bytesTotal": total, "state": self.mappedState(for: task), "metadata": metadata, - "startTime": startTime.timeIntervalSince1970, + "startTime": startTime, ]) } return downloads @@ -142,6 +146,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { var providedId: String = "" var downloadedSeconds: Double = 0 var totalSeconds: Double = 0 + var startTime: Double = 0 init(module: HlsDownloaderModule) { self.module = module @@ -157,9 +162,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { } let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) - let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier] - let metadata = downloadInfo?.metadata ?? [:] - let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970 + let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] + let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0 self.downloadedSeconds = downloaded self.totalSeconds = total @@ -183,9 +187,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { _ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL ) { - let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier] - let metadata = downloadInfo?.metadata ?? [:] - let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970 + let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:] + let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0 let folderName = providedId do { guard let module = module else { return } @@ -224,10 +227,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error = error { - let downloadInfo = module?.activeDownloads[task.taskIdentifier] - let metadata = downloadInfo?.metadata ?? [:] - let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970 - + let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:] + let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0 module?.sendEvent( "onError", [ diff --git a/modules/hls-downloader/src/HlsDownloader.types.ts b/modules/hls-downloader/src/HlsDownloader.types.ts index a4d3ab40..96369888 100644 --- a/modules/hls-downloader/src/HlsDownloader.types.ts +++ b/modules/hls-downloader/src/HlsDownloader.types.ts @@ -20,13 +20,14 @@ export interface DownloadMetadata { export type BaseEventPayload = { id: string; state: DownloadState; - metadata?: DownloadMetadata; + metadata: DownloadMetadata; }; export type OnProgressEventPayload = BaseEventPayload & { progress: number; bytesDownloaded: number; bytesTotal: number; + startTime?: number; }; export type OnErrorEventPayload = BaseEventPayload & { @@ -55,5 +56,5 @@ export interface DownloadInfo { bytesTotal?: number; location?: string; error?: string; - metadata?: DownloadMetadata; + metadata: DownloadMetadata; } diff --git a/providers/JobQueueProvider.tsx b/providers/JobQueueProvider.tsx deleted file mode 100644 index 00358e48..00000000 --- a/providers/JobQueueProvider.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { createContext } from "react"; -import { useJobProcessor } from "@/utils/atoms/queue"; - -const JobQueueContext = createContext(null); - -export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - useJobProcessor(); - - return ( - {children} - ); -}; diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index 88abc8fa..53c64fc1 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -11,20 +11,19 @@ import { DownloadMetadata, } from "@/modules/hls-downloader/src/HlsDownloader.types"; import { getItemImage } from "@/utils/getItemImage"; +import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools"; +import download from "@/utils/profiles/download"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import RNBackgroundDownloader from "@kesha-antonov/react-native-background-downloader"; +import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useAtomValue } from "jotai"; import { createContext, useContext, useEffect, useState } from "react"; import { toast } from "sonner-native"; import { apiAtom, userAtom } from "./JellyfinProvider"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import download from "@/utils/profiles/download"; -import { useQuery } from "@tanstack/react-query"; type DownloadOptionsData = { selectedAudioStream: number; @@ -45,7 +44,6 @@ type DownloadContextType = { maxBitrate, }: DownloadOptionsData ) => Promise; - cancelDownload: (id: string) => void; getDownloadedItem: (id: string) => Promise; activeDownloads: DownloadInfo[]; downloadedFiles: DownloadedFileInfo[]; @@ -83,7 +81,7 @@ export type DownloadedFileInfo = { metadata: DownloadMetadata; }; -const listDownloadedFiles = async (): Promise => { +const getDownloadedFiles = async (): Promise => { const downloadsDir = FileSystem.documentDirectory + "downloads/"; const dirInfo = await FileSystem.getInfoAsync(downloadsDir); if (!dirInfo.exists) return []; @@ -113,7 +111,7 @@ const listDownloadedFiles = async (): Promise => { return downloaded; }; -const getDownloadedItem = async (id: string) => { +const getDownloadedFile = async (id: string) => { const downloadsDir = FileSystem.documentDirectory + "downloads/"; const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json"); if (!fileInfo.exists) return null; @@ -136,7 +134,7 @@ export const NativeDownloadProvider: React.FC<{ const { data: downloadedFiles } = useQuery({ queryKey: ["downloadedFiles"], - queryFn: listDownloadedFiles, + queryFn: getDownloadedFiles, }); useEffect(() => { @@ -153,35 +151,21 @@ export const NativeDownloadProvider: React.FC<{ state: download.state, bytesDownloaded: download.bytesDownloaded, bytesTotal: download.bytesTotal, + metadata: download.metadata, + startTime: download?.startTime, }, }), {} ); - // Check regular downloads - const regularDownloads = - await RNBackgroundDownloader.checkForExistingDownloads(); - const regularDownloadStates = regularDownloads.reduce( - (acc, download) => ({ - ...acc, - [download.id]: { - id: download.id, - progress: download.bytesDownloaded / download.bytesTotal, - state: download.state, - bytesDownloaded: download.bytesDownloaded, - bytesTotal: download.bytesTotal, - }, - }), - {} - ); - - setDownloads({ ...hlsDownloadStates, ...regularDownloadStates }); + setDownloads({ ...hlsDownloadStates }); }; initializeDownloads(); - // Set up HLS download listeners const progressListener = addProgressListener((download) => { + if (!download.metadata) throw new Error("No metadata found in download"); + console.log("[HLS] Download progress:", download); setDownloads((prev) => ({ ...prev, @@ -191,12 +175,14 @@ export const NativeDownloadProvider: React.FC<{ state: download.state, bytesDownloaded: download.bytesDownloaded, bytesTotal: download.bytesTotal, + metadata: download.metadata, + startTime: download?.startTime, }, })); }); const completeListener = addCompleteListener(async (payload) => { - if (!payload?.id) throw new Error("No id found in payload"); + if (!payload.id) throw new Error("No id found in payload"); try { rewriteM3U8Files(payload.location); @@ -235,7 +221,6 @@ export const NativeDownloadProvider: React.FC<{ useEffect(() => { // Go through all the files in the folder downloads, check for the file id.json and id-done.json, if the id.json exists but id-done.json does not exist, then the download is still in done but not parsed. Parse it. const checkForUnparsedDownloads = async () => { - let found = false; const downloadsFolder = await FileSystem.getInfoAsync( FileSystem.documentDirectory + "downloads" ); @@ -263,7 +248,6 @@ export const NativeDownloadProvider: React.FC<{ loading: "Finishing up download...", success: () => "Download complete ✅", }); - found = true; } } } @@ -300,83 +284,17 @@ export const NativeDownloadProvider: React.FC<{ }); if (!res) throw new Error("Failed to get stream URL"); - const { mediaSource } = res; - if (!mediaSource) throw new Error("Failed to get media source"); await saveImage(item.Id, itemImage?.uri); - if (url.includes("master.m3u8")) { - // HLS download - downloadHLSAsset(jobId, url, { - item, - mediaSource, - }); - } else { - // Regular download - try { - const task = RNBackgroundDownloader.download({ - id: jobId, - url: url, - destination: `${FileSystem.documentDirectory}${jobId}/${item.Name}.mkv`, - }); + if (!url.includes("master.m3u8")) + throw new Error("Only HLS downloads are supported"); - task.begin(({ expectedBytes }) => { - setDownloads((prev) => ({ - ...prev, - [jobId]: { - id: jobId, - progress: 0, - state: "DOWNLOADING", - }, - })); - }); - - task.progress(({ bytesDownloaded, bytesTotal }) => { - console.log( - "[Normal] Download progress:", - bytesDownloaded, - bytesTotal - ); - setDownloads((prev) => ({ - ...prev, - [jobId]: { - id: jobId, - progress: bytesDownloaded / bytesTotal, - state: "DOWNLOADING", - }, - })); - }); - - task.done(() => { - setDownloads((prev) => { - const newDownloads = { ...prev }; - delete newDownloads[jobId]; - return newDownloads; - }); - }); - - task.error(({ error }) => { - console.error("Download error:", error); - setDownloads((prev) => { - const newDownloads = { ...prev }; - delete newDownloads[jobId]; - return newDownloads; - }); - }); - } catch (error) { - console.error("Error starting download:", error); - } - } - }; - - const cancelDownload = (id: string) => { - // Implement cancel logic here - setDownloads((prev) => { - const newDownloads = { ...prev }; - delete newDownloads[id]; - return newDownloads; + downloadHLSAsset(jobId, url, { + item, + mediaSource, }); }; @@ -385,9 +303,8 @@ export const NativeDownloadProvider: React.FC<{ value={{ downloads, startDownload, - cancelDownload, - downloadedFiles, - getDownloadedItem: getDownloadedItem, + downloadedFiles: downloadedFiles ?? [], + getDownloadedItem: getDownloadedFile, activeDownloads: Object.values(downloads), }} > diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts deleted file mode 100644 index 70f85de9..00000000 --- a/utils/atoms/queue.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { atom, useAtom } from "jotai"; -import { useEffect } from "react"; -import {JobStatus} from "@/utils/optimize-server"; -import {processesAtom} from "@/providers/DownloadProvider"; -import {useSettings} from "@/utils/atoms/settings"; - -export interface Job { - id: string; - item: BaseItemDto; - execute: () => void | Promise; -} - -export const runningAtom = atom(false); - -export const queueAtom = atom([]); - -export const queueActions = { - enqueue: (queue: Job[], setQueue: (update: Job[]) => void, ...job: Job[]) => { - const updatedQueue = [...queue, ...job]; - console.info("Enqueueing job", job, updatedQueue); - setQueue(updatedQueue); - }, - processJob: async ( - queue: Job[], - setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void - ) => { - const [job, ...rest] = queue; - - console.info("Processing job", job); - - setProcessing(true); - - // Allow job to execute so that it gets added as a processes first BEFORE updating new queue - try { - await job.execute(); - } finally { - setQueue(rest); - } - - console.info("Job done", job); - - setProcessing(false); - }, - clear: ( - setQueue: (update: Job[]) => void, - setProcessing: (processing: boolean) => void - ) => { - setQueue([]); - setProcessing(false); - }, -}; - -export const useJobProcessor = () => { - const [queue, setQueue] = useAtom(queueAtom); - const [running, setRunning] = useAtom(runningAtom); - const [processes] = useAtom(processesAtom); - const [settings] = useSettings(); - - useEffect(() => { - if (!running && queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) { - console.info("Processing queue", queue); - queueActions.processJob(queue, setQueue, setRunning); - } - }, [processes, queue, running, setQueue, setRunning]); -};