diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 5c25f04c..374a014c 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -21,8 +21,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueAtom } from "@/utils/atoms/queue"; import { useSettings } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import download from "@/utils/profiles/download"; +import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl"; import { AudioTrackSelector } from "./AudioTrackSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { Button } from "./Button"; @@ -167,31 +166,29 @@ export const DownloadItems: React.FC = ({ audioIndex: selectedAudioStream, subtitleIndex: selectedSubtitleStream, }; - const res = await getStreamUrl({ + + const url = await getDownloadUrl({ api, item, - startTimeTicks: 0, - userId: user?.Id, - audioStreamIndex: audioIndex, - maxStreamingBitrate: maxBitrate.value, - mediaSourceId: mediaSource?.Id, - subtitleStreamIndex: subtitleIndex, - deviceProfile: download, - download: true, + userId: user.Id!, + mediaSource: mediaSource!, + audioStreamIndex: audioIndex ?? -1, + subtitleStreamIndex: subtitleIndex ?? -1, + maxBitrate, + deviceId: api.deviceInfo.id, }); - return { res, item }; + return { url, item, mediaSource }; }); const downloadDetails = await Promise.all(downloadDetailsPromises); - for (const { res, item } of downloadDetails) { - if (!res) { + for (const { url, item, mediaSource } of downloadDetails) { + if (!url) { Alert.alert( t("home.downloads.something_went_wrong"), t("home.downloads.could_not_get_stream_url_from_jellyfin"), ); continue; } - const { mediaSource: source, url } = res; - if (!url || !source) { + if (!mediaSource) { console.error(`Could not get download URL for ${item.Name}`); toast.error( t("Could not get download URL for {{itemName}}", { @@ -200,7 +197,7 @@ export const DownloadItems: React.FC = ({ ); continue; } - await startBackgroundDownload(url, item, source, maxBitrate); + await startBackgroundDownload(url, item, mediaSource, maxBitrate); } }, [ diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index c5355086..c922b0e8 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -70,13 +70,6 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const cancelJobMutation = useMutation({ mutationFn: async (id: string) => { if (!process) throw new Error("No active download"); - const tasks = await BackGroundDownloader.checkForExistingDownloads(); - - for (const task of tasks) { - if (task.id === id) { - await task.stop(); - } - } removeProcess(id); }, onSuccess: () => { diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index e84ab310..a9048534 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -575,7 +575,7 @@ export const Controls: FC = ({ className={"flex flex-row w-full pt-2"} > - {!Platform.isTV && !offline && ( + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( { ]; const router = useRouter(); - const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } = + const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = useLocalSearchParams<{ itemId: string; audioIndex: string; @@ -27,8 +27,11 @@ const DropdownView = () => { mediaSourceId: string; bitrateValue: string; playbackPosition: string; + offline: string; }>(); + const isOffline = offline === "true"; + const changeBitrate = useCallback( (bitrate: string) => { const queryParams = new URLSearchParams({ @@ -61,32 +64,34 @@ const DropdownView = () => { collisionPadding={8} sideOffset={8} > - - - Quality - - - {BITRATES?.map((bitrate, idx: number) => ( - - changeBitrate(bitrate.value?.toString() ?? "") - } - > - - {bitrate.key} - - - ))} - - + {!isOffline && ( + + + Quality + + + {BITRATES?.map((bitrate, idx: number) => ( + + changeBitrate(bitrate.value?.toString() ?? "") + } + > + + {bitrate.key} + + + ))} + + + )} Subtitle diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 6609ac83..a458154f 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -9,16 +9,15 @@ import * as FileSystem from "expo-file-system"; import * as Notifications from "expo-notifications"; import { router } from "expo-router"; import { atom, useAtom } from "jotai"; +import { throttle } from "lodash"; import React, { createContext, useCallback, useContext, useEffect, useMemo, - useState, } from "react"; import { useTranslation } from "react-i18next"; -import { AppState, type AppStateStatus } from "react-native"; import { toast } from "sonner-native"; import { useHaptic } from "@/hooks/useHaptic"; import useImageStorage from "@/hooks/useImageStorage"; @@ -40,7 +39,6 @@ import { } from "./Downloads/types"; import { apiAtom } from "./JellyfinProvider"; -// Helper to calculate estimated download size based on bitrate const calculateEstimatedSize = (p: JobStatus): number => { let size = p.mediaSource.Size; const maxBitrate = p.maxBitrate.value; @@ -91,6 +89,14 @@ function useDownloadProvider() { const [settings] = useSettings(); const successHapticFeedback = useHaptic("success"); + const removeProcess = useCallback(async (id: string) => { + const tasks = await BackGroundDownloader.checkForExistingDownloads(); + const task = tasks?.find((t) => t.id === id); + task?.stop(); + BackGroundDownloader.completeHandler(id); + setProcesses((prev) => prev.filter((process) => process.id !== id)); + }, [setProcesses]); + /// Cant use the background downloader callback. As its not triggered if size is unknown. const updateProgress = async () => { const tasks = await BackGroundDownloader.checkForExistingDownloads(); @@ -98,40 +104,42 @@ function useDownloadProvider() { return; } // check if processes are missing - const missingProcesses = tasks - .filter((t) => t.metadata && !processes.some((p) => p.id === t.id)) - .map((t) => { - return t.metadata as JobStatus; + setProcesses((processes) => { + const missingProcesses = tasks + .filter((t) => t.metadata && !processes.some((p) => p.id === t.id)) + .map((t) => { + return t.metadata as JobStatus; + }); + + const currentProcesses = [...processes, ...missingProcesses]; + const updatedProcesses = currentProcesses.map((p) => { + // fallback. Doesn't really work for transcodes as they may be a lot smaller. + // We make an wild guess by comparing bitrates + const task = tasks.find((s) => s.id === p.id); + if (task && p.status === "downloading") { + const estimatedSize = calculateEstimatedSize(p); + let progress = p.progress; + if (estimatedSize > 0) { + progress = (100 / estimatedSize) * task.bytesDownloaded; + } + if (progress >= 100) { + progress = 99; + } + const speed = calculateSpeed(p, task.bytesDownloaded); + return { + ...p, + progress, + speed, + bytesDownloaded: task.bytesDownloaded, + lastProgressUpdateTime: new Date(), + estimatedTotalSizeBytes: estimatedSize, + }; + } + return p; }); - const currentProcesses = [...processes, ...missingProcesses]; - const updatedProcesses = currentProcesses.map((p) => { - // fallback. Doesn't really work for transcodes as they may be a lot smaller. - // We make an wild guess by comparing bitrates - const task = tasks.find((s) => s.id === p.id); - if (task && p.status === "downloading") { - const estimatedSize = calculateEstimatedSize(p); - let progress = p.progress; - if (estimatedSize > 0) { - progress = (100 / estimatedSize) * task.bytesDownloaded; - } - if (progress >= 100) { - progress = 99; - } - const speed = calculateSpeed(p, task.bytesDownloaded); - return { - ...p, - progress, - speed, - bytesDownloaded: task.bytesDownloaded, - lastProgressUpdateTime: new Date(), - estimatedTotalSizeBytes: estimatedSize, - }; - } - return p; + return updatedProcesses; }); - - setProcesses(updatedProcesses); }; useInterval(updateProgress, 2000); @@ -159,16 +167,21 @@ function useDownloadProvider() { }; const updateProcess = useCallback( - (processId: string, newStatus: Partial) => { + ( + processId: string, + updater: + | Partial + | ((current: JobStatus) => Partial), + ) => { setProcesses((prev) => - prev.map((p) => - p.id === processId - ? { - ...p, - ...newStatus, - } - : p, - ), + prev.map((p) => { + if (p.id !== processId) return p; + const newStatus = typeof updater === "function" ? updater(p) : updater; + return { + ...p, + ...newStatus, + }; + }), ); }, [setProcesses], @@ -201,10 +214,6 @@ function useDownloadProvider() { networkMode: "always", }); - const removeProcess = useCallback((id: string) => { - setProcesses((prev) => prev.filter((process) => process.id !== id)); - }, [setProcesses]); - const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; const getDownloadsDatabase = (): DownloadsDatabase => { @@ -314,16 +323,24 @@ function useDownloadProvider() { updateProcess(process.id, { status: "downloading", progress: 0, + bytesDownloaded: 0, + lastProgressUpdateTime: new Date(), }); }) - .progress((data) => { - const percent = (data.bytesDownloaded / data.bytesTotal) * 100; - updateProcess(process.id, { - speed: undefined, - status: "downloading", - progress: percent, - }); - }) + .progress( + throttle((data) => { + updateProcess(process.id, (currentProcess) => { + const percent = (data.bytesDownloaded / data.bytesTotal) * 100; + return { + speed: calculateSpeed(currentProcess, data.bytesDownloaded), + status: "downloading", + progress: percent, + bytesDownloaded: data.bytesDownloaded, + lastProgressUpdateTime: new Date(), + }; + }); + }, 500), + ) .done(async () => { const trickPlayData = await downloadTrickplayImages(process.item); const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath); @@ -393,7 +410,6 @@ function useDownloadProvider() { item: process.item.Name, }), ); - BackGroundDownloader.completeHandler(process.id); removeProcess(process.id); const itemName = process.item.Type === "Episode" && @@ -410,7 +426,7 @@ function useDownloadProvider() { item: itemName, }), data: { - url: `/items/${process.item.Id}`, + url: `/(auth)/(tabs)/home/items/page?id=${process.item.Id}?offline=true`, }, }, trigger: null, diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 934d6f34..f720a700 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -91,9 +91,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const headers = useMemo(() => { if (!deviceId) return {}; return { - authorization: `MediaBrowser Client="Streamyfin", Device=${ - Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.28.1"`, + authorization: `MediaBrowser Client="Streamyfin", Device=${Platform.OS === "android" ? "Android" : "iOS" + }, DeviceId="${deviceId}", Version="0.28.1"`, }; }, [deviceId]); diff --git a/utils/jellyfin/media/getDownloadUrl.ts b/utils/jellyfin/media/getDownloadUrl.ts new file mode 100644 index 00000000..4975959e --- /dev/null +++ b/utils/jellyfin/media/getDownloadUrl.ts @@ -0,0 +1,61 @@ +import type { Api } from "@jellyfin/sdk"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl"; +import { Bitrate } from "@/components/BitrateSelector"; +import native from "@/utils/profiles/native"; + +export const getDownloadUrl = async ({ + api, + item, + userId, + mediaSource, + maxBitrate, + audioStreamIndex, + subtitleStreamIndex, + deviceId, +}: { + api: Api; + item: BaseItemDto; + userId: string; + mediaSource: MediaSourceInfo; + maxBitrate: Bitrate; + audioStreamIndex: number; + subtitleStreamIndex: number; + deviceId: string; +}): Promise => { + + // Try check if we can play the item directly + const directPlayUrl = await getStreamUrl({ + api, + item, + userId, + startTimeTicks: 0, + mediaSourceId: mediaSource.Id, + maxStreamingBitrate: maxBitrate.value, + audioStreamIndex, + subtitleStreamIndex, + deviceId, + deviceProfile: native, + }); + + if (maxBitrate.key === "Max" && !directPlayUrl?.mediaSource?.TranscodingUrl) { + console.log("Downloading item directly"); + return `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`; + } + + const streamUrl = await getDownloadStreamUrl({ + api, + item, + userId, + mediaSourceId: mediaSource.Id, + maxStreamingBitrate: maxBitrate.value, + audioStreamIndex, + subtitleStreamIndex, + deviceId, + }); + + return streamUrl?.url ?? null; +}; diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index fbabf2a4..bfce4d72 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -5,6 +5,7 @@ import type { } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import generateDeviceProfile from "@/utils/profiles/native"; +import download from "@/utils/profiles/download"; export const getStreamUrl = async ({ api, @@ -17,7 +18,6 @@ export const getStreamUrl = async ({ audioStreamIndex = 0, subtitleStreamIndex = undefined, mediaSourceId, - download = false, deviceId, }: { api: Api | null | undefined; @@ -31,7 +31,6 @@ export const getStreamUrl = async ({ subtitleStreamIndex?: number; height?: number; mediaSourceId?: string | null; - download?: boolean; deviceId?: string | null; }): Promise<{ url: string | null; @@ -75,9 +74,6 @@ export const getStreamUrl = async ({ let transcodeUrl = mediaSource?.TranscodingUrl; if (transcodeUrl) { - if (download) { - transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); - } console.log("Video is being transcoded:", transcodeUrl); return { url: `${api.basePath}${transcodeUrl}`, @@ -86,21 +82,6 @@ export const getStreamUrl = async ({ }; } - 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 || "", - container: "ts", - }; - } - const streamParams = new URLSearchParams({ static: "true", container: "mp4", @@ -112,7 +93,6 @@ export const getStreamUrl = async ({ startTimeTicks: startTimeTicks.toString(), maxStreamingBitrate: maxStreamingBitrate?.toString() || "", userId: userId || "", - ...downloadParams, }); const directPlayUrl = `${ @@ -127,3 +107,111 @@ export const getStreamUrl = async ({ mediaSource, }; }; + +export const getDownloadStreamUrl = async ({ + api, + item, + userId, + maxStreamingBitrate, + audioStreamIndex = 0, + subtitleStreamIndex = undefined, + mediaSourceId, + deviceId, +}: { + api: Api | null | undefined; + item: BaseItemDto | null | undefined; + userId: string | null | undefined; + maxStreamingBitrate?: number; + audioStreamIndex?: number; + subtitleStreamIndex?: number; + mediaSourceId?: string | null; + deviceId?: string | null; +}): Promise<{ + url: string | null; + sessionId: string | null; + mediaSource: MediaSourceInfo | undefined; +} | null> => { + if (!api || !userId || !item?.Id) { + console.warn("Missing required parameters for getStreamUrl"); + return null; + } + + let mediaSource: MediaSourceInfo | undefined; + let sessionId: string | null | undefined; + + const res = await getMediaInfoApi(api).getPlaybackInfo( + { + itemId: item.Id!, + }, + { + method: "POST", + data: { + userId, + deviceProfile: download, + subtitleStreamIndex, + startTimeTicks: 0, + isPlayback: true, + autoOpenLiveStream: true, + maxStreamingBitrate, + audioStreamIndex, + mediaSourceId, + }, + }, + ); + + if (res.status !== 200) { + console.error("Error getting playback info:", res.status, res.statusText); + } + + sessionId = res.data.PlaySessionId || null; + mediaSource = res.data.MediaSources?.[0]; + let transcodeUrl = mediaSource?.TranscodingUrl; + + if (transcodeUrl) { + transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); + console.log("Video is being transcoded:", transcodeUrl); + return { + url: `${api.basePath}${transcodeUrl}`, + sessionId, + mediaSource, + }; + } + + const downloadParams = { + // We need to disable static so we can have a remux with subtitle. + subtitleMethod: "Embed", + enableSubtitlesInManifest: true, + allowVideoStreamCopy: true, + allowAudioStreamCopy: true, + playSessionId: sessionId || "", + }; + + const streamParams = new URLSearchParams({ + static: "false", + container: "ts", + mediaSourceId: mediaSource?.Id || "", + subtitleStreamIndex: subtitleStreamIndex?.toString() || "", + audioStreamIndex: audioStreamIndex?.toString() || "", + deviceId: deviceId || api.deviceInfo.id, + api_key: api.accessToken, + startTimeTicks: "0", + maxStreamingBitrate: maxStreamingBitrate?.toString() || "", + userId: userId || "", + }); + + Object.entries(downloadParams).forEach(([key, value]) => { + streamParams.append(key, value.toString()); + }); + + const directPlayUrl = `${ + api.basePath + }/Videos/${item.Id}/stream?${streamParams.toString()}`; + + console.log("Video is being direct played:", directPlayUrl); + + return { + url: directPlayUrl, + sessionId: sessionId || null, + mediaSource, + }; +};