feat: move to custom download handler with background download support (#675)

This commit is contained in:
lostb1t
2025-05-04 11:46:34 +02:00
committed by GitHub
parent 9f706a348e
commit 1c1345a3b7
7 changed files with 163 additions and 401 deletions

View File

@@ -48,7 +48,6 @@
"@react-native-tvos/config-tv", "@react-native-tvos/config-tv",
"expo-router", "expo-router",
"expo-font", "expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[ [
"react-native-video", "react-native-video",
{ {

View File

@@ -1,4 +1,3 @@
//import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { queueActions, queueAtom } from "@/utils/atoms/queue";
@@ -152,18 +151,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
} }
closeModal(); closeModal();
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded); initiateDownload(...itemsNotDownloaded);
else {
queueActions.enqueue(
queue,
setQueue,
...itemsNotDownloaded.map((item) => ({
id: item.Id!,
execute: async () => await initiateDownload(item),
item,
})),
);
}
} else { } else {
toast.error( toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"), t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -203,7 +191,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSource = defaults.mediaSource; mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex; audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex; subtitleIndex = defaults.subtitleIndex;
// Keep using the selected bitrate for consistency across all downloads
} }
const res = await getStreamUrl({ const res = await getStreamUrl({
@@ -216,6 +203,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
mediaSourceId: mediaSource?.Id, mediaSourceId: mediaSource?.Id,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: download, deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
}); });
if (!res) { if (!res) {
@@ -230,12 +219,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!url || !source) throw new Error("No url"); if (!url || !source) throw new Error("No url");
if (usingOptimizedServer) { saveDownloadItemInfoToDiskTmp(item, source, url);
saveDownloadItemInfoToDiskTmp(item, source, url); await startBackgroundDownload(url, item, source, maxBitrate);
await startBackgroundDownload(url, item, source);
} else {
//await startRemuxing(item, url, source);
}
} }
}, },
[ [
@@ -249,7 +234,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
maxBitrate, maxBitrate,
usingOptimizedServer, usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
//startRemuxing,
], ],
); );

View File

@@ -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 };
};

View File

