diff --git a/app.json b/app.json index 19a35516..dc80d99b 100644 --- a/app.json +++ b/app.json @@ -48,7 +48,6 @@ "@react-native-tvos/config-tv", "expo-router", "expo-font", - "@config-plugins/ffmpeg-kit-react-native", [ "react-native-video", { diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index be5d49f6..a4b642af 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -152,18 +152,7 @@ export const DownloadItems: React.FC = ({ } closeModal(); - if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded); - else { - queueActions.enqueue( - queue, - setQueue, - ...itemsNotDownloaded.map((item) => ({ - id: item.Id!, - execute: async () => await initiateDownload(item), - item, - })), - ); - } + initiateDownload(...itemsNotDownloaded); } else { toast.error( t("home.downloads.toasts.you_are_not_allowed_to_download_files"), @@ -216,6 +205,7 @@ export const DownloadItems: React.FC = ({ mediaSourceId: mediaSource?.Id, subtitleStreamIndex: subtitleIndex, deviceProfile: download, + forceStream: true, }); if (!res) { @@ -230,12 +220,8 @@ export const DownloadItems: React.FC = ({ if (!url || !source) throw new Error("No url"); - if (usingOptimizedServer) { - saveDownloadItemInfoToDiskTmp(item, source, url); - await startBackgroundDownload(url, item, source); - } else { - //await startRemuxing(item, url, source); - } + saveDownloadItemInfoToDiskTmp(item, source, url); + await startBackgroundDownload(url, item, source); } }, [ diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts deleted file mode 100644 index 2788bd37..00000000 --- a/hooks/useRemuxHlsToMp4.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { useDownload } from "@/providers/DownloadProvider"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getItemImage } from "@/utils/getItemImage"; -import { writeErrorLog, writeInfoLog } from "@/utils/log"; -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import { useQueryClient } from "@tanstack/react-query"; -import * as FileSystem from "expo-file-system"; -import { useRouter } from "expo-router"; - -// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native"; -const FFMPEGKitReactNative = !Platform.isTV - ? require("ffmpeg-kit-react-native") - : null; -import { useSettings } from "@/utils/atoms/settings"; -import useDownloadHelper from "@/utils/download"; -import type { JobStatus } from "@/utils/optimize-server"; -import type { Api } from "@jellyfin/sdk"; -import { useAtomValue } from "jotai"; -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { Platform } from "react-native"; -import { toast } from "sonner-native"; -import useImageStorage from "./useImageStorage"; - -type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession; -type Statistics = typeof FFMPEGKitReactNative.Statistics; -const FFmpegKit = Platform.isTV - ? null - : (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit); -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. - * - * @param url - The URL of the HLS stream - * @param item - The BaseItemDto object representing the media item - * @returns An object with remuxing-related functions - */ -export const useRemuxHlsToMp4 = () => { - const api = useAtomValue(apiAtom); - const router = useRouter(); - const queryClient = useQueryClient(); - const { t } = useTranslation(); - - const [settings] = useSettings(); - const { saveImage } = useImageStorage(); - const { saveSeriesPrimaryImage } = useDownloadHelper(); - const { - saveDownloadedItemInfo, - setProcesses, - processes, - APP_CACHE_DOWNLOAD_DIRECTORY, - } = 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 { - console.log("completeCallback"); - const returnCode = await session.getReturnCode(); - - if (returnCode.isValueSuccess()) { - const stat = await session.getLastReceivedStatistics(); - await FileSystem.moveAsync({ - from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`, - to: `${FileSystem.documentDirectory}${item.Id}.mp4`, - }); - await queryClient.invalidateQueries({ - queryKey: ["downloadedItems"], - }); - saveDownloadedItemInfo(item, stat.getSize()); - toast.success(t("home.downloads.toasts.download_completed")); - } - - setProcesses((prev: any[]) => { - return prev.filter( - (process: { itemId: string | undefined }) => - process.itemId !== item.Id, - ); - }); - } catch (e) { - console.error(e); - } - - console.log("completeCallback ~ end"); - }, - [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: JobStatus[]) => { - return prev.map((process: JobStatus) => { - 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) => { - const cacheDir = await FileSystem.getInfoAsync( - APP_CACHE_DOWNLOAD_DIRECTORY, - ); - if (!cacheDir.exists) { - await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { - intermediates: true, - }); - } - - const output = `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`; - - if (!api) throw new Error("API is not defined"); - if (!item.Id) throw new Error("Item must have an Id"); - - // First lets save any important assets we want to present to the user offline - await onSaveAssets(api, item); - - toast.success( - t("home.downloads.toasts.download_started_for", { item: item.Name }), - { - action: { - label: "Go to download", - onClick: () => { - router.push("/downloads"); - toast.dismiss(); - }, - }, - }, - ); - - try { - const job: JobStatus = { - id: "", - deviceId: "", - inputUrl: url, - item: item, - itemId: item.Id!, - outputPath: output, - progress: 0, - status: "downloading", - timestamp: new Date(), - }; - - writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`); - setProcesses((prev: any) => [...prev, job]); - - await FFmpegKit.executeAsync( - createFFmpegCommand(url, output).join(" "), - (session: any) => completeCallback(session, item), - undefined, - (s: any) => statisticsCallback(s, item), - ); - } catch (e) { - const error = e as Error; - console.error("Failed to remux:", error); - writeErrorLog( - `useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, - Error: ${error.message}, Stack: ${error.stack}`, - ); - setProcesses((prev: any[]) => { - return prev.filter( - (process: { itemId: string | undefined }) => - process.itemId !== item.Id, - ); - }); - throw error; // Re-throw the error to propagate it to the caller - } - }, - [settings, processes, setProcesses, completeCallback, statisticsCallback], - ); - - const cancelRemuxing = useCallback(() => { - FFmpegKit.cancel(); - setProcesses([]); - }, []); - - return { startRemuxing, cancelRemuxing }; -}; diff --git a/package.json b/package.json index f8f250fd..6f148f5d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ }, "dependencies": { "@bottom-tabs/react-navigation": "0.8.6", - "@config-plugins/ffmpeg-kit-react-native": "^9.0.0", "@expo/config-plugins": "~9.0.15", "@expo/react-native-action-sheet": "^4.1.0", "@expo/vector-icons": "^14.0.4", diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 8bc0488a..44f2b4c9 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -74,6 +74,11 @@ function useDownloadProvider() { return api?.accessToken; }, [api]); + const usingOptimizedServer = useMemo( + () => settings?.downloadMethod === DownloadMethod.Optimized, + [settings], + ); + const { data: downloadedFiles, refetch } = useQuery({ queryKey: ["downloadedItems"], queryFn: getAllDownloadedItems, @@ -127,6 +132,7 @@ function useDownloadProvider() { job.status === "completed" ) { if (settings.autoDownload) { + job.inputUrl = `${settings?.optimizedVersionsServerUrl}download/${process.id}`; startDownload(job); } else { toast.info( @@ -176,18 +182,25 @@ function useDownloadProvider() { const removeProcess = useCallback( async (id: string) => { const deviceId = await getOrSetDeviceId(); - if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl) - return; + if (!deviceId || !authHeader) return; - try { - await cancelJobById({ - authHeader, - id, - url: settings?.optimizedVersionsServerUrl, - }); - } catch (error) { - console.error(error); + if (usingOptimizedServer) { + try { + await cancelJobById({ + authHeader, + id, + url: settings?.optimizedVersionsServerUrl, + }); + } catch (error) { + console.error(error); + } } + + setProcesses((prev: any[]) => { + return prev.filter( + (process: { itemId: string | undefined }) => process.id !== id, + ); + }); }, [settings?.optimizedVersionsServerUrl, authHeader], ); @@ -238,7 +251,7 @@ function useDownloadProvider() { BackGroundDownloader?.download({ id: process.id, - url: `${settings?.optimizedVersionsServerUrl}download/${process.id}`, + url: process.inputUrl, destination: `${baseDirectory}/${process.item.Id}.mp4`, }) .begin(() => { @@ -345,26 +358,40 @@ function useDownloadProvider() { width: 500, }); await saveImage(item.Id, itemImage?.uri); - - const response = await axios.post( - `${settings?.optimizedVersionsServerUrl}optimize-version`, - { - url, - fileExtension, - deviceId, - itemId: item.Id, - item, - }, - { - headers: { - "Content-Type": "application/json", - Authorization: authHeader, + if (usingOptimizedServer) { + const response = await axios.post( + `${settings?.optimizedVersionsServerUrl}optimize-version`, + { + url, + fileExtension, + deviceId, + itemId: item.Id, + item, }, - }, - ); + { + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + }, + ); - if (response.status !== 201) { - throw new Error("Failed to start optimization job"); + if (response.status !== 201) { + throw new Error("Failed to start optimization job"); + } + } else { + const job: JobStatus = { + id: item.Id!, + deviceId: deviceId, + inputUrl: url, + item: item, + itemId: item.Id!, + progress: 0, + status: "downloading", + timestamp: new Date(), + }; + setProcesses([...processes, job]); + startDownload(job); } toast.success( diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index d4616387..3e3aa0e2 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -19,6 +19,7 @@ export const getStreamUrl = async ({ audioStreamIndex = 0, subtitleStreamIndex = undefined, mediaSourceId, + forceStream = false, }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; @@ -31,6 +32,7 @@ export const getStreamUrl = async ({ subtitleStreamIndex?: number; height?: number; mediaSourceId?: string | null; + forceStream: bool; }): Promise<{ url: string | null; sessionId: string | null; @@ -70,9 +72,12 @@ export const getStreamUrl = async ({ sessionId = res.data.PlaySessionId || null; mediaSource = res.data.MediaSources[0]; - const transcodeUrl = mediaSource.TranscodingUrl; + let transcodeUrl = mediaSource.TranscodingUrl; if (transcodeUrl) { + if (forceStream) { + transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); + } console.log("Video is being transcoded:", transcodeUrl); return { url: `${api.basePath}${transcodeUrl}`,