This commit is contained in:
Fredrik Burmester
2025-02-16 16:01:49 +01:00
parent 696543d1b2
commit 1a2e044da6
31 changed files with 639 additions and 3062 deletions

View File

@@ -1,8 +1,4 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import RNBackgroundDownloader, {
DownloadTaskState,
} from "@kesha-antonov/react-native-background-downloader";
import { createContext, useContext, useEffect, useState } from "react";
import useImageStorage from "@/hooks/useImageStorage";
import {
addCompleteListener,
addErrorListener,
@@ -10,66 +6,138 @@ import {
checkForExistingDownloads,
downloadHLSAsset,
} from "@/modules/hls-downloader";
import {
DownloadInfo,
DownloadMetadata,
} from "@/modules/hls-downloader/src/HlsDownloader.types";
import { getItemImage } from "@/utils/getItemImage";
import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import RNBackgroundDownloader from "@kesha-antonov/react-native-background-downloader";
import * as FileSystem from "expo-file-system";
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
import { parseBootXML, processStream } from "@/utils/hls/av-file-parser";
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;
selectedSubtitleStream: number;
selectedMediaSource: MediaSourceInfo;
maxBitrate?: number;
};
type DownloadContextType = {
downloads: Record<string, DownloadInfo>;
startDownload: (item: BaseItemDto, url: string) => Promise<void>;
startDownload: (
item: BaseItemDto,
url: string,
{
selectedAudioStream,
selectedSubtitleStream,
selectedMediaSource,
maxBitrate,
}: DownloadOptionsData
) => Promise<void>;
cancelDownload: (id: string) => void;
getDownloadedItem: (id: string) => Promise<DownloadMetadata | null>;
activeDownloads: DownloadInfo[];
downloadedFiles: DownloadedFileInfo[];
};
const DownloadContext = createContext<DownloadContextType | undefined>(
undefined
);
const persistDownloadedFile = async (
originalLocation: string,
fileName: string
) => {
const destinationDir = `${FileSystem.documentDirectory}downloads/`;
const newLocation = `${destinationDir}${fileName}`;
try {
// Ensure the downloads directory exists
await FileSystem.makeDirectoryAsync(destinationDir, {
intermediates: true,
});
// Move the file to its final destination
await FileSystem.moveAsync({
from: originalLocation,
to: newLocation,
});
return newLocation;
} catch (error) {
console.error("Error persisting file:", error);
throw error;
}
/**
* Marks a file as done by creating a file with the same name in the downloads directory.
* @param doneFile - The name of the file to mark as done.
*/
const markFileAsDone = async (id: string) => {
await FileSystem.writeAsStringAsync(
`${FileSystem.documentDirectory}downloads/${id}-done`,
"done"
);
};
/**
* Opens the boot.xml file and parses it to get the streams
* Checks if a file is marked as done by checking if a file with the same name exists in the downloads directory.
* @param doneFile - The name of the file to check.
* @returns True if the file is marked as done, false otherwise.
*/
const getBootStreams = async (path: string) => {
const b = `${path}/boot.xml`;
const fileInfo = await FileSystem.getInfoAsync(b);
if (fileInfo.exists) {
const boot = await FileSystem.readAsStringAsync(b, {
encoding: FileSystem.EncodingType.UTF8,
const isFileMarkedAsDone = async (id: string) => {
const fileUri = `${FileSystem.documentDirectory}downloads/${id}-done`;
const fileInfo = await FileSystem.getInfoAsync(fileUri);
return fileInfo.exists;
};
export type DownloadedFileInfo = {
id: string;
path: string;
metadata: DownloadMetadata;
};
const listDownloadedFiles = async (): Promise<DownloadedFileInfo[]> => {
const downloadsDir = FileSystem.documentDirectory + "downloads/";
const dirInfo = await FileSystem.getInfoAsync(downloadsDir);
if (!dirInfo.exists) return [];
const files = await FileSystem.readDirectoryAsync(downloadsDir);
const downloaded: DownloadedFileInfo[] = [];
for (const file of files) {
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file);
if (fileInfo.isDirectory) continue;
console.log(file);
const doneFile = await isFileMarkedAsDone(file.replace(".json", ""));
if (!doneFile) continue;
const fileContent = await FileSystem.readAsStringAsync(
downloadsDir + file.replace("-done", "")
);
downloaded.push({
id: file.replace(".json", ""),
path: downloadsDir + file.replace(".json", ""),
metadata: JSON.parse(fileContent) as DownloadMetadata,
});
return parseBootXML(boot);
} else {
console.log(`No boot.xml found in ${path}`);
}
console.log(downloaded);
return downloaded;
};
const getDownloadedItem = async (id: string) => {
const downloadsDir = FileSystem.documentDirectory + "downloads/";
const fileInfo = await FileSystem.getInfoAsync(downloadsDir + id + ".json");
if (!fileInfo.exists) return null;
const doneFile = await isFileMarkedAsDone(id);
if (!doneFile) return null;
const fileContent = await FileSystem.readAsStringAsync(
downloadsDir + id + ".json"
);
return JSON.parse(fileContent) as DownloadMetadata;
};
export const NativeDownloadProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const [downloads, setDownloads] = useState<Record<string, DownloadInfo>>({});
const { saveImage } = useImageStorage();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { data: downloadedFiles } = useQuery({
queryKey: ["downloadedFiles"],
queryFn: listDownloadedFiles,
});
useEffect(() => {
// Initialize downloads from both HLS and regular downloads
@@ -83,6 +151,8 @@ export const NativeDownloadProvider: React.FC<{
id: download.id,
progress: download.progress,
state: download.state,
bytesDownloaded: download.bytesDownloaded,
bytesTotal: download.bytesTotal,
},
}),
{}
@@ -98,17 +168,14 @@ export const NativeDownloadProvider: React.FC<{
id: download.id,
progress: download.bytesDownloaded / download.bytesTotal,
state: download.state,
bytesDownloaded: download.bytesDownloaded,
bytesTotal: download.bytesTotal,
},
}),
{}
);
setDownloads({ ...hlsDownloadStates, ...regularDownloadStates });
console.log("Existing downloads:", {
...hlsDownloadStates,
...regularDownloadStates,
});
};
initializeDownloads();
@@ -122,29 +189,23 @@ export const NativeDownloadProvider: React.FC<{
id: download.id,
progress: download.progress,
state: download.state,
bytesDownloaded: download.bytesDownloaded,
bytesTotal: download.bytesTotal,
},
}));
});
const completeListener = addCompleteListener(async (payload) => {
console.log("Download complete to:", payload.location);
if (!payload?.id) throw new Error("No id found in payload");
// try {
// if (payload?.id) {
// const newLocation = await persistDownloadedFile(
// payload.location,
// payload.id
// );
// console.log("File successfully persisted to:", newLocation);
// } else {
// console.log(
// "No filename in metadata, using original location",
// payload
// );
// }
// } catch (error) {
// console.error("Failed to persist file:", error);
// }
try {
rewriteM3U8Files(payload.location);
markFileAsDone(payload.id);
toast.success("Download complete ✅");
} catch (error) {
console.error("Failed to persist file:", error);
toast.error("Failed to download ❌");
}
setDownloads((prev) => {
const newDownloads = { ...prev };
@@ -171,14 +232,86 @@ export const NativeDownloadProvider: React.FC<{
};
}, []);
const startDownload = async (item: BaseItemDto, url: string) => {
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"
);
if (!downloadsFolder.exists) return;
const files = await FileSystem.readDirectoryAsync(
FileSystem.documentDirectory + "downloads"
);
for (const file of files) {
if (file.endsWith(".json")) {
const id = file.replace(".json", "");
const doneFile = await FileSystem.getInfoAsync(
FileSystem.documentDirectory + "downloads/" + id + "-done"
);
if (!doneFile.exists) {
console.log("Found unparsed download:", id);
const p = async () => {
await markFileAsDone(id);
rewriteM3U8Files(
FileSystem.documentDirectory + "downloads/" + id
);
};
toast.promise(p(), {
error: () => "Failed to download ❌",
loading: "Finishing up download...",
success: () => "Download complete ✅",
});
found = true;
}
}
}
};
checkForUnparsedDownloads();
}, []);
const startDownload = async (
item: BaseItemDto,
url: string,
data: DownloadOptionsData
) => {
if (!item.Id || !item.Name) throw new Error("Item ID or Name is missing");
const jobId = item.Id;
const itemImage = getItemImage({
item,
api: api!,
variant: "Primary",
quality: 90,
width: 500,
});
const res = await getStreamUrl({
api,
item,
startTimeTicks: 0,
userId: user?.Id,
audioStreamIndex: data.selectedAudioStream,
maxStreamingBitrate: data.maxBitrate,
mediaSourceId: data.selectedMediaSource.Id,
subtitleStreamIndex: data.selectedSubtitleStream,
deviceProfile: download,
});
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.Name, {
Name: item.Name,
downloadHLSAsset(jobId, url, {
item,
mediaSource,
});
} else {
// Regular download
@@ -186,7 +319,7 @@ export const NativeDownloadProvider: React.FC<{
const task = RNBackgroundDownloader.download({
id: jobId,
url: url,
destination: `${FileSystem.documentDirectory}${jobId}`,
destination: `${FileSystem.documentDirectory}${jobId}/${item.Name}.mkv`,
});
task.begin(({ expectedBytes }) => {
@@ -249,7 +382,14 @@ export const NativeDownloadProvider: React.FC<{
return (
<DownloadContext.Provider
value={{ downloads, startDownload, cancelDownload }}
value={{
downloads,
startDownload,
cancelDownload,
downloadedFiles,
getDownloadedItem: getDownloadedItem,
activeDownloads: Object.values(downloads),
}}
>
{children}
</DownloadContext.Provider>