import useImageStorage from "@/hooks/useImageStorage"; import { addCompleteListener, addErrorListener, addProgressListener, checkForExistingDownloads, downloadHLSAsset, } from "@/modules/hls-downloader"; import { DownloadInfo, DownloadMetadata, } from "@/modules/hls-downloader/src/HlsDownloader.types"; import { getItemImage } from "@/utils/getItemImage"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { rewriteM3U8Files } from "@/utils/movpkg-to-vlc/tools"; import download from "@/utils/profiles/download"; import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useAtomValue } from "jotai"; import { createContext, useContext, useEffect, useState } from "react"; import { toast } from "sonner-native"; import { apiAtom, userAtom } from "./JellyfinProvider"; type DownloadOptionsData = { selectedAudioStream: number; selectedSubtitleStream: number; selectedMediaSource: MediaSourceInfo; maxBitrate?: number; }; type DownloadContextType = { downloads: Record; startDownload: ( item: BaseItemDto, url: string, { selectedAudioStream, selectedSubtitleStream, selectedMediaSource, maxBitrate, }: DownloadOptionsData ) => Promise; getDownloadedItem: (id: string) => Promise; activeDownloads: DownloadInfo[]; downloadedFiles: DownloadedFileInfo[]; }; const DownloadContext = createContext( undefined ); /** * 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" ); }; /** * 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 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 getDownloadedFiles = async (): Promise => { 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; 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 downloaded; }; const getDownloadedFile = 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>({}); const { saveImage } = useImageStorage(); const queryClient = useQueryClient(); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); const { data: downloadedFiles } = useQuery({ queryKey: ["downloadedFiles"], queryFn: getDownloadedFiles, refetchOnWindowFocus: true, refetchOnMount: true, refetchOnReconnect: true, staleTime: 0, }); useEffect(() => { // Initialize downloads from both HLS and regular downloads const initializeDownloads = async () => { // Check HLS downloads const hlsDownloads = await checkForExistingDownloads(); const hlsDownloadStates = hlsDownloads.reduce( (acc, download) => ({ ...acc, [download.id]: { id: download.id, progress: download.progress, state: download.state, secondsDownloaded: download.secondsDownloaded, secondsTotal: download.secondsTotal, metadata: download.metadata, startTime: download?.startTime, }, }), {} ); setDownloads({ ...hlsDownloadStates }); }; initializeDownloads(); const progressListener = addProgressListener((download) => { console.log("Attempting to add progress listener"); if (!download.metadata) throw new Error("No metadata found in download"); console.log( "[HLS] Download progress:", download.secondsTotal, download.secondsDownloaded, download.progress, download.state ); setDownloads((prev) => ({ ...prev, [download.id]: { id: download.id, progress: download.progress, state: download.state, secondsDownloaded: download.secondsDownloaded, secondsTotal: download.secondsTotal, metadata: download.metadata, startTime: download?.startTime, }, })); }); const completeListener = addCompleteListener(async (payload) => { try { await rewriteM3U8Files(payload.location); await markFileAsDone(payload.id); setDownloads((prev) => { const newDownloads = { ...prev }; delete newDownloads[payload.id]; return newDownloads; }); await queryClient.invalidateQueries({ queryKey: ["downloadedFiles"] }); toast.success("Download complete ✅"); } catch (error) { console.error("Failed to download file:", error); toast.error("Failed to download ❌"); } }); const errorListener = addErrorListener((error) => { setDownloads((prev) => { const newDownloads = { ...prev }; delete newDownloads[error.id]; return newDownloads; }); toast.error("Failed to download ❌"); }); return () => { progressListener.remove(); completeListener.remove(); errorListener.remove(); }; }, []); 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 () => { 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 rewriteM3U8Files( FileSystem.documentDirectory + "downloads/" + id ); await markFileAsDone(id); }; toast.promise(p(), { error: () => "Failed to download ❌", loading: "Finishing up download...", success: () => "Download complete ✅", }); } } } }; 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")) throw new Error("Only HLS downloads are supported"); downloadHLSAsset(jobId, url, { item, mediaSource, }); }; return ( {children} ); }; export const useNativeDownloads = () => { const context = useContext(DownloadContext); if (context === undefined) { throw new Error( "useDownloads must be used within a NativeDownloadProvider" ); } return context; };