mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
refactor: Feature/offline mode rework (#859)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com> Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com> Co-authored-by: Gauvino <uruknarb20@gmail.com> Co-authored-by: storm1er <le.storm1er@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com> Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com> Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
@@ -51,18 +51,9 @@ export function getDefaultPlaySettings(
|
||||
|
||||
const mediaSource = item.MediaSources?.[0];
|
||||
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const _preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage,
|
||||
)?.Index;
|
||||
const _firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio",
|
||||
)?.Index;
|
||||
|
||||
// We prefer the previous track over the default track.
|
||||
const trackOptions: TrackOptions = {
|
||||
DefaultAudioStreamIndex: defaultAudioIndex ?? -1,
|
||||
DefaultAudioStreamIndex: mediaSource?.DefaultAudioStreamIndex ?? -1,
|
||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||
};
|
||||
|
||||
|
||||
68
utils/jellyfin/media/getDownloadUrl.ts
Normal file
68
utils/jellyfin/media/getDownloadUrl.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Bitrate } from "@/components/BitrateSelector";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
|
||||
|
||||
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<{
|
||||
url: string | null;
|
||||
mediaSource: MediaSourceInfo | null;
|
||||
} | null> => {
|
||||
const streamDetails = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
startTimeTicks: 0,
|
||||
mediaSourceId: mediaSource.Id,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
deviceId,
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
|
||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||
console.log("Downloading item directly");
|
||||
return {
|
||||
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
|
||||
mediaSource: streamDetails?.mediaSource ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const downloadStreamDetails = await getDownloadStreamUrl({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
mediaSourceId: mediaSource.Id,
|
||||
deviceId,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
});
|
||||
|
||||
return {
|
||||
url: downloadStreamDetails?.url ?? null,
|
||||
mediaSource: downloadStreamDetails?.mediaSource ?? null,
|
||||
};
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} 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,
|
||||
@@ -13,11 +13,10 @@ export const getStreamUrl = async ({
|
||||
startTimeTicks = 0,
|
||||
maxStreamingBitrate,
|
||||
playSessionId,
|
||||
deviceProfile = generateDeviceProfile(),
|
||||
deviceProfile,
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = undefined,
|
||||
mediaSourceId,
|
||||
download = false,
|
||||
deviceId,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
@@ -26,12 +25,11 @@ export const getStreamUrl = async ({
|
||||
startTimeTicks: number;
|
||||
maxStreamingBitrate?: number;
|
||||
playSessionId?: string | null;
|
||||
deviceProfile?: any;
|
||||
deviceProfile: any;
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
height?: number;
|
||||
mediaSourceId?: string | null;
|
||||
download?: bool;
|
||||
deviceId?: string | null;
|
||||
}): Promise<{
|
||||
url: string | null;
|
||||
@@ -71,12 +69,16 @@ export const getStreamUrl = async ({
|
||||
}
|
||||
|
||||
sessionId = res.data.PlaySessionId || null;
|
||||
mediaSource = res.data.MediaSources[0];
|
||||
let transcodeUrl = mediaSource.TranscodingUrl;
|
||||
mediaSource = res.data.MediaSources?.[0];
|
||||
let transcodeUrl = mediaSource?.TranscodingUrl;
|
||||
|
||||
if (transcodeUrl) {
|
||||
if (download) {
|
||||
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream");
|
||||
// We need to change the subtitle method to hls for the transcoded url.
|
||||
if (subtitleStreamIndex === -1) {
|
||||
transcodeUrl = transcodeUrl.replace(
|
||||
"SubtitleMethod=Encode",
|
||||
"SubtitleMethod=Hls",
|
||||
);
|
||||
}
|
||||
console.log("Video is being transcoded:", transcodeUrl);
|
||||
return {
|
||||
@@ -86,21 +88,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",
|
||||
@@ -111,8 +98,7 @@ export const getStreamUrl = async ({
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: startTimeTicks.toString(),
|
||||
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||
userId: userId || "",
|
||||
...downloadParams,
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
const directPlayUrl = `${
|
||||
@@ -123,7 +109,113 @@ export const getStreamUrl = async ({
|
||||
|
||||
return {
|
||||
url: directPlayUrl,
|
||||
sessionId: sessionId || playSessionId,
|
||||
sessionId: sessionId || playSessionId || null,
|
||||
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()}`;
|
||||
|
||||
return {
|
||||
url: directPlayUrl,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { AxiosError } from "axios";
|
||||
|
||||
interface MarkAsNotPlayedParams {
|
||||
api: Api | null | undefined;
|
||||
itemId: string | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a media item as not played for a specific user.
|
||||
*
|
||||
* @param params - The parameters for marking an item as not played
|
||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||
*/
|
||||
export const markAsNotPlayed = async ({
|
||||
api,
|
||||
itemId,
|
||||
userId,
|
||||
}: MarkAsNotPlayedParams): Promise<void> => {
|
||||
if (!api || !itemId || !userId) {
|
||||
console.error("Invalid parameters for markAsNotPlayed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.axiosInstance.delete(
|
||||
`${api.basePath}/UserPlayedItems/${itemId}`,
|
||||
{
|
||||
params: { userId },
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
console.error(
|
||||
"Failed to mark item as not played:",
|
||||
axiosError.message,
|
||||
axiosError.response?.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
interface MarkAsPlayedParams {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a media item as played and updates its progress to completion.
|
||||
*
|
||||
* @param params - The parameters for marking an item as played∏
|
||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||
*/
|
||||
export const markAsPlayed = async ({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
}: MarkAsPlayedParams): Promise<boolean> => {
|
||||
if (!api || !item?.Id || !userId || !item.RunTimeTicks) {
|
||||
console.error("Invalid parameters for markAsPlayed");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getPlaystateApi(api).markPlayedItem({
|
||||
itemId: item.Id,
|
||||
datePlayed: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response.status === 200;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
|
||||
interface ReportPlaybackProgressParams {
|
||||
api?: Api | null;
|
||||
sessionId?: string | null;
|
||||
itemId?: string | null;
|
||||
positionTicks?: number | null;
|
||||
IsPaused?: boolean;
|
||||
deviceProfile?: Settings["deviceProfile"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports playback progress to the Jellyfin server.
|
||||
*
|
||||
* @param params - The parameters for reporting playback progress
|
||||
* @throws {Error} If any required parameter is missing
|
||||
*/
|
||||
export const reportPlaybackProgress = async ({
|
||||
api,
|
||||
sessionId,
|
||||
itemId,
|
||||
positionTicks,
|
||||
IsPaused = false,
|
||||
}: ReportPlaybackProgressParams): Promise<void> => {
|
||||
if (!api || !sessionId || !itemId || !positionTicks) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
|
||||
|
||||
try {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId,
|
||||
audioStreamIndex: 0,
|
||||
subtitleStreamIndex: 0,
|
||||
mediaSourceId: itemId,
|
||||
positionTicks: Math.round(positionTicks),
|
||||
isPaused: IsPaused,
|
||||
isMuted: false,
|
||||
playMethod: "Transcode",
|
||||
});
|
||||
// await api.axiosInstance.post(
|
||||
// `${api.basePath}/Sessions/Playing/Progress`,
|
||||
// {
|
||||
// ItemId: itemId,
|
||||
// PlaySessionId: sessionId,
|
||||
// IsPaused,
|
||||
// PositionTicks: Math.round(positionTicks),
|
||||
// CanSeek: true,
|
||||
// MediaSourceId: itemId,
|
||||
// EventName: "timeupdate",
|
||||
// },
|
||||
// { headers: getAuthHeaders(api) }
|
||||
// );
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
interface PostCapabilitiesParams {
|
||||
|
||||
Reference in New Issue
Block a user