import type { DownloadProgressData } from "expo-file-system"; import type { Asset } from "expo-media-library"; import { useCallback, useState } from "react"; import * as FileSystem from "expo-file-system"; import * as MediaLibrary from "expo-media-library"; import * as Network from "expo-network"; import { NetworkStateType } from "expo-network"; import VideoManager from "@salihgun/react-native-video-processor"; import type { ScrapeMedia } from "@movie-web/provider-utils"; import { extractSegmentsFromHLS } from "@movie-web/provider-utils"; import { useToast } from "~/hooks/useToast"; import { useDownloadHistoryStore, useNetworkSettingsStore, } from "~/stores/settings"; export interface Download { id: string; progress: number; speed: number; fileSize: number; downloaded: number; url: string; type: "mp4" | "hls"; status: | "downloading" | "finished" | "error" | "merging" | "cancelled" | "importing"; localPath?: string; media: ScrapeMedia; downloadTask?: FileSystem.DownloadResumable; } export interface DownloadContent { media: Pick; downloads: Download[]; } export const useDownloadManager = () => { const cancellationFlags = useState>({})[0]; const downloads = useDownloadHistoryStore((state) => state.downloads); const setDownloads = useDownloadHistoryStore((state) => state.setDownloads); const { showToast } = useToast(); const setCancellationFlag = (downloadId: string, flag: boolean): void => { cancellationFlags[downloadId] = flag; }; const getCancellationFlag = useCallback( (downloadId: string): boolean => { return cancellationFlags[downloadId] ?? false; }, [cancellationFlags], ); const cancelDownload = async (download: Download) => { setCancellationFlag(download.id, true); if (download?.downloadTask) { await download.downloadTask.cancelAsync(); } showToast("Download cancelled", { burntOptions: { preset: "done" }, }); }; const updateDownloadItem = useCallback( (downloadId: string, download: Partial) => { setDownloads((prev) => { const updatedDownloads = prev.map((content) => { const updatedDownloadsArray = content.downloads.map((d) => d.id === downloadId ? { ...d, ...download, } : d, ); return { ...content, downloads: updatedDownloadsArray, }; }); return updatedDownloads; }); }, [setDownloads], ); const removeDownload = useCallback( (download: Download) => { if (download.media.type === "movie") { setDownloads((prev) => prev.filter((d) => d.media.tmdbId !== download.media.tmdbId), ); return; } else if (download.media.type === "show") { setDownloads((prev) => { const existingDownload = prev.find( (d) => d.media.tmdbId === download.media.tmdbId, ); if (existingDownload?.downloads.length === 1) { return prev.filter((d) => d.media.tmdbId !== download.media.tmdbId); } else { return prev.map((content) => { return { ...content, downloads: content.downloads.filter( (d) => d.id !== download.id, ), }; }); } }); } }, [setDownloads], ); const saveFileToMediaLibraryAndDeleteOriginal = useCallback( async (fileUri: string, download: Download): Promise => { console.log( "Saving file to media library and deleting original", fileUri, ); try { updateDownloadItem(download.id, { status: "importing" }); const asset = await MediaLibrary.createAssetAsync(fileUri); const { localUri } = await MediaLibrary.getAssetInfoAsync(asset); await FileSystem.deleteAsync(fileUri); updateDownloadItem(download.id, { status: "finished", localPath: localUri, }); console.log("File saved to media library and original deleted"); showToast("Download finished", { burntOptions: { preset: "done" }, }); return asset; } catch (error) { console.error("Error saving file to media library:", error); showToast("Download failed", { burntOptions: { preset: "error" }, }); } }, [updateDownloadItem, showToast], ); const downloadMP4 = useCallback( async ( url: string, downloadItem: Download, headers: Record, ): Promise => { let lastBytesWritten = 0; let lastTimestamp = Date.now(); const updateProgress = (downloadProgress: DownloadProgressData) => { const currentTime = Date.now(); const timeElapsed = (currentTime - lastTimestamp) / 1000; if (timeElapsed === 0) return; const newBytes = downloadProgress.totalBytesWritten - lastBytesWritten; const speed = newBytes / timeElapsed / 1024 / 1024; const progress = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite; updateDownloadItem(downloadItem.id, { progress, speed, fileSize: downloadProgress.totalBytesExpectedToWrite, downloaded: downloadProgress.totalBytesWritten, }); lastBytesWritten = downloadProgress.totalBytesWritten; lastTimestamp = currentTime; }; const fileUri = `${FileSystem.cacheDirectory}movie-web${url.split("/").pop()}`; if ( !( await FileSystem.getInfoAsync(`${FileSystem.cacheDirectory}movie-web`) ).exists ) { console.error("Cache directory is unavailable"); return; } const downloadResumable = FileSystem.createDownloadResumable( url, fileUri, { headers, }, 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); } }, [updateDownloadItem, saveFileToMediaLibraryAndDeleteOriginal], ); const cleanupDownload = useCallback( async (segmentDir: string, download: Download) => { await FileSystem.deleteAsync(segmentDir, { idempotent: true }); removeDownload(download); }, [removeDownload], ); const downloadHLS = useCallback( async ( url: string, download: Download, headers: Record, ) => { const segments = await extractSegmentsFromHLS(url, headers); if (!segments || segments.length === 0) { return removeDownload(download); } const totalSegments = segments.length; let segmentsDownloaded = 0; const segmentDir = `${FileSystem.cacheDirectory}movie-web/segments/`; await ensureDirExists(segmentDir); const updateProgress = () => { const progress = segmentsDownloaded / totalSegments; updateDownloadItem(download.id, { progress, downloaded: segmentsDownloaded, fileSize: totalSegments, }); }; const localSegmentPaths = []; for (const [index, segment] of segments.entries()) { if (getCancellationFlag(download.id)) { await cleanupDownload(segmentDir, download); return; } const segmentFile = `${segmentDir}${index}.ts`; localSegmentPaths.push(segmentFile); try { await downloadSegment(segment, segmentFile, headers); if (getCancellationFlag(download.id)) { await cleanupDownload(segmentDir, download); return; } segmentsDownloaded++; updateProgress(); } catch (e) { console.error(e); if (getCancellationFlag(download.id)) { await cleanupDownload(segmentDir, download); return; } } } if (getCancellationFlag(download.id)) { return removeDownload(download); } updateDownloadItem(download.id, { status: "merging" }); const uri = await VideoManager.mergeVideos( localSegmentPaths, `${FileSystem.cacheDirectory}movie-web/output.mp4`, ); const asset = await saveFileToMediaLibraryAndDeleteOriginal( uri, download, ); return asset; }, [ getCancellationFlag, updateDownloadItem, saveFileToMediaLibraryAndDeleteOriginal, removeDownload, cleanupDownload, ], ); const startDownload = useCallback( async ( url: string, type: "mp4" | "hls", media: ScrapeMedia, headers?: Record, ): Promise => { const { allowMobileData } = useNetworkSettingsStore.getState(); const { type: networkType } = await Network.getNetworkStateAsync(); if (networkType === NetworkStateType.CELLULAR && !allowMobileData) { showToast("Mobile data downloads are disabled", { burntOptions: { preset: "error" }, }); return; } const { status } = await MediaLibrary.requestPermissionsAsync(); if (status !== MediaLibrary.PermissionStatus.GRANTED) { showToast("Permission denied", { burntOptions: { preset: "error" }, }); return; } const existingDownload = downloads.find( (d) => d.media.tmdbId === media.tmdbId, ); if (existingDownload && media.type === "movie") { showToast("Download already exists", { burntOptions: { preset: "error" }, }); return; } if (existingDownload && media.type === "show") { const existingEpisode = existingDownload.downloads.find( (d) => d.media.type === "show" && d.media.episode.tmdbId === media.episode.tmdbId, ); if (existingEpisode) { showToast("Download already exists", { burntOptions: { preset: "error" }, }); return; } } showToast("Download started", { burntOptions: { preset: "none" }, }); const newDownload: Download = { id: `download-${Date.now()}-${Math.random().toString(16).slice(2)}`, progress: 0, speed: 0, fileSize: 0, downloaded: 0, type, url, status: "downloading", media, }; if (existingDownload) { existingDownload.downloads.push(newDownload); setDownloads((prev) => { return prev.map((d) => d.media.tmdbId === media.tmdbId ? existingDownload : d, ); }); } else { setDownloads((prev) => { return [...prev, { media, downloads: [newDownload] }]; }); } if (type === "mp4") { const asset = await downloadMP4(url, newDownload, headers ?? {}); return asset; } else if (type === "hls") { const asset = await downloadHLS(url, newDownload, headers ?? {}); return asset; } }, [downloads, showToast, setDownloads, downloadMP4, downloadHLS], ); const downloadSegment = async ( segmentUrl: string, segmentFile: string, headers: Record, ) => { const downloadResumable = FileSystem.createDownloadResumable( segmentUrl, segmentFile, { headers, }, ); try { const result = await downloadResumable.downloadAsync(); if (result) { console.log("Finished downloading to ", result.uri); } } catch (e) { console.error(e); } }; async function ensureDirExists(dir: string) { await FileSystem.deleteAsync(dir, { idempotent: true }); await FileSystem.makeDirectoryAsync(dir, { intermediates: true }); } return { startDownload, removeDownload, cancelDownload, }; };