Files
native-app/apps/expo/src/hooks/DownloadManagerContext.tsx
2024-03-27 10:40:16 +01:00

354 lines
9.7 KiB
TypeScript

import type { Asset } from "expo-media-library";
import type { ReactNode } from "react";
import React, { createContext, useContext, useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library";
import VideoManager from "@salihgun/react-native-video-processor";
import { useToastController } from "@tamagui/toast";
import type { ScrapeMedia } from "@movie-web/provider-utils";
import { extractSegmentsFromHLS } from "@movie-web/provider-utils";
import { useDownloadHistoryStore } from "~/stores/settings";
export interface DownloadItem {
id: string;
filename: string;
progress: number;
speed: number;
fileSize: number;
downloaded: number;
url: string;
type: "mp4" | "hls";
isFinished: boolean;
statusText?: string;
asset?: Asset;
isHLS?: boolean;
media: ScrapeMedia;
downloadResumable?: FileSystem.DownloadResumable;
}
interface DownloadManagerContextType {
downloads: DownloadItem[];
startDownload: (
url: string,
type: "mp4" | "hls",
media: ScrapeMedia,
headers?: Record<string, string>,
) => Promise<Asset | void>;
removeDownload: (id: string) => void;
cancelDownload: (id: string) => Promise<void>;
}
const DownloadManagerContext = createContext<
DownloadManagerContextType | undefined
>(undefined);
export const useDownloadManager = () => {
const context = useContext(DownloadManagerContext);
if (!context) {
throw new Error(
"useDownloadManager must be used within a DownloadManagerProvider",
);
}
return context;
};
export const DownloadManagerProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const toastController = useToastController();
useEffect(() => {
const initializeDownloads = () => {
const { downloads: storedDownloads } = useDownloadHistoryStore.getState();
if (storedDownloads) {
setDownloads(storedDownloads);
}
};
void initializeDownloads();
}, []);
useEffect(() => {
useDownloadHistoryStore.setState({ downloads });
}, [downloads]);
const cancellationFlags = useState<Record<string, boolean>>({})[0];
const setCancellationFlag = (downloadId: string, flag: boolean): void => {
cancellationFlags[downloadId] = flag;
};
const getCancellationFlag = (downloadId: string): boolean => {
return cancellationFlags[downloadId] ?? false;
};
const cancelDownload = async (downloadId: string): Promise<void> => {
setCancellationFlag(downloadId, true);
const downloadItem = downloads.find((d) => d.id === downloadId);
if (downloadItem?.downloadResumable) {
await downloadItem.downloadResumable.cancelAsync();
}
toastController.show("Download cancelled", {
burntOptions: { preset: "done" },
native: true,
duration: 500,
});
};
const startDownload = async (
url: string,
type: "mp4" | "hls",
media: ScrapeMedia,
headers?: Record<string, string>,
): Promise<Asset | void> => {
toastController.show("Download started", {
burntOptions: { preset: "none" },
native: true,
duration: 500,
});
const newDownload: DownloadItem = {
id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`,
filename: url.split("/").pop() ?? "unknown",
progress: 0,
speed: 0,
fileSize: 0,
downloaded: 0,
type,
url,
isFinished: false,
isHLS: type === "hls",
media,
};
setDownloads((currentDownloads) => [newDownload, ...currentDownloads]);
if (type === "mp4") {
const asset = await downloadMP4(url, newDownload.id, headers ?? {});
return asset;
} else if (type === "hls") {
const asset = await downloadHLS(url, newDownload.id, headers ?? {});
return asset;
}
};
const updateDownloadItem = (id: string, updates: Partial<DownloadItem>) => {
setDownloads((currentDownloads) =>
currentDownloads.map((download) =>
download.id === id ? { ...download, ...updates } : download,
),
);
};
const downloadMP4 = async (
url: string,
downloadId: string,
headers: Record<string, string>,
) => {
let lastBytesWritten = 0;
let lastTimestamp = Date.now();
const callback = (downloadProgress: FileSystem.DownloadProgressData) => {
const currentTime = Date.now();
const timeElapsed = (currentTime - lastTimestamp) / 1000;
if (timeElapsed === 0) return;
const bytesWritten = downloadProgress.totalBytesWritten;
const newBytes = bytesWritten - lastBytesWritten;
const speed = newBytes / timeElapsed / 1024;
const progress =
bytesWritten / downloadProgress.totalBytesExpectedToWrite;
updateDownloadItem(downloadId, {
progress,
speed,
fileSize: downloadProgress.totalBytesExpectedToWrite,
downloaded: bytesWritten,
});
lastBytesWritten = bytesWritten;
lastTimestamp = currentTime;
};
const fileUri = FileSystem.cacheDirectory
? FileSystem.cacheDirectory + url.split("/").pop()
: null;
if (!fileUri) {
console.error("Cache directory is unavailable");
return;
}
const downloadResumable = FileSystem.createDownloadResumable(
url,
fileUri,
{ headers },
callback,
);
updateDownloadItem(downloadId, { downloadResumable });
try {
const result = await downloadResumable.downloadAsync();
if (result) {
console.log("Finished downloading to ", result.uri);
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
result.uri,
downloadId,
);
return asset;
}
} catch (e) {
console.error(e);
}
};
const downloadHLS = async (
url: string,
downloadId: string,
headers: Record<string, string>,
) => {
const segments = await extractSegmentsFromHLS(url, headers);
if (!segments || segments.length === 0) {
return removeDownload(downloadId);
}
const totalSegments = segments.length;
let segmentsDownloaded = 0;
const segmentDir = FileSystem.cacheDirectory + "segments/";
await ensureDirExists(segmentDir);
const updateProgress = () => {
const progress = segmentsDownloaded / totalSegments;
updateDownloadItem(downloadId, {
progress,
downloaded: segmentsDownloaded,
fileSize: totalSegments,
});
};
const localSegmentPaths = [];
for (const [index, segment] of segments.entries()) {
if (getCancellationFlag(downloadId)) {
await cleanupDownload(segmentDir, downloadId);
return;
}
const segmentFile = `${segmentDir}${index}.ts`;
localSegmentPaths.push(segmentFile);
try {
await downloadSegment(segment, segmentFile, headers);
if (getCancellationFlag(downloadId)) {
await cleanupDownload(segmentDir, downloadId);
return;
}
segmentsDownloaded++;
updateProgress();
} catch (e) {
console.error(e);
if (getCancellationFlag(downloadId)) {
await cleanupDownload(segmentDir, downloadId);
return;
}
}
}
if (getCancellationFlag(downloadId)) {
return removeDownload(downloadId);
}
updateDownloadItem(downloadId, { statusText: "Merging" });
const uri = await VideoManager.mergeVideos(
localSegmentPaths,
`${FileSystem.cacheDirectory}output.mp4`,
);
const asset = await saveFileToMediaLibraryAndDeleteOriginal(
uri,
downloadId,
);
return asset;
};
const downloadSegment = async (
segmentUrl: string,
segmentFile: string,
headers: Record<string, string>,
) => {
const downloadResumable = FileSystem.createDownloadResumable(
segmentUrl,
segmentFile,
{ headers },
);
await downloadResumable.downloadAsync();
};
const cleanupDownload = async (segmentDir: string, downloadId: string) => {
await FileSystem.deleteAsync(segmentDir, { idempotent: true });
removeDownload(downloadId);
};
async function ensureDirExists(dir: string) {
await FileSystem.deleteAsync(dir, { idempotent: true });
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
}
const saveFileToMediaLibraryAndDeleteOriginal = async (
fileUri: string,
downloadId: string,
): Promise<Asset | void> => {
try {
updateDownloadItem(downloadId, { statusText: "Importing" });
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== MediaLibrary.PermissionStatus.GRANTED) {
throw new Error("MediaLibrary permission not granted");
}
const asset = await MediaLibrary.createAssetAsync(fileUri);
await FileSystem.deleteAsync(fileUri);
updateDownloadItem(downloadId, {
statusText: undefined,
asset,
isFinished: true,
});
console.log("File saved to media library and original deleted");
toastController.show("Download finished", {
burntOptions: { preset: "done" },
native: true,
duration: 500,
});
return asset;
} catch (error) {
console.error("Error saving file to media library:", error);
toastController.show("Download failed", {
burntOptions: { preset: "error" },
native: true,
duration: 500,
});
}
};
const removeDownload = (id: string) => {
const updatedDownloads = downloads.filter((download) => download.id !== id);
setDownloads(updatedDownloads);
useDownloadHistoryStore.setState({ downloads: updatedDownloads });
};
return (
<DownloadManagerContext.Provider
value={{ downloads, startDownload, removeDownload, cancelDownload }}
>
{children}
</DownloadManagerContext.Provider>
);
};