Преглед на файлове

Reduce allocations, simplifed code, faster implementation, included tests - StreamInfo.ToUrl (#9369)

* Rework PR 6168

* Fix test
Tim Eisele преди 2 месеца
родител
ревизия
9657708b38

+ 2 - 4
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)
                     {

+ 1 - 1
MediaBrowser.Model/Dlna/StreamBuilder.cs

@@ -800,7 +800,7 @@ namespace MediaBrowser.Model.Dlna
                 options.SubtitleStreamIndex,
                 playlistItem.PlayMethod,
                 playlistItem.TranscodeReasons,
-                playlistItem.ToUrl("media:", "<token>"));
+                playlistItem.ToUrl("media:", "<token>", null));
 
             item.Container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile);
             return playlistItem;

+ 203 - 121
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
     /// </summary>
     /// <param name="baseUrl">The base Url.</param>
     /// <param name="accessToken">The access Token.</param>
+    /// <param name="query">Optional extra query.</param>
     /// <returns>A querystring representation of this object.</returns>
-    public string ToUrl(string baseUrl, string? accessToken)
+    public string ToUrl(string? baseUrl, string? accessToken, string? query)
     {
-        ArgumentException.ThrowIfNullOrEmpty(baseUrl);
+        var sb = new StringBuilder();
+        if (!string.IsNullOrEmpty(baseUrl))
+        {
+            sb.Append(baseUrl.TrimEnd('/'));
+        }
 
-        List<string> list = [];
-        foreach (NameValuePair pair in BuildParams(this, accessToken))
+        if (MediaType == DlnaProfileType.Audio)
         {
-            if (string.IsNullOrEmpty(pair.Value))
-            {
-                continue;
-            }
+            sb.Append("/audio/");
+        }
+        else
+        {
+            sb.Append("/videos/");
+        }
 
-            // 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;
-            }
+        sb.Append(ItemId);
 
-            if (string.Equals(pair.Name, "SubtitleStreamIndex", StringComparison.OrdinalIgnoreCase)
-                && string.Equals(pair.Value, "-1", StringComparison.OrdinalIgnoreCase))
-            {
-                continue;
-            }
+        if (SubProtocol == MediaStreamProtocol.hls)
+        {
+            sb.Append("/master.m3u8?");
+        }
+        else
+        {
+            sb.Append("/stream");
 
-            if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase)
-                && string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase))
+            if (!string.IsNullOrEmpty(Container))
             {
-                continue;
+                sb.Append('.');
+                sb.Append(Container);
             }
 
-            var encodedValue = pair.Value.Replace(" ", "%20", StringComparison.Ordinal);
+            sb.Append('?');
+        }
+
+        if (!string.IsNullOrEmpty(DeviceProfileId))
+        {
+            sb.Append("&DeviceProfileId=");
+            sb.Append(DeviceProfileId);
+        }
 
-            list.Add(string.Format(CultureInfo.InvariantCulture, "{0}={1}", pair.Name, encodedValue));
+        if (!string.IsNullOrEmpty(DeviceId))
+        {
+            sb.Append("&DeviceId=");
+            sb.Append(DeviceId);
         }
 
-        string queryString = string.Join('&', list);
+        if (!string.IsNullOrEmpty(MediaSourceId))
+        {
+            sb.Append("&MediaSourceId=");
+            sb.Append(MediaSourceId);
+        }
 
-        return GetUrl(baseUrl, queryString);
-    }
+        // default true so don't store.
+        if (IsDirectStream)
+        {
+            sb.Append("&Static=true");
+        }
 
-    private string GetUrl(string baseUrl, string queryString)
-    {
-        ArgumentException.ThrowIfNullOrEmpty(baseUrl);
+        if (VideoCodecs.Count != 0)
+        {
+            sb.Append("&VideoCodec=");
+            sb.AppendJoin(',', VideoCodecs);
+        }
 
-        string extension = string.IsNullOrEmpty(Container) ? string.Empty : "." + Container;
+        if (AudioCodecs.Count != 0)
+        {
+            sb.Append("&AudioCodec=");
+            sb.AppendJoin(',', AudioCodecs);
+        }
 
-        baseUrl = baseUrl.TrimEnd('/');
+        if (AudioStreamIndex.HasValue)
+        {
+            sb.Append("&AudioStreamIndex=");
+            sb.Append(AudioStreamIndex.Value.ToString(CultureInfo.InvariantCulture));
+        }
 
-        if (MediaType == DlnaProfileType.Audio)
+        if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod != SubtitleDeliveryMethod.External && SubtitleStreamIndex != -1)
         {
-            if (SubProtocol == MediaStreamProtocol.hls)
-            {
-                return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString);
-            }
+            sb.Append("&SubtitleStreamIndex=");
+            sb.Append(SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture));
+            sb.Append("&SubtitleMethod=");
+            sb.Append(SubtitleDeliveryMethod.ToString());
+        }
 
