using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Jellyfin.Api.Helpers { /// /// The streaming helpers. /// public static class StreamingHelpers { public static async Task GetStreamingState( Guid itemId, long? startTimeTicks, string? audioCodec, string? subtitleCodec, string? videoCodec, string? @params, bool? @static, string? container, string? liveStreamId, string? playSessionId, string? mediaSourceId, string? deviceId, string? deviceProfileId, int? audioBitRate, HttpRequest request, IAuthorizationContext authorizationContext, IMediaSourceManager mediaSourceManager, IUserManager userManager, ILibraryManager libraryManager, IServerConfigurationManager serverConfigurationManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, ISubtitleEncoder subtitleEncoder, IConfiguration configuration, IDlnaManager dlnaManager, IDeviceManager deviceManager, TranscodingJobHelper transcodingJobHelper, TranscodingJobType transcodingJobType, bool isVideoRequest, CancellationToken cancellationToken) { EncodingHelper encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); // Parse the DLNA time seek header if (!startTimeTicks.HasValue) { var timeSeek = request.Headers["TimeSeekRange.dlna.org"]; startTimeTicks = ParseTimeSeekHeader(timeSeek); } if (!string.IsNullOrWhiteSpace(@params)) { // What is this? ParseParams(request); } var streamOptions = ParseStreamOptions(request.Query); var url = request.Path.Value.Split('.').Last(); if (string.IsNullOrEmpty(audioCodec)) { audioCodec = encodingHelper.InferAudioCodec(url); } var enableDlnaHeaders = !string.IsNullOrWhiteSpace(@params) || string.Equals(request.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) { // TODO request was the StreamingRequest living in MediaBrowser.Api.Playback.Progressive // Request = request, DeviceId = deviceId, PlaySessionId = playSessionId, LiveStreamId = liveStreamId, RequestedUrl = url, UserAgent = request.Headers[HeaderNames.UserAgent], EnableDlnaHeaders = enableDlnaHeaders }; var auth = authorizationContext.GetAuthorizationInfo(request); if (!auth.UserId.Equals(Guid.Empty)) { state.User = userManager.GetUserById(auth.UserId); } /* if ((Request.UserAgent ?? string.Empty).IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || (Request.UserAgent ?? string.Empty).IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || (Request.UserAgent ?? string.Empty).IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) { state.SegmentLength = 6; } */ if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.VideoCodec)) { state.SupportedVideoCodecs = state.VideoCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); state.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); } if (!string.IsNullOrWhiteSpace(audioCodec)) { state.SupportedAudioCodecs = audioCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); state.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToAudioCodec(i)) ?? state.SupportedAudioCodecs.FirstOrDefault(); } if (!string.IsNullOrWhiteSpace(subtitleCodec)) { state.SupportedSubtitleCodecs = subtitleCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); state.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToSubtitleCodec(i)) ?? state.SupportedSubtitleCodecs.FirstOrDefault(); } var item = libraryManager.GetItemById(itemId); state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); /* var primaryImage = item.GetImageInfo(ImageType.Primary, 0) ?? item.Parents.Select(i => i.GetImageInfo(ImageType.Primary, 0)).FirstOrDefault(i => i != null); if (primaryImage != null) { state.AlbumCoverPath = primaryImage.Path; } */ MediaSourceInfo? mediaSource = null; if (string.IsNullOrWhiteSpace(liveStreamId)) { var currentJob = !string.IsNullOrWhiteSpace(playSessionId) ? transcodingJobHelper.GetTranscodingJob(playSessionId) : null; if (currentJob != null) { mediaSource = currentJob.MediaSource; } if (mediaSource == null) { var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(itemId), null, false, false, cancellationToken).ConfigureAwait(false); mediaSource = string.IsNullOrEmpty(mediaSourceId) ? mediaSources[0] : mediaSources.Find(i => string.Equals(i.Id, mediaSourceId, StringComparison.InvariantCulture)); if (mediaSource == null && Guid.Parse(mediaSourceId) == itemId) { mediaSource = mediaSources[0]; } } } else { var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(liveStreamId, cancellationToken).ConfigureAwait(false); mediaSource = liveStreamInfo.Item1; state.DirectStreamProvider = liveStreamInfo.Item2; } encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); var containerInternal = Path.GetExtension(state.RequestedUrl); if (string.IsNullOrEmpty(container)) { containerInternal = container; } if (string.IsNullOrEmpty(containerInternal)) { containerInternal = (@static.HasValue && @static.Value) ? StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio) : GetOutputFileExtension(state); } state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(audioBitRate, state.AudioStream); state.OutputAudioCodec = audioCodec; state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); if (isVideoRequest) { state.OutputVideoCodec = state.VideoCodec; state.OutputVideoBitrate = EncodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); encodingHelper.TryStreamCopy(state); if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { var resolution = ResolutionNormalizer.Normalize( state.VideoStream?.BitRate, state.VideoStream?.Width, state.VideoStream?.Height, state.OutputVideoBitrate.Value, state.VideoStream?.Codec, state.OutputVideoCodec, videoRequest.MaxWidth, videoRequest.MaxHeight); videoRequest.MaxWidth = resolution.MaxWidth; videoRequest.MaxHeight = resolution.MaxHeight; } } ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, request, deviceProfileId, @static); var ext = string.IsNullOrWhiteSpace(state.OutputContainer) ? GetOutputFileExtension(state) : ('.' + state.OutputContainer); state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, deviceId, playSessionId); return state; } /// /// Adds the dlna headers. /// /// The state. /// The response headers. /// if set to true [is statically streamed]. /// The start time in ticks. /// The . /// Instance of the interface. public static void AddDlnaHeaders( StreamState state, IHeaderDictionary responseHeaders, bool isStaticallyStreamed, long? startTimeTicks, HttpRequest request, IDlnaManager dlnaManager) { if (!state.EnableDlnaHeaders) { return; } var profile = state.DeviceProfile; StringValues transferMode = request.Headers["transferMode.dlna.org"]; responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString()); responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"); if (state.RunTimeTicks.HasValue) { if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase)) { var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; responseHeaders.Add("MediaInfo.sec", string.Format( CultureInfo.InvariantCulture, "SEC_Duration={0};", Convert.ToInt32(ms))); } if (!isStaticallyStreamed && profile != null) { AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks); } } if (profile == null) { profile = dlnaManager.GetDefaultProfile(); } var audioCodec = state.ActualOutputAudioCodec; if (!state.IsVideoRequest) { responseHeaders.Add("contentFeatures.dlna.org", new ContentFeatureBuilder(profile).BuildAudioHeader( state.OutputContainer, audioCodec, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioChannels, state.OutputAudioBitDepth, isStaticallyStreamed, state.RunTimeTicks, state.TranscodeSeekInfo)); } else { var videoCodec = state.ActualOutputVideoCodec; responseHeaders.Add( "contentFeatures.dlna.org", new ContentFeatureBuilder(profile).BuildVideoHeader(state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); } } /// /// Parses the time seek header. /// /// The time seek header string. /// A nullable representing the seek time in ticks. public static long? ParseTimeSeekHeader(string value) { if (string.IsNullOrWhiteSpace(value)) { return null; } const string Npt = "npt="; if (!value.StartsWith(Npt, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("Invalid timeseek header"); } int index = value.IndexOf('-', StringComparison.InvariantCulture); value = index == -1 ? value.Substring(Npt.Length) : value.Substring(Npt.Length, index - Npt.Length); if (value.IndexOf(':', StringComparison.InvariantCulture) == -1) { // Parses npt times in the format of '417.33' if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) { return TimeSpan.FromSeconds(seconds).Ticks; } throw new ArgumentException("Invalid timeseek header"); } // Parses npt times in the format of '10:19:25.7' var tokens = value.Split(new[] { ':' }, 3); double secondsSum = 0; var timeFactor = 3600; foreach (var time in tokens) { if (double.TryParse(time, NumberStyles.Any, CultureInfo.InvariantCulture, out var digit)) { secondsSum += digit * timeFactor; } else { throw new ArgumentException("Invalid timeseek header"); } timeFactor /= 60; } return TimeSpan.FromSeconds(secondsSum).Ticks; } /// /// Parses query parameters as StreamOptions. /// /// The query string. /// A containing the stream options. public static Dictionary ParseStreamOptions(IQueryCollection queryString) { Dictionary streamOptions = new Dictionary(); foreach (var param in queryString) { if (char.IsLower(param.Key[0])) { // This was probably not parsed initially and should be a StreamOptions // or the generated URL should correctly serialize it // TODO: This should be incorporated either in the lower framework for parsing requests streamOptions[param.Key] = param.Value; } } return streamOptions; } /// /// Adds the dlna time seek headers to the response. /// /// The current . /// The of the response. /// The start time in ticks. public static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks) { var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( CultureInfo.InvariantCulture, "npt={0}-{1}/{1}", startSeconds, runtimeSeconds)); responseHeaders.Add("X-AvailableSeekRange", string.Format( CultureInfo.InvariantCulture, "1 npt={0}-{1}", startSeconds, runtimeSeconds)); } /// /// Gets the output file extension. /// /// The state. /// System.String. public static string? GetOutputFileExtension(StreamState state) { var ext = Path.GetExtension(state.RequestedUrl); if (!string.IsNullOrEmpty(ext)) { return ext; } // Try to infer based on the desired video codec if (state.IsVideoRequest) { var videoCodec = state.VideoCodec; if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) || string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)) { return ".ts"; } if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) { return ".ogv"; } if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) { return ".webm"; } if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) { return ".asf"; } } // Try to infer based on the desired audio codec if (!state.IsVideoRequest) { var audioCodec = state.AudioCodec; if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) { return ".aac"; } if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) { return ".mp3"; } if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) { return ".ogg"; } if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) { return ".wma"; } } return null; } /// /// Gets the output file path for transcoding. /// /// The current . /// The file extension of the output file. /// Instance of the interface. /// The device id. /// The play session id. /// The complete file path, including the folder, for the transcoding file. private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId) { var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}"; var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); var ext = outputFileExtension?.ToLowerInvariant(); var folder = serverConfigurationManager.GetTranscodePath(); return Path.Combine(folder, filename + ext); } private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) { var headers = request.Headers; if (!string.IsNullOrWhiteSpace(deviceProfileId)) { state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); } else if (!string.IsNullOrWhiteSpace(deviceProfileId)) { var caps = deviceManager.GetCapabilities(deviceProfileId); state.DeviceProfile = caps == null ? dlnaManager.GetProfile(headers) : caps.DeviceProfile; } var profile = state.DeviceProfile; if (profile == null) { // Don't use settings from the default profile. // Only use a specific profile if it was requested. return; } var audioCodec = state.ActualOutputAudioCodec; var videoCodec = state.ActualOutputVideoCodec; var mediaProfile = !state.IsVideoRequest ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) : profile.GetVideoMediaProfile( state.OutputContainer, audioCodec, videoCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TargetTimestamp, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC); if (mediaProfile != null) { state.MimeType = mediaProfile.MimeType; } if (!(@static.HasValue && @static.Value)) { var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); if (transcodingProfile != null) { state.EstimateContentLength = transcodingProfile.EstimateContentLength; // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; if (!state.IsVideoRequest) { state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; } } } } /// /// Parses the parameters. /// /// The request. private void ParseParams(StreamRequest request) { var vals = request.Params.Split(';'); var videoRequest = request as VideoStreamRequest; for (var i = 0; i < vals.Length; i++) { var val = vals[i]; if (string.IsNullOrWhiteSpace(val)) { continue; } switch (i) { case 0: request.DeviceProfileId = val; break; case 1: request.DeviceId = val; break; case 2: request.MediaSourceId = val; break; case 3: request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); break; case 4: if (videoRequest != null) { videoRequest.VideoCodec = val; } break; case 5: request.AudioCodec = val; break; case 6: if (videoRequest != null) { videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); } break; case 7: if (videoRequest != null) { videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); } break; case 8: if (videoRequest != null) { videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture); } break; case 9: request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture); break; case 10: request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); break; case 11: if (videoRequest != null) { videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); } break; case 12: if (videoRequest != null) { videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); } break; case 13: if (videoRequest != null) { videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); } break; case 14: request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); break; case 15: if (videoRequest != null) { videoRequest.Level = val; } break; case 16: if (videoRequest != null) { videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); } break; case 17: if (videoRequest != null) { videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); } break; case 18: if (videoRequest != null) { videoRequest.Profile = val; } break; case 19: // cabac no longer used break; case 20: request.PlaySessionId = val; break; case 21: // api_key break; case 22: request.LiveStreamId = val; break; case 23: // Duplicating ItemId because of MediaMonkey break; case 24: if (videoRequest != null) { videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); } break; case 25: if (!string.IsNullOrWhiteSpace(val) && videoRequest != null) { if (Enum.TryParse(val, out SubtitleDeliveryMethod method)) { videoRequest.SubtitleMethod = method; } } break; case 26: request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); break; case 27: if (videoRequest != null) { videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); } break; case 28: request.Tag = val; break; case 29: if (videoRequest != null) { videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); } break; case 30: request.SubtitleCodec = val; break; case 31: if (videoRequest != null) { videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); } break; case 32: if (videoRequest != null) { videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); } break; case 33: request.TranscodeReasons = val; break; } } } } }