mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-28 23:12:23 +00:00
wip
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user