From 9657708b384dfca474c28f673a2d79a3f3e4db9f Mon Sep 17 00:00:00 2001 From: Tim Eisele Date: Fri, 28 Mar 2025 13:51:44 +0100 Subject: [PATCH] Reduce allocations, simplifed code, faster implementation, included tests - StreamInfo.ToUrl (#9369) * Rework PR 6168 * Fix test --- Jellyfin.Api/Helpers/MediaInfoHelper.cs | 6 +- MediaBrowser.Model/Dlna/StreamBuilder.cs | 2 +- MediaBrowser.Model/Dlna/StreamInfo.cs | 400 +++++++++++------- MediaBrowser.Model/Dto/MediaSourceInfo.cs | 10 +- .../EnumerableExtensions.cs | 19 + .../Dlna/LegacyStreamInfo.cs | 224 ++++++++++ .../Dlna/StreamBuilderTests.cs | 2 +- .../Dlna/StreamInfoTests.cs | 243 +++++++++++ 8 files changed, 735 insertions(+), 171 deletions(-) create mode 100644 tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs create mode 100644 tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 7b493d3fa0..63c9c173b9 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -290,9 +290,7 @@ public class MediaInfoHelper mediaSource.SupportsDirectPlay = false; mediaSource.SupportsDirectStream = false; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), "&allowVideoStreamCopy=false&allowAudioStreamCopy=false"); mediaSource.TranscodingContainer = streamInfo.Container; mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding) @@ -305,7 +303,7 @@ public class MediaInfoHelper if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream)) { streamInfo.PlayMethod = PlayMethod.Transcode; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); + mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), null); if (!allowVideoStreamCopy) { diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index b12e9c2d7e..806900e9ad 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -800,7 +800,7 @@ namespace MediaBrowser.Model.Dlna options.SubtitleStreamIndex, playlistItem.PlayMethod, playlistItem.TranscodeReasons, - playlistItem.ToUrl("media:", "")); + playlistItem.ToUrl("media:", "", null)); item.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile); return playlistItem; diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index e441522136..f9aab2d676 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -1,7 +1,12 @@ +#pragma warning disable CA1819 // Properties should not return arrays + using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; +using System.Text; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -871,202 +876,279 @@ public class StreamInfo /// /// The base Url. /// The access Token. + /// Optional extra query. /// A querystring representation of this object. - public string ToUrl(string baseUrl, string? accessToken) + public string ToUrl(string? baseUrl, string? accessToken, string? query) { - ArgumentException.ThrowIfNullOrEmpty(baseUrl); - - List list = []; - foreach (NameValuePair pair in BuildParams(this, accessToken)) + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(baseUrl)) { - if (string.IsNullOrEmpty(pair.Value)) - { - continue; - } - - // Try to keep the url clean by omitting defaults - if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal); - - list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); + sb.Append(baseUrl.TrimEnd('/')); } - string queryString = string.Join('&', list); - - return GetUrl(baseUrl, queryString); - } - - private string GetUrl(string baseUrl, string queryString) - { - ArgumentException.ThrowIfNullOrEmpty(baseUrl); - - string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; - - baseUrl = baseUrl.TrimEnd('/'); - if (MediaType == DlnaProfileType.Audio) { - if (SubProtocol == MediaStreamProtocol.hls) + sb.Append("/audio/"); + } + else + { + sb.Append("/videos/"); + } + + sb.Append(ItemId); + + if (SubProtocol == MediaStreamProtocol.hls) + { + sb.Append("/master.m3u8?"); + } + else + { + sb.Append("/stream"); + + if (!string.IsNullOrEmpty(Container)) { - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + sb.Append('.'); + sb.Append(Container); } - return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + sb.Append('?'); + } + + if (!string.IsNullOrEmpty(DeviceProfileId)) + { + sb.Append("&DeviceProfileId="); + sb.Append(DeviceProfileId); + } + + if (!string.IsNullOrEmpty(DeviceId)) + { + sb.Append("&DeviceId="); + sb.Append(DeviceId); + } + + if (!string.IsNullOrEmpty(MediaSourceId)) + { + sb.Append("&MediaSourceId="); + sb.Append(MediaSourceId); + } + + // default true so don't store. + if (IsDirectStream) + { + sb.Append("&Static=true"); + } + + if (VideoCodecs.Count != 0) + { + sb.Append("&VideoCodec="); + sb.AppendJoin(',', VideoCodecs); + } + + if (AudioCodecs.Count != 0) + { + sb.Append("&AudioCodec="); + sb.AppendJoin(',', AudioCodecs); + } + + if (AudioStreamIndex.HasValue) + { + sb.Append("&AudioStreamIndex="); + sb.Append(AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod != SubtitleDeliveryMethod.External && SubtitleStreamIndex != -1) + { + sb.Append("&SubtitleStreamIndex="); + sb.Append(SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture)); + sb.Append("&SubtitleMethod="); + sb.Append(SubtitleDeliveryMethod.ToString()); + } + + if (VideoBitrate.HasValue) + { + sb.Append("&VideoBitrate="); + sb.Append(VideoBitrate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (AudioBitrate.HasValue) + { + sb.Append("&AudioBitrate="); + sb.Append(AudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (AudioSampleRate.HasValue) + { + sb.Append("&AudioSampleRate="); + sb.Append(AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (MaxFramerate.HasValue) + { + sb.Append("&MaxFramerate="); + sb.Append(MaxFramerate.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (MaxWidth.HasValue) + { + sb.Append("&MaxWidth="); + sb.Append(MaxWidth.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (MaxHeight.HasValue) + { + sb.Append("&MaxHeight="); + sb.Append(MaxHeight.Value.ToString(CultureInfo.InvariantCulture)); } if (SubProtocol == MediaStreamProtocol.hls) { - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); - } + if (!string.IsNullOrEmpty(Container)) + { + sb.Append("&SegmentContainer="); + sb.Append(Container); + } - return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); - } + if (SegmentLength.HasValue) + { + sb.Append("&SegmentLength="); + sb.Append(SegmentLength.Value.ToString(CultureInfo.InvariantCulture)); + } - private static List BuildParams(StreamInfo item, string? accessToken) - { - List list = []; + if (MinSegments.HasValue) + { + sb.Append("&MinSegments="); + sb.Append(MinSegments.Value.ToString(CultureInfo.InvariantCulture)); + } - string audioCodecs = item.AudioCodecs.Count == 0 ? - string.Empty : - string.Join(',', item.AudioCodecs); - - string videoCodecs = item.VideoCodecs.Count == 0 ? - string.Empty : - string.Join(',', item.VideoCodecs); - - list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); - list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); - list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); - list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - list.Add(new NameValuePair("VideoCodec", videoCodecs)); - list.Add(new NameValuePair("AudioCodec", audioCodecs)); - list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && (item.AlwaysBurnInSubtitleWhenTranscoding || item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - long startPositionTicks = item.StartPositionTicks; - - if (item.SubProtocol == MediaStreamProtocol.hls) - { - list.Add(new NameValuePair("StartTimeTicks", string.Empty)); + sb.Append("&BreakOnNonKeyFrames="); + sb.Append(BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture)); } else { - list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); + if (StartPositionTicks != 0) + { + sb.Append("&StartTimeTicks="); + sb.Append(StartPositionTicks.ToString(CultureInfo.InvariantCulture)); + } } - list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); - list.Add(new NameValuePair("ApiKey", accessToken ?? string.Empty)); - - string? liveStreamId = item.MediaSource?.LiveStreamId; - list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); - - list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); - - if (!item.IsDirectStream) + if (!string.IsNullOrEmpty(PlaySessionId)) { - if (item.RequireNonAnamorphic) - { - list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); - - if (item.EnableSubtitlesInManifest) - { - list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.EnableMpegtsM2TsMode) - { - list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.EstimateContentLength) - { - list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) - { - list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); - } - - if (item.CopyTimestamps) - { - list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - } - - list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); - - list.Add(new NameValuePair("EnableAudioVbrEncoding", item.EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + sb.Append("&PlaySessionId="); + sb.Append(PlaySessionId); } - list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty)); - - string subtitleCodecs = item.SubtitleCodecs.Count == 0 ? - string.Empty : - string.Join(",", item.SubtitleCodecs); - - list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); - - if (item.SubProtocol == MediaStreamProtocol.hls) + if (!string.IsNullOrEmpty(accessToken)) { - list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); - - if (item.SegmentLength.HasValue) - { - list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); - } - - if (item.MinSegments.HasValue) - { - list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); - } - - list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); + sb.Append("&ApiKey="); + sb.Append(accessToken); } - foreach (var pair in item.StreamOptions) + var liveStreamId = MediaSource?.LiveStreamId; + if (!string.IsNullOrEmpty(liveStreamId)) { - if (string.IsNullOrEmpty(pair.Value)) + sb.Append("&LiveStreamId="); + sb.Append(liveStreamId); + } + + if (!IsDirectStream) + { + if (RequireNonAnamorphic) { - continue; + sb.Append("&RequireNonAnamorphic="); + sb.Append(RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture)); } - // strip spaces to avoid having to encode h264 profile names - list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal))); + if (TranscodingMaxAudioChannels.HasValue) + { + sb.Append("&TranscodingMaxAudioChannels="); + sb.Append(TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (EnableSubtitlesInManifest) + { + sb.Append("&EnableSubtitlesInManifest="); + sb.Append(EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture)); + } + + if (EnableMpegtsM2TsMode) + { + sb.Append("&EnableMpegtsM2TsMode="); + sb.Append(EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture)); + } + + if (EstimateContentLength) + { + sb.Append("&EstimateContentLength="); + sb.Append(EstimateContentLength.ToString(CultureInfo.InvariantCulture)); + } + + if (TranscodeSeekInfo != TranscodeSeekInfo.Auto) + { + sb.Append("&TranscodeSeekInfo="); + sb.Append(TranscodeSeekInfo.ToString()); + } + + if (CopyTimestamps) + { + sb.Append("&CopyTimestamps="); + sb.Append(CopyTimestamps.ToString(CultureInfo.InvariantCulture)); + } + + if (RequireAvc) + { + sb.Append("&RequireAvc="); + sb.Append(RequireAvc.ToString(CultureInfo.InvariantCulture)); + } + + if (EnableAudioVbrEncoding) + { + sb.Append("EnableAudioVbrEncoding="); + sb.Append(EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()); + } } - if (!item.IsDirectStream) + var etag = MediaSource?.ETag; + if (!string.IsNullOrEmpty(etag)) { - list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString())); + sb.Append("&Tag="); + sb.Append(etag); } - return list; + if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod != SubtitleDeliveryMethod.External) + { + sb.Append("&SubtitleMethod="); + sb.AppendJoin(',', SubtitleDeliveryMethod); + } + + if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed && SubtitleCodecs.Count != 0) + { + sb.Append("&SubtitleCodec="); + sb.AppendJoin(',', SubtitleCodecs); + } + + foreach (var pair in StreamOptions) + { + // Strip spaces to avoid having to encode h264 profile names + sb.Append('&'); + sb.Append(pair.Key); + sb.Append('='); + sb.Append(pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal)); + } + + var transcodeReasonsValues = TranscodeReasons.GetUniqueFlags().ToArray(); + if (!IsDirectStream && transcodeReasonsValues.Length > 0) + { + sb.Append("&TranscodeReasons="); + sb.AppendJoin(',', transcodeReasonsValues); + } + + if (!string.IsNullOrEmpty(query)) + { + sb.Append(query); + } + + return sb.ToString(); } /// diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs index eff2e09da1..66de18cfe1 100644 --- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs +++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs @@ -1,12 +1,10 @@ #nullable disable #pragma warning disable CS1591 -using System; using System.Collections.Generic; using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; -using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; @@ -17,10 +15,10 @@ namespace MediaBrowser.Model.Dto { public MediaSourceInfo() { - Formats = Array.Empty(); - MediaStreams = Array.Empty(); - MediaAttachments = Array.Empty(); - RequiredHttpHeaders = new Dictionary(); + Formats = []; + MediaStreams = []; + MediaAttachments = []; + RequiredHttpHeaders = []; SupportsTranscoding = true; SupportsDirectStream = true; SupportsDirectPlay = true; diff --git a/src/Jellyfin.Extensions/EnumerableExtensions.cs b/src/Jellyfin.Extensions/EnumerableExtensions.cs index fd46358a4f..3eb9da01f2 100644 --- a/src/Jellyfin.Extensions/EnumerableExtensions.cs +++ b/src/Jellyfin.Extensions/EnumerableExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace Jellyfin.Extensions; @@ -55,4 +56,22 @@ public static class EnumerableExtensions { yield return item; } + + /// + /// Gets an IEnumerable consisting of all flags of an enum. + /// + /// The flags enum. + /// The type of item. + /// The IEnumerable{Enum}. + public static IEnumerable GetUniqueFlags(this T flags) + where T : Enum + { + foreach (Enum value in Enum.GetValues(flags.GetType())) + { + if (flags.HasFlag(value)) + { + yield return (T)value; + } + } + } } diff --git a/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs new file mode 100644 index 0000000000..981287c033 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Data.Enums; +using Jellyfin.Extensions; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Model.Tests.Dlna; + +public class LegacyStreamInfo : StreamInfo +{ + public LegacyStreamInfo(Guid itemId, DlnaProfileType mediaType) + { + ItemId = itemId; + MediaType = mediaType; + } + + /// + /// The 10.6 ToUrl code from StreamInfo.cs with which to compare new version. + /// + /// The base url to use. + /// The Access token. + /// A url. + public string ToUrl_Original(string baseUrl, string? accessToken) + { + ArgumentException.ThrowIfNullOrEmpty(baseUrl); + + var list = new List(); + foreach (NameValuePair pair in BuildParams(this, accessToken)) + { + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } + + // Try to keep the url clean by omitting defaults + if (string.Equals(pair.Name, "StartTimeTicks", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "0", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal); + + list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue)); + } + + string queryString = string.Join('&', list); + + return GetUrl(baseUrl, queryString); + } + + private string GetUrl(string baseUrl, string queryString) + { + ArgumentException.ThrowIfNullOrEmpty(baseUrl); + + string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container; + + baseUrl = baseUrl.TrimEnd('/'); + + if (MediaType == DlnaProfileType.Audio) + { + if (SubProtocol == MediaStreamProtocol.hls) + { + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + } + + return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + } + + if (SubProtocol == MediaStreamProtocol.hls) + { + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString); + } + + return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); + } + + private static List BuildParams(StreamInfo item, string? accessToken) + { + var list = new List(); + + string audioCodecs = item.AudioCodecs.Count == 0 ? + string.Empty : + string.Join(',', item.AudioCodecs); + + string videoCodecs = item.VideoCodecs.Count == 0 ? + string.Empty : + string.Join(',', item.VideoCodecs); + + list.Add(new NameValuePair("DeviceProfileId", item.DeviceProfileId ?? string.Empty)); + list.Add(new NameValuePair("DeviceId", item.DeviceId ?? string.Empty)); + list.Add(new NameValuePair("MediaSourceId", item.MediaSourceId ?? string.Empty)); + list.Add(new NameValuePair("Static", item.IsDirectStream.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + list.Add(new NameValuePair("VideoCodec", videoCodecs)); + list.Add(new NameValuePair("AudioCodec", audioCodecs)); + list.Add(new NameValuePair("AudioStreamIndex", item.AudioStreamIndex.HasValue ? item.AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("MaxHeight", item.MaxHeight.HasValue ? item.MaxHeight.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + long startPositionTicks = item.StartPositionTicks; + + if (item.SubProtocol == MediaStreamProtocol.hls) + { + list.Add(new NameValuePair("StartTimeTicks", string.Empty)); + list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty)); + + if (item.SegmentLength.HasValue) + { + list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture))); + } + + if (item.MinSegments.HasValue) + { + list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture))); + } + + list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture))); + } + else + { + list.Add(new NameValuePair("StartTimeTicks", startPositionTicks.ToString(CultureInfo.InvariantCulture))); + } + + list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); + list.Add(new NameValuePair("ApiKey", accessToken ?? string.Empty)); + + string? liveStreamId = item.MediaSource?.LiveStreamId; + list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); + + if (!item.IsDirectStream) + { + if (item.RequireNonAnamorphic) + { + list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + + if (item.EnableSubtitlesInManifest) + { + list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.EnableMpegtsM2TsMode) + { + list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.EstimateContentLength) + { + list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto) + { + list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant())); + } + + if (item.CopyTimestamps) + { + list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.RequireAvc) + { + list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + + if (item.EnableAudioVbrEncoding) + { + list.Add(new NameValuePair("EnableAudioVbrEncoding", item.EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); + } + } + + list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty)); + + string subtitleCodecs = item.SubtitleCodecs.Count == 0 ? + string.Empty : + string.Join(",", item.SubtitleCodecs); + + list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); + list.Add(new NameValuePair("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty)); + + foreach (var pair in item.StreamOptions) + { + if (string.IsNullOrEmpty(pair.Value)) + { + continue; + } + + // strip spaces to avoid having to encode h264 profile names + list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal))); + } + + var transcodeReasonsValues = item.TranscodeReasons.GetUniqueFlags().ToArray(); + if (!item.IsDirectStream && transcodeReasonsValues.Length > 0) + { + list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString())); + } + + return list; + } +} diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index db9791263e..ae9edd3867 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -594,7 +594,7 @@ namespace Jellyfin.Model.Tests private static (string Path, NameValueCollection Query, string Filename, string Extension) ParseUri(StreamInfo val) { - var href = val.ToUrl("media:", "ACCESSTOKEN").Split("?", 2); + var href = val.ToUrl("media:", "ACCESSTOKEN", null).Split("?", 2); var path = href[0]; var queryString = href.ElementAtOrDefault(1); diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs new file mode 100644 index 0000000000..86819de8c0 --- /dev/null +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Text; +using MediaBrowser.Model.Dlna; +using Xunit; + +namespace Jellyfin.Model.Tests.Dlna; + +public class StreamInfoTests +{ + private const string BaseUrl = "/test/"; + private const int RandomSeed = 298347823; + + /// + /// Returns a random float. + /// + /// The instance. + /// A random . + private static float RandomFloat(Random random) + { + var buffer = new byte[4]; + random.NextBytes(buffer); + return BitConverter.ToSingle(buffer, 0); + } + + /// + /// Creates a random array. + /// + /// The instance. + /// The element of the array. + /// An of . + private static object? RandomArray(Random random, Type? elementType) + { + if (elementType == null) + { + return null; + } + + if (elementType == typeof(string)) + { + return RandomStringArray(random); + } + + if (elementType == typeof(int)) + { + return RandomIntArray(random); + } + + if (elementType.IsEnum) + { + var values = Enum.GetValues(elementType); + return RandomIntArray(random, 0, values.Length - 1); + } + + throw new ArgumentException("Unsupported array type " + elementType.ToString()); + } + + /// + /// Creates a random length string. + /// + /// The instance. + /// The minimum length of the string. + /// The maximum length of the string. + /// The string. + private static string RandomString(Random random, int minLength = 0, int maxLength = 256) + { + var len = random.Next(minLength, maxLength); + var sb = new StringBuilder(len); + + while (len > 0) + { + sb.Append((char)random.Next(65, 97)); + len--; + } + + return sb.ToString(); + } + + /// + /// Creates a random long. + /// + /// The instance. + /// Min value. + /// Max value. + /// A random between and . + private static long RandomLong(Random random, long min = -9223372036854775808, long max = 9223372036854775807) + { + long result = random.Next((int)(min >> 32), (int)(max >> 32)); + result <<= 32; + result |= (long)random.Next((int)(min >> 32) << 32, (int)(max >> 32) << 32); + return result; + } + + /// + /// Creates a random string array containing between and . + /// + /// The instance. + /// The minimum number of elements. + /// The maximum number of elements. + /// A random instance. + private static string[] RandomStringArray(Random random, int minLength = 0, int maxLength = 9) + { + var len = random.Next(minLength, maxLength); + var arr = new List(len); + while (len > 0) + { + arr.Add(RandomString(random, 1, 30)); + len--; + } + + return arr.ToArray(); + } + + /// + /// Creates a random int array containing between and . + /// + /// The instance. + /// The minimum number of elements. + /// The maximum number of elements. + /// A random instance. + private static int[] RandomIntArray(Random random, int minLength = 0, int maxLength = 9) + { + var len = random.Next(minLength, maxLength); + var arr = new List(len); + while (len > 0) + { + arr.Add(random.Next()); + len--; + } + + return arr.ToArray(); + } + + /// + /// Fills most properties with random data. + /// + /// The instance to fill with data. + private static void FillAllProperties(T destination) + { + var random = new Random(RandomSeed); + var objectType = destination!.GetType(); + foreach (var property in objectType.GetProperties()) + { + if (!(property.CanRead && property.CanWrite)) + { + continue; + } + + var type = property.PropertyType; + // If nullable, then set it to null, 25% of the time. + if (Nullable.GetUnderlyingType(type) != null) + { + if (random.Next(0, 4) == 0) + { + // Set it to null. + property.SetValue(destination, null); + continue; + } + } + + if (type == typeof(Guid)) + { + property.SetValue(destination, Guid.NewGuid()); + continue; + } + + if (type.IsEnum) + { + Array values = Enum.GetValues(property.PropertyType); + property.SetValue(destination, values.GetValue(random.Next(0, values.Length - 1))); + continue; + } + + if (type == typeof(long)) + { + property.SetValue(destination, RandomLong(random)); + continue; + } + + if (type == typeof(string)) + { + property.SetValue(destination, RandomString(random)); + continue; + } + + if (type == typeof(bool)) + { + property.SetValue(destination, random.Next(0, 1) == 1); + continue; + } + + if (type == typeof(float)) + { + property.SetValue(destination, RandomFloat(random)); + continue; + } + + if (type.IsArray) + { + property.SetValue(destination, RandomArray(random, type.GetElementType())); + continue; + } + } + } + + [InlineData(DlnaProfileType.Audio)] + [InlineData(DlnaProfileType.Video)] + [InlineData(DlnaProfileType.Photo)] + [Theory] + public void Test_Blank_Url_Method(DlnaProfileType type) + { + var streamInfo = new LegacyStreamInfo(Guid.Empty, type) + { + DeviceProfile = new DeviceProfile() + }; + + string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123"); + + // New version will return and & after the ? due to optional parameters. + string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase); + + Assert.Equal(legacyUrl, newUrl, ignoreCase: true); + } + + [Fact] + public void Fuzzy_Comparison() + { + var streamInfo = new LegacyStreamInfo(Guid.Empty, DlnaProfileType.Video) + { + DeviceProfile = new DeviceProfile() + }; + for (int i = 0; i < 100000; i++) + { + FillAllProperties(streamInfo); + string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123"); + + // New version will return and & after the ? due to optional parameters. + string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase); + + Assert.Equal(legacyUrl, newUrl, ignoreCase: true); + } + } +}