-            return string.Format(CultureInfo.InvariantCulture, "{0}/audio/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString);
+        if (VideoBitrate.HasValue)
+        {
+            sb.Append("&VideoBitrate=");
+            sb.Append(VideoBitrate.Value.ToString(CultureInfo.InvariantCulture));
         }
 
-        if (SubProtocol == MediaStreamProtocol.hls)
+        if (AudioBitrate.HasValue)
         {
-            return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/master.m3u8?{2}", baseUrl, ItemId, queryString);
+            sb.Append("&AudioBitrate=");
+            sb.Append(AudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
         }
 
-        return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString);
-    }
+        if (AudioSampleRate.HasValue)
+        {
+            sb.Append("&AudioSampleRate=");
+            sb.Append(AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture));
+        }
 
-    private static List<NameValuePair> BuildParams(StreamInfo item, string? accessToken)
-    {
-        List<NameValuePair> list = [];
+        if (MaxFramerate.HasValue)
+        {
+            sb.Append("&MaxFramerate=");
+            sb.Append(MaxFramerate.Value.ToString(CultureInfo.InvariantCulture));
+        }
 
-        string audioCodecs = item.AudioCodecs.Count == 0 ?
-            string.Empty :
-            string.Join(',', item.AudioCodecs);
+        if (MaxWidth.HasValue)
+        {
+            sb.Append("&MaxWidth=");
+            sb.Append(MaxWidth.Value.ToString(CultureInfo.InvariantCulture));
+        }
 
-        string videoCodecs = item.VideoCodecs.Count == 0 ?
-            string.Empty :
-            string.Join(',', item.VideoCodecs);
+        if (MaxHeight.HasValue)
+        {
+            sb.Append("&MaxHeight=");
+            sb.Append(MaxHeight.Value.ToString(CultureInfo.InvariantCulture));
+        }
 
-        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));
+        if (SubProtocol == MediaStreamProtocol.hls)
+        {
+            if (!string.IsNullOrEmpty(Container))
+            {
+                sb.Append("&SegmentContainer=");
+                sb.Append(Container);
+            }
 
-        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));
+            if (SegmentLength.HasValue)
+            {
+                sb.Append("&SegmentLength=");
+                sb.Append(SegmentLength.Value.ToString(CultureInfo.InvariantCulture));
+            }
 
-        long startPositionTicks = item.StartPositionTicks;
+            if (MinSegments.HasValue)
+            {
+                sb.Append("&MinSegments=");
+                sb.Append(MinSegments.Value.ToString(CultureInfo.InvariantCulture));
+            }
 
-        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));
+        if (!string.IsNullOrEmpty(PlaySessionId))
+        {
+            sb.Append("&PlaySessionId=");
+            sb.Append(PlaySessionId);
+        }
 
-        string? liveStreamId = item.MediaSource?.LiveStreamId;
-        list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty));
+        if (!string.IsNullOrEmpty(accessToken))
+        {
+            sb.Append("&ApiKey=");
+            sb.Append(accessToken);
+        }
 
-        list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty));
+        var liveStreamId = MediaSource?.LiveStreamId;
+        if (!string.IsNullOrEmpty(liveStreamId))
+        {
+            sb.Append("&LiveStreamId=");
+            sb.Append(liveStreamId);
+        }
 
