diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx index 8cf6ad48..06669d56 100644 --- a/app/(auth)/(tabs)/(home)/downloads/index.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -29,11 +29,10 @@ const formatETA = (seconds: number): string => { }; const getETA = (download: DownloadInfo): string | null => { - console.log("getETA", download); if ( !download.startTime || - !download.bytesDownloaded || - !download.bytesTotal + !download.secondsDownloaded || + !download.secondsTotal ) { console.log(download); return null; @@ -41,12 +40,10 @@ const getETA = (download: DownloadInfo): string | null => { const elapsed = Date.now() / 1000 - download.startTime; // seconds - console.log("Elapsed (s):", Number(download.startTime), Date.now(), elapsed); + if (elapsed <= 0 || download.secondsDownloaded <= 0) return null; - if (elapsed <= 0 || download.bytesDownloaded <= 0) return null; - - const speed = download.bytesDownloaded / elapsed; // bytes per second - const remainingBytes = download.bytesTotal - download.bytesDownloaded; + const speed = download.secondsDownloaded / elapsed; // downloaded seconds per second + const remainingBytes = download.secondsTotal - download.secondsDownloaded; if (speed <= 0) return null; @@ -108,8 +105,8 @@ export default function Index() { {activeDownloads.map((i) => { const progress = - i.bytesTotal && i.bytesDownloaded - ? i.bytesDownloaded / i.bytesTotal + i.secondsTotal && i.secondsDownloaded + ? i.secondsDownloaded / i.secondsTotal : 0; const eta = getETA(i); const item = i.metadata?.item; @@ -130,11 +127,6 @@ export default function Index() { {eta ? `${eta} remaining` : "Calculating time..."} - {i.state === "DOWNLOADING" && i.bytesTotal ? ( - - {formatBytes(i.bytesTotal * 100000)} - - ) : null} diff --git a/components/downloads/NativeDownloadButton.tsx b/components/downloads/NativeDownloadButton.tsx index ec34cbf7..94a184a6 100644 --- a/components/downloads/NativeDownloadButton.tsx +++ b/components/downloads/NativeDownloadButton.tsx @@ -15,11 +15,16 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { useFocusEffect } from "expo-router"; +import { useFocusEffect, useRouter } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import React, { useCallback, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, View, ViewProps } from "react-native"; +import { + ActivityIndicator, + TouchableOpacity, + View, + ViewProps, +} from "react-native"; import { toast } from "sonner-native"; import { AudioTrackSelector } from "../AudioTrackSelector"; import { Bitrate, BitrateSelector } from "../BitrateSelector"; @@ -158,6 +163,8 @@ export const NativeDownloadButton: React.FC = ({ [] ); + const router = useRouter(); + const activeDownload = item.Id ? downloads[item.Id] : undefined; return ( @@ -168,7 +175,11 @@ export const NativeDownloadButton: React.FC = ({ onPress={handlePresentModalPress} > {activeDownload ? ( - <> + { + router.push(`/downloads`); + }} + > {activeDownload.state === "PENDING" && ( )} @@ -193,7 +204,7 @@ export const NativeDownloadButton: React.FC = ({ {activeDownload.state === "DONE" && ( )} - + ) : ( )} diff --git a/modules/hls-downloader/index.ts b/modules/hls-downloader/index.ts index 62a2175c..25740d74 100644 --- a/modules/hls-downloader/index.ts +++ b/modules/hls-downloader/index.ts @@ -28,7 +28,6 @@ function downloadHLSAsset( /** * Checks for existing downloads. * Returns an array of downloads with additional fields: - * id, progress, bytesDownloaded, bytesTotal, and state. */ async function checkForExistingDownloads(): Promise { return HlsDownloaderModule.checkForExistingDownloads(); @@ -99,91 +98,9 @@ function useDownloadError(): string | null { return error; } -/** - * Moves a file from a temporary URI to a permanent location in the document directory. - * @param tempFileUri The temporary file URI returned by the native module. - * @param newFilename The desired filename (with extension) for the persisted file. - * @returns A promise that resolves with the new file URI. - */ -async function persistDownloadedFile( - tempFileUri: string, - newFilename: string -): Promise { - const newUri = FileSystem.documentDirectory + newFilename; - try { - await FileSystem.moveAsync({ - from: tempFileUri, - to: newUri, - }); - console.log("File persisted to:", newUri); - return newUri; - } catch (error) { - console.error("Error moving file:", error); - throw error; - } -} - -/** - * React hook that returns the completion location of the download. - * If a destinationFileName is provided, the hook will move the downloaded file - * to the document directory under that name, then return the new URI. - * - * @param destinationFileName Optional filename (with extension) to persist the file. - * @returns The final file URI or null if not completed. - */ -function useDownloadComplete(destinationFileName?: string): string | null { - const [location, setLocation] = useState(null); - - useEffect(() => { - console.log("Setting up download complete listener"); - - const subscription = addCompleteListener( - async (event: OnCompleteEventPayload) => { - console.log("Download complete event received:", event); - console.log("Original download location:", event.location); - - if (destinationFileName) { - console.log( - "Attempting to persist file with name:", - destinationFileName - ); - try { - const newLocation = await persistDownloadedFile( - event.location, - destinationFileName - ); - console.log("File successfully persisted to:", newLocation); - setLocation(newLocation); - } catch (error) { - console.error("Failed to persist file:", error); - console.error("Error details:", { - originalLocation: event.location, - destinationFileName, - error: error instanceof Error ? error.message : error, - }); - } - } else { - console.log( - "No destination filename provided, using original location" - ); - setLocation(event.location); - } - } - ); - - return () => { - console.log("Cleaning up download complete listener"); - subscription.remove(); - }; - }, [destinationFileName]); - - return location; -} - export { downloadHLSAsset, checkForExistingDownloads, - useDownloadComplete, useDownloadError, useDownloadProgress, addCompleteListener, diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index 4ee5d215..deb99f58 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -38,7 +38,7 @@ public class HlsDownloaderModule: Module { url: assetURL, options: [ "AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL", - "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "YourAppNameHere/1.0"], + "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "Streamyfin/1.0"], "AVURLAssetAllowsCellularAccessKey": true, ]) @@ -134,8 +134,8 @@ public class HlsDownloaderModule: Module { downloads.append([ "id": delegate.providedId.isEmpty ? String(id) : delegate.providedId, "progress": progress, - "bytesDownloaded": downloaded, - "bytesTotal": total, + "secondsDownloaded": downloaded, + "secondsTotal": total, "state": self.mappedState(for: task), "metadata": metadata, "startTime": startTime, @@ -243,8 +243,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { [ "id": providedId, "progress": progress, - "bytesDownloaded": downloaded, - "bytesTotal": total, + "secondsDownloaded": downloaded, + "secondsTotal": total, "state": progress >= 1.0 ? "DONE" : "DOWNLOADING", "metadata": metadata, "startTime": startTime, @@ -263,6 +263,26 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { let newLocation = try module.persistDownloadedFolder( originalLocation: location, folderName: folderName) + // Calculate download size + let fileManager = FileManager.default + let enumerator = fileManager.enumerator( + at: newLocation, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey], + options: [.skipsHiddenFiles], + errorHandler: nil)! + + var totalSize: Int64 = 0 + while let filePath = enumerator.nextObject() as? URL { + do { + let resourceValues = try filePath.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + if let size = resourceValues.totalFileAllocatedSize { + totalSize += Int64(size) + } + } catch { + print("Error calculating size: \(error)") + } + } + if !metadata.isEmpty { let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent( "\(providedId).json") @@ -278,6 +298,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { "state": "DONE", "metadata": metadata, "startTime": startTime, + "bytesDownloaded": totalSize, ]) } catch { module?.sendEvent( diff --git a/modules/hls-downloader/src/HlsDownloader.types.ts b/modules/hls-downloader/src/HlsDownloader.types.ts index 96369888..229c10d7 100644 --- a/modules/hls-downloader/src/HlsDownloader.types.ts +++ b/modules/hls-downloader/src/HlsDownloader.types.ts @@ -21,13 +21,13 @@ export type BaseEventPayload = { id: string; state: DownloadState; metadata: DownloadMetadata; + startTime?: number; }; export type OnProgressEventPayload = BaseEventPayload & { progress: number; - bytesDownloaded: number; - bytesTotal: number; - startTime?: number; + secondsDownloaded: number; + secondsTotal: number; }; export type OnErrorEventPayload = BaseEventPayload & { @@ -38,6 +38,7 @@ export type OnErrorEventPayload = BaseEventPayload & { export type OnCompleteEventPayload = BaseEventPayload & { location: string; + bytesDownloaded?: number; }; export type HlsDownloaderModuleEvents = { @@ -52,8 +53,8 @@ export interface DownloadInfo { startTime?: number; progress: number; state: DownloadState; - bytesDownloaded?: number; - bytesTotal?: number; + secondsDownloaded?: number; + secondsTotal?: number; location?: string; error?: string; metadata: DownloadMetadata; diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index 7c3e9563..f202c832 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -136,6 +136,7 @@ export const NativeDownloadProvider: React.FC<{ refetchOnWindowFocus: true, refetchOnMount: true, refetchOnReconnect: true, + staleTime: 0, }); useEffect(() => { @@ -150,8 +151,8 @@ export const NativeDownloadProvider: React.FC<{ id: download.id, progress: download.progress, state: download.state, - bytesDownloaded: download.bytesDownloaded, - bytesTotal: download.bytesTotal, + secondsDownloaded: download.secondsDownloaded, + secondsTotal: download.secondsTotal, metadata: download.metadata, startTime: download?.startTime, }, @@ -165,23 +166,25 @@ export const NativeDownloadProvider: React.FC<{ 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.bytesTotal, - download.bytesDownloaded, + download.secondsTotal, + download.secondsDownloaded, download.progress, download.state ); + setDownloads((prev) => ({ ...prev, [download.id]: { id: download.id, progress: download.progress, state: download.state, - bytesDownloaded: download.bytesDownloaded, - bytesTotal: download.bytesTotal, + secondsDownloaded: download.secondsDownloaded, + secondsTotal: download.secondsTotal, metadata: download.metadata, startTime: download?.startTime, }, @@ -189,8 +192,6 @@ export const NativeDownloadProvider: React.FC<{ }); const completeListener = addCompleteListener(async (payload) => { - if (!payload.id) throw new Error("No id found in payload"); - try { await rewriteM3U8Files(payload.location); await markFileAsDone(payload.id); @@ -205,21 +206,18 @@ export const NativeDownloadProvider: React.FC<{ toast.success("Download complete ✅"); } catch (error) { - console.error("Failed to persist file:", error); + console.error("Failed to download file:", error); toast.error("Failed to download ❌"); } }); const errorListener = addErrorListener((error) => { - console.error("Download error:", error); - if (error.id) { - setDownloads((prev) => { - const newDownloads = { ...prev }; - delete newDownloads[error.id]; - return newDownloads; - }); - toast.error("Failed to download ❌"); - } + setDownloads((prev) => { + const newDownloads = { ...prev }; + delete newDownloads[error.id]; + return newDownloads; + }); + toast.error("Failed to download ❌"); }); return () => { @@ -249,10 +247,10 @@ export const NativeDownloadProvider: React.FC<{ console.log("Found unparsed download:", id); const p = async () => { - await markFileAsDone(id); - rewriteM3U8Files( + await rewriteM3U8Files( FileSystem.documentDirectory + "downloads/" + id ); + await markFileAsDone(id); }; toast.promise(p(), { error: () => "Failed to download ❌", diff --git a/utils/movpkg-to-vlc/parse/boot.ts b/utils/movpkg-to-vlc/parse/boot.ts index 5af8e509..2583ed5b 100644 --- a/utils/movpkg-to-vlc/parse/boot.ts +++ b/utils/movpkg-to-vlc/parse/boot.ts @@ -38,7 +38,5 @@ export async function parseBootXML(xml: string): Promise { parseAttributeValue: true, }); const jsonObj = parser.parse(xml); - const b = jsonObj.HLSMoviePackage as Boot; - console.log(b.Streams); return jsonObj.HLSMoviePackage as Boot; } diff --git a/utils/movpkg-to-vlc/tools.ts b/utils/movpkg-to-vlc/tools.ts index 354a6647..3a720af5 100644 --- a/utils/movpkg-to-vlc/tools.ts +++ b/utils/movpkg-to-vlc/tools.ts @@ -3,6 +3,7 @@ import { parseBootXML } from "./parse/boot"; import { parseStreamInfoXml, StreamInfo } from "./parse/streamInfoBoot"; export async function rewriteM3U8Files(baseDir: string): Promise { + console.log(`[1] Rewriting M3U8 files in ${baseDir}`); const bootData = await loadBootData(baseDir); if (!bootData) return; @@ -14,6 +15,7 @@ export async function rewriteM3U8Files(baseDir: string): Promise { } async function loadBootData(baseDir: string): Promise { + console.log(`[2] Loading boot.xml from ${baseDir}`); const bootPath = `${baseDir}/boot.xml`; try { const bootInfo = await FileSystem.getInfoAsync(bootPath); @@ -31,15 +33,19 @@ async function processAllStreams( baseDir: string, bootData: any ): Promise { + console.log(`[3] Processing all streams in ${baseDir}`); const localPaths: string[] = []; + const streams = Array.isArray(bootData.Streams.Stream) + ? bootData.Streams.Stream + : [bootData.Streams.Stream]; - for (const stream of bootData.Streams.Stream) { + for (const stream of streams) { const streamDir = `${baseDir}/${stream.ID}`; try { const streamInfo = await processStream(streamDir); if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) { localPaths.push( - `${streamDir}${streamInfo.MediaPlaylist.PathToLocalCopy}` + `${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}` ); } } catch (error) { @@ -84,7 +90,9 @@ export function updatePlaylistWithLocalSegments( export async function processStream( streamDir: string ): Promise { + console.log(`[4] Processing stream at ${streamDir}`); const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`; + console.log(`Processing stream at ${streamDir}...`); try { const streamXML = await FileSystem.readAsStringAsync(streamInfoPath);