import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { generateDownloadProfile } from "@/utils/profiles/download"; import type { AudioTranscodeModeType } from "@/utils/profiles/native"; interface StreamResult { url: string; sessionId: string | null; mediaSource: MediaSourceInfo | undefined; } /** * Gets the actual streaming URL - handles both transcoded and direct play logic * Returns only the URL string */ const getPlaybackUrl = ( api: Api, itemId: string, mediaSource: MediaSourceInfo | undefined, params: { subtitleStreamIndex?: number; audioStreamIndex?: number; deviceId?: string | null; startTimeTicks?: number; maxStreamingBitrate?: number; userId: string; playSessionId?: string | null; container?: string; static?: string; }, ): string => { let transcodeUrl = mediaSource?.TranscodingUrl; // Handle transcoded URL if available if (transcodeUrl) { // For regular streaming, change subtitle method to HLS for transcoded URL if (params.subtitleStreamIndex === -1) { transcodeUrl = transcodeUrl.replace( "SubtitleMethod=Encode", "SubtitleMethod=Hls", ); } console.log("Video is being transcoded:", transcodeUrl); return `${api.basePath}${transcodeUrl}`; } // Fall back to direct play const streamParams = new URLSearchParams({ static: params.static || "true", container: params.container || "mp4", mediaSourceId: mediaSource?.Id || "", subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "", audioStreamIndex: params.audioStreamIndex?.toString() || "", deviceId: params.deviceId || api.deviceInfo.id, api_key: api.accessToken, startTimeTicks: params.startTimeTicks?.toString() || "0", maxStreamingBitrate: params.maxStreamingBitrate?.toString() || "", userId: params.userId, }); // Add additional parameters if provided if (params.playSessionId) { streamParams.append("playSessionId", params.playSessionId); } const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`; console.log("Video is being direct played:", directPlayUrl); return directPlayUrl; }; /** Wrapper around {@link getPlaybackUrl} that applies download-specific transformations */ const getDownloadUrl = ( api: Api, itemId: string, mediaSource: MediaSourceInfo | undefined, sessionId: string | null | undefined, params: { subtitleStreamIndex?: number; audioStreamIndex?: number; deviceId?: string | null; startTimeTicks?: number; maxStreamingBitrate?: number; userId: string; playSessionId?: string | null; }, ): StreamResult => { // First, handle download-specific transcoding modifications let downloadMediaSource = mediaSource; if (mediaSource?.TranscodingUrl) { downloadMediaSource = { ...mediaSource, TranscodingUrl: mediaSource.TranscodingUrl.replace( "master.m3u8", "stream", ), }; } // Get the base URL with download-specific parameters let url = getPlaybackUrl(api, itemId, downloadMediaSource, { ...params, container: "ts", static: "false", }); // If it's a direct play URL, add download-specific parameters if (!mediaSource?.TranscodingUrl) { const urlObj = new URL(url); const downloadParams = { subtitleMethod: "Embed", enableSubtitlesInManifest: "true", allowVideoStreamCopy: "true", allowAudioStreamCopy: "true", }; Object.entries(downloadParams).forEach(([key, value]) => { urlObj.searchParams.append(key, value); }); url = urlObj.toString(); } return { url, sessionId: sessionId || null, mediaSource, }; }; export const getStreamUrl = async ({ api, item, userId, startTimeTicks = 0, maxStreamingBitrate, playSessionId, deviceProfile, audioStreamIndex = 0, subtitleStreamIndex = undefined, mediaSourceId, deviceId, }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; userId: string | null | undefined; startTimeTicks: number; maxStreamingBitrate?: number; playSessionId?: string | null; deviceProfile: any; audioStreamIndex?: number; subtitleStreamIndex?: number; height?: 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; // Please do not remove this we need this for live TV to be working correctly. if (item.Type === BaseItemKind.Program) { console.log("Item is of type program..."); const res = await getMediaInfoApi(api).getPlaybackInfo( { userId, itemId: item.ChannelId!, }, { method: "POST", params: { startTimeTicks: 0, isPlayback: true, autoOpenLiveStream: true, maxStreamingBitrate, audioStreamIndex, }, data: { deviceProfile, }, }, ); sessionId = res.data.PlaySessionId || null; mediaSource = res.data.MediaSources?.[0]; const url = getPlaybackUrl(api, item.ChannelId!, mediaSource, { subtitleStreamIndex, audioStreamIndex, deviceId, startTimeTicks: 0, maxStreamingBitrate, userId, }); return { url, sessionId: sessionId || null, mediaSource, }; } const res = await getMediaInfoApi(api).getPlaybackInfo( { itemId: item.Id!, }, { method: "POST", data: { userId, deviceProfile, subtitleStreamIndex, startTimeTicks, 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]; const url = getPlaybackUrl(api, item.Id!, mediaSource, { subtitleStreamIndex, audioStreamIndex, deviceId, startTimeTicks, maxStreamingBitrate, userId, playSessionId: playSessionId || undefined, }); return { url, sessionId: sessionId || null, mediaSource, }; }; export const getDownloadStreamUrl = async ({ api, item, userId, maxStreamingBitrate, audioStreamIndex = 0, subtitleStreamIndex = undefined, mediaSourceId, deviceId, audioMode = "auto", }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; userId: string | null | undefined; maxStreamingBitrate?: number; audioStreamIndex?: number; subtitleStreamIndex?: number; mediaSourceId?: string | null; deviceId?: string | null; audioMode?: AudioTranscodeModeType; }): 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; } const res = await getMediaInfoApi(api).getPlaybackInfo( { itemId: item.Id!, }, { method: "POST", data: { userId, deviceProfile: generateDownloadProfile(audioMode), subtitleStreamIndex, startTimeTicks: 0, isPlayback: true, autoOpenLiveStream: true, maxStreamingBitrate, audioStreamIndex, mediaSourceId, }, }, ); if (res.status !== 200) { console.error("Error getting playback info:", res.status, res.statusText); } const sessionId = res.data.PlaySessionId || null; const mediaSource = res.data.MediaSources?.[0]; return getDownloadUrl(api, item.Id!, mediaSource, sessionId, { subtitleStreamIndex, audioStreamIndex, deviceId, startTimeTicks: 0, maxStreamingBitrate, userId, playSessionId: sessionId || undefined, }); };