-        if (!item.IsDirectStream)
+        if (!IsDirectStream)
         {
-            if (item.RequireNonAnamorphic)
+            if (RequireNonAnamorphic)
             {
-                list.Add(new NameValuePair("RequireNonAnamorphic", item.RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
+                sb.Append("&RequireNonAnamorphic=");
+                sb.Append(RequireNonAnamorphic.ToString(CultureInfo.InvariantCulture));
             }
 
-            list.Add(new NameValuePair("TranscodingMaxAudioChannels", item.TranscodingMaxAudioChannels.HasValue ? item.TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : string.Empty));
-
-            if (item.EnableSubtitlesInManifest)
+            if (TranscodingMaxAudioChannels.HasValue)
             {
-                list.Add(new NameValuePair("EnableSubtitlesInManifest", item.EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
+                sb.Append("&TranscodingMaxAudioChannels=");
+                sb.Append(TranscodingMaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture));
             }
 
-            if (item.EnableMpegtsM2TsMode)
+            if (EnableSubtitlesInManifest)
             {
-                list.Add(new NameValuePair("EnableMpegtsM2TsMode", item.EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
+                sb.Append("&EnableSubtitlesInManifest=");
+                sb.Append(EnableSubtitlesInManifest.ToString(CultureInfo.InvariantCulture));
             }
 
-            if (item.EstimateContentLength)
+            if (EnableMpegtsM2TsMode)
             {
-                list.Add(new NameValuePair("EstimateContentLength", item.EstimateContentLength.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
+                sb.Append("&EnableMpegtsM2TsMode=");
+                sb.Append(EnableMpegtsM2TsMode.ToString(CultureInfo.InvariantCulture));
             }
 
-            if (item.TranscodeSeekInfo != TranscodeSeekInfo.Auto)
+            if (EstimateContentLength)
             {
-                list.Add(new NameValuePair("TranscodeSeekInfo", item.TranscodeSeekInfo.ToString().ToLowerInvariant()));
+                sb.Append("&EstimateContentLength=");
+                sb.Append(EstimateContentLength.ToString(CultureInfo.InvariantCulture));
             }
 
-            if (item.CopyTimestamps)
+            if (TranscodeSeekInfo != TranscodeSeekInfo.Auto)
             {
-                list.Add(new NameValuePair("CopyTimestamps", item.CopyTimestamps.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
+                sb.Append("&TranscodeSeekInfo=");
+                sb.Append(TranscodeSeekInfo.ToString());
             }
 
-            list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
-
-            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("SubtitleCodec", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed ? subtitleCodecs : string.Empty));
-
-        if (item.SubProtocol == MediaStreamProtocol.hls)
-        {
-            list.Add(new NameValuePair("SegmentContainer", item.Container ?? string.Empty));
+            if (CopyTimestamps)
+            {
+                sb.Append("&CopyTimestamps=");
+                sb.Append(CopyTimestamps.ToString(CultureInfo.InvariantCulture));
+            }
 
-            if (item.SegmentLength.HasValue)
+            if (RequireAvc)
             {
-                list.Add(new NameValuePair("SegmentLength", item.SegmentLength.Value.ToString(CultureInfo.InvariantCulture)));
+                sb.Append("&RequireAvc=");
+                sb.Append(RequireAvc.ToString(CultureInfo.InvariantCulture));
             }
 
-            if (item.MinSegments.HasValue)
+            if (EnableAudioVbrEncoding)
             {
-                list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture)));
+                sb.Append("EnableAudioVbrEncoding=");
+                sb.Append(EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant());
             }
+        }
 
-            list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture)));
+        var etag = MediaSource?.ETag;
+        if (!string.IsNullOrEmpty(etag))
+        {
+            sb.Append("&Tag=");
+            sb.Append(etag);
         }
 
-        foreach (var pair in item.StreamOptions)
+        if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod != SubtitleDeliveryMethod.External)
         {
-            if (string.IsNullOrEmpty(pair.Value))
-            {
-                continue;
-            }
+            sb.Append("&SubtitleMethod=");
+            sb.AppendJoin(',', SubtitleDeliveryMethod);
+        }
 
-            // strip spaces to avoid having to encode h264 profile names
-            list.Add(new NameValuePair(pair.Key, pair.Value.Replace(" ", string.Empty, StringComparison.Ordinal)));
+        if (SubtitleStreamIndex.HasValue && SubtitleDeliveryMethod == SubtitleDeliveryMethod.Embed && SubtitleCodecs.Count != 0)
+        {
+            sb.Append("&SubtitleCodec=");
+            sb.AppendJoin(',', SubtitleCodecs);
         }
 
-        if (!item.IsDirectStream)
+        foreach (var pair in StreamOptions)
         {
-            list.Add(new NameValuePair("TranscodeReasons", item.TranscodeReasons.ToString()));
+            // 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));
         }
 
-        return list;
+        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();
     }
 
     /// <summary>

+ 4 - 6
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<string>();
-            MediaStreams = Array.Empty<MediaStream>();
-            MediaAttachments = Array.Empty<MediaAttachment>();
-            RequiredHttpHeaders = new Dictionary<string, string>();
+            Formats = [];
+            MediaStreams = [];
+            MediaAttachments = [];
+            RequiredHttpHeaders = [];
             SupportsTranscoding = true;
             SupportsDirectStream = true;
             SupportsDirectPlay = true;

+ 19 - 0
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;
     }
