diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 9a418357..556ae8c7 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -40,7 +40,7 @@ export const ActiveDownloads: React.FC = ({ ...props }) => { Active downloads {processes?.map((p) => ( - + ))} @@ -77,7 +77,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { await queryClient.refetchQueries({ queryKey: ["jobs"] }); } } else { - FFmpegKit.cancel(); + FFmpegKit.cancel(Number(id)); setProcesses((prev) => prev.filter((p) => p.id !== id)); } }, diff --git a/components/inputs/Stepper.tsx b/components/inputs/Stepper.tsx new file mode 100644 index 00000000..eb5032cf --- /dev/null +++ b/components/inputs/Stepper.tsx @@ -0,0 +1,44 @@ +import {TouchableOpacity, View} from "react-native"; +import {Text} from "@/components/common/Text"; + +interface StepperProps { + value: number, + step: number, + min: number, + max: number, + onUpdate: (value: number) => void, + appendValue?: string, +} + +export const Stepper: React.FC = ({ + value, + step, + min, + max, + onUpdate, + appendValue +}) => { + return ( + + onUpdate(Math.max(min, value - step))} + className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center" + > + - + + + {value}{appendValue} + + onUpdate(Math.min(max, value + step))} + > + + + + + ) +} \ No newline at end of file diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index a2229c4f..0390e14d 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -4,7 +4,7 @@ import { getOrSetDeviceId, userAtom, } from "@/providers/JellyfinProvider"; -import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; +import {ScreenOrientationEnum, Settings, useSettings} from "@/utils/atoms/settings"; import { BACKGROUND_FETCH_TASK, registerBackgroundFetchAsync, @@ -17,7 +17,7 @@ import * as BackgroundFetch from "expo-background-fetch"; import * as ScreenOrientation from "expo-screen-orientation"; import * as TaskManager from "expo-task-manager"; import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; +import {useEffect, useState} from "react"; import { Linking, Switch, @@ -32,6 +32,7 @@ import { Input } from "../common/Input"; import { Text } from "../common/Text"; import { Loader } from "../Loader"; import { MediaToggles } from "./MediaToggles"; +import {Stepper} from "@/components/inputs/Stepper"; interface Props extends ViewProps {} @@ -483,7 +484,44 @@ export const SettingToggles: React.FC = ({ ...props }) => { - + + + Remux max download + + This is the total media you want to be able to download at the same time. + + + updateSettings({remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"]})} + /> + + Auto download diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts index dfbec4da..b722c3e6 100644 --- a/hooks/useRemuxHlsToMp4.ts +++ b/hooks/useRemuxHlsToMp4.ts @@ -1,7 +1,7 @@ import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getItemImage } from "@/utils/getItemImage"; -import { writeToLog } from "@/utils/log"; +import {writeErrorLog, writeInfoLog, writeToLog} from "@/utils/log"; import { BaseItemDto, MediaSourceInfo, @@ -9,12 +9,34 @@ import { import { useQueryClient } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; -import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native"; -import { useAtomValue } from "jotai"; -import { useCallback } from "react"; +import {FFmpegKit, FFmpegSession, Statistics} from "ffmpeg-kit-react-native"; +import {useAtomValue} from "jotai"; +import {useCallback} from "react"; import { toast } from "sonner-native"; import useImageStorage from "./useImageStorage"; import useDownloadHelper from "@/utils/download"; +import {Api} from "@jellyfin/sdk"; +import {useSettings} from "@/utils/atoms/settings"; +import {JobStatus} from "@/utils/optimize-server"; + +const createFFmpegCommand = (url: string, output: string) => [ + "-y", // overwrite output files without asking + "-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options + + // region ffmpeg protocol commands // https://ffmpeg.org/ffmpeg-protocols.html + "-protocol_whitelist file,http,https,tcp,tls,crypto", // whitelist + "-multiple_requests 1", // http + "-tcp_nodelay 1", // http + // endregion ffmpeg protocol commands + + "-fflags +genpts", // format flags + `-i ${url}`, // infile + "-map 0:v -map 0:a", // select all streams for video & audio + "-c copy", // streamcopy, preventing transcoding + "-bufsize 25M", // amount of data processed before calculating current bitrate + "-max_muxing_queue_size 4096", // sets the size of stream buffer in packets for output + output +] /** * Custom hook for remuxing HLS to MP4 using FFmpeg. @@ -25,11 +47,105 @@ import useDownloadHelper from "@/utils/download"; */ export const useRemuxHlsToMp4 = () => { const api = useAtomValue(apiAtom); - const queryClient = useQueryClient(); - const { saveDownloadedItemInfo, setProcesses } = useDownload(); const router = useRouter(); - const { saveImage } = useImageStorage(); - const { saveSeriesPrimaryImage } = useDownloadHelper(); + const queryClient = useQueryClient(); + + const [settings] = useSettings(); + const {saveImage} = useImageStorage(); + const {saveSeriesPrimaryImage} = useDownloadHelper(); + const {saveDownloadedItemInfo, setProcesses, processes} = useDownload(); + + const onSaveAssets = async (api: Api, item: BaseItemDto) => { + await saveSeriesPrimaryImage(item); + const itemImage = getItemImage({ + item, + api, + variant: "Primary", + quality: 90, + width: 500, + }); + + await saveImage(item.Id, itemImage?.uri); + } + + const completeCallback = useCallback(async (session: FFmpegSession, item: BaseItemDto) => { + try { + let endTime; + const returnCode = await session.getReturnCode(); + const startTime = new Date(); + + if (returnCode.isValueSuccess()) { + endTime = new Date(); + const stat = await session.getLastReceivedStatistics(); + await queryClient.invalidateQueries({queryKey: ["downloadedItems"]}); + + saveDownloadedItemInfo(item, stat.getSize()); + writeInfoLog( + `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}, + start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, + duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s` + .replace(/^ +/g, '') + ) + toast.success("Download completed"); + } else if (returnCode.isValueError()) { + endTime = new Date(); + const allLogs = session.getAllLogsAsString(); + writeErrorLog( + `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, + start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, + duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s. All logs: ${allLogs}` + .replace(/^ +/g, '') + ) + } else if (returnCode.isValueCancel()) { + endTime = new Date(); + writeInfoLog( + `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}, + start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, + duration: ${(endTime.getTime() - startTime.getTime()) / 1000}s` + .replace(/^ +/g, '') + ) + } + + setProcesses((prev) => { + return prev.filter((process) => process.itemId !== item.Id); + }); + } catch (e) { + const error = e as Error; + writeErrorLog( + `useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, + Error: ${error.message}, Stack: ${error.stack}` + .replace(/^ +/g, '') + ); + } + }, [processes, setProcesses]); + + const statisticsCallback = useCallback((statistics: Statistics, item: BaseItemDto) => { + const videoLength = (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds + const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; + const totalFrames = videoLength * fps; + const processedFrames = statistics.getVideoFrameNumber(); + const speed = statistics.getSpeed(); + + const percentage = + totalFrames > 0 + ? Math.floor((processedFrames / totalFrames) * 100) + : 0; + + if (!item.Id) throw new Error("Item is undefined"); + setProcesses((prev) => { + return prev.map((process) => { + if (process.itemId === item.Id) { + return { + ...process, + id: statistics.getSessionId().toString(), + progress: percentage, + speed: Math.max(speed, 0), + }; + } + return process; + }); + }); + }, [setProcesses, completeCallback]); const startRemuxing = useCallback( async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { @@ -37,16 +153,8 @@ export const useRemuxHlsToMp4 = () => { if (!api) throw new Error("API is not defined"); if (!item.Id) throw new Error("Item must have an Id"); - await saveSeriesPrimaryImage(item); - const itemImage = getItemImage({ - item, - api, - variant: "Primary", - quality: 90, - width: 500, - }); - - await saveImage(item.Id, itemImage?.uri); + // First lets save any important assets we want to present to the user offline + await onSaveAssets(api, item); toast.success(`Download started for ${item.Name}`, { action: { @@ -58,129 +166,34 @@ export const useRemuxHlsToMp4 = () => { }, }); - const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`; - - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}` - ); - try { - setProcesses((prev) => [ - ...prev, - { - id: "", - deviceId: "", - inputUrl: "", - item: item, - itemId: item.Id!, - outputPath: "", - progress: 0, - status: "downloading", - timestamp: new Date(), - }, - ]); + const job: JobStatus = { + id: "", + deviceId: "", + inputUrl: url, + item: item, + itemId: item.Id!, + outputPath: output, + progress: 0, + status: "downloading", + timestamp: new Date(), + } - FFmpegKitConfig.enableStatisticsCallback((statistics) => { - const videoLength = - (item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds - const fps = item.MediaStreams?.[0]?.RealFrameRate || 25; - const totalFrames = videoLength * fps; - const processedFrames = statistics.getVideoFrameNumber(); - const speed = statistics.getSpeed(); + writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); + setProcesses((prev) => [...prev, job]); - const percentage = - totalFrames > 0 - ? Math.floor((processedFrames / totalFrames) * 100) - : 0; - - if (!item.Id) throw new Error("Item is undefined"); - setProcesses((prev) => { - return prev.map((process) => { - if (process.itemId === item.Id) { - return { - ...process, - progress: percentage, - speed: Math.max(speed, 0), - }; - } - return process; - }); - }); - }); - - // Await the execution of the FFmpeg command and ensure that the callback is awaited properly. - await new Promise((resolve, reject) => { - FFmpegKit.executeAsync(command, async (session) => { - try { - const returnCode = await session.getReturnCode(); - const startTime = new Date(); - - let endTime; - if (returnCode.isValueSuccess()) { - endTime = new Date(); - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${ - item.Name - }, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${ - (endTime.getTime() - startTime.getTime()) / 1000 - }s` - ); - if (!item) throw new Error("Item is undefined"); - const stat = await session.getLastReceivedStatistics(); - await saveDownloadedItemInfo(item, stat.getSize()); - toast.success("Download completed"); - await queryClient.invalidateQueries({ - queryKey: ["downloadedItems"], - }); - resolve(); - } else if (returnCode.isValueError()) { - endTime = new Date(); - const allLogs = session.getAllLogsAsString(); - writeToLog( - "ERROR", - `useRemuxHlsToMp4 ~ remuxing failed for item: ${ - item.Name - }, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${ - (endTime.getTime() - startTime.getTime()) / 1000 - }s. All logs: ${allLogs}` - ); - reject(new Error("Remuxing failed")); - } else if (returnCode.isValueCancel()) { - endTime = new Date(); - writeToLog( - "INFO", - `useRemuxHlsToMp4 ~ remuxing was canceled for item: ${ - item.Name - }, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${ - (endTime.getTime() - startTime.getTime()) / 1000 - }s` - ); - resolve(); - } - - setProcesses((prev) => { - return prev.filter((process) => process.itemId !== item.Id); - }); - } catch (e) { - const error = e as Error; - const errorLog = `Error: ${error.message}, Stack: ${error.stack}`; - writeToLog( - "ERROR", - `useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}` - ); - reject(error); - } - }); - }); + await FFmpegKit.executeAsync( + createFFmpegCommand(url, output).join(" "), + session => completeCallback(session, item), + undefined, + s => statisticsCallback(s, item) + ) } catch (e) { const error = e as Error; - const errorLog = `Error: ${error.message}, Stack: ${error.stack}`; console.error("Failed to remux:", error); - writeToLog( - "ERROR", - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}` + writeErrorLog( + `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, + Error: ${error.message}, Stack: ${error.stack}` ); setProcesses((prev) => { return prev.filter((process) => process.itemId !== item.Id); @@ -188,7 +201,7 @@ export const useRemuxHlsToMp4 = () => { throw error; // Re-throw the error to propagate it to the caller } }, - [] + [settings, processes, setProcesses, completeCallback, statisticsCallback] ); const cancelRemuxing = useCallback(() => { diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 56072c07..48f7a536 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -30,7 +30,7 @@ import { import axios from "axios"; import * as FileSystem from "expo-file-system"; import { useRouter } from "expo-router"; -import { useAtom } from "jotai"; +import {atom, useAtom} from "jotai"; import React, { createContext, useCallback, @@ -56,6 +56,8 @@ export type DownloadedItem = { size: number | undefined; }; +export const processesAtom = atom([]) + function onAppStateChange(status: AppStateStatus) { focusManager.setFocused(status === "active"); } @@ -74,7 +76,7 @@ function useDownloadProvider() { const {saveSeriesPrimaryImage} = useDownloadHelper(); const { saveImage } = useImageStorage(); - const [processes, setProcesses] = useState([]); + const [processes, setProcesses] = useAtom(processesAtom); const authHeader = useMemo(() => { return api?.accessToken; diff --git a/utils/atoms/queue.ts b/utils/atoms/queue.ts index 8ec21e65..a6f967bb 100644 --- a/utils/atoms/queue.ts +++ b/utils/atoms/queue.ts @@ -1,6 +1,9 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { atom, useAtom } from "jotai"; import { useEffect } from "react"; +import {JobStatus} from "@/utils/optimize-server"; +import {processesAtom} from "@/providers/DownloadProvider"; +import {useSettings} from "@/utils/atoms/settings"; export interface Job { id: string; @@ -49,11 +52,13 @@ export const queueActions = { export const useJobProcessor = () => { const [queue, setQueue] = useAtom(queueAtom); const [running, setRunning] = useAtom(runningAtom); + const [processes] = useAtom(processesAtom); + const [settings] = useSettings(); useEffect(() => { - if (queue.length > 0 && !running) { + if (queue.length > 0 && settings && processes.length < settings?.remuxConcurrentLimit) { console.info("Processing queue", queue); queueActions.processJob(queue, setQueue, setRunning); } - }, [queue, running, setQueue, setRunning]); + }, [processes, queue, running, setQueue, setRunning]); }; diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index bb1f26ab..5b97ed06 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -75,6 +75,7 @@ export type Settings = { downloadMethod: "optimized" | "remux"; autoDownload: boolean, showCustomMenuLinks: boolean; + remuxConcurrentLimit: 1 | 2 | 3 | 4; // TODO: Maybe let people choose their own limit? 4 seems like a safe max? }; const loadSettings = (): Settings => { @@ -105,6 +106,7 @@ const loadSettings = (): Settings => { downloadMethod: "remux", autoDownload: false, showCustomMenuLinks: false, + remuxConcurrentLimit: 1, }; try { diff --git a/utils/log.tsx b/utils/log.tsx index 5658ec3d..7c432406 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -55,6 +55,9 @@ export const writeToLog = (level: LogLevel, message: string, data?: any) => { storage.set("logs", JSON.stringify(recentLogs)); }; +export const writeInfoLog = (message: string, data?: any) => writeToLog("INFO", message, data); +export const writeErrorLog = (message: string, data?: any) => writeToLog("ERROR", message, data); + export const readFromLog = (): LogEntry[] => { const logs = storage.getString("logs"); return logs ? JSON.parse(logs) : [];