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,751 +0,0 @@
import { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download";
import { getItemImage } from "@/utils/getItemImage";
import { useLog, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import {
cancelAllJobs,
cancelJobById,
deleteDownloadItemInfoFromDiskTmp,
getAllJobsByDeviceId,
getDownloadItemInfoFromDiskTmp,
JobStatus,
} from "@/utils/optimize-server";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import * as Application from "expo-application";
import * as FileSystem from "expo-file-system";
import { FileInfo } from "expo-file-system";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from "react";
import { useTranslation } from "react-i18next";
import { AppState, AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native";
import { apiAtom } from "./JellyfinProvider";
const BackGroundDownloader = !Platform.isTV
? (require("@kesha-antonov/react-native-background-downloader") as typeof import("@kesha-antonov/react-native-background-downloader"))
: null;
// import * as Notifications from "expo-notifications";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
export type DownloadedItem = {
item: Partial<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
function useDownloadProvider() {
if (Platform.isTV) return;
const queryClient = useQueryClient();
const { t } = useTranslation();
const [settings] = useSettings();
const router = useRouter();
const [api] = useAtom(apiAtom);
const { logs } = useLog();
const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage();
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const successHapticFeedback = useHaptic("success");
const authHeader = useMemo(() => {
return api?.accessToken;
}, [api]);
const { data: downloadedFiles, refetch } = useQuery({
queryKey: ["downloadedItems"],
queryFn: getAllDownloadedItems,
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
});
useEffect(() => {
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, []);
useQuery({
queryKey: ["jobs"],
queryFn: async () => {
const deviceId = await getOrSetDeviceId();
const url = settings?.optimizedVersionsServerUrl;
if (
settings?.downloadMethod !== DownloadMethod.Optimized ||
!url ||
!deviceId ||
!authHeader
)
return [];
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader,
url,
});
const downloadingProcesses = processes
.filter((p) => p.status === "downloading")
.filter((p) => jobs.some((j) => j.id === p.id));
const updatedProcesses = jobs.filter(
(j) => !downloadingProcesses.some((p) => p.id === j.id)
);
setProcesses([...updatedProcesses, ...downloadingProcesses]);
for (let job of jobs) {
const process = processes.find((p) => p.id === job.id);
if (
process &&
process.status === "optimizing" &&
job.status === "completed"
) {
if (settings.autoDownload) {
startDownload(job);
} else {
toast.info(
t("home.downloads.toasts.item_is_ready_to_be_downloaded", {
item: job.item.Name,
}),
{
action: {
label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
}
);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: `${job.item.Name} is ready to be downloaded`,
data: {
url: `/downloads`,
},
},
trigger: null,
});
}
}
}
return jobs;
},
staleTime: 0,
refetchInterval: 2000,
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
});
useEffect(() => {
const checkIfShouldStartDownload = async () => {
if (processes.length === 0) return;
await BackGroundDownloader?.checkForExistingDownloads();
};
checkIfShouldStartDownload();
}, [settings, processes]);
const removeProcess = useCallback(
async (id: string) => {
const deviceId = await getOrSetDeviceId();
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
return;
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.error(error);
}
},
[settings?.optimizedVersionsServerUrl, authHeader]
);
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
const startDownload = useCallback(
async (process: JobStatus) => {
if (!process?.item.Id || !authHeader) throw new Error("No item id");
setProcesses((prev) =>
prev.map((p) =>
p.id === process.id
? {
...p,
speed: undefined,
status: "downloading",
progress: 0,
}
: p
)
);
BackGroundDownloader?.setConfig({
isLogsEnabled: true,
progressInterval: 500,
headers: {
Authorization: authHeader,
},
});
toast.info(
t("home.downloads.toasts.download_stated_for_item", {
item: process.item.Name,
}),
{
action: {
label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
}
);
const baseDirectory = FileSystem.documentDirectory;
BackGroundDownloader?.download({
id: process.id,
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
destination: `${baseDirectory}/${process.item.Id}.mp4`,
})
.begin(() => {
setProcesses((prev) =>
prev.map((p) =>
p.id === process.id
? {
...p,
speed: undefined,
status: "downloading",
progress: 0,
}
: p
)
);
})
.progress((data) => {
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
setProcesses((prev) =>
prev.map((p) =>
p.id === process.id
? {
...p,
speed: undefined,
status: "downloading",
progress: percent,
}
: p
)
);
})
.done(async (doneHandler) => {
await saveDownloadedItemInfo(
process.item,
doneHandler.bytesDownloaded
);
toast.success(
t("home.downloads.toasts.download_completed_for_item", {
item: process.item.Name,
}),
{
duration: 3000,
action: {
label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
}
);
setTimeout(() => {
BackGroundDownloader.completeHandler(process.id);
removeProcess(process.id);
}, 1000);
})
.error(async (error) => {
removeProcess(process.id);
BackGroundDownloader.completeHandler(process.id);
let errorMsg = "";
if (error.errorCode === 1000) {
errorMsg = "No space left";
}
if (error.errorCode === 404) {
errorMsg = "File not found on server";
}
toast.error(
t("home.downloads.toasts.download_failed_for_item", {
item: process.item.Name,
error: errorMsg,
})
);
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
error,
processDetails: {
id: process.id,
itemName: process.item.Name,
itemId: process.item.Id,
},
});
console.error("Error details:", {
errorCode: error.errorCode,
});
});
},
[queryClient, settings?.optimizedVersionsServerUrl, authHeader]
);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
if (!api || !item.Id || !authHeader)
throw new Error("startBackgroundDownload ~ Missing required params");
try {
const fileExtension = mediaSource.TranscodingContainer;
const deviceId = await getOrSetDeviceId();
await saveSeriesPrimaryImage(item);
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
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 (response.status !== 201) {
throw new Error("Failed to start optimization job");
}
toast.success(
t("home.downloads.toasts.queued_item_for_optimization", {
item: item.Name,
}),
{
action: {
label: t("home.downloads.toasts.go_to_downloads"),
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
}
);
} catch (error) {
writeToLog("ERROR", "Error in startBackgroundDownload", error);
console.error("Error in startBackgroundDownload:", error);
if (axios.isAxiosError(error)) {
console.error("Axios error details:", {
message: error.message,
response: error.response?.data,
status: error.response?.status,
headers: error.response?.headers,
});
toast.error(
t("home.downloads.toasts.failed_to_start_download_for_item", {
item: item.Name,
message: error.message,
})
);
if (error.response) {
toast.error(
t("home.downloads.toasts.server_responded_with_status", {
statusCode: error.response.status,
})
);
} else if (error.request) {
t("home.downloads.toasts.no_response_received_from_server");
} else {
toast.error("Error setting up the request");
}
} else {
console.error("Non-Axios error:", error);
toast.error(
t(
"home.downloads.toasts.failed_to_start_download_for_item_unexpected_error",
{ item: item.Name }
)
);
}
}
},
[settings?.optimizedVersionsServerUrl, authHeader]
);
const deleteAllFiles = async (): Promise<void> => {
Promise.all([
deleteLocalFiles(),
removeDownloadedItemsFromStorage(),
cancelAllServerJobs(),
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
])
.then(() =>
toast.success(
t(
"home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"
)
)
)
.catch((reason) => {
console.error("Failed to delete all files, folders, and jobs:", reason);
toast.error(
t(
"home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"
)
);
});
};
const forEveryDocumentDirFile = async (
includeMMKV: boolean = true,
ignoreList: string[] = [],
callback: (file: FileInfo) => void
) => {
const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) {
throw new Error("Base directory not found");
}
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
for (const item of dirContents) {
// Exclude mmkv directory.
// Deleting this deletes all user information as well. Logout should handle this.
if (
(item == "mmkv" && !includeMMKV) ||
ignoreList.some((i) => item.includes(i))
) {
continue;
}
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
.then((itemInfo) => {
if (itemInfo.exists && !itemInfo.isDirectory) {
callback(itemInfo);
}
})
.catch((e) => console.error(e));
}
};
const deleteLocalFiles = async (): Promise<void> => {
await forEveryDocumentDirFile(false, [], (file) => {
console.warn("Deleting file", file.uri);
FileSystem.deleteAsync(file.uri, { idempotent: true });
});
};
const removeDownloadedItemsFromStorage = async () => {
// delete any saved images first
Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")])
.then(() => storage.delete("downloadedItems"))
.catch((reason) => {
console.error("Failed to remove downloadedItems from storage:", reason);
throw reason;
});
};
const cancelAllServerJobs = async (): Promise<void> => {
if (!authHeader) {
throw new Error("No auth header available");
}
if (!settings?.optimizedVersionsServerUrl) {
console.error("No server URL configured");
return;
}
const deviceId = await getOrSetDeviceId();
if (!deviceId) {
throw new Error("Failed to get device ID");
}
try {
await cancelAllJobs({
authHeader,
url: settings.optimizedVersionsServerUrl,
deviceId,
});
} catch (error) {
console.error("Failed to cancel all server jobs:", error);
throw error;
}
};
const deleteFile = async (id: string): Promise<void> => {
if (!id) {
console.error("Invalid file ID");
return;
}
try {
const directory = FileSystem.documentDirectory;
if (!directory) {
console.error("Document directory not found");
return;
}
const dirContents = await FileSystem.readDirectoryAsync(directory);
for (const item of dirContents) {
const itemNameWithoutExtension = item.split(".")[0];
if (itemNameWithoutExtension === id) {
const filePath = `${directory}${item}`;
await FileSystem.deleteAsync(filePath, { idempotent: true });
break;
}
}
const downloadedItems = storage.getString("downloadedItems");
if (downloadedItems) {
let items = JSON.parse(downloadedItems) as DownloadedItem[];
items = items.filter((item) => item.item.Id !== id);
storage.set("downloadedItems", JSON.stringify(items));
}
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
} catch (error) {
console.error(
`Failed to delete file and storage entry for ID ${id}:`,
error
);
}
};
const deleteItems = async (items: BaseItemDto[]) => {
Promise.all(
items.map((i) => {
if (i.Id) return deleteFile(i.Id);
return;
})
).then(() => successHapticFeedback());
};
const cleanCacheDirectory = async () => {
const cacheDir = await FileSystem.getInfoAsync(
APP_CACHE_DOWNLOAD_DIRECTORY
);
if (cacheDir.exists) {
const cachedFiles = await FileSystem.readDirectoryAsync(
APP_CACHE_DOWNLOAD_DIRECTORY
);
let position = 0;
const batchSize = 3;
// batching promise.all to avoid OOM
while (position < cachedFiles.length) {
const itemsForBatch = cachedFiles.slice(position, position + batchSize);
await Promise.all(
itemsForBatch.map(async (file) => {
const info = await FileSystem.getInfoAsync(
`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`
);
if (info.exists) {
await FileSystem.deleteAsync(info.uri, { idempotent: true });
return Promise.resolve(file);
}
return Promise.reject();
})
);
position += batchSize;
}
}
};
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
await Promise.all(
downloadedFiles
?.filter((file) => file.item.Type == type)
?.flatMap((file) => {
const promises = [];
if (type == "Episode" && file.item.SeriesId)
promises.push(deleteFile(file.item.SeriesId));
promises.push(deleteFile(file.item.Id!));
return promises;
}) || []
);
};
const appSizeUsage = useMemo(async () => {
const sizes: number[] =
downloadedFiles?.map((d) => {
return getDownloadedItemSize(d.item.Id!!);
}) || [];
await forEveryDocumentDirFile(
true,
getAllDownloadedItems().map((d) => d.item.Id!!),
(file) => {
if (file.exists) {
sizes.push(file.size);
}
}
).catch((e) => {
console.error(e);
});
return sizes.reduce((sum, size) => sum + size, 0);
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
function getDownloadedItem(itemId: string): DownloadedItem | null {
try {
const downloadedItems = storage.getString("downloadedItems");
if (downloadedItems) {
const items: DownloadedItem[] = JSON.parse(downloadedItems);
const item = items.find((i) => i.item.Id === itemId);
return item || null;
}
return null;
} catch (error) {
console.error(`Failed to retrieve item with ID ${itemId}:`, error);
return null;
}
}
function getAllDownloadedItems(): DownloadedItem[] {
try {
const downloadedItems = storage.getString("downloadedItems");
if (downloadedItems) {
return JSON.parse(downloadedItems) as DownloadedItem[];
} else {
return [];
}
} catch (error) {
console.error("Failed to retrieve downloaded items:", error);
return [];
}
}
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
try {
const downloadedItems = storage.getString("downloadedItems");
let items: DownloadedItem[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
if (!data?.mediaSource)
throw new Error(
"Media source not found in tmp storage. Did you forget to save it before starting download?"
);
const newItem = { item, mediaSource: data.mediaSource };
if (existingItemIndex !== -1) {
items[existingItemIndex] = newItem;
} else {
items.push(newItem);
}
deleteDownloadItemInfoFromDiskTmp(item.Id!);
storage.set("downloadedItems", JSON.stringify(items));
storage.set("downloadedItemSize-" + item.Id, size.toString());
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
refetch();
} catch (error) {
console.error(
"Failed to save downloaded item information with media source:",
error
);
}
}
function getDownloadedItemSize(itemId: string): number {
const size = storage.getString("downloadedItemSize-" + itemId);
return size ? parseInt(size) : 0;
}
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;
}

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>