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..d7939684 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,4 +1,3 @@ -//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueActions, queueAtom } from "@/utils/atoms/queue"; @@ -152,18 +151,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"), @@ -203,7 +191,6 @@ export const DownloadItems: React.FC = ({ mediaSource = defaults.mediaSource; audioIndex = defaults.audioIndex; subtitleIndex = defaults.subtitleIndex; - // Keep using the selected bitrate for consistency across all downloads } const res = await getStreamUrl({ @@ -216,6 +203,8 @@ export const DownloadItems: React.FC = ({ mediaSourceId: mediaSource?.Id, subtitleStreamIndex: subtitleIndex, deviceProfile: download, + download: true, + // deviceId: mediaSource?.Id, }); if (!res) { @@ -230,12 +219,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, maxBitrate); } }, [ @@ -249,7 +234,6 @@ export const DownloadItems: React.FC = ({ maxBitrate, usingOptimizedServer, startBackgroundDownload, - //startRemuxing, ], ); 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..82133307 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -1,5 +1,6 @@ import { useHaptic } from "@/hooks/useHaptic"; import useImageStorage from "@/hooks/useImageStorage"; +import { useInterval } from "@/hooks/useInterval"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import { getOrSetDeviceId } from "@/utils/device"; import useDownloadHelper from "@/utils/download"; @@ -18,6 +19,7 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; +import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader"; import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; @@ -38,6 +40,7 @@ import { import { useTranslation } from "react-i18next"; import { AppState, type AppStateStatus, Platform } from "react-native"; import { toast } from "sonner-native"; +import { Bitrate } from "../components/BitrateSelector"; import { apiAtom } from "./JellyfinProvider"; export type DownloadedItem = { @@ -74,6 +77,17 @@ function useDownloadProvider() { return api?.accessToken; }, [api]); + const usingOptimizedServer = useMemo( + () => settings?.downloadMethod === DownloadMethod.Optimized, + [settings], + ); + + const getDownloadUrl = (process: JobStatus) => { + return usingOptimizedServer + ? `${settings.optimizedVersionsServerUrl}download/${process.id}` + : process.inputUrl; + }; + const { data: downloadedFiles, refetch } = useQuery({ queryKey: ["downloadedItems"], queryFn: getAllDownloadedItems, @@ -164,6 +178,59 @@ function useDownloadProvider() { enabled: settings?.downloadMethod === DownloadMethod.Optimized, }); + /// Cant use the background downloader callback. As its not triggered if size is unknown. + const updateProgress = async () => { + if (settings?.downloadMethod === DownloadMethod.Optimized) { + return; + } + // const response = await getSessionApi(api).getSessions({ + // activeWithinSeconds: 300, + // }); + + const tasks = await BackGroundDownloader.checkForExistingDownloads(); + + const updatedProcesses = processes.map((p) => { + // const result = response.data.find((s) => s.Id == p.sessionId); + // if (result) { + // return { + // ...p, + // progress: result.TranscodingInfo?.CompletionPercentage, + // }; + // } + + // fallback. Doesn't really work for transcodes as they may be a lot smaller. We make an wild guess + const task = tasks.find((s) => s.id === p.id); + if (task) { + let progress = p.progress; + let size = p.mediaSource.Size; + const maxBitrate = p.maxBitrate.value; + if (maxBitrate && maxBitrate < p.mediaSource.Bitrate) { + size = (size / p.mediaSource.Bitrate) * maxBitrate; + } + // console.log( + // p.mediaSource.Size, + // size, + // maxBitrate, + // p.mediaSource.Bitrate, + // ); + progress = (100 / size) * task.bytesDownloaded; + if (progress >= 100) { + progress = 99; + } + + return { + ...p, + progress, + }; + } + return p; + }); + + setProcesses(updatedProcesses); + }; + + useInterval(updateProgress, 3000); + useEffect(() => { const checkIfShouldStartDownload = async () => { if (processes.length === 0) return; @@ -176,18 +243,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 +312,7 @@ function useDownloadProvider() { BackGroundDownloader?.download({ id: process.id, - url: `${settings?.optimizedVersionsServerUrl}download/${process.id}`, + url: getDownloadUrl(process), destination: `${baseDirectory}/${process.item.Id}.mp4`, }) .begin(() => { @@ -256,6 +330,9 @@ function useDownloadProvider() { ); }) .progress((data) => { + if (!usingOptimizedServer) { + return; + } const percent = (data.bytesDownloaded / data.bytesTotal) * 100; setProcesses((prev) => prev.map((p) => @@ -328,7 +405,12 @@ function useDownloadProvider() { ); const startBackgroundDownload = useCallback( - async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => { + async ( + url: string, + item: BaseItemDto, + mediaSource: MediaSourceInfo, + maxBitrate?: Bitrate, + ) => { if (!api || !item.Id || !authHeader) throw new Error("startBackgroundDownload ~ Missing required params"); @@ -345,26 +427,42 @@ 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!, + mediaSource, + progress: 0, + maxBitrate, + status: "downloading", + timestamp: new Date(), + }; + setProcesses([...processes, job]); + startDownload(job); } toast.success( diff --git a/providers/DownloadProvider.tv.tsx b/providers/DownloadProvider.tv.tsx deleted file mode 100644 index 8d7e63bc..00000000 --- a/providers/DownloadProvider.tv.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { storage } from "@/utils/mmkv"; -import type { JobStatus } from "@/utils/optimize-server"; -import type { - BaseItemDto, - MediaSourceInfo, -} from "@jellyfin/sdk/lib/generated-client/models"; -import * as Application from "expo-application"; -import * as FileSystem from "expo-file-system"; -import { atom, useAtom } from "jotai"; -import type React from "react"; -import { createContext, useCallback, useContext, useMemo } from "react"; - -export type DownloadedItem = { - item: Partial; - mediaSource: MediaSourceInfo; -}; - -export const processesAtom = atom([]); - -const DownloadContext = createContext | null>(null); - -/** - * Dummy download provider for tvOS - */ -function useDownloadProvider() { - const [processes, setProcesses] = useAtom(processesAtom); - - const downloadedFiles: DownloadedItem[] = []; - - const removeProcess = useCallback(async (id: string) => {}, []); - - const startDownload = useCallback(async (process: JobStatus) => { - return null; - }, []); - - const startBackgroundDownload = useCallback( - async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => { - return null; - }, - [], - ); - - const deleteAllFiles = async (): Promise => {}; - - const deleteFile = async (id: string): Promise => {}; - - const deleteItems = async (items: BaseItemDto[]) => {}; - - const cleanCacheDirectory = async () => {}; - - const deleteFileByType = async (type: BaseItemDto["Type"]) => {}; - - const appSizeUsage = useMemo(async () => { - return 0; - }, []); - - function getDownloadedItem(itemId: string): DownloadedItem | null { - return null; - } - - function saveDownloadedItemInfo(item: BaseItemDto, size = 0) {} - - function getDownloadedItemSize(itemId: string): number { - const size = storage.getString(`downloadedItemSize-${itemId}`); - return size ? Number.parseInt(size) : 0; - } - - const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; - - return { - processes, - startBackgroundDownload, - downloadedFiles, - deleteAllFiles, - deleteFile, - deleteItems, - saveDownloadedItemInfo, - removeProcess, - setProcesses, - startDownload, - getDownloadedItem, - deleteFileByType, - appSizeUsage, - getDownloadedItemSize, - APP_CACHE_DOWNLOAD_DIRECTORY, - cleanCacheDirectory, - }; -} - -export function DownloadProvider({ children }: { children: React.ReactNode }) { - const downloadProviderValue = useDownloadProvider(); - - return ( - - {children} - - ); -} - -export function useDownload() { - const context = useContext(DownloadContext); - if (context === null) { - throw new Error("useDownload must be used within a DownloadProvider"); - } - return context; -} diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index d4616387..fda26024 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -14,23 +14,27 @@ export const getStreamUrl = async ({ userId, startTimeTicks = 0, maxStreamingBitrate, - sessionData, + playSessionId, deviceProfile = generateDeviceProfile(), audioStreamIndex = 0, subtitleStreamIndex = undefined, mediaSourceId, + download = false, + deviceId, }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; userId: string | null | undefined; startTimeTicks: number; maxStreamingBitrate?: number; - sessionData?: PlaybackInfoResponse | null; + playSessionId?: string | null; deviceProfile?: any; audioStreamIndex?: number; subtitleStreamIndex?: number; height?: number; mediaSourceId?: string | null; + download?: bool; + deviceId?: string | null; }): Promise<{ url: string | null; sessionId: string | null; @@ -70,9 +74,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 (download) { + transcodeUrl = transcodeUrl.replace("master.m3u8", "stream.mp4"); + } console.log("Video is being transcoded:", transcodeUrl); return { url: `${api.basePath}${transcodeUrl}`, @@ -81,28 +88,42 @@ export const getStreamUrl = async ({ }; } - const searchParams = new URLSearchParams({ - playSessionId: sessionData?.PlaySessionId || "", - mediaSourceId: mediaSource?.Id || "", + let downloadParams = {}; + + if (download) { + // We need to disable static so we can have a remux with subtitle. + downloadParams = { + subtitleMethod: "Embed", + enableSubtitlesInManifest: true, + static: "false", + allowVideoStreamCopy: true, + allowAudioStreamCopy: true, + playSessionId: sessionId || "", + }; + } + + const streamParams = new URLSearchParams({ static: "true", + mediaSourceId: mediaSource?.Id || "", subtitleStreamIndex: subtitleStreamIndex?.toString() || "", audioStreamIndex: audioStreamIndex?.toString() || "", - deviceId: api.deviceInfo.id, + deviceId: deviceId || api.deviceInfo.id, api_key: api.accessToken, startTimeTicks: startTimeTicks.toString(), maxStreamingBitrate: maxStreamingBitrate?.toString() || "", userId: userId || "", + ...downloadParams, }); const directPlayUrl = `${ api.basePath - }/Videos/${item.Id}/stream.mp4?${searchParams.toString()}`; + }/Videos/${item.Id}/stream.mp4?${streamParams.toString()}`; console.log("Video is being direct played:", directPlayUrl); return { url: directPlayUrl, - sessionId: sessionId, + sessionId: sessionId || playSessionId, mediaSource, }; };