refactor: use expo filsesystem for downloads

This commit is contained in:
Adrian Castro
2024-04-08 22:26:37 +02:00
parent 96b00064c6
commit 45d12bbf41
5 changed files with 234 additions and 366 deletions

View File

@@ -2,7 +2,6 @@ import type { ExpoConfig } from "expo/config";
import { version } from "./package.json"; import { version } from "./package.json";
import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement"; import withRemoveiOSNotificationEntitlement from "./src/plugins/withRemoveiOSNotificationEntitlement";
import withRNBackgroundDownloader from "./src/plugins/withRNBackgroundDownloader";
const defineConfig = (): ExpoConfig => ({ const defineConfig = (): ExpoConfig => ({
name: "movie-web", name: "movie-web",
@@ -48,7 +47,6 @@ const defineConfig = (): ExpoConfig => ({
plugins: [ plugins: [
"expo-router", "expo-router",
[withRemoveiOSNotificationEntitlement as unknown as string], [withRemoveiOSNotificationEntitlement as unknown as string],
[withRNBackgroundDownloader as unknown as string],
[ [
"expo-screen-orientation", "expo-screen-orientation",
{ {

View File

@@ -19,7 +19,6 @@
}, },
"dependencies": { "dependencies": {
"@expo/metro-config": "^0.17.3", "@expo/metro-config": "^0.17.3",
"@kesha-antonov/react-native-background-downloader": "^3.1.2",
"@movie-web/api": "*", "@movie-web/api": "*",
"@movie-web/colors": "*", "@movie-web/colors": "*",
"@movie-web/provider-utils": "*", "@movie-web/provider-utils": "*",

View File

@@ -1,16 +1,10 @@
import type { DownloadTask } from "@kesha-antonov/react-native-background-downloader"; import type { DownloadProgressData } from "expo-file-system";
import type { Asset } from "expo-media-library"; import type { Asset } from "expo-media-library";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import * as MediaLibrary from "expo-media-library"; import * as MediaLibrary from "expo-media-library";
import * as Network from "expo-network"; import * as Network from "expo-network";
import { NetworkStateType } from "expo-network"; import { NetworkStateType } from "expo-network";
import {
checkForExistingDownloads,
completeHandler,
download,
setConfig,
} from "@kesha-antonov/react-native-background-downloader";
import VideoManager from "@salihgun/react-native-video-processor"; import VideoManager from "@salihgun/react-native-video-processor";
import type { ScrapeMedia } from "@movie-web/provider-utils"; import type { ScrapeMedia } from "@movie-web/provider-utils";
@@ -22,11 +16,6 @@ import {
useNetworkSettingsStore, useNetworkSettingsStore,
} from "~/stores/settings"; } from "~/stores/settings";
interface DownloadProgress {
bytesDownloaded: number;
bytesTotal: number;
}
export interface Download { export interface Download {
id: string; id: string;
progress: number; progress: number;
@@ -44,7 +33,7 @@ export interface Download {
| "importing"; | "importing";
localPath?: string; localPath?: string;
media: ScrapeMedia; media: ScrapeMedia;
downloadTask?: DownloadTask; downloadTask?: FileSystem.DownloadResumable;
} }
export interface DownloadContent { export interface DownloadContent {
@@ -52,12 +41,6 @@ export interface DownloadContent {
downloads: Download[]; downloads: Download[];
} }
// @ts-expect-error - types are not up to date
setConfig({
isLogsEnabled: false,
progressInterval: 250,
});
export const useDownloadManager = () => { export const useDownloadManager = () => {
const cancellationFlags = useState<Record<string, boolean>>({})[0]; const cancellationFlags = useState<Record<string, boolean>>({})[0];
@@ -76,10 +59,10 @@ export const useDownloadManager = () => {
[cancellationFlags], [cancellationFlags],
); );
const cancelDownload = (download: Download) => { const cancelDownload = async (download: Download) => {
setCancellationFlag(download.id, true); setCancellationFlag(download.id, true);
if (download?.downloadTask) { if (download?.downloadTask) {
download.downloadTask.stop(); await download.downloadTask.cancelAsync();
} }
showToast("Download cancelled", { showToast("Download cancelled", {
burntOptions: { preset: "done" }, burntOptions: { preset: "done" },
@@ -141,12 +124,11 @@ export const useDownloadManager = () => {
[setDownloads], [setDownloads],
); );
const saveFileToMediaLibraryAndDeleteOriginal = useCallback( const saveFileToMediaLibraryAndDeleteOriginal = async (
async (fileUri: string, download: Download): Promise<Asset | void> => { fileUri: string,
console.log( download: Download,
"Saving file to media library and deleting original", ): Promise<Asset | void> => {
fileUri, console.log("Saving file to media library and deleting original", fileUri);
);
try { try {
updateDownloadItem(download.id, { status: "importing" }); updateDownloadItem(download.id, { status: "importing" });
@@ -169,39 +151,36 @@ export const useDownloadManager = () => {
burntOptions: { preset: "error" }, burntOptions: { preset: "error" },
}); });
} }
}, };
[updateDownloadItem, showToast],
);
const downloadMP4 = useCallback( const downloadMP4 = async (
(
url: string, url: string,
downloadItem: Download, downloadItem: Download,
headers: Record<string, string>, headers: Record<string, string>,
): Promise<Asset> => { ): Promise<Asset | void> => {
return new Promise<Asset>((resolve, reject) => {
let lastBytesWritten = 0; let lastBytesWritten = 0;
let lastTimestamp = Date.now(); let lastTimestamp = Date.now();
const updateProgress = (downloadProgress: DownloadProgress) => { const updateProgress = (downloadProgress: DownloadProgressData) => {
const currentTime = Date.now(); const currentTime = Date.now();
const timeElapsed = (currentTime - lastTimestamp) / 1000; const timeElapsed = (currentTime - lastTimestamp) / 1000;
if (timeElapsed === 0) return; if (timeElapsed === 0) return;
const newBytes = downloadProgress.bytesDownloaded - lastBytesWritten; const newBytes = downloadProgress.totalBytesWritten - lastBytesWritten;
const speed = newBytes / timeElapsed / 1024 / 1024; const speed = newBytes / timeElapsed / 1024 / 1024;
const progress = const progress =
downloadProgress.bytesDownloaded / downloadProgress.bytesTotal; downloadProgress.totalBytesWritten /
downloadProgress.totalBytesExpectedToWrite;
updateDownloadItem(downloadItem.id, { updateDownloadItem(downloadItem.id, {
progress, progress,
speed, speed,
fileSize: downloadProgress.bytesTotal, fileSize: downloadProgress.totalBytesExpectedToWrite,
downloaded: downloadProgress.bytesDownloaded, downloaded: downloadProgress.totalBytesWritten,
}); });
lastBytesWritten = downloadProgress.bytesDownloaded; lastBytesWritten = downloadProgress.totalBytesWritten;
lastTimestamp = currentTime; lastTimestamp = currentTime;
}; };
@@ -211,43 +190,32 @@ export const useDownloadManager = () => {
: null; : null;
if (!fileUri) { if (!fileUri) {
console.error("Cache directory is unavailable"); console.error("Cache directory is unavailable");
reject(new Error("Cache directory is unavailable"));
return; return;
} }
const downloadTask = download({ const downloadResumable = FileSystem.createDownloadResumable(
id: downloadItem.id,
url, url,
destination: fileUri, fileUri,
{
headers, headers,
isNotificationVisible: true,
})
.begin(() => {
updateDownloadItem(downloadItem.id, { downloadTask });
})
.progress(({ bytesDownloaded, bytesTotal }) => {
updateProgress({ bytesDownloaded, bytesTotal });
})
.done(() => {
saveFileToMediaLibraryAndDeleteOriginal(fileUri, downloadItem)
.then((asset) => {
if (asset) {
resolve(asset);
} else {
reject(new Error("No asset returned"));
}
})
.catch((error) => reject(error));
})
.error(({ error, errorCode }) => {
console.error(`Download error: ${errorCode} - ${error}`);
reject(new Error(`Download error: ${errorCode} - ${error}`));
});
});
}, },
[saveFileToMediaLibraryAndDeleteOriginal, updateDownloadItem], updateProgress,
); );
try {
const result = await downloadResumable.downloadAsync();
if (result) {
console.log("Finished downloading to ", result.uri);
return saveFileToMediaLibraryAndDeleteOriginal(
result.uri,
downloadItem,
);
}
} catch (e) {
console.error(e);
}
};
const cleanupDownload = useCallback( const cleanupDownload = useCallback(
async (segmentDir: string, download: Download) => { async (segmentDir: string, download: Download) => {
await FileSystem.deleteAsync(segmentDir, { idempotent: true }); await FileSystem.deleteAsync(segmentDir, { idempotent: true });
@@ -256,8 +224,7 @@ export const useDownloadManager = () => {
[removeDownload], [removeDownload],
); );
const downloadHLS = useCallback( const downloadHLS = async (
async (
url: string, url: string,
download: Download, download: Download,
headers: Record<string, string>, headers: Record<string, string>,
@@ -295,7 +262,7 @@ export const useDownloadManager = () => {
localSegmentPaths.push(segmentFile); localSegmentPaths.push(segmentFile);
try { try {
await downloadSegment(download.id, segment, segmentFile, headers); await downloadSegment(segment, segmentFile, headers);
if (getCancellationFlag(download.id)) { if (getCancellationFlag(download.id)) {
await cleanupDownload(segmentDir, download); await cleanupDownload(segmentDir, download);
@@ -322,23 +289,11 @@ export const useDownloadManager = () => {
localSegmentPaths, localSegmentPaths,
`${FileSystem.cacheDirectory}movie-web/output.mp4`, `${FileSystem.cacheDirectory}movie-web/output.mp4`,
); );
const asset = await saveFileToMediaLibraryAndDeleteOriginal( const asset = await saveFileToMediaLibraryAndDeleteOriginal(uri, download);
uri,
download,
);
return asset; return asset;
}, };
[
cleanupDownload,
getCancellationFlag,
removeDownload,
saveFileToMediaLibraryAndDeleteOriginal,
updateDownloadItem,
],
);
const startDownload = useCallback( const startDownload = async (
async (
url: string, url: string,
type: "mp4" | "hls", type: "mp4" | "hls",
media: ScrapeMedia, media: ScrapeMedia,
@@ -424,33 +379,29 @@ export const useDownloadManager = () => {
const asset = await downloadHLS(url, newDownload, headers ?? {}); const asset = await downloadHLS(url, newDownload, headers ?? {});
return asset; return asset;
} }
}, };
[downloadHLS, downloadMP4, downloads, setDownloads, showToast],
);
const downloadSegment = async ( const downloadSegment = async (
downloadId: string,
segmentUrl: string, segmentUrl: string,
segmentFile: string, segmentFile: string,
headers: Record<string, string>, headers: Record<string, string>,
) => { ) => {
return new Promise<void>((resolve, reject) => { const downloadResumable = FileSystem.createDownloadResumable(
const task = download({ segmentUrl,
id: `${downloadId}-${segmentUrl.split("/").pop()}`, segmentFile,
url: segmentUrl, {
destination: segmentFile, headers,
headers: headers, },
}); );
task try {
.done(() => { const result = await downloadResumable.downloadAsync();
resolve(); if (result) {
}) console.log("Finished downloading to ", result.uri);
.error((error) => { }
console.error(error); } catch (e) {
reject(error); console.error(e);
}); }
});
}; };
async function ensureDirExists(dir: string) { async function ensureDirExists(dir: string) {
@@ -458,27 +409,6 @@ export const useDownloadManager = () => {
await FileSystem.makeDirectoryAsync(dir, { intermediates: true }); await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
} }
useEffect(() => {
const checkRunningTasks = async () => {
const existingTasks = await checkForExistingDownloads();
existingTasks.forEach((task) => {
task
.progress(({ bytesDownloaded, bytesTotal }) => {
const progress = bytesDownloaded / bytesTotal;
updateDownloadItem(task.id, { progress });
})
.done(() => {
completeHandler(task.id);
})
.error(({ error, errorCode }) => {
console.error(`Download error: ${errorCode} - ${error}`);
});
});
};
void checkRunningTasks();
}, [updateDownloadItem]);
return { return {
startDownload, startDownload,
removeDownload, removeDownload,

View File

@@ -1,48 +0,0 @@
const { withAppDelegate } = require("@expo/config-plugins");
function withRNBackgroundDownloader(expoConfig) {
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
const { modResults: appDelegate } = appDelegateConfig;
const appDelegateLines = appDelegate.contents.split("\n");
// Define the code to be added to AppDelegate.mm
const backgroundDownloaderImport =
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
{
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
}`;
// Find the index of the AppDelegate import statement
const importIndex = appDelegateLines.findIndex((line) =>
/^#import "AppDelegate.h"/.test(line),
);
// Find the index of the last line before the @end statement
const endStatementIndex = appDelegateLines.findIndex((line) =>
/@end/.test(line),
);
// Insert the import statement if it's not already present
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
}
// Insert the delegate method above the @end statement
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
appDelegateLines.splice(
endStatementIndex,
0,
backgroundDownloaderDelegate,
);
}
// Update the contents of the AppDelegate file
appDelegate.contents = appDelegateLines.join("\n");
return appDelegateConfig;
});
}
module.exports = withRNBackgroundDownloader;

11
pnpm-lock.yaml generated
View File

@@ -29,9 +29,6 @@ importers:
'@expo/metro-config': '@expo/metro-config':
specifier: ^0.17.3 specifier: ^0.17.3
version: 0.17.3(@react-native/babel-preset@0.73.21) version: 0.17.3(@react-native/babel-preset@0.73.21)
'@kesha-antonov/react-native-background-downloader':
specifier: ^3.1.2
version: 3.1.2(react-native@0.73.6)
'@movie-web/api': '@movie-web/api':
specifier: '*' specifier: '*'
version: link:../../packages/api version: link:../../packages/api
@@ -2842,14 +2839,6 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
dev: true dev: true
/@kesha-antonov/react-native-background-downloader@3.1.2(react-native@0.73.6):
resolution: {integrity: sha512-xBs1DyGOdGCSI7mfE7cT7V1Ecv6G99A4zh08o0q7/DXOqttX4tymiTQNuWQxW2dMe7clOHGjzeW1BLYwofSYNw==}
peerDependencies:
react-native: '>=0.57.0'
dependencies:
react-native: 0.73.6(@babel/core@7.23.9)(@babel/preset-env@7.23.9)(react@18.2.0)
dev: false
/@motionone/animation@10.17.0: /@motionone/animation@10.17.0:
resolution: {integrity: sha512-ANfIN9+iq1kGgsZxs+Nz96uiNcPLGTXwfNo2Xz/fcJXniPYpaz/Uyrfa+7I5BPLxCP82sh7quVDudf1GABqHbg==} resolution: {integrity: sha512-ANfIN9+iq1kGgsZxs+Nz96uiNcPLGTXwfNo2Xz/fcJXniPYpaz/Uyrfa+7I5BPLxCP82sh7quVDudf1GABqHbg==}
dependencies: dependencies: