feat: move to custom download handler with background download support (#675)

This commit is contained in:
lostb1t
2025-05-04 11:46:34 +02:00
committed by GitHub
parent 9f706a348e
commit 1c1345a3b7
7 changed files with 163 additions and 401 deletions

View File

@@ -1,5 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage";
import { useInterval } from "@/hooks/useInterval";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
@@ -18,6 +19,7 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
@@ -38,6 +40,7 @@ import {
import { useTranslation } from "react-i18next";
import { AppState, type AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native";
import { Bitrate } from "../components/BitrateSelector";
import { apiAtom } from "./JellyfinProvider";
export type DownloadedItem = {
@@ -74,6 +77,17 @@ function useDownloadProvider() {
return api?.accessToken;
}, [api]);
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const getDownloadUrl = (process: JobStatus) => {
return usingOptimizedServer
? `${settings.optimizedVersionsServerUrl}download/${process.id}`
: process.inputUrl;
};
const { data: downloadedFiles, refetch } = useQuery({
queryKey: ["downloadedItems"],
queryFn: getAllDownloadedItems,
@@ -164,6 +178,59 @@ function useDownloadProvider() {
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
});
/// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => {
if (settings?.downloadMethod === DownloadMethod.Optimized) {
return;
}
// const response = await getSessionApi(api).getSessions({
// activeWithinSeconds: 300,
// });
const tasks = await BackGroundDownloader.checkForExistingDownloads();
const updatedProcesses = processes.map((p) => {
// const result = response.data.find((s) => s.Id == p.sessionId);
// if (result) {
// return {
// ...p,
// progress: result.TranscodingInfo?.CompletionPercentage,
// };
// }
// fallback. Doesn't really work for transcodes as they may be a lot smaller. We make an wild guess
const task = tasks.find((s) => s.id === p.id);
if (task) {
let progress = p.progress;
let size = p.mediaSource.Size;
const maxBitrate = p.maxBitrate.value;
if (maxBitrate && maxBitrate < p.mediaSource.Bitrate) {
size = (size / p.mediaSource.Bitrate) * maxBitrate;
}
// console.log(
// p.mediaSource.Size,
// size,
// maxBitrate,
// p.mediaSource.Bitrate,
// );
progress = (100 / size) * task.bytesDownloaded;
if (progress >= 100) {
progress = 99;
}
return {
...p,
progress,
};
}
return p;
});
setProcesses(updatedProcesses);
};
useInterval(updateProgress, 3000);
useEffect(() => {
const checkIfShouldStartDownload = async () => {
if (processes.length === 0) return;
@@ -176,18 +243,25 @@ function useDownloadProvider() {
const removeProcess = useCallback(
async (id: string) => {
const deviceId = await getOrSetDeviceId();
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
return;
if (!deviceId || !authHeader) return;
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.error(error);
if (usingOptimizedServer) {
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.error(error);
}
}
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) => process.id !== id,
);
});
},
[settings?.optimizedVersionsServerUrl, authHeader],
);
@@ -238,7 +312,7 @@ function useDownloadProvider() {
BackGroundDownloader?.download({
id: process.id,
url: `${settings?.optimizedVersionsServerUrl}download/${process.id}`,
url: getDownloadUrl(process),
destination: `${baseDirectory}/${process.item.Id}.mp4`,
})
.begin(() => {
@@ -256,6 +330,9 @@ function useDownloadProvider() {
);
})
.progress((data) => {
if (!usingOptimizedServer) {
return;
}
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
setProcesses((prev) =>
prev.map((p) =>
@@ -328,7 +405,12 @@ function useDownloadProvider() {
);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
async (
url: string,
item: BaseItemDto,
mediaSource: MediaSourceInfo,
maxBitrate?: Bitrate,
) => {
if (!api || !item.Id || !authHeader)
throw new Error("startBackgroundDownload ~ Missing required params");
@@ -345,26 +427,42 @@ function useDownloadProvider() {
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
const response = await axios.post(
`${settings?.optimizedVersionsServerUrl}optimize-version`,
{
url,
fileExtension,
deviceId,
itemId: item.Id,
item,
},
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
if (usingOptimizedServer) {
const response = await axios.post(
`${settings?.optimizedVersionsServerUrl}optimize-version`,
{
url,
fileExtension,
deviceId,
itemId: item.Id,
item,
},
},
);
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
},
);
if (response.status !== 201) {
throw new Error("Failed to start optimization job");
if (response.status !== 201) {
throw new Error("Failed to start optimization job");
}
} else {
const job: JobStatus = {
id: item.Id!,
deviceId: deviceId,
inputUrl: url,
item: item,
itemId: item.Id!,
mediaSource,
progress: 0,
maxBitrate,
status: "downloading",
timestamp: new Date(),
};
setProcesses([...processes, job]);
startDownload(job);
}
toast.success(

View File

@@ -1,108 +0,0 @@
import { storage } from "@/utils/mmkv";
import type { JobStatus } from "@/utils/optimize-server";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { atom, useAtom } from "jotai";
import type React from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
/**
* Dummy download provider for tvOS
*/
function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const downloadedFiles: DownloadedItem[] = [];
const removeProcess = useCallback(async (id: string) => {}, []);
const startDownload = useCallback(async (process: JobStatus) => {
return null;
}, []);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
return null;
},
[],
);
const deleteAllFiles = async (): Promise<void> => {};
const deleteFile = async (id: string): Promise<void> => {};
const deleteItems = async (items: BaseItemDto[]) => {};
const cleanCacheDirectory = async () => {};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
const appSizeUsage = useMemo(async () => {
return 0;
}, []);
function getDownloadedItem(itemId: string): DownloadedItem | null {
return null;
}
function saveDownloadedItemInfo(item: BaseItemDto, size = 0) {}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString(`downloadedItemSize-${itemId}`);
return size ? Number.parseInt(size) : 0;
}
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
deleteItems,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
getDownloadedItem,
deleteFileByType,
appSizeUsage,
getDownloadedItemSize,
APP_CACHE_DOWNLOAD_DIRECTORY,
cleanCacheDirectory,
};
}
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
return (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}