This commit is contained in:
Fredrik Burmester
2025-02-16 16:58:24 +01:00
parent 1a2e044da6
commit d2ecfac44e
11 changed files with 210 additions and 537 deletions

View File

@@ -1,39 +1,103 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; import { useNativeDownloads } from "@/providers/NativeDownloadProvider";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
export default function index() { const PROGRESSBAR_HEIGHT = 10;
const { downloadedFiles, getDownloadedItem, activeDownloads } =
useNativeDownloads(); 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 router = useRouter();
const goToVideo = (item: any) => { const goToVideo = (item: any) => {
console.log(item);
// @ts-expect-error // @ts-expect-error
router.push("/player/direct-player?offline=true&itemId=" + item.id); router.push("/player/direct-player?offline=true&itemId=" + item.id);
}; };
useEffect(() => {
console.log(activeDownloads);
}, [activeDownloads]);
return ( return (
<View className="p-4 space-y-2"> <View className="p-4 space-y-2">
{activeDownloads.map((i) => ( {activeDownloads.map((i) => {
<View> const progress =
<Text>{i.id}</Text> i.bytesTotal && i.bytesDownloaded
</View> ? i.bytesDownloaded / i.bytesTotal
))} : 0;
const eta = getETA(i);
return (
<View key={i.id}>
<Text>{i.metadata?.item?.Name}</Text>
{i.state === "PENDING" ? (
<ActivityIndicator size={"small"} color={"white"} />
) : i.state === "DOWNLOADING" ? (
<Text>
{i.bytesDownloaded} / {i.bytesTotal}
</Text>
) : null}
<View
className="bg-neutral-800"
style={{
height: PROGRESSBAR_HEIGHT,
borderRadius: 5,
overflow: "hidden",
marginVertical: 4,
}}
>
<View
className="bg-purple-600"
style={{
width: `${progress * 100}%`,
height: "100%",
}}
/>
</View>
{eta ? <Text>ETA: {eta}</Text> : <Text>Calculating...</Text>}
</View>
);
})}
{downloadedFiles.map((i) => ( {downloadedFiles.map((i) => (
<TouchableOpacity <TouchableOpacity
key={i.id} key={i.id}
onPress={() => goToVideo(i)} onPress={() => goToVideo(i)}
className="bg-neutral-800 p-4 rounded-lg" className="bg-neutral-800 p-4 rounded-lg"
> >
<Text>{i.metadata.item.Name}</Text> <Text>{i.metadata.item?.Name}</Text>
<Text>{i.metadata.item.Type}</Text> <Text>{i.metadata.item?.Type}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>

View File

@@ -4,6 +4,7 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
@@ -11,7 +12,7 @@ import {
useSplashScreenVisible, useSplashScreenVisible,
} from "@/providers/SplashScreenProvider"; } from "@/providers/SplashScreenProvider";
import { useSettings } from "@/utils/atoms/settings"; 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 { Api } from "@jellyfin/sdk";
import { import {
BaseItemDto, BaseItemDto,
@@ -26,7 +27,11 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query"; import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import {
useNavigation,
useNavigationContainerRef,
useRouter,
} from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -34,6 +39,7 @@ import {
ActivityIndicator, ActivityIndicator,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -83,6 +89,23 @@ export default function index() {
setLoadingRetry(false); setLoadingRetry(false);
}, []); }, []);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather name="download" color={Colors.primary} size={22} />
</TouchableOpacity>
),
});
}, []);
useEffect(() => { useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => { const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false) if (state.isConnected == false || state.isInternetReachable === false)

View File

@@ -21,9 +21,6 @@ import React, { lazy, useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
export default function settings() { export default function settings() {
const router = useRouter(); const router = useRouter();
@@ -72,8 +69,6 @@ export default function settings() {
<OtherSettings /> <OtherSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings /> <PluginSettings />
<AppLanguageSelector /> <AppLanguageSelector />

View File

@@ -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<string>(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 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
</TouchableOpacity>
),
});
}
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}
onChangeValue={setOptimizedVersionsServerUrl}
/>
</DisabledSetting>
);
}

View File

@@ -1,53 +1,38 @@
import "@/augmentations"; import "@/augmentations";
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { import { JellyfinProvider } from "@/providers/JellyfinProvider";
getOrSetDeviceId, import { NativeDownloadProvider } from "@/providers/NativeDownloadProvider";
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { import {
SplashScreenProvider, SplashScreenProvider,
useSplashScreenLoading, useSplashScreenLoading,
} from "@/providers/SplashScreenProvider"; } from "@/providers/SplashScreenProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log"; import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; 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 { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake"; 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 { getLocales } from "expo-localization";
import { router, Stack } from "expo-router";
import { Provider as JotaiProvider } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { I18nextProvider, useTranslation } from "react-i18next"; 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 { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated"; import "react-native-reanimated";
import { Toaster } from "sonner-native"; 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) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ 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<Settings> = 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 () => { const checkAndRequestPermissions = async () => {
try { try {
const hasAskedBefore = storage.getString( const hasAskedBefore = storage.getString(
@@ -316,64 +205,62 @@ function Layout() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JobQueueProvider> <JellyfinProvider>
<JellyfinProvider> <PlaySettingsProvider>
<PlaySettingsProvider> <LogProvider>
<LogProvider> <WebSocketProvider>
<WebSocketProvider> <NativeDownloadProvider>
<NativeDownloadProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <SystemBars style="light" hidden={false} />
<SystemBars style="light" hidden={false} /> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <Stack>
<Stack> <Stack.Screen
<Stack.Screen name="(auth)/(tabs)"
name="(auth)/(tabs)" options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/player"
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
</ThemeProvider> <Stack.Screen
</BottomSheetModalProvider> name="(auth)/player"
</NativeDownloadProvider> options={{
</WebSocketProvider> headerShown: false,
</LogProvider> title: "",
</PlaySettingsProvider> header: () => null,
</JellyfinProvider> }}
</JobQueueProvider> />
<Stack.Screen
name="login"
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</NativeDownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -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<Props> = ({
value,
onChangeValue,
}) => {
const handleOpenLink = () => {
Linking.openURL("https://github.com/streamyfin/optimized-versions-server");
};
const { t } = useTranslation();
return (
<View>
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
<Text className="mr-4">{t("home.settings.downloads.url")}</Text>
<TextInput
className="text-white"
placeholder={t("home.settings.downloads.server_url_placeholder")}
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => onChangeValue(text)}
/>
</View>
</View>
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.downloads.optimized_version_hint")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
{t("home.settings.downloads.read_more_about_optimized_server")}
</Text>
</Text>
</View>
);
};

View File

@@ -5,7 +5,7 @@ public class HlsDownloaderModule: Module {
var activeDownloads: var activeDownloads:
[Int: ( [Int: (
task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any], task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
startTime: Date startTime: Double
)] = [:] )] = [:]
public func definition() -> ModuleDefinition { public func definition() -> ModuleDefinition {
@@ -15,8 +15,9 @@ public class HlsDownloaderModule: Module {
Function("downloadHLSAsset") { Function("downloadHLSAsset") {
(providedId: String, url: String, metadata: [String: Any]?) -> Void in (providedId: String, url: String, metadata: [String: Any]?) -> Void in
let startTime = Date().timeIntervalSince1970
print( 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 { guard let assetURL = URL(string: url) else {
@@ -27,6 +28,7 @@ public class HlsDownloaderModule: Module {
"error": "Invalid URL", "error": "Invalid URL",
"state": "FAILED", "state": "FAILED",
"metadata": metadata ?? [:], "metadata": metadata ?? [:],
"startTime": startTime,
]) ])
return return
} }
@@ -36,6 +38,7 @@ public class HlsDownloaderModule: Module {
withIdentifier: "com.example.hlsdownload") withIdentifier: "com.example.hlsdownload")
let delegate = HLSDownloadDelegate(module: self) let delegate = HLSDownloadDelegate(module: self)
delegate.providedId = providedId delegate.providedId = providedId
delegate.startTime = startTime
let downloadSession = AVAssetDownloadURLSession( let downloadSession = AVAssetDownloadURLSession(
configuration: configuration, configuration: configuration,
assetDownloadDelegate: delegate, assetDownloadDelegate: delegate,
@@ -47,7 +50,7 @@ public class HlsDownloaderModule: Module {
asset: asset, asset: asset,
assetTitle: providedId, assetTitle: providedId,
assetArtworkData: nil, assetArtworkData: nil,
options: nil options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: startTime]
) )
else { else {
self.sendEvent( self.sendEvent(
@@ -57,12 +60,13 @@ public class HlsDownloaderModule: Module {
"error": "Failed to create download task", "error": "Failed to create download task",
"state": "FAILED", "state": "FAILED",
"metadata": metadata ?? [:], "metadata": metadata ?? [:],
"startTime": startTime,
]) ])
return return
} }
delegate.taskIdentifier = task.taskIdentifier delegate.taskIdentifier = task.taskIdentifier
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], Date()) self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
self.sendEvent( self.sendEvent(
"onProgress", "onProgress",
[ [
@@ -70,7 +74,7 @@ public class HlsDownloaderModule: Module {
"progress": 0.0, "progress": 0.0,
"state": "PENDING", "state": "PENDING",
"metadata": metadata ?? [:], "metadata": metadata ?? [:],
"startTime": Date().timeIntervalSince1970, "startTime": startTime,
]) ])
task.resume() task.resume()
@@ -95,7 +99,7 @@ public class HlsDownloaderModule: Module {
"bytesTotal": total, "bytesTotal": total,
"state": self.mappedState(for: task), "state": self.mappedState(for: task),
"metadata": metadata, "metadata": metadata,
"startTime": startTime.timeIntervalSince1970, "startTime": startTime,
]) ])
} }
return downloads return downloads
@@ -142,6 +146,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
var providedId: String = "" var providedId: String = ""
var downloadedSeconds: Double = 0 var downloadedSeconds: Double = 0
var totalSeconds: Double = 0 var totalSeconds: Double = 0
var startTime: Double = 0
init(module: HlsDownloaderModule) { init(module: HlsDownloaderModule) {
self.module = module self.module = module
@@ -157,9 +162,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
} }
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier] let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
let metadata = downloadInfo?.metadata ?? [:] let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
self.downloadedSeconds = downloaded self.downloadedSeconds = downloaded
self.totalSeconds = total self.totalSeconds = total
@@ -183,9 +187,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, _ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
didFinishDownloadingTo location: URL didFinishDownloadingTo location: URL
) { ) {
let downloadInfo = module?.activeDownloads[assetDownloadTask.taskIdentifier] let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
let metadata = downloadInfo?.metadata ?? [:] let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
let folderName = providedId let folderName = providedId
do { do {
guard let module = module else { return } guard let module = module else { return }
@@ -224,10 +227,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error { if let error = error {
let downloadInfo = module?.activeDownloads[task.taskIdentifier] let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
let metadata = downloadInfo?.metadata ?? [:] let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0
let startTime = downloadInfo?.startTime.timeIntervalSince1970 ?? Date().timeIntervalSince1970
module?.sendEvent( module?.sendEvent(
"onError", "onError",
[ [

View File

@@ -20,13 +20,14 @@ export interface DownloadMetadata {
export type BaseEventPayload = { export type BaseEventPayload = {
id: string; id: string;
state: DownloadState; state: DownloadState;
metadata?: DownloadMetadata; metadata: DownloadMetadata;
}; };
export type OnProgressEventPayload = BaseEventPayload & { export type OnProgressEventPayload = BaseEventPayload & {
progress: number; progress: number;
bytesDownloaded: number; bytesDownloaded: number;
bytesTotal: number; bytesTotal: number;
startTime?: number;
}; };
export type OnErrorEventPayload = BaseEventPayload & { export type OnErrorEventPayload = BaseEventPayload & {
@@ -55,5 +56,5 @@ export interface DownloadInfo {
bytesTotal?: number; bytesTotal?: number;
location?: string; location?: string;
error?: string; error?: string;
metadata?: DownloadMetadata; metadata: DownloadMetadata;
} }

View File

@@ -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 (
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
);
};

View File

@@ -11,20 +11,19 @@ import {
DownloadMetadata, DownloadMetadata,
} from "@/modules/hls-downloader/src/HlsDownloader.types"; } from "@/modules/hls-downloader/src/HlsDownloader.types";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools"; import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools";
import download from "@/utils/profiles/download";
import { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } 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 * as FileSystem from "expo-file-system";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { apiAtom, userAtom } from "./JellyfinProvider"; 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 = { type DownloadOptionsData = {
selectedAudioStream: number; selectedAudioStream: number;
@@ -45,7 +44,6 @@ type DownloadContextType = {
maxBitrate, maxBitrate,
}: DownloadOptionsData }: DownloadOptionsData
) => Promise<void>; ) => Promise<void>;
cancelDownload: (id: string) => void;
getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>; getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>;
activeDownloads: DownloadInfo[]; activeDownloads: DownloadInfo[];
downloadedFiles: DownloadedFileInfo[]; downloadedFiles: DownloadedFileInfo[];
@@ -83,7 +81,7 @@ export type DownloadedFileInfo = {
metadata: DownloadMetadata; metadata: DownloadMetadata;
}; };
const listDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => { const getDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
const downloadsDir = FileSystem.documentDirectory + "downloads/"; const downloadsDir = FileSystem.documentDirectory + "downloads/";
const dirInfo = await FileSystem.getInfoAsync(downloadsDir); const dirInfo = await FileSystem.getInfoAsync(downloadsDir);
if (!dirInfo.exists) return []; if (!dirInfo.exists) return [];
@@ -113,7 +111,7 @@ const listDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
return downloaded; return downloaded;
}; };
const getDownloadedItem = async (id: string) => { const getDownloadedFile = async (id: string) => {
const downloadsDir = FileSystem.documentDirectory + "downloads/"; const downloadsDir = FileSystem.documentDirectory + "downloads/";
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json"); const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json");
if (!fileInfo.exists) return null; if (!fileInfo.exists) return null;
@@ -136,7 +134,7 @@ export const NativeDownloadProvider: React.FC<{
const { data: downloadedFiles } = useQuery({ const { data: downloadedFiles } = useQuery({
queryKey: ["downloadedFiles"], queryKey: ["downloadedFiles"],
queryFn: listDownloadedFiles, queryFn: getDownloadedFiles,
}); });
useEffect(() => { useEffect(() => {
@@ -153,35 +151,21 @@ export const NativeDownloadProvider: React.FC<{
state: download.state, state: download.state,
bytesDownloaded: download.bytesDownloaded, bytesDownloaded: download.bytesDownloaded,
bytesTotal: download.bytesTotal, bytesTotal: download.bytesTotal,
metadata: download.metadata,
startTime: download?.startTime,
}, },
}), }),
{} {}
); );
// Check regular downloads setDownloads({ ...hlsDownloadStates });
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 });
}; };
initializeDownloads(); initializeDownloads();
// Set up HLS download listeners
const progressListener = addProgressListener((download) => { const progressListener = addProgressListener((download) => {
if (!download.metadata) throw new Error("No metadata found in download");
console.log("[HLS] Download progress:", download); console.log("[HLS] Download progress:", download);
setDownloads((prev) => ({ setDownloads((prev) => ({
...prev, ...prev,
@@ -191,12 +175,14 @@ export const NativeDownloadProvider: React.FC<{
state: download.state, state: download.state,
bytesDownloaded: download.bytesDownloaded, bytesDownloaded: download.bytesDownloaded,
bytesTotal: download.bytesTotal, bytesTotal: download.bytesTotal,
metadata: download.metadata,
startTime: download?.startTime,
}, },
})); }));
}); });
const completeListener = addCompleteListener(async (payload) => { 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 { try {
rewriteM3U8Files(payload.location); rewriteM3U8Files(payload.location);
@@ -235,7 +221,6 @@ export const NativeDownloadProvider: React.FC<{
useEffect(() => { 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. // 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 () => { const checkForUnparsedDownloads = async () => {
let found = false;
const downloadsFolder = await FileSystem.getInfoAsync( const downloadsFolder = await FileSystem.getInfoAsync(
FileSystem.documentDirectory + "downloads" FileSystem.documentDirectory + "downloads"
); );
@@ -263,7 +248,6 @@ export const NativeDownloadProvider: React.FC<{
loading: "Finishing up download...", loading: "Finishing up download...",
success: () => "Download complete ✅", success: () => "Download complete ✅",
}); });
found = true;
} }
} }
} }
@@ -300,83 +284,17 @@ export const NativeDownloadProvider: React.FC<{
}); });
if (!res) throw new Error("Failed to get stream URL"); if (!res) throw new Error("Failed to get stream URL");
const { mediaSource } = res; const { mediaSource } = res;
if (!mediaSource) throw new Error("Failed to get media source"); if (!mediaSource) throw new Error("Failed to get media source");
await saveImage(item.Id, itemImage?.uri); await saveImage(item.Id, itemImage?.uri);
if (url.includes("master.m3u8")) { if (!url.includes("master.m3u8"))
// HLS download throw new Error("Only HLS downloads are supported");
downloadHLSAsset(jobId, url, {
item,
mediaSource,
});
} else {
// Regular download
try {
const task = RNBackgroundDownloader.download({
id: jobId,
url: url,
destination: `${FileSystem.documentDirectory}${jobId}/${item.Name}.mkv`,
});
task.begin(({ expectedBytes }) => { downloadHLSAsset(jobId, url, {
setDownloads((prev) => ({ item,
...prev, mediaSource,
[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;
}); });
}; };
@@ -385,9 +303,8 @@ export const NativeDownloadProvider: React.FC<{
value={{ value={{
downloads, downloads,
startDownload, startDownload,
cancelDownload, downloadedFiles: downloadedFiles ?? [],
downloadedFiles, getDownloadedItem: getDownloadedFile,
getDownloadedItem: getDownloadedItem,
activeDownloads: Object.values(downloads), activeDownloads: Object.values(downloads),
}} }}
> >

View File

@@ -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<void>;
}
export const runningAtom = atom<boolean>(false);
export const queueAtom = atom<Job[]>([]);
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<JobStatus[]>(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]);
};