forked from Ninjalama/streamyfin_mirror
Compare commits
4 Commits
develop
...
fix/downlo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899d5c935c | ||
|
|
fe260102cb | ||
|
|
9acd4335a4 | ||
|
|
bdcfc2b613 |
1
app.json
1
app.json
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user