+
+    /// <summary>
+    /// Gets an IEnumerable consisting of all flags of an enum.
+    /// </summary>
+    /// <param name="flags">The flags enum.</param>
+    /// <typeparam name="T">The type of item.</typeparam>
+    /// <returns>The IEnumerable{Enum}.</returns>
+    public static IEnumerable<T> GetUniqueFlags<T>(this T flags)
+        where T : Enum
+    {
+        foreach (Enum value in Enum.GetValues(flags.GetType()))
+        {
+            if (flags.HasFlag(value))
+            {
+                yield return (T)value;
+            }
+        }
+    }
 }

+ 224 - 0
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;
+    }
+
+    /// <summary>
+    /// The 10.6 ToUrl code from StreamInfo.cs with which to compare new version.
+    /// </summary>
+    /// <param name="baseUrl">The base url to use.</param>
+    /// <param name="accessToken">The Access token.</param>
+    /// <returns>A url.</returns>
+    public string ToUrl_Original(string baseUrl, string? accessToken)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(baseUrl);
+
+        var list = new List<string>();
+        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<NameValuePair> BuildParams(StreamInfo item, string? accessToken)
+    {
+        var list = new List<NameValuePair>();
+
+        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;
+    }
+}

+ 1 - 1
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);

+ 243 - 0
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;
+
+    /// <summary>
+    /// Returns a random float.
+    /// </summary>
+    /// <param name="random">The <see cref="Random"/> instance.</param>
+    /// <returns>A random <see cref="float"/>.</returns>
+    private static float RandomFloat(Random random)
+    {
+        var buffer = new byte[4];
+        random.NextBytes(buffer);
+        return BitConverter.ToSingle(buffer, 0);
+    }
+
+    /// <summary>
+    /// Creates a random array.
+    /// </summary>
+    /// <param name="random">The <see cref="Random"/> instance.</param>
+    /// <param name="elementType">The element <see cref="Type"/> of the array.</param>
+    /// <returns>An <see cref="Array"/> of <see cref="Type"/>.</returns>
+    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());
+    }
+
+    /// <summary>
+    /// Creates a random length string.
+    /// </summary>
+    /// <param name="random">The <see cref="Random"/> instance.</param>
+    /// <param name="minLength">The minimum length of the string.</param>
+    /// <param name="maxLength">The maximum length of the string.</param>
+    /// <returns>The string.</returns>
+    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();
+    }
+
+    /// <summary>
+    /// Creates a random long.
+    /// </summary>
+    /// <param name="random">The <see cref="Random"/> instance.</param>
+    /// <param name="min">Min value.</param>
+    /// <param name="max">Max value.</param>
+    /// <returns>A random <see cref="long"/> between <paramref name="min"/> and <paramref name="max"/>.</returns>
+    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;
+    }
+
+    /// <summary>
+    /// Creates a random string array containing between <paramref name="minLength"/> and <paramref name="maxLength"/>.
+    /// </summary>
+    /// <param name="random">The <see cref="Random"/> instance.</param>
+    /// <param name="minLength">The minimum number of elements.</param>
+    /// <param name="maxLength">The maximum number of elements.</param>
+    /// <returns>A random <see cref="string[]"/> instance.</returns>
+    private static string[] RandomStringArray(Random random, int minLength = 0, int maxLength = 9)
+    {
+        var len = random.Next(minLength, maxLength);
+        var arr = new List<string>(len);
+        while (len > 0)
+        {
+            arr.Add(RandomString(random, 1, 30));
+            len--;
+        }
+
+        return arr.ToArray();
+    }
+
+    /// <summary>
+    /// Creates a random int array containing between <paramref name="minLength"/> and <paramref name="maxLength"/>.
+    /// </summary>
+    /// <param name="random">The <see cref="Random"/> instance.</param>
+    /// <param name="minLength">The minimum number of elements.</param>
+    /// <param name="maxLength">The maximum number of elements.</param>
+    /// <returns>A random <see cref="int[]"/> instance.</returns>
+    private static int[] RandomIntArray(Random random, int minLength = 0, int maxLength = 9)
+    {
+        var len = random.Next(minLength, maxLength);
+        var arr = new List<int>(len);
+        while (len > 0)
+        {
+            arr.Add(random.Next());
+            len--;
+        }
+
+        return arr.ToArray();
+    }
+
+    /// <summary>
+    /// Fills most properties with random data.
+    /// </summary>
+    /// <param name="destination">The instance to fill with data.</param>
+    private static void FillAllProperties<T>(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);
+        }
+    }
+}