From 78961feb6db494d7e12310c0d6b5164931ff6b9c Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 16 Feb 2025 21:22:03 +0100 Subject: [PATCH] wip --- app/(auth)/(tabs)/(home)/downloads/index.tsx | 227 ++++++++++++++---- modules/hls-downloader/index.ts | 5 + .../ios/HlsDownloaderModule.swift | 174 ++++++++++---- providers/NativeDownloadProvider.tsx | 37 ++- utils/movpkg-to-vlc/tools.ts | 2 +- 5 files changed, 325 insertions(+), 120 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 281ed07e..8cf6ad48 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -1,18 +1,31 @@ import { Text } from "@/components/common/Text"; +import ProgressCircle from "@/components/ProgressCircle"; import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types"; import { useNativeDownloads } from "@/providers/NativeDownloadProvider"; +import { Ionicons } from "@expo/vector-icons"; +import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; -import { useEffect } from "react"; -import { ActivityIndicator, TouchableOpacity, View } from "react-native"; - -const PROGRESSBAR_HEIGHT = 10; +import { useMemo } from "react"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; const formatETA = (seconds: number): string => { - const pad = (n: number) => n.toString().padStart(2, "0"); const hrs = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); - return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`; + + const parts: string[] = []; + + if (hrs > 0) parts.push(`${hrs}h`); + if (mins > 0) parts.push(`${mins}m`); + if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); + + return parts.join(" "); }; const getETA = (download: DownloadInfo): string | null => { @@ -26,7 +39,7 @@ const getETA = (download: DownloadInfo): string | null => { return null; } - const elapsed = Date.now() / 100 - download.startTime; // seconds + const elapsed = Date.now() / 1000 - download.startTime; // seconds console.log("Elapsed (s):", Number(download.startTime), Date.now(), elapsed); @@ -42,6 +55,16 @@ const getETA = (download: DownloadInfo): string | null => { return formatETA(secondsLeft); }; +const formatBytes = (i: number) => { + const units = ["B", "KB", "MB", "GB", "TB"]; + let l = 0; + let n = parseInt(i.toString(), 10) || 0; + while (n >= 1024 && ++l) { + n = n / 1024; + } + return n.toFixed(n < 10 && l > 0 ? 1 : 0) + " " + units[l]; +}; + export default function Index() { const { downloadedFiles, activeDownloads } = useNativeDownloads(); const router = useRouter(); @@ -51,55 +74,153 @@ export default function Index() { router.push("/player/direct-player?offline=true&itemId=" + item.id); }; + const movies = useMemo( + () => downloadedFiles.filter((i) => i.metadata.item?.Type === "Movie"), + [downloadedFiles] + ); + const episodes = useMemo( + () => downloadedFiles.filter((i) => i.metadata.item?.Type === "Episode"), + [downloadedFiles] + ); + + const queryClient = useQueryClient(); + return ( - - {activeDownloads.map((i) => { - const progress = - i.bytesTotal && i.bytesDownloaded - ? i.bytesDownloaded / i.bytesTotal - : 0; - const eta = getETA(i); - return ( - - {i.metadata?.item?.Name} - {i.state === "PENDING" ? ( - - ) : i.state === "DOWNLOADING" ? ( - - {i.bytesDownloaded} / {i.bytesTotal} - - ) : null} - + 0 + } + onRefresh={async () => { + await queryClient.invalidateQueries({ + queryKey: ["downloadedFiles"], + }); + }} + /> + } + className="p-4 space-y-2" + > + {activeDownloads.length ? ( + + + ACTIVE DOWNLOADS + + {activeDownloads.map((i) => { + const progress = + i.bytesTotal && i.bytesDownloaded + ? i.bytesDownloaded / i.bytesTotal + : 0; + const eta = getETA(i); + const item = i.metadata?.item; + return ( + key={i.id} + className="flex flex-row items-center justify-between p-4 rounded-xl bg-neutral-900" + > + + {item.Type === "Episode" ? ( + {item?.SeriesName} + ) : null} + {item?.Name} + + {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} + + + + {eta ? `${eta} remaining` : "Calculating time..."} + + {i.state === "DOWNLOADING" && i.bytesTotal ? ( + + {formatBytes(i.bytesTotal * 100000)} + + ) : null} + + + + {i.state === "PENDING" ? ( + + ) : ( + + )} + + ); + })} + + ) : null} + + + {movies && movies.length ? ( + + MOVIES + + {movies.map((i) => ( + goToVideo(i)} + className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between" + > + + {i.metadata.item.Type === "Episode" ? ( + + {i.metadata.item?.SeriesName} + + ) : null} + + {i.metadata.item?.Name} + + + {`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`} + + + + + ))} - {eta ? ETA: {eta} : Calculating...} - ); - })} - {downloadedFiles.map((i) => ( - goToVideo(i)} - className="bg-neutral-800 p-4 rounded-lg" - > - {i.metadata.item?.Name} - {i.metadata.item?.Type} - - ))} - + ) : null} + {episodes && episodes.length ? ( + + EPISODES + + {episodes.map((i) => ( + goToVideo(i)} + className="bg-neutral-900 p-4 rounded-xl flex flex-row items-center justify-between" + > + + {i.metadata.item.Type === "Episode" ? ( + + {i.metadata.item?.SeriesName} + + ) : null} + + {i.metadata.item?.Name} + + + {`S${i.metadata.item.ParentIndexNumber?.toString()}:E${i.metadata.item.IndexNumber?.toString()}`} + + + + + ))} + + + ) : null} + + ); } diff --git a/modules/hls-downloader/index.ts b/modules/hls-downloader/index.ts index b2eaf916..62a2175c 100644 --- a/modules/hls-downloader/index.ts +++ b/modules/hls-downloader/index.ts @@ -34,6 +34,10 @@ async function checkForExistingDownloads(): Promise { return HlsDownloaderModule.checkForExistingDownloads(); } +async function cancelDownload(id: string): Promise { + return HlsDownloaderModule.cancelDownload(id); +} + /** * Subscribes to download progress events. * @param listener A callback invoked with progress updates. @@ -186,4 +190,5 @@ export { addErrorListener, addProgressListener, HlsDownloaderModule, + cancelDownload, }; diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index a23ba6c3..36196d3d 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -33,52 +33,91 @@ public class HlsDownloaderModule: Module { return } - let asset = AVURLAsset(url: assetURL) - let configuration = URLSessionConfiguration.background( - withIdentifier: "com.example.hlsdownload") - let delegate = HLSDownloadDelegate(module: self) - delegate.providedId = providedId - delegate.startTime = startTime - let downloadSession = AVAssetDownloadURLSession( - configuration: configuration, - assetDownloadDelegate: delegate, - delegateQueue: OperationQueue.main - ) - - guard - let task = downloadSession.makeAssetDownloadTask( - asset: asset, - assetTitle: providedId, - assetArtworkData: nil, - options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: startTime] - ) - else { - self.sendEvent( - "onError", - [ - "id": providedId, - "error": "Failed to create download task", - "state": "FAILED", - "metadata": metadata ?? [:], - "startTime": startTime, - ]) - return - } - - delegate.taskIdentifier = task.taskIdentifier - self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime) - self.sendEvent( - "onProgress", - [ - "id": providedId, - "progress": 0.0, - "state": "PENDING", - "metadata": metadata ?? [:], - "startTime": startTime, + // Add asset options to allow cellular downloads and specify allowed media types + let asset = AVURLAsset( + url: assetURL, + options: [ + "AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL", + "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "YourAppNameHere/1.0"], + "AVURLAssetAllowsCellularAccessKey": true, ]) - task.resume() - print("Download task started with identifier: \(task.taskIdentifier)") + // Validate the asset before proceeding + asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) { + var error: NSError? + let status = asset.statusOfValue(forKey: "playable", error: &error) + + DispatchQueue.main.async { + if status == .failed || error != nil { + self.sendEvent( + "onError", + [ + "id": providedId, + "error": + "Asset validation failed: \(error?.localizedDescription ?? "Unknown error")", + "state": "FAILED", + "metadata": metadata ?? [:], + "startTime": startTime, + ]) + return + } + + let configuration = URLSessionConfiguration.background( + withIdentifier: "com.example.hlsdownload.\(providedId)") + configuration.allowsCellularAccess = true + configuration.sessionSendsLaunchEvents = true + configuration.isDiscretionary = false + + let delegate = HLSDownloadDelegate(module: self) + delegate.providedId = providedId + delegate.startTime = startTime + + let downloadSession = AVAssetDownloadURLSession( + configuration: configuration, + assetDownloadDelegate: delegate, + delegateQueue: OperationQueue.main + ) + + guard + let task = downloadSession.makeAssetDownloadTask( + asset: asset, + assetTitle: providedId, + assetArtworkData: nil, + options: [ + AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000, + AVAssetDownloadTaskMinimumRequiredPresentationSizeKey: NSValue( + cgSize: CGSize(width: 480, height: 360)), + ] + ) + else { + self.sendEvent( + "onError", + [ + "id": providedId, + "error": "Failed to create download task", + "state": "FAILED", + "metadata": metadata ?? [:], + "startTime": startTime, + ]) + return + } + + delegate.taskIdentifier = task.taskIdentifier + self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime) + self.sendEvent( + "onProgress", + [ + "id": providedId, + "progress": 0.0, + "state": "PENDING", + "metadata": metadata ?? [:], + "startTime": startTime, + ]) + + task.resume() + print("Download task started with identifier: \(task.taskIdentifier)") + } + } } Function("checkForExistingDownloads") { @@ -105,6 +144,29 @@ public class HlsDownloaderModule: Module { return downloads } + Function("cancelDownload") { (providedId: String) -> Void in + guard + let entry = self.activeDownloads.first(where: { $0.value.delegate.providedId == providedId } + ) + else { + print("No active download found with identifier: \(providedId)") + return + } + let (task, delegate, metadata, startTime) = entry.value + task.cancel() + self.activeDownloads.removeValue(forKey: task.taskIdentifier) + self.sendEvent( + "onError", + [ + "id": providedId, + "error": "Download cancelled", + "state": "CANCELLED", + "metadata": metadata, + "startTime": startTime, + ]) + print("Download cancelled for identifier: \(providedId)") + } + OnStartObserving {} OnStopObserving {} } @@ -114,17 +176,23 @@ public class HlsDownloaderModule: Module { } func persistDownloadedFolder(originalLocation: URL, folderName: String) throws -> URL { - let fileManager = FileManager.default - let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] - let destinationDir = documents.appendingPathComponent("downloads", isDirectory: true) - if !fileManager.fileExists(atPath: destinationDir.path) { - try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true) + let fm = FileManager.default + let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0] + let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true) + if !fm.fileExists(atPath: downloadsDir.path) { + try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true) } - let newLocation = destinationDir.appendingPathComponent(folderName, isDirectory: true) - if fileManager.fileExists(atPath: newLocation.path) { - try fileManager.removeItem(at: newLocation) + let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true) + if fm.fileExists(atPath: newLocation.path) { + try fm.removeItem(at: newLocation) + } + + // If the original file exists, move it. Otherwise, if newLocation already exists, assume it was moved. + if fm.fileExists(atPath: originalLocation.path) { + try fm.moveItem(at: originalLocation, to: newLocation) + } else if !fm.fileExists(atPath: newLocation.path) { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError, userInfo: nil) } - try fileManager.moveItem(at: originalLocation, to: newLocation) return newLocation } diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index 53c64fc1..7c3e9563 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -18,7 +18,7 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { useQuery } from "@tanstack/react-query"; +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"; @@ -92,8 +92,6 @@ const getDownloadedFiles = async (): Promise => { const fileInfo = await FileSystem.getInfoAsync(downloadsDir + file); if (fileInfo.isDirectory) continue; - console.log(file); - const doneFile = await isFileMarkedAsDone(file.replace(".json", "")); if (!doneFile) continue; @@ -107,7 +105,6 @@ const getDownloadedFiles = async (): Promise => { metadata: JSON.parse(fileContent) as DownloadMetadata, }); } - console.log(downloaded); return downloaded; }; @@ -128,6 +125,7 @@ export const NativeDownloadProvider: React.FC<{ }> = ({ children }) => { const [downloads, setDownloads] = useState>({}); const { saveImage } = useImageStorage(); + const queryClient = useQueryClient(); const user = useAtomValue(userAtom); const api = useAtomValue(apiAtom); @@ -135,6 +133,9 @@ export const NativeDownloadProvider: React.FC<{ const { data: downloadedFiles } = useQuery({ queryKey: ["downloadedFiles"], queryFn: getDownloadedFiles, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, }); useEffect(() => { @@ -166,7 +167,13 @@ export const NativeDownloadProvider: React.FC<{ const progressListener = addProgressListener((download) => { if (!download.metadata) throw new Error("No metadata found in download"); - console.log("[HLS] Download progress:", download); + console.log( + "[HLS] Download progress:", + download.bytesTotal, + download.bytesDownloaded, + download.progress, + download.state + ); setDownloads((prev) => ({ ...prev, [download.id]: { @@ -185,19 +192,22 @@ export const NativeDownloadProvider: React.FC<{ if (!payload.id) throw new Error("No id found in payload"); try { - rewriteM3U8Files(payload.location); - markFileAsDone(payload.id); + 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 persist file:", error); toast.error("Failed to download ❌"); } - - setDownloads((prev) => { - const newDownloads = { ...prev }; - delete newDownloads[payload.id]; - return newDownloads; - }); }); const errorListener = addErrorListener((error) => { @@ -208,6 +218,7 @@ export const NativeDownloadProvider: React.FC<{ delete newDownloads[error.id]; return newDownloads; }); + toast.error("Failed to download ❌"); } }); diff --git a/utils/movpkg-to-vlc/tools.ts b/utils/movpkg-to-vlc/tools.ts index 9c8679e3..354a6647 100644 --- a/utils/movpkg-to-vlc/tools.ts +++ b/utils/movpkg-to-vlc/tools.ts @@ -39,7 +39,7 @@ async function processAllStreams( const streamInfo = await processStream(streamDir); if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) { localPaths.push( - `${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}` + `${streamDir}${streamInfo.MediaPlaylist.PathToLocalCopy}` ); } } catch (error) {