This commit is contained in:
Alex Kim
2025-08-02 04:35:29 +10:00
parent 4aea5c0155
commit e9673cca62
8 changed files with 292 additions and 133 deletions

View File

@@ -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<DownloadProps> = ({
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<DownloadProps> = ({
);
continue;
}
await startBackgroundDownload(url, item, source, maxBitrate);
await startBackgroundDownload(url, item, mediaSource, maxBitrate);
}
},
[

View File

@@ -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: () => {

View File

@@ -575,7 +575,7 @@ export const Controls: FC<Props> = ({
className={"flex flex-row w-full pt-2"}
>
<View className='mr-auto'>
{!Platform.isTV && !offline && (
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}

View File

@@ -19,7 +19,7 @@ const DropdownView = () => {
];
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}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='qualitytrigger'>
Quality
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{BITRATES?.map((bitrate, idx: number) => (
<DropdownMenu.CheckboxItem
key={`quality-item-${idx}`}
value={bitrateValue === (bitrate.value?.toString() ?? "")}
onValueChange={() =>
changeBitrate(bitrate.value?.toString() ?? "")
}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{bitrate.key}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{!isOffline && (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='qualitytrigger'>
Quality
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{BITRATES?.map((bitrate, idx: number) => (
<DropdownMenu.CheckboxItem
key={`quality-item-${idx}`}
value={bitrateValue === (bitrate.value?.toString() ?? "")}
onValueChange={() =>
changeBitrate(bitrate.value?.toString() ?? "")
}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{bitrate.key}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='subtitle-trigger'>
Subtitle

View File

@@ -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<JobStatus>) => {
(
processId: string,
updater:
| Partial<JobStatus>
| ((current: JobStatus) => Partial<JobStatus>),
) => {
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,

View File

@@ -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]);

View File

@@ -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<string | null> => {
// 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;
};

View File

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