This commit is contained in:
Fredrik Burmester
2025-02-17 08:14:27 +01:00
parent e4d2de2f8a
commit b8bebfb272
8 changed files with 82 additions and 136 deletions

View File

@@ -29,11 +29,10 @@ const formatETA = (seconds: number): string => {
}; };
const getETA = (download: DownloadInfo): string | null => { const getETA = (download: DownloadInfo): string | null => {
console.log("getETA", download);
if ( if (
!download.startTime || !download.startTime ||
!download.bytesDownloaded || !download.secondsDownloaded ||
!download.bytesTotal !download.secondsTotal
) { ) {
console.log(download); console.log(download);
return null; return null;
@@ -41,12 +40,10 @@ const getETA = (download: DownloadInfo): string | null => {
const elapsed = Date.now() / 1000 - download.startTime; // seconds 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.secondsDownloaded / elapsed; // downloaded seconds per second
const remainingBytes = download.secondsTotal - download.secondsDownloaded;
const speed = download.bytesDownloaded / elapsed; // bytes per second
const remainingBytes = download.bytesTotal - download.bytesDownloaded;
if (speed <= 0) return null; if (speed <= 0) return null;
@@ -108,8 +105,8 @@ export default function Index() {
</Text> </Text>
{activeDownloads.map((i) => { {activeDownloads.map((i) => {
const progress = const progress =
i.bytesTotal && i.bytesDownloaded i.secondsTotal && i.secondsDownloaded
? i.bytesDownloaded / i.bytesTotal ? i.secondsDownloaded / i.secondsTotal
: 0; : 0;
const eta = getETA(i); const eta = getETA(i);
const item = i.metadata?.item; const item = i.metadata?.item;
@@ -130,11 +127,6 @@ export default function Index() {
<Text className="text-xs text-neutral-500"> <Text className="text-xs text-neutral-500">
{eta ? `${eta} remaining` : "Calculating time..."} {eta ? `${eta} remaining` : "Calculating time..."}
</Text> </Text>
{i.state === "DOWNLOADING" && i.bytesTotal ? (
<Text className="text-xs text-neutral-500">
{formatBytes(i.bytesTotal * 100000)}
</Text>
) : null}
</View> </View>
</View> </View>

View File

@@ -15,11 +15,16 @@ import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useFocusEffect } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; 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 { toast } from "sonner-native";
import { AudioTrackSelector } from "../AudioTrackSelector"; import { AudioTrackSelector } from "../AudioTrackSelector";
import { Bitrate, BitrateSelector } from "../BitrateSelector"; import { Bitrate, BitrateSelector } from "../BitrateSelector";
@@ -158,6 +163,8 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
[] []
); );
const router = useRouter();
const activeDownload = item.Id ? downloads[item.Id] : undefined; const activeDownload = item.Id ? downloads[item.Id] : undefined;
return ( return (
@@ -168,7 +175,11 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
onPress={handlePresentModalPress} onPress={handlePresentModalPress}
> >
{activeDownload ? ( {activeDownload ? (
<> <TouchableOpacity
onPress={() => {
router.push(`/downloads`);
}}
>
{activeDownload.state === "PENDING" && ( {activeDownload.state === "PENDING" && (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
)} )}
@@ -193,7 +204,7 @@ export const NativeDownloadButton: React.FC<NativeDownloadButton> = ({
{activeDownload.state === "DONE" && ( {activeDownload.state === "DONE" && (
<Ionicons name="cloud-done-outline" size={24} color={"white"} /> <Ionicons name="cloud-done-outline" size={24} color={"white"} />
)} )}
</> </TouchableOpacity>
) : ( ) : (
<Ionicons name="cloud-download-outline" size={24} color="white" /> <Ionicons name="cloud-download-outline" size={24} color="white" />
)} )}

View File

@@ -28,7 +28,6 @@ function downloadHLSAsset(
/** /**
* Checks for existing downloads. * Checks for existing downloads.
* Returns an array of downloads with additional fields: * Returns an array of downloads with additional fields:
* id, progress, bytesDownloaded, bytesTotal, and state.
*/ */
async function checkForExistingDownloads(): Promise<DownloadInfo[]> { async function checkForExistingDownloads(): Promise<DownloadInfo[]> {
return HlsDownloaderModule.checkForExistingDownloads(); return HlsDownloaderModule.checkForExistingDownloads();
@@ -99,91 +98,9 @@ function useDownloadError(): string | null {
return error; 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<string> {
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<string | null>(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 { export {
downloadHLSAsset, downloadHLSAsset,
checkForExistingDownloads, checkForExistingDownloads,
useDownloadComplete,
useDownloadError, useDownloadError,
useDownloadProgress, useDownloadProgress,
addCompleteListener, addCompleteListener,

View File

@@ -38,7 +38,7 @@ public class HlsDownloaderModule: Module {
url: assetURL, url: assetURL,
options: [ options: [
"AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL", "AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL",
"AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "YourAppNameHere/1.0"], "AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "Streamyfin/1.0"],
"AVURLAssetAllowsCellularAccessKey": true, "AVURLAssetAllowsCellularAccessKey": true,
]) ])
@@ -134,8 +134,8 @@ public class HlsDownloaderModule: Module {
downloads.append([ downloads.append([
"id": delegate.providedId.isEmpty ? String(id) : delegate.providedId, "id": delegate.providedId.isEmpty ? String(id) : delegate.providedId,
"progress": progress, "progress": progress,
"bytesDownloaded": downloaded, "secondsDownloaded": downloaded,
"bytesTotal": total, "secondsTotal": total,
"state": self.mappedState(for: task), "state": self.mappedState(for: task),
"metadata": metadata, "metadata": metadata,
"startTime": startTime, "startTime": startTime,
@@ -243,8 +243,8 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
[ [
"id": providedId, "id": providedId,
"progress": progress, "progress": progress,
"bytesDownloaded": downloaded, "secondsDownloaded": downloaded,
"bytesTotal": total, "secondsTotal": total,
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING", "state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
"metadata": metadata, "metadata": metadata,
"startTime": startTime, "startTime": startTime,
@@ -263,6 +263,26 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
let newLocation = try module.persistDownloadedFolder( let newLocation = try module.persistDownloadedFolder(
originalLocation: location, folderName: folderName) 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 { if !metadata.isEmpty {
let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent( let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent(
"\(providedId).json") "\(providedId).json")
@@ -278,6 +298,7 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
"state": "DONE", "state": "DONE",
"metadata": metadata, "metadata": metadata,
"startTime": startTime, "startTime": startTime,
"bytesDownloaded": totalSize,
]) ])
} catch { } catch {
module?.sendEvent( module?.sendEvent(

View File

@@ -21,13 +21,13 @@ export type BaseEventPayload = {
id: string; id: string;
state: DownloadState; state: DownloadState;
metadata: DownloadMetadata; metadata: DownloadMetadata;
startTime?: number;
}; };
export type OnProgressEventPayload = BaseEventPayload & { export type OnProgressEventPayload = BaseEventPayload & {
progress: number; progress: number;
bytesDownloaded: number; secondsDownloaded: number;
bytesTotal: number; secondsTotal: number;
startTime?: number;
}; };
export type OnErrorEventPayload = BaseEventPayload & { export type OnErrorEventPayload = BaseEventPayload & {
@@ -38,6 +38,7 @@ export type OnErrorEventPayload = BaseEventPayload & {
export type OnCompleteEventPayload = BaseEventPayload & { export type OnCompleteEventPayload = BaseEventPayload & {
location: string; location: string;
bytesDownloaded?: number;
}; };
export type HlsDownloaderModuleEvents = { export type HlsDownloaderModuleEvents = {
@@ -52,8 +53,8 @@ export interface DownloadInfo {
startTime?: number; startTime?: number;
progress: number; progress: number;
state: DownloadState; state: DownloadState;
bytesDownloaded?: number; secondsDownloaded?: number;
bytesTotal?: number; secondsTotal?: number;
location?: string; location?: string;
error?: string; error?: string;
metadata: DownloadMetadata; metadata: DownloadMetadata;

View File

@@ -136,6 +136,7 @@ export const NativeDownloadProvider: React.FC<{
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchOnMount: true, refetchOnMount: true,
refetchOnReconnect: true, refetchOnReconnect: true,
staleTime: 0,
}); });
useEffect(() => { useEffect(() => {
@@ -150,8 +151,8 @@ export const NativeDownloadProvider: React.FC<{
id: download.id, id: download.id,
progress: download.progress, progress: download.progress,
state: download.state, state: download.state,
bytesDownloaded: download.bytesDownloaded, secondsDownloaded: download.secondsDownloaded,
bytesTotal: download.bytesTotal, secondsTotal: download.secondsTotal,
metadata: download.metadata, metadata: download.metadata,
startTime: download?.startTime, startTime: download?.startTime,
}, },
@@ -165,23 +166,25 @@ export const NativeDownloadProvider: React.FC<{
initializeDownloads(); initializeDownloads();
const progressListener = addProgressListener((download) => { const progressListener = addProgressListener((download) => {
console.log("Attempting to add progress listener");
if (!download.metadata) throw new Error("No metadata found in download"); if (!download.metadata) throw new Error("No metadata found in download");
console.log( console.log(
"[HLS] Download progress:", "[HLS] Download progress:",
download.bytesTotal, download.secondsTotal,
download.bytesDownloaded, download.secondsDownloaded,
download.progress, download.progress,
download.state download.state
); );
setDownloads((prev) => ({ setDownloads((prev) => ({
...prev, ...prev,
[download.id]: { [download.id]: {
id: download.id, id: download.id,
progress: download.progress, progress: download.progress,
state: download.state, state: download.state,
bytesDownloaded: download.bytesDownloaded, secondsDownloaded: download.secondsDownloaded,
bytesTotal: download.bytesTotal, secondsTotal: download.secondsTotal,
metadata: download.metadata, metadata: download.metadata,
startTime: download?.startTime, startTime: download?.startTime,
}, },
@@ -189,8 +192,6 @@ export const NativeDownloadProvider: React.FC<{
}); });
const completeListener = addCompleteListener(async (payload) => { const completeListener = addCompleteListener(async (payload) => {
if (!payload.id) throw new Error("No id found in payload");
try { try {
await rewriteM3U8Files(payload.location); await rewriteM3U8Files(payload.location);
await markFileAsDone(payload.id); await markFileAsDone(payload.id);
@@ -205,21 +206,18 @@ export const NativeDownloadProvider: React.FC<{
toast.success("Download complete ✅"); toast.success("Download complete ✅");
} catch (error) { } catch (error) {
console.error("Failed to persist file:", error); console.error("Failed to download file:", error);
toast.error("Failed to download ❌"); toast.error("Failed to download ❌");
} }
}); });
const errorListener = addErrorListener((error) => { const errorListener = addErrorListener((error) => {
console.error("Download error:", error); setDownloads((prev) => {
if (error.id) { const newDownloads = { ...prev };
setDownloads((prev) => { delete newDownloads[error.id];
const newDownloads = { ...prev }; return newDownloads;
delete newDownloads[error.id]; });
return newDownloads; toast.error("Failed to download ❌");
});
toast.error("Failed to download ❌");
}
}); });
return () => { return () => {
@@ -249,10 +247,10 @@ export const NativeDownloadProvider: React.FC<{
console.log("Found unparsed download:", id); console.log("Found unparsed download:", id);
const p = async () => { const p = async () => {
await markFileAsDone(id); await rewriteM3U8Files(
rewriteM3U8Files(
FileSystem.documentDirectory + "downloads/" + id FileSystem.documentDirectory + "downloads/" + id
); );
await markFileAsDone(id);
}; };
toast.promise(p(), { toast.promise(p(), {
error: () => "Failed to download ❌", error: () => "Failed to download ❌",

View File

@@ -38,7 +38,5 @@ export async function parseBootXML(xml: string): Promise<Boot> {
parseAttributeValue: true, parseAttributeValue: true,
}); });
const jsonObj = parser.parse(xml); const jsonObj = parser.parse(xml);
const b = jsonObj.HLSMoviePackage as Boot;
console.log(b.Streams);
return jsonObj.HLSMoviePackage as Boot; return jsonObj.HLSMoviePackage as Boot;
} }

View File

@@ -3,6 +3,7 @@ import { parseBootXML } from "./parse/boot";
import { parseStreamInfoXml, StreamInfo } from "./parse/streamInfoBoot"; import { parseStreamInfoXml, StreamInfo } from "./parse/streamInfoBoot";
export async function rewriteM3U8Files(baseDir: string): Promise<void> { export async function rewriteM3U8Files(baseDir: string): Promise<void> {
console.log(`[1] Rewriting M3U8 files in ${baseDir}`);
const bootData = await loadBootData(baseDir); const bootData = await loadBootData(baseDir);
if (!bootData) return; if (!bootData) return;
@@ -14,6 +15,7 @@ export async function rewriteM3U8Files(baseDir: string): Promise<void> {
} }
async function loadBootData(baseDir: string): Promise<any | null> { async function loadBootData(baseDir: string): Promise<any | null> {
console.log(`[2] Loading boot.xml from ${baseDir}`);
const bootPath = `${baseDir}/boot.xml`; const bootPath = `${baseDir}/boot.xml`;
try { try {
const bootInfo = await FileSystem.getInfoAsync(bootPath); const bootInfo = await FileSystem.getInfoAsync(bootPath);
@@ -31,15 +33,19 @@ async function processAllStreams(
baseDir: string, baseDir: string,
bootData: any bootData: any
): Promise<string[]> { ): Promise<string[]> {
console.log(`[3] Processing all streams in ${baseDir}`);
const localPaths: string[] = []; 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}`; const streamDir = `${baseDir}/${stream.ID}`;
try { try {
const streamInfo = await processStream(streamDir); const streamInfo = await processStream(streamDir);
if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) { if (streamInfo && streamInfo.MediaPlaylist.PathToLocalCopy) {
localPaths.push( localPaths.push(
`${streamDir}${streamInfo.MediaPlaylist.PathToLocalCopy}` `${streamDir}/${streamInfo.MediaPlaylist.PathToLocalCopy}`
); );
} }
} catch (error) { } catch (error) {
@@ -84,7 +90,9 @@ export function updatePlaylistWithLocalSegments(
export async function processStream( export async function processStream(
streamDir: string streamDir: string
): Promise<StreamInfo | null> { ): Promise<StreamInfo | null> {
console.log(`[4] Processing stream at ${streamDir}`);
const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`; const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`;
console.log(`Processing stream at ${streamDir}...`);
try { try {
const streamXML = await FileSystem.readAsStringAsync(streamInfoPath); const streamXML = await FileSystem.readAsStringAsync(streamInfoPath);