@@ -18,7 +18,6 @@
}, },
"dependencies": { "dependencies": {
"@bottom-tabs/react-navigation": "0.8.6", "@bottom-tabs/react-navigation": "0.8.6",
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
"@expo/config-plugins": "~9.0.15", "@expo/config-plugins": "~9.0.15",
"@expo/react-native-action-sheet": "^4.1.0", "@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.4", "@expo/vector-icons": "^14.0.4",

View File

@@ -1,5 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage"; import useImageStorage from "@/hooks/useImageStorage";
import { useInterval } from "@/hooks/useInterval";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device"; import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download"; import useDownloadHelper from "@/utils/download";
@@ -18,6 +19,7 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } 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 BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query"; import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
@@ -38,6 +40,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AppState, type AppStateStatus, Platform } from "react-native"; import { AppState, type AppStateStatus, Platform } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Bitrate } from "../components/BitrateSelector";
import { apiAtom } from "./JellyfinProvider"; import { apiAtom } from "./JellyfinProvider";
export type DownloadedItem = { export type DownloadedItem = {
@@ -74,6 +77,17 @@ function useDownloadProvider() {
return api?.accessToken; return api?.accessToken;
}, [api]); }, [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({ const { data: downloadedFiles, refetch } = useQuery({
queryKey: ["downloadedItems"], queryKey: ["downloadedItems"],
queryFn: getAllDownloadedItems, queryFn: getAllDownloadedItems,
@@ -164,6 +178,59 @@ function useDownloadProvider() {
enabled: settings?.downloadMethod === DownloadMethod.Optimized, 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(() => { useEffect(() => {
const checkIfShouldStartDownload = async () => { const checkIfShouldStartDownload = async () => {
if (processes.length === 0) return; if (processes.length === 0) return;
@@ -176,18 +243,25 @@ function useDownloadProvider() {
const removeProcess = useCallback( const removeProcess = useCallback(
async (id: string) => { async (id: string) => {
const deviceId = await getOrSetDeviceId(); const deviceId = await getOrSetDeviceId();
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl) if (!deviceId || !authHeader) return;
return;
try { if (usingOptimizedServer) {
await cancelJobById({ try {
authHeader, await cancelJobById({
id, authHeader,
url: settings?.optimizedVersionsServerUrl, id,
}); url: settings?.optimizedVersionsServerUrl,
} catch (error) { });
console.error(error); } catch (error) {
console.error(error);
}
} }
setProcesses((prev: any[]) => {
return prev.filter(
(process: { itemId: string | undefined }) => process.id !== id,
);
});
}, },
[settings?.optimizedVersionsServerUrl, authHeader], [settings?.optimizedVersionsServerUrl, authHeader],
); );
@@ -238,7 +312,7 @@ function useDownloadProvider() {
BackGroundDownloader?.download({ BackGroundDownloader?.download({
id: process.id, id: process.id,
url: `${settings?.optimizedVersionsServerUrl}download/${process.id}`, url: getDownloadUrl(process),
destination: `${baseDirectory}/${process.item.Id}.mp4`, destination: `${baseDirectory}/${process.item.Id}.mp4`,
}) })
.begin(() => { .begin(() => {
@@ -256,6 +330,9 @@ function useDownloadProvider() {
); );
}) })
.progress((data) => { .progress((data) => {
if (!usingOptimizedServer) {
return;
}
const percent = (data.bytesDownloaded / data.bytesTotal) * 100; const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
setProcesses((prev) => setProcesses((prev) =>
prev.map((p) => prev.map((p) =>
@@ -328,7 +405,12 @@ function useDownloadProvider() {
); );
const startBackgroundDownload = useCallback( const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => { async (
url: string,
item: BaseItemDto,
mediaSource: MediaSourceInfo,
maxBitrate?: Bitrate,
) => {
if (!api || !item.Id || !authHeader) if (!api || !item.Id || !authHeader)
throw new Error("startBackgroundDownload ~ Missing required params"); throw new Error("startBackgroundDownload ~ Missing required params");
@@ -345,26 +427,42 @@ function useDownloadProvider() {
width: 500, width: 500,
}); });
await saveImage(item.Id, itemImage?.uri); await saveImage(item.Id, itemImage?.uri);
if (usingOptimizedServer) {
const response = await axios.post( const response = await axios.post(
`${settings?.optimizedVersionsServerUrl}optimize-version`, `${settings?.optimizedVersionsServerUrl}optimize-version`,
{ {
url, url,
fileExtension, fileExtension,
deviceId, deviceId,
itemId: item.Id, itemId: item.Id,
item, item,
},
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
}, },
}, {
); headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
},
);
if (response.status !== 201) { if (response.status !== 201) {
throw new Error("Failed to start optimization job"); 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( toast.success(

View File

@@ -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<BaseItemDto>;
mediaSource: MediaSourceInfo;
};
export const processesAtom = atom<JobStatus[]>([]);
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
/**
* Dummy download provider for tvOS
*/
function useDownloadProvider() {
const [processes, setProcesses] = useAtom<JobStatus[]>(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<void> => {};
const deleteFile = async (id: string): Promise<void> => {};
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 (
<DownloadContext.Provider value={downloadProviderValue}>
{children}
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -14,23 +14,27 @@ export const getStreamUrl = async ({
userId, userId,
startTimeTicks = 0, startTimeTicks = 0,
maxStreamingBitrate, maxStreamingBitrate,
sessionData, playSessionId,
deviceProfile = generateDeviceProfile(), deviceProfile = generateDeviceProfile(),
audioStreamIndex = 0, audioStreamIndex = 0,
subtitleStreamIndex = undefined, subtitleStreamIndex = undefined,
mediaSourceId, mediaSourceId,
download = false,
deviceId,
}: { }: {
api: Api | null | undefined; api: Api | null | undefined;
item: BaseItemDto | null | undefined; item: BaseItemDto | null | undefined;
userId: string | null | undefined; userId: string | null | undefined;
startTimeTicks: number; startTimeTicks: number;
maxStreamingBitrate?: number; maxStreamingBitrate?: number;
sessionData?: PlaybackInfoResponse | null; playSessionId?: string | null;
deviceProfile?: any; deviceProfile?: any;
audioStreamIndex?: number; audioStreamIndex?: number;
subtitleStreamIndex?: number; subtitleStreamIndex?: number;
height?: number; height?: number;
mediaSourceId?: string | null; mediaSourceId?: string | null;
download?: bool;
deviceId?: string | null;
}): Promise<{ }): Promise<{
url: string | null; url: string | null;
sessionId: string | null; sessionId: string | null;
@@ -70,9 +74,12 @@ export const getStreamUrl = async ({
sessionId = res.data.PlaySessionId || null; sessionId = res.data.PlaySessionId || null;
mediaSource = res.data.MediaSources[0]; mediaSource = res.data.MediaSources[0];
const transcodeUrl = mediaSource.TranscodingUrl; let transcodeUrl = mediaSource.TranscodingUrl;
if (transcodeUrl) { if (transcodeUrl) {
if (download) {
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream.mp4");
}
console.log("Video is being transcoded:", transcodeUrl); console.log("Video is being transcoded:", transcodeUrl);
return { return {
url: `${api.basePath}${transcodeUrl}`, url: `${api.basePath}${transcodeUrl}`,
@@ -81,28 +88,42 @@ export const getStreamUrl = async ({
}; };
} }
const searchParams = new URLSearchParams({ let downloadParams = {};
playSessionId: sessionData?.PlaySessionId || "",
mediaSourceId: mediaSource?.Id || "", 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", static: "true",
mediaSourceId: mediaSource?.Id || "",
subtitleStreamIndex: subtitleStreamIndex?.toString() || "", subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
audioStreamIndex: audioStreamIndex?.toString() || "", audioStreamIndex: audioStreamIndex?.toString() || "",
deviceId: api.deviceInfo.id, deviceId: deviceId || api.deviceInfo.id,
api_key: api.accessToken, api_key: api.accessToken,
startTimeTicks: startTimeTicks.toString(), startTimeTicks: startTimeTicks.toString(),
maxStreamingBitrate: maxStreamingBitrate?.toString() || "", maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
userId: userId || "", userId: userId || "",
...downloadParams,
}); });
const directPlayUrl = `${ const directPlayUrl = `${
api.basePath api.basePath
}/Videos/${item.Id}/stream.mp4?${searchParams.toString()}`; }/Videos/${item.Id}/stream.mp4?${streamParams.toString()}`;
console.log("Video is being direct played:", directPlayUrl); console.log("Video is being direct played:", directPlayUrl);
return { return {
url: directPlayUrl, url: directPlayUrl,
sessionId: sessionId, sessionId: sessionId || playSessionId,
mediaSource, mediaSource,
}; };
}; };