Explorar el Código

Merge branch 'master' into network-rewrite

Shadowghost hace 2 años
padre
commit
c5a363a007
Se han modificado 86 ficheros con 763 adiciones y 528 borrados
  1. 2 2
      Directory.Packages.props
  2. 2 7
      Emby.Naming/Audio/AlbumParser.cs
  3. 2 2
      Emby.Naming/AudioBook/AudioBookFilePathParser.cs
  4. 1 1
      Emby.Naming/AudioBook/AudioBookNameParser.cs
  5. 9 33
      Emby.Naming/Common/NamingOptions.cs
  6. 7 7
      Emby.Naming/TV/EpisodePathParser.cs
  7. 1 1
      Emby.Naming/Video/CleanDateTimeParser.cs
  8. 1 1
      Emby.Naming/Video/ExtraRuleResolver.cs
  9. 14 15
      Emby.Naming/Video/VideoListResolver.cs
  10. 1 1
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  11. 2 7
      Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
  12. 5 7
      Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
  13. 13 22
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  14. 2 2
      Emby.Server.Implementations/Localization/Core/es-AR.json
  15. 1 1
      Emby.Server.Implementations/Localization/Core/id.json
  16. 8 8
      Emby.Server.Implementations/Localization/Core/nl.json
  17. 3 3
      Emby.Server.Implementations/Localization/Core/ru.json
  18. 2 1
      Emby.Server.Implementations/Localization/Core/sl-SI.json
  19. 2 4
      Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
  20. 8 5
      Emby.Server.Implementations/Session/SessionWebSocketListener.cs
  21. 1 4
      Emby.Server.Implementations/Sorting/RuntimeComparer.cs
  22. 2 5
      Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs
  23. 1 4
      Emby.Server.Implementations/Sorting/SortNameComparer.cs
  24. 2 4
      Emby.Server.Implementations/Sorting/StartDateComparer.cs
  25. 1 4
      Emby.Server.Implementations/Sorting/StudioComparer.cs
  26. 5 7
      Emby.Server.Implementations/TV/TVSeriesManager.cs
  27. 6 3
      Jellyfin.Api/Controllers/ArtistsController.cs
  28. 6 3
      Jellyfin.Api/Controllers/ChannelsController.cs
  29. 2 0
      Jellyfin.Api/Controllers/DevicesController.cs
  30. 6 2
      Jellyfin.Api/Controllers/FilterController.cs
  31. 4 2
      Jellyfin.Api/Controllers/GenresController.cs
  32. 50 15
      Jellyfin.Api/Controllers/ImageController.cs
  33. 15 7
      Jellyfin.Api/Controllers/InstantMixController.cs
  34. 2 1
      Jellyfin.Api/Controllers/ItemsController.cs
  35. 14 8
      Jellyfin.Api/Controllers/LibraryController.cs
  36. 17 9
      Jellyfin.Api/Controllers/LiveTvController.cs
  37. 4 1
      Jellyfin.Api/Controllers/MediaInfoController.cs
  38. 3 1
      Jellyfin.Api/Controllers/MoviesController.cs
  39. 4 2
      Jellyfin.Api/Controllers/MusicGenresController.cs
  40. 5 2
      Jellyfin.Api/Controllers/PersonsController.cs
  41. 6 2
      Jellyfin.Api/Controllers/PlaylistsController.cs
  42. 3 8
      Jellyfin.Api/Controllers/QuickConnectController.cs
  43. 4 1
      Jellyfin.Api/Controllers/SearchController.cs
  44. 4 2
      Jellyfin.Api/Controllers/StudiosController.cs
  45. 10 5
      Jellyfin.Api/Controllers/TvShowsController.cs
  46. 1 5
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  47. 3 2
      Jellyfin.Api/Controllers/VideosController.cs
  48. 4 2
      Jellyfin.Api/Controllers/YearsController.cs
  49. 1 2
      Jellyfin.Api/Extensions/ClaimsPrincipalExtensions.cs
  50. 27 0
      Jellyfin.Api/Helpers/RequestHelpers.cs
  51. 2 2
      Jellyfin.Api/Helpers/StreamingHelpers.cs
  52. 1 2
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  53. 1 1
      Jellyfin.Server.Implementations/Users/UserManager.cs
  54. 2 1
      Jellyfin.Server/Migrations/MigrationRunner.cs
  55. 89 0
      Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
  56. 1 1
      Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
  57. 4 8
      MediaBrowser.Controller/LiveTv/LiveTvChannel.cs
  58. 38 67
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  59. 5 10
      MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
  60. 4 4
      MediaBrowser.Controller/MediaEncoding/JobLogger.cs
  61. 0 2
      MediaBrowser.Controller/Session/ISessionController.cs
  62. 2 5
      MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs
  63. 3 3
      MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
  64. 13 20
      MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
  65. 2 2
      MediaBrowser.Model/Dlna/ConditionProcessor.cs
  66. 1 1
      MediaBrowser.Model/Dlna/SortCriteria.cs
  67. 11 12
      MediaBrowser.Model/Dlna/StreamBuilder.cs
  68. 4 20
      MediaBrowser.Model/Dlna/StreamInfo.cs
  69. 5 0
      MediaBrowser.Model/Net/MimeTypes.cs
  70. 2 2
      MediaBrowser.Model/Tasks/ITaskManager.cs
  71. 0 1
      MediaBrowser.Providers/Manager/MetadataService.cs
  72. 1 2
      MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs
  73. 11 15
      MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
  74. 54 36
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
  75. 30 25
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
  76. 3 5
      MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
  77. 28 28
      MediaBrowser.Providers/Subtitles/SubtitleManager.cs
  78. 2 5
      MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
  79. 36 0
      tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs
  80. 80 0
      tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs
  81. 2 1
      tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs
  82. 0 2
      tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs
  83. 5 0
      tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs
  84. 1 0
      tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs
  85. 19 0
      tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
  86. 2 2
      tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs

+ 2 - 2
Directory.Packages.props

@@ -54,9 +54,9 @@
     <PackageVersion Include="NEbml" Version="0.11.0" />
     <PackageVersion Include="Newtonsoft.Json" Version="13.0.2" />
     <PackageVersion Include="PlaylistsNET" Version="1.3.1" />
-    <PackageVersion Include="prometheus-net.AspNetCore" Version="7.0.0" />
+    <PackageVersion Include="prometheus-net.AspNetCore" Version="8.0.0" />
     <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
-    <PackageVersion Include="prometheus-net" Version="7.0.0" />
+    <PackageVersion Include="prometheus-net" Version="8.0.0" />
     <PackageVersion Include="Serilog.AspNetCore" Version="6.1.0" />
     <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" />
     <PackageVersion Include="Serilog.Settings.Configuration" Version="3.4.0" />

+ 2 - 7
Emby.Naming/Audio/AlbumParser.cs

@@ -3,6 +3,7 @@ using System.Globalization;
 using System.IO;
 using System.Text.RegularExpressions;
 using Emby.Naming.Common;
+using Jellyfin.Extensions;
 
 namespace Emby.Naming.Audio
 {
@@ -58,13 +59,7 @@ namespace Emby.Naming.Audio
 
                 var tmp = trimmedFilename.Slice(prefix.Length).Trim();
 
-                int index = tmp.IndexOf(' ');
-                if (index != -1)
-                {
-                    tmp = tmp.Slice(0, index);
-                }
-
-                if (int.TryParse(tmp, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
+                if (int.TryParse(tmp.LeftPart(' '), CultureInfo.InvariantCulture, out _))
                 {
                     return true;
                 }

+ 2 - 2
Emby.Naming/AudioBook/AudioBookFilePathParser.cs

@@ -40,7 +40,7 @@ namespace Emby.Naming.AudioBook
                         var value = match.Groups["chapter"];
                         if (value.Success)
                         {
-                            if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
+                            if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
                             {
                                 result.ChapterNumber = intValue;
                             }
@@ -52,7 +52,7 @@ namespace Emby.Naming.AudioBook
                         var value = match.Groups["part"];
                         if (value.Success)
                         {
-                            if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
+                            if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
                             {
                                 result.PartNumber = intValue;
                             }

+ 1 - 1
Emby.Naming/AudioBook/AudioBookNameParser.cs

@@ -47,7 +47,7 @@ namespace Emby.Naming.AudioBook
                         var value = match.Groups["year"];
                         if (value.Success)
                         {
-                            if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
+                            if (int.TryParse(value.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue))
                             {
                                 result.Year = intValue;
                             }

+ 9 - 33
Emby.Naming/Common/NamingOptions.cs

@@ -338,7 +338,15 @@ namespace Emby.Naming.Common
                     }
                 },
 
-                // This isn't a Kodi naming rule, but the expression below causes false positives,
+                // This isn't a Kodi naming rule, but the expression below causes false episode numbers for
+                // Title Season X Episode X naming schemes.
+                // "Series Season X Episode X - Title.avi", "Series S03 E09.avi", "s3 e9 - Title.avi"
+                new EpisodeExpression(@".*[\\\/]((?<seriesname>[^\\/]+?)\s)?[Ss](?:eason)?\s*(?<seasonnumber>[0-9]+)\s+[Ee](?:pisode)?\s*(?<epnumber>[0-9]+).*$")
+                {
+                    IsNamed = true
+                },
+
+                // Not a Kodi rule as well, but the expression below also causes false positives,
                 // so we make sure this one gets tested first.
                 // "Foo Bar 889"
                 new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
@@ -453,16 +461,6 @@ namespace Emby.Naming.Common
                 },
             };
 
-            EpisodeWithoutSeasonExpressions = new[]
-            {
-                @"[/\._ \-]()([0-9]+)(-[0-9]+)?"
-            };
-
-            EpisodeMultiPartExpressions = new[]
-            {
-                @"^[-_ex]+([0-9]+(?:(?:[a-i]|\\.[1-9])(?![0-9]))?)"
-            };
-
             VideoExtraRules = new[]
             {
                 new ExtraRule(
@@ -797,16 +795,6 @@ namespace Emby.Naming.Common
         /// </summary>
         public EpisodeExpression[] EpisodeExpressions { get; set; }
 
-        /// <summary>
-        /// Gets or sets list of raw episode without season regular expressions strings.
-        /// </summary>
-        public string[] EpisodeWithoutSeasonExpressions { get; set; }
-
-        /// <summary>
-        /// Gets or sets list of raw multi-part episodes regular expressions strings.
-        /// </summary>
-        public string[] EpisodeMultiPartExpressions { get; set; }
-
         /// <summary>
         /// Gets or sets list of video file extensions.
         /// </summary>
@@ -877,16 +865,6 @@ namespace Emby.Naming.Common
         /// </summary>
         public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>();
 
-        /// <summary>
-        /// Gets list of episode without season regular expressions.
-        /// </summary>
-        public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } = Array.Empty<Regex>();
-
-        /// <summary>
-        /// Gets list of multi-part episode regular expressions.
-        /// </summary>
-        public Regex[] EpisodeMultiPartRegexes { get; private set; } = Array.Empty<Regex>();
-
         /// <summary>
         /// Compiles raw regex strings into regexes.
         /// </summary>
@@ -894,8 +872,6 @@ namespace Emby.Naming.Common
         {
             CleanDateTimeRegexes = CleanDateTimes.Select(Compile).ToArray();
             CleanStringRegexes = CleanStrings.Select(Compile).ToArray();
-            EpisodeWithoutSeasonRegexes = EpisodeWithoutSeasonExpressions.Select(Compile).ToArray();
-            EpisodeMultiPartRegexes = EpisodeMultiPartExpressions.Select(Compile).ToArray();
         }
 
         private Regex Compile(string exp)

+ 7 - 7
Emby.Naming/TV/EpisodePathParser.cs

@@ -113,7 +113,7 @@ namespace Emby.Naming.TV
                     if (expression.DateTimeFormats.Length > 0)
                     {
                         if (DateTime.TryParseExact(
-                            match.Groups[0].Value,
+                            match.Groups[0].ValueSpan,
                             expression.DateTimeFormats,
                             CultureInfo.InvariantCulture,
                             DateTimeStyles.None,
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
                             result.Success = true;
                         }
                     }
-                    else if (DateTime.TryParse(match.Groups[0].Value, out date))
+                    else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
                     {
                         result.Year = date.Year;
                         result.Month = date.Month;
@@ -138,12 +138,12 @@ namespace Emby.Naming.TV
                 }
                 else if (expression.IsNamed)
                 {
-                    if (int.TryParse(match.Groups["seasonnumber"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
+                    if (int.TryParse(match.Groups["seasonnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
                     {
                         result.SeasonNumber = num;
                     }
 
-                    if (int.TryParse(match.Groups["epnumber"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
+                    if (int.TryParse(match.Groups["epnumber"].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
                     {
                         result.EpisodeNumber = num;
                     }
@@ -158,7 +158,7 @@ namespace Emby.Naming.TV
                         if (nextIndex >= name.Length
                             || !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
                         {
-                            if (int.TryParse(endingNumberGroup.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
+                            if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
                             {
                                 result.EndingEpisodeNumber = num;
                             }
@@ -170,12 +170,12 @@ namespace Emby.Naming.TV
                 }
                 else
                 {
-                    if (int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
+                    if (int.TryParse(match.Groups[1].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
                     {
                         result.SeasonNumber = num;
                     }
 
-                    if (int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
+                    if (int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
                     {
                         result.EpisodeNumber = num;
                     }

+ 1 - 1
Emby.Naming/Video/CleanDateTimeParser.cs

@@ -43,7 +43,7 @@ namespace Emby.Naming.Video
                 && match.Groups.Count == 5
                 && match.Groups[1].Success
                 && match.Groups[2].Success
-                && int.TryParse(match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
+                && int.TryParse(match.Groups[2].ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
             {
                 result = new CleanDateTimeResult(match.Groups[1].Value.TrimEnd(), year);
                 return true;

+ 1 - 1
Emby.Naming/Video/ExtraRuleResolver.cs

@@ -56,7 +56,7 @@ namespace Emby.Naming.Video
                 }
                 else if (rule.RuleType == ExtraRuleType.Regex)
                 {
-                    var filename = Path.GetFileName(path);
+                    var filename = Path.GetFileName(path.AsSpan());
 
                     var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
 

+ 14 - 15
Emby.Naming/Video/VideoListResolver.cs

@@ -106,6 +106,7 @@ namespace Emby.Naming.Video
             }
 
             // Cannot use Span inside local functions and delegates thus we cannot use LINQ here nor merge with the above [if]
+            VideoInfo? primary = null;
             for (var i = 0; i < videos.Count; i++)
             {
                 var video = videos[i];
@@ -118,25 +119,24 @@ namespace Emby.Naming.Video
                 {
                     return videos;
                 }
+
+                if (folderName.Equals(Path.GetFileNameWithoutExtension(video.Files[0].Path.AsSpan()), StringComparison.Ordinal))
+                {
+                    primary = video;
+                }
             }
 
             // The list is created and overwritten in the caller, so we are allowed to do in-place sorting
             videos.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.Ordinal));
+            primary ??= videos[0];
+            videos.Remove(primary);
 
             var list = new List<VideoInfo>
             {
-                videos[0]
+                primary
             };
 
-            var alternateVersionsLen = videos.Count - 1;
-            var alternateVersions = new VideoFileInfo[alternateVersionsLen];
-            for (int i = 0; i < alternateVersionsLen; i++)
-            {
-                var video = videos[i + 1];
-                alternateVersions[i] = video.Files[0];
-            }
-
-            list[0].AlternateVersions = alternateVersions;
+            list[0].AlternateVersions = videos.Select(x => x.Files[0]).ToArray();
             list[0].Name = folderName.ToString();
 
             return list;
@@ -176,16 +176,15 @@ namespace Emby.Naming.Video
             }
 
             // There are no span overloads for regex unfortunately
-            var tmpTestFilename = testFilename.ToString();
-            if (CleanStringParser.TryClean(tmpTestFilename, namingOptions.CleanStringRegexes, out var cleanName))
+            if (CleanStringParser.TryClean(testFilename.ToString(), namingOptions.CleanStringRegexes, out var cleanName))
             {
-                tmpTestFilename = cleanName.Trim();
+                testFilename = cleanName.AsSpan().Trim();
             }
 
             // The CleanStringParser should have removed common keywords etc.
-            return string.IsNullOrEmpty(tmpTestFilename)
+            return testFilename.IsEmpty
                    || testFilename[0] == '-'
-                   || Regex.IsMatch(tmpTestFilename, @"^\[([^]]*)\]", RegexOptions.Compiled);
+                   || Regex.IsMatch(testFilename, @"^\[([^]]*)\]", RegexOptions.Compiled);
         }
     }
 }

+ 1 - 1
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -1195,7 +1195,7 @@ namespace Emby.Server.Implementations.Data
                 Path = RestorePath(path.ToString())
             };
 
-            if (long.TryParse(dateModified, NumberStyles.Any, CultureInfo.InvariantCulture, out var ticks)
+            if (long.TryParse(dateModified, CultureInfo.InvariantCulture, out var ticks)
                 && ticks >= DateTime.MinValue.Ticks
                 && ticks <= DateTime.MaxValue.Ticks)
             {

+ 2 - 7
Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs

@@ -313,13 +313,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
             return result;
         }
 
-        private static bool IsIgnored(string filename)
-        {
-            // Ignore samples
-            Match m = Regex.Match(filename, @"\bsample\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
-            return m.Success;
-        }
+        private static bool IsIgnored(ReadOnlySpan<char> filename)
+            => Regex.IsMatch(filename, @"\bsample\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
 
         private static bool ContainsFile(IReadOnlyList<VideoInfo> result, FileSystemMetadata file)
         {

+ 5 - 7
Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs

@@ -570,15 +570,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings
                 _tokens.TryAdd(username, savedToken);
             }
 
-            if (!string.IsNullOrEmpty(savedToken.Name) && !string.IsNullOrEmpty(savedToken.Value))
+            if (!string.IsNullOrEmpty(savedToken.Name)
+                && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks))
             {
-                if (long.TryParse(savedToken.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out long ticks))
+                // If it's under 24 hours old we can still use it
+                if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks)
                 {
-                    // If it's under 24 hours old we can still use it
-                    if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks)
-                    {
-                        return savedToken.Name;
-                    }
+                    return savedToken.Name;
                 }
             }
 

+ 13 - 22
Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs

@@ -168,28 +168,24 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             string numberString = null;
             string attributeValue;
 
-            if (attributes.TryGetValue("tvg-chno", out attributeValue))
+            if (attributes.TryGetValue("tvg-chno", out attributeValue)
+                && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
             {
-                if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
-                {
-                    numberString = attributeValue;
-                }
+                numberString = attributeValue;
             }
 
             if (!IsValidChannelNumber(numberString))
             {
                 if (attributes.TryGetValue("tvg-id", out attributeValue))
                 {
-                    if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
+                    if (double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
                     {
                         numberString = attributeValue;
                     }
-                    else if (attributes.TryGetValue("channel-id", out attributeValue))
+                    else if (attributes.TryGetValue("channel-id", out attributeValue)
+                        && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
                     {
-                        if (double.TryParse(attributeValue, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
-                        {
-                            numberString = attributeValue;
-                        }
+                        numberString = attributeValue;
                     }
                 }
 
@@ -207,7 +203,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
                         {
                             var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' });
 
-                            if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
+                            if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
                             {
                                 numberString = numberPart.ToString();
                             }
@@ -255,19 +251,14 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
 
         private static bool IsValidChannelNumber(string numberString)
         {
-            if (string.IsNullOrWhiteSpace(numberString) ||
-                string.Equals(numberString, "-1", StringComparison.OrdinalIgnoreCase) ||
-                string.Equals(numberString, "0", StringComparison.OrdinalIgnoreCase))
-            {
-                return false;
-            }
-
-            if (!double.TryParse(numberString, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
+            if (string.IsNullOrWhiteSpace(numberString)
+                || string.Equals(numberString, "-1", StringComparison.Ordinal)
+                || string.Equals(numberString, "0", StringComparison.Ordinal))
             {
                 return false;
             }
 
-            return true;
+            return double.TryParse(numberString, CultureInfo.InvariantCulture, out _);
         }
 
         private static string GetChannelName(string extInf, Dictionary<string, string> attributes)
@@ -285,7 +276,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
                 {
                     var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' });
 
-                    if (double.TryParse(numberPart, NumberStyles.Any, CultureInfo.InvariantCulture, out _))
+                    if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
                     {
                         // channel.Number = number.ToString();
                         nameInExtInf = nameInExtInf.Substring(numberIndex + 1).Trim(new[] { ' ', '-' });

+ 2 - 2
Emby.Server.Implementations/Localization/Core/es-AR.json

@@ -118,11 +118,11 @@
     "TaskCleanActivityLog": "Borrar log de actividades",
     "Undefined": "Indefinido",
     "Forced": "Forzado",
-    "Default": "Por Defecto",
+    "Default": "Predeterminado",
     "TaskOptimizeDatabaseDescription": "Compacta la base de datos y restaura el espacio libre. Ejecutar esta tarea después de actualizar las librerías o realizar otros cambios que impliquen modificar las bases de datos puede mejorar la performance.",
     "TaskOptimizeDatabase": "Optimización de base de datos",
     "External": "Externo",
     "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reprodución HLS más precisas. Esta tarea puede durar mucho tiempo.",
     "TaskKeyframeExtractor": "Extractor de Fotogramas Clave",
-    "HearingImpaired": "Personas con discapacidad auditiva"
+    "HearingImpaired": "Discapacidad Auditiva"
 }

+ 1 - 1
Emby.Server.Implementations/Localization/Core/id.json

@@ -82,7 +82,7 @@
     "MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui",
     "MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui",
     "FailedLoginAttemptWithUserName": "Gagal melakukan login dari {0}",
-    "CameraImageUploadedFrom": "Gambar kamera baru telah diunggah dari {0}",
+    "CameraImageUploadedFrom": "Sebuah gambar kamera baru telah diunggah dari {0}",
     "DeviceOfflineWithName": "{0} telah terputus",
     "DeviceOnlineWithName": "{0} telah terhubung",
     "NotificationOptionVideoPlaybackStopped": "Pemutaran video berhenti",

+ 8 - 8
Emby.Server.Implementations/Localization/Core/nl.json

@@ -58,8 +58,8 @@
     "NotificationOptionServerRestartRequired": "Server herstart nodig",
     "NotificationOptionTaskFailed": "Geplande taak mislukt",
     "NotificationOptionUserLockedOut": "Gebruiker is vergrendeld",
-    "NotificationOptionVideoPlayback": "Video gestart",
-    "NotificationOptionVideoPlaybackStopped": "Video gestopt",
+    "NotificationOptionVideoPlayback": "Afspelen van video gestart",
+    "NotificationOptionVideoPlaybackStopped": "Afspelen van video gestopt",
     "Photos": "Foto's",
     "Playlists": "Afspeellijsten",
     "Plugin": "Plug-in",
@@ -95,26 +95,26 @@
     "TaskDownloadMissingSubtitlesDescription": "Zoekt op het internet naar ontbrekende ondertiteling gebaseerd op metadataconfiguratie.",
     "TaskDownloadMissingSubtitles": "Ontbrekende ondertiteling downloaden",
     "TaskRefreshChannelsDescription": "Vernieuwt informatie van internet kanalen.",
-    "TaskRefreshChannels": "Vernieuw Kanalen",
+    "TaskRefreshChannels": "Vernieuw kanalen",
     "TaskCleanTranscodeDescription": "Verwijdert transcode bestanden ouder dan 1 dag.",
     "TaskCleanLogs": "Logboekmap opschonen",
     "TaskCleanTranscode": "Transcoderingsmap opschonen",
     "TaskUpdatePluginsDescription": "Downloadt en installeert updates van plug-ins waarvoor automatisch bijwerken is ingeschakeld.",
     "TaskUpdatePlugins": "Plug-ins bijwerken",
-    "TaskRefreshPeopleDescription": "Update metadata for acteurs en regisseurs in de media bibliotheek.",
+    "TaskRefreshPeopleDescription": "Update metadata voor acteurs en regisseurs in de media bibliotheek.",
     "TaskRefreshPeople": "Personen vernieuwen",
     "TaskCleanLogsDescription": "Verwijdert log bestanden ouder dan {0} dagen.",
     "TaskRefreshLibraryDescription": "Scant de mediabibliotheek op nieuwe bestanden en vernieuwt de metadata.",
     "TaskRefreshLibrary": "Mediabibliotheek scannen",
-    "TaskRefreshChapterImagesDescription": "Maakt thumbnails aan voor videos met hoofdstukken.",
-    "TaskRefreshChapterImages": "Hoofdstukafbeeldingen uitpakken",
+    "TaskRefreshChapterImagesDescription": "Maakt voorbeeldafbeedingen aan voor video's met hoofdstukken.",
+    "TaskRefreshChapterImages": "Hoofdstukafbeeldingen extraheren",
     "TaskCleanCacheDescription": "Verwijdert gecachte bestanden die het systeem niet langer nodig heeft.",
     "TaskCleanCache": "Cache-map opschonen",
-    "TasksChannelsCategory": "Internet Kanalen",
+    "TasksChannelsCategory": "Internetkanalen",
     "TasksApplicationCategory": "Toepassing",
     "TasksLibraryCategory": "Bibliotheek",
     "TasksMaintenanceCategory": "Onderhoud",
-    "TaskCleanActivityLogDescription": "Verwijdert activiteiten logs ouder dan de ingestelde tijd.",
+    "TaskCleanActivityLogDescription": "Verwijdert activiteiten logs ouder dan de ingestelde leeftijd.",
     "TaskCleanActivityLog": "Activiteitenlogboek legen",
     "Undefined": "Niet gedefinieerd",
     "Forced": "Geforceerd",

+ 3 - 3
Emby.Server.Implementations/Localization/Core/ru.json

@@ -16,14 +16,14 @@
     "Folders": "Папки",
     "Genres": "Жанры",
     "HeaderAlbumArtists": "Исполнители альбома",
-    "HeaderContinueWatching": "Продолжение просмотра",
+    "HeaderContinueWatching": "Продолжить просмотр",
     "HeaderFavoriteAlbums": "Избранные альбомы",
     "HeaderFavoriteArtists": "Избранные исполнители",
     "HeaderFavoriteEpisodes": "Избранные эпизоды",
     "HeaderFavoriteShows": "Избранные сериалы",
     "HeaderFavoriteSongs": "Избранные композиции",
     "HeaderLiveTV": "Эфир",
-    "HeaderNextUp": "Очередное",
+    "HeaderNextUp": "Следующий",
     "HeaderRecordingGroups": "Группы записей",
     "HomeVideos": "Домашние видео",
     "Inherit": "Наследуемое",
@@ -70,7 +70,7 @@
     "ScheduledTaskFailedWithName": "{0} - неудачна",
     "ScheduledTaskStartedWithName": "{0} - запущена",
     "ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}",
-    "Shows": "Передачи",
+    "Shows": "Телешоу",
     "Songs": "Композиции",
     "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
     "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",

+ 2 - 1
Emby.Server.Implementations/Localization/Core/sl-SI.json

@@ -123,5 +123,6 @@
     "TaskOptimizeDatabase": "Optimiziraj bazo podatkov",
     "TaskKeyframeExtractor": "Ekstraktor ključnih sličic",
     "External": "Zunanji",
-    "TaskKeyframeExtractorDescription": "Iz video datoteke Izvleče ključne sličice, da ustvari bolj natančne sezname predvajanja HLS. Proces lahko traja dolgo časa."
+    "TaskKeyframeExtractorDescription": "Iz video datoteke Izvleče ključne sličice, da ustvari bolj natančne sezname predvajanja HLS. Proces lahko traja dolgo časa.",
+    "HearingImpaired": "Oslabljen sluh"
 }

+ 2 - 4
Emby.Server.Implementations/ScheduledTasks/TaskManager.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -43,9 +41,9 @@ namespace Emby.Server.Implementations.ScheduledTasks
             ScheduledTasks = Array.Empty<IScheduledTaskWorker>();
         }
 
-        public event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting;
+        public event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting;
 
-        public event EventHandler<TaskCompletionEventArgs> TaskCompleted;
+        public event EventHandler<TaskCompletionEventArgs>? TaskCompleted;
 
         /// <summary>
         /// Gets the list of Scheduled Tasks.

+ 8 - 5
Emby.Server.Implementations/Session/SessionWebSocketListener.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Linq;
@@ -58,7 +56,7 @@ namespace Emby.Server.Implementations.Session
         /// <summary>
         /// The KeepAlive cancellation token.
         /// </summary>
-        private CancellationTokenSource _keepAliveCancellationToken;
+        private CancellationTokenSource? _keepAliveCancellationToken;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
@@ -105,7 +103,7 @@ namespace Emby.Server.Implementations.Session
             }
         }
 
-        private async Task<SessionInfo> GetSession(HttpContext httpContext, string remoteEndpoint)
+        private async Task<SessionInfo?> GetSession(HttpContext httpContext, string? remoteEndpoint)
         {
             if (!httpContext.User.Identity?.IsAuthenticated ?? false)
             {
@@ -138,8 +136,13 @@ namespace Emby.Server.Implementations.Session
         /// </summary>
         /// <param name="sender">The WebSocket.</param>
         /// <param name="e">The event arguments.</param>
-        private void OnWebSocketClosed(object sender, EventArgs e)
+        private void OnWebSocketClosed(object? sender, EventArgs e)
         {
+            if (sender is null)
+            {
+                return;
+            }
+
             var webSocket = (IWebSocketConnection)sender;
             _logger.LogDebug("WebSocket {0} is closed.", webSocket);
             RemoveWebSocket(webSocket);

+ 1 - 4
Emby.Server.Implementations/Sorting/RuntimeComparer.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Sorting;
@@ -24,10 +22,9 @@ namespace Emby.Server.Implementations.Sorting
         /// <param name="x">The x.</param>
         /// <param name="y">The y.</param>
         /// <returns>System.Int32.</returns>
-        public int Compare(BaseItem x, BaseItem y)
+        public int Compare(BaseItem? x, BaseItem? y)
         {
             ArgumentNullException.ThrowIfNull(x);
-
             ArgumentNullException.ThrowIfNull(y);
 
             return (x.RunTimeTicks ?? 0).CompareTo(y.RunTimeTicks ?? 0);

+ 2 - 5
Emby.Server.Implementations/Sorting/SeriesSortNameComparer.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -23,15 +21,14 @@ namespace Emby.Server.Implementations.Sorting
         /// <param name="x">The x.</param>
         /// <param name="y">The y.</param>
         /// <returns>System.Int32.</returns>
-        public int Compare(BaseItem x, BaseItem y)
+        public int Compare(BaseItem? x, BaseItem? y)
         {
             return string.Compare(GetValue(x), GetValue(y), StringComparison.OrdinalIgnoreCase);
         }
 
-        private static string GetValue(BaseItem item)
+        private static string? GetValue(BaseItem? item)
         {
             var hasSeries = item as IHasSeries;
-
             return hasSeries?.FindSeriesSortName();
         }
     }

+ 1 - 4
Emby.Server.Implementations/Sorting/SortNameComparer.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Sorting;
@@ -24,10 +22,9 @@ namespace Emby.Server.Implementations.Sorting
         /// <param name="x">The x.</param>
         /// <param name="y">The y.</param>
         /// <returns>System.Int32.</returns>
-        public int Compare(BaseItem x, BaseItem y)
+        public int Compare(BaseItem? x, BaseItem? y)
         {
             ArgumentNullException.ThrowIfNull(x);
-
             ArgumentNullException.ThrowIfNull(y);
 
             return string.Compare(x.SortName, y.SortName, StringComparison.OrdinalIgnoreCase);

+ 2 - 4
Emby.Server.Implementations/Sorting/StartDateComparer.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -24,7 +22,7 @@ namespace Emby.Server.Implementations.Sorting
         /// <param name="x">The x.</param>
         /// <param name="y">The y.</param>
         /// <returns>System.Int32.</returns>
-        public int Compare(BaseItem x, BaseItem y)
+        public int Compare(BaseItem? x, BaseItem? y)
         {
             return GetDate(x).CompareTo(GetDate(y));
         }
@@ -34,7 +32,7 @@ namespace Emby.Server.Implementations.Sorting
         /// </summary>
         /// <param name="x">The x.</param>
         /// <returns>DateTime.</returns>
-        private static DateTime GetDate(BaseItem x)
+        private static DateTime GetDate(BaseItem? x)
         {
             if (x is LiveTvProgram hasStartDate)
             {

+ 1 - 4
Emby.Server.Implementations/Sorting/StudioComparer.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -24,10 +22,9 @@ namespace Emby.Server.Implementations.Sorting
         /// <param name="x">The x.</param>
         /// <param name="y">The y.</param>
         /// <returns>System.Int32.</returns>
-        public int Compare(BaseItem x, BaseItem y)
+        public int Compare(BaseItem? x, BaseItem? y)
         {
             ArgumentNullException.ThrowIfNull(x);
-
             ArgumentNullException.ThrowIfNull(y);
 
             return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault());

+ 5 - 7
Emby.Server.Implementations/TV/TVSeriesManager.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -42,7 +40,7 @@ namespace Emby.Server.Implementations.TV
                 throw new ArgumentException("User not found");
             }
 
-            string presentationUniqueKey = null;
+            string? presentationUniqueKey = null;
             if (query.SeriesId.HasValue && !query.SeriesId.Value.Equals(default))
             {
                 if (_libraryManager.GetItemById(query.SeriesId.Value) is Series series)
@@ -91,7 +89,7 @@ namespace Emby.Server.Implementations.TV
                 throw new ArgumentException("User not found");
             }
 
-            string presentationUniqueKey = null;
+            string? presentationUniqueKey = null;
             int? limit = null;
             if (request.SeriesId.HasValue && !request.SeriesId.Value.Equals(default))
             {
@@ -168,7 +166,7 @@ namespace Emby.Server.Implementations.TV
                     return !anyFound && i.LastWatchedDate == DateTime.MinValue;
                 })
                 .Select(i => i.GetEpisodeFunction())
-                .Where(i => i is not null);
+                .Where(i => i is not null)!;
         }
 
         private static string GetUniqueSeriesKey(Episode episode)
@@ -185,7 +183,7 @@ namespace Emby.Server.Implementations.TV
         /// Gets the next up.
         /// </summary>
         /// <returns>Task{Episode}.</returns>
-        private (DateTime LastWatchedDate, Func<Episode> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool rewatching)
+        private (DateTime LastWatchedDate, Func<Episode?> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool rewatching)
         {
             var lastQuery = new InternalItemsQuery(user)
             {
@@ -209,7 +207,7 @@ namespace Emby.Server.Implementations.TV
 
             var lastWatchedEpisode = _libraryManager.GetItemList(lastQuery).Cast<Episode>().FirstOrDefault();
 
-            Episode GetEpisode()
+            Episode? GetEpisode()
             {
                 var nextQuery = new InternalItemsQuery(user)
                 {

+ 6 - 3
Jellyfin.Api/Controllers/ArtistsController.cs

@@ -118,6 +118,7 @@ public class ArtistsController : BaseJellyfinApiController
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -125,7 +126,7 @@ public class ArtistsController : BaseJellyfinApiController
         User? user = null;
         BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-        if (userId.HasValue && !userId.Equals(default))
+        if (!userId.Value.Equals(default))
         {
             user = _userManager.GetUserById(userId.Value);
         }
@@ -321,6 +322,7 @@ public class ArtistsController : BaseJellyfinApiController
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -328,7 +330,7 @@ public class ArtistsController : BaseJellyfinApiController
         User? user = null;
         BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
 
-        if (userId.HasValue && !userId.Equals(default))
+        if (!userId.Value.Equals(default))
         {
             user = _userManager.GetUserById(userId.Value);
         }
@@ -462,11 +464,12 @@ public class ArtistsController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions().AddClientFields(User);
 
         var item = _libraryManager.GetArtist(name, dtoOptions);
 
-        if (userId.HasValue && !userId.Value.Equals(default))
+        if (!userId.Value.Equals(default))
         {
             var user = _userManager.GetUserById(userId.Value);
 

+ 6 - 3
Jellyfin.Api/Controllers/ChannelsController.cs

@@ -60,11 +60,12 @@ public class ChannelsController : BaseJellyfinApiController
         [FromQuery] bool? supportsMediaDeletion,
         [FromQuery] bool? isFavorite)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         return _channelManager.GetChannels(new ChannelQuery
         {
             Limit = limit,
             StartIndex = startIndex,
-            UserId = userId ?? Guid.Empty,
+            UserId = userId.Value,
             SupportsLatestItems = supportsLatestItems,
             SupportsMediaDeletion = supportsMediaDeletion,
             IsFavorite = isFavorite
@@ -124,7 +125,8 @@ public class ChannelsController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -198,7 +200,8 @@ public class ChannelsController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 

+ 2 - 0
Jellyfin.Api/Controllers/DevicesController.cs

@@ -2,6 +2,7 @@ using System;
 using System.ComponentModel.DataAnnotations;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Data.Dtos;
 using Jellyfin.Data.Entities.Security;
 using Jellyfin.Data.Queries;
@@ -48,6 +49,7 @@ public class DevicesController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false);
     }
 

+ 6 - 2
Jellyfin.Api/Controllers/FilterController.cs

@@ -1,5 +1,7 @@
 using System;
 using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
@@ -51,7 +53,8 @@ public class FilterController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -143,7 +146,8 @@ public class FilterController : BaseJellyfinApiController
         [FromQuery] bool? isSeries,
         [FromQuery] bool? recursive)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 

+ 4 - 2
Jellyfin.Api/Controllers/GenresController.cs

@@ -90,11 +90,12 @@ public class GenresController : BaseJellyfinApiController
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
-        User? user = userId is null || userId.Value.Equals(default)
+        User? user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -155,6 +156,7 @@ public class GenresController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions()
             .AddClientFields(User);
 
@@ -170,7 +172,7 @@ public class GenresController : BaseJellyfinApiController
 
         item ??= new Genre();
 
-        if (userId is null || userId.Value.Equals(default))
+        if (userId.Value.Equals(default))
         {
             return _dtoService.GetBaseItemDto(item, dtoOptions);
         }

+ 50 - 15
Jellyfin.Api/Controllers/ImageController.cs

@@ -91,6 +91,7 @@ public class ImageController : BaseJellyfinApiController
     [Authorize]
     [AcceptsImageFile]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
@@ -110,6 +111,11 @@ public class ImageController : BaseJellyfinApiController
             return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
         }
 
+        if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension))
+        {
+            return BadRequest("Incorrect ContentType.");
+        }
+
         var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
         await using (memoryStream.ConfigureAwait(false))
         {
@@ -121,7 +127,7 @@ public class ImageController : BaseJellyfinApiController
                 await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             }
 
-            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension));
 
             await _providerManager
                 .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
@@ -145,6 +151,7 @@ public class ImageController : BaseJellyfinApiController
     [Authorize]
     [AcceptsImageFile]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
@@ -164,6 +171,11 @@ public class ImageController : BaseJellyfinApiController
             return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image.");
         }
 
+        if (!TryGetImageExtensionFromContentType(Request.ContentType, out string? extension))
+        {
+            return BadRequest("Incorrect ContentType.");
+        }
+
         var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
         await using (memoryStream.ConfigureAwait(false))
         {
@@ -175,7 +187,7 @@ public class ImageController : BaseJellyfinApiController
                 await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
             }
 
-            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty)));
+            user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension));
 
             await _providerManager
                 .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
@@ -342,6 +354,7 @@ public class ImageController : BaseJellyfinApiController
     [Authorize(Policy = Policies.RequiresElevation)]
     [AcceptsImageFile]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
     public async Task<ActionResult> SetItemImage(
@@ -354,6 +367,11 @@ public class ImageController : BaseJellyfinApiController
             return NotFound();
         }
 
+        if (!TryGetImageExtensionFromContentType(Request.ContentType, out _))
+        {
+            return BadRequest("Incorrect ContentType.");
+        }
+
         var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
         await using (memoryStream.ConfigureAwait(false))
         {
@@ -379,6 +397,7 @@ public class ImageController : BaseJellyfinApiController
     [Authorize(Policy = Policies.RequiresElevation)]
     [AcceptsImageFile]
     [ProducesResponseType(StatusCodes.Status204NoContent)]
+    [ProducesResponseType(StatusCodes.Status400BadRequest)]
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
     public async Task<ActionResult> SetItemImageByIndex(
@@ -392,6 +411,11 @@ public class ImageController : BaseJellyfinApiController
             return NotFound();
         }
 
+        if (!TryGetImageExtensionFromContentType(Request.ContentType, out _))
+        {
+            return BadRequest("Incorrect ContentType.");
+        }
+
         var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
         await using (memoryStream.ConfigureAwait(false))
         {
@@ -1763,22 +1787,14 @@ public class ImageController : BaseJellyfinApiController
     [AcceptsImageFile]
     public async Task<ActionResult> UploadCustomSplashscreen()
     {
+        if (!TryGetImageExtensionFromContentType(Request.ContentType, out var extension))
+        {
+            return BadRequest("Incorrect ContentType.");
+        }
+
         var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
         await using (memoryStream.ConfigureAwait(false))
         {
-            var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType;
-
-            if (!mimeType.HasValue)
-            {
-                return BadRequest("Error reading mimetype from uploaded image");
-            }
-
-            var extension = MimeTypes.ToExtension(mimeType.Value);
-            if (string.IsNullOrEmpty(extension))
-            {
-                return BadRequest("Error converting mimetype to an image extension");
-            }
-
             var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension);
             var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
             brandingOptions.SplashscreenLocation = filePath;
@@ -2106,4 +2122,23 @@ public class ImageController : BaseJellyfinApiController
 
         return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain);
     }
+
+    internal static bool TryGetImageExtensionFromContentType(string? contentType, [NotNullWhen(true)] out string? extension)
+    {
+        extension = null;
+        if (string.IsNullOrEmpty(contentType))
+        {
+            return false;
+        }
+
+        if (MediaTypeHeaderValue.TryParse(contentType, out var parsedValue)
+            && parsedValue.MediaType.HasValue
+            && MimeTypes.IsImage(parsedValue.MediaType.Value))
+        {
+            extension = MimeTypes.ToExtension(parsedValue.MediaType.Value);
+            return extension is not null;
+        }
+
+        return false;
+    }
 }

+ 15 - 7
Jellyfin.Api/Controllers/InstantMixController.cs

@@ -2,6 +2,7 @@ using System;
 using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
@@ -74,7 +75,8 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
         var item = _libraryManager.GetItemById(id);
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }
@@ -110,7 +112,8 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
         var album = _libraryManager.GetItemById(id);
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }
@@ -146,7 +149,8 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
         var playlist = (Playlist)_libraryManager.GetItemById(id);
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }
@@ -181,7 +185,8 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery] int? imageTypeLimit,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }
@@ -217,7 +222,8 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
         var item = _libraryManager.GetItemById(id);
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }
@@ -253,7 +259,8 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
         var item = _libraryManager.GetItemById(id);
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }
@@ -326,7 +333,8 @@ public class InstantMixController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
     {
         var item = _libraryManager.GetItemById(id);
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }

+ 2 - 1
Jellyfin.Api/Controllers/ItemsController.cs

@@ -240,7 +240,8 @@ public class ItemsController : BaseJellyfinApiController
     {
         var isApiKey = User.GetIsApiKey();
         // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method
-        var user = !isApiKey && userId.HasValue && !userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = !isApiKey && !userId.Value.Equals(default)
             ? _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException()
             : null;
 

+ 14 - 8
Jellyfin.Api/Controllers/LibraryController.cs

@@ -9,6 +9,7 @@ using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.LibraryDtos;
 using Jellyfin.Data.Entities;
@@ -142,12 +143,13 @@ public class LibraryController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] bool inheritFromParent = false)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
         var item = itemId.Equals(default)
-            ? (userId is null || userId.Value.Equals(default)
+            ? (userId.Value.Equals(default)
                 ? _libraryManager.RootFolder
                 : _libraryManager.GetUserRootFolder())
             : _libraryManager.GetItemById(itemId);
@@ -208,12 +210,13 @@ public class LibraryController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] bool inheritFromParent = false)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
         var item = itemId.Equals(default)
-            ? (userId is null || userId.Value.Equals(default)
+            ? (userId.Value.Equals(default)
                 ? _libraryManager.RootFolder
                 : _libraryManager.GetUserRootFolder())
             : _libraryManager.GetItemById(itemId);
@@ -403,7 +406,8 @@ public class LibraryController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] bool? isFavorite)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -437,6 +441,7 @@ public class LibraryController : BaseJellyfinApiController
     public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
     {
         var item = _libraryManager.GetItemById(itemId);
+        userId = RequestHelpers.GetUserId(User, userId);
 
         if (item is null)
         {
@@ -445,7 +450,7 @@ public class LibraryController : BaseJellyfinApiController
 
         var baseItemDtos = new List<BaseItemDto>();
 
-        var user = userId is null || userId.Value.Equals(default)
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -675,8 +680,9 @@ public class LibraryController : BaseJellyfinApiController
         [FromQuery] int? limit,
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var item = itemId.Equals(default)
-            ? (userId is null || userId.Value.Equals(default)
+            ? (userId.Value.Equals(default)
                 ? _libraryManager.RootFolder
                 : _libraryManager.GetUserRootFolder())
             : _libraryManager.GetItemById(itemId);
@@ -691,7 +697,7 @@ public class LibraryController : BaseJellyfinApiController
             return new QueryResult<BaseItemDto>();
         }
 
-        var user = userId is null || userId.Value.Equals(default)
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }

+ 17 - 9
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -153,6 +153,7 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] bool enableFavoriteSorting = false,
         [FromQuery] bool addCurrentProgram = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -161,7 +162,7 @@ public class LiveTvController : BaseJellyfinApiController
             new LiveTvChannelQuery
             {
                 ChannelType = type,
-                UserId = userId ?? Guid.Empty,
+                UserId = userId.Value,
                 StartIndex = startIndex,
                 Limit = limit,
                 IsFavorite = isFavorite,
@@ -180,7 +181,7 @@ public class LiveTvController : BaseJellyfinApiController
             dtoOptions,
             CancellationToken.None);
 
-        var user = userId is null || userId.Value.Equals(default)
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -211,7 +212,8 @@ public class LiveTvController : BaseJellyfinApiController
     [Authorize(Policy = Policies.LiveTvAccess)]
     public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var item = channelId.Equals(default)
@@ -271,6 +273,7 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] bool? isLibraryItem,
         [FromQuery] bool enableTotalRecordCount = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -279,7 +282,7 @@ public class LiveTvController : BaseJellyfinApiController
             new RecordingQuery
             {
                 ChannelId = channelId,
-                UserId = userId ?? Guid.Empty,
+                UserId = userId.Value,
                 StartIndex = startIndex,
                 Limit = limit,
                 Status = status,
@@ -382,7 +385,8 @@ public class LiveTvController : BaseJellyfinApiController
     [Authorize(Policy = Policies.LiveTvAccess)]
     public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var folders = _liveTvManager.GetRecordingFolders(user);
@@ -404,7 +408,8 @@ public class LiveTvController : BaseJellyfinApiController
     [Authorize(Policy = Policies.LiveTvAccess)]
     public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId);
@@ -560,7 +565,8 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
         [FromQuery] bool enableTotalRecordCount = true)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -699,7 +705,8 @@ public class LiveTvController : BaseJellyfinApiController
         [FromQuery] bool? enableUserData,
         [FromQuery] bool enableTotalRecordCount = true)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -737,7 +744,8 @@ public class LiveTvController : BaseJellyfinApiController
         [FromRoute, Required] string programId,
         [FromQuery] Guid? userId)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 

+ 4 - 1
Jellyfin.Api/Controllers/MediaInfoController.cs

@@ -132,6 +132,7 @@ public class MediaInfoController : BaseJellyfinApiController
         // Copy params from posted body
         // TODO clean up when breaking API compatibility.
         userId ??= playbackInfoDto?.UserId;
+        userId = RequestHelpers.GetUserId(User, userId);
         maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate;
         startTimeTicks ??= playbackInfoDto?.StartTimeTicks;
         audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex;
@@ -253,10 +254,12 @@ public class MediaInfoController : BaseJellyfinApiController
         [FromQuery] bool? enableDirectPlay,
         [FromQuery] bool? enableDirectStream)
     {
+        userId ??= openLiveStreamDto?.UserId;
+        userId = RequestHelpers.GetUserId(User, userId);
         var request = new LiveStreamRequest
         {
             OpenToken = openToken ?? openLiveStreamDto?.OpenToken,
-            UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty,
+            UserId = userId.Value,
             PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId,
             MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate,
             StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks,

+ 3 - 1
Jellyfin.Api/Controllers/MoviesController.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
@@ -67,7 +68,8 @@ public class MoviesController : BaseJellyfinApiController
         [FromQuery] int categoryLimit = 5,
         [FromQuery] int itemLimit = 8)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         var dtoOptions = new DtoOptions { Fields = fields }

+ 4 - 2
Jellyfin.Api/Controllers/MusicGenresController.cs

@@ -90,11 +90,12 @@ public class MusicGenresController : BaseJellyfinApiController
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
 
-        User? user = userId is null || userId.Value.Equals(default)
+        User? user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -144,6 +145,7 @@ public class MusicGenresController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions().AddClientFields(User);
 
         MusicGenre? item;
@@ -162,7 +164,7 @@ public class MusicGenresController : BaseJellyfinApiController
             return NotFound();
         }
 
-        if (userId.HasValue && !userId.Value.Equals(default))
+        if (!userId.Value.Equals(default))
         {
             var user = _userManager.GetUserById(userId.Value);
 

+ 5 - 2
Jellyfin.Api/Controllers/PersonsController.cs

@@ -2,6 +2,7 @@ using System;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Entities;
 using MediaBrowser.Controller.Dto;
@@ -77,11 +78,12 @@ public class PersonsController : BaseJellyfinApiController
         [FromQuery] Guid? userId,
         [FromQuery] bool? enableImages = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-        User? user = userId is null || userId.Value.Equals(default)
+        User? user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -117,6 +119,7 @@ public class PersonsController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions()
             .AddClientFields(User);
 
@@ -126,7 +129,7 @@ public class PersonsController : BaseJellyfinApiController
             return NotFound();
         }
 
-        if (userId.HasValue && !userId.Value.Equals(default))
+        if (!userId.Value.Equals(default))
         {
             var user = _userManager.GetUserById(userId.Value);
             return _dtoService.GetBaseItemDto(item, dtoOptions, user);

+ 6 - 2
Jellyfin.Api/Controllers/PlaylistsController.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using System.Threading.Tasks;
 using Jellyfin.Api.Attributes;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Api.Models.PlaylistDtos;
 using MediaBrowser.Controller.Dto;
@@ -81,11 +82,13 @@ public class PlaylistsController : BaseJellyfinApiController
             ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>();
         }
 
+        userId ??= createPlaylistRequest?.UserId ?? default;
+        userId = RequestHelpers.GetUserId(User, userId);
         var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
         {
             Name = name ?? createPlaylistRequest?.Name,
             ItemIdList = ids,
-            UserId = userId ?? createPlaylistRequest?.UserId ?? default,
+            UserId = userId.Value,
             MediaType = mediaType ?? createPlaylistRequest?.MediaType
         }).ConfigureAwait(false);
 
@@ -107,7 +110,8 @@ public class PlaylistsController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
         [FromQuery] Guid? userId)
     {
-        await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false);
+        userId = RequestHelpers.GetUserId(User, userId);
+        await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false);
         return NoContent();
     }
 

+ 3 - 8
Jellyfin.Api/Controllers/QuickConnectController.cs

@@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
 using System.Threading.Tasks;
 using Jellyfin.Api.Constants;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Authentication;
 using MediaBrowser.Controller.Net;
@@ -116,17 +117,11 @@ public class QuickConnectController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status403Forbidden)]
     public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null)
     {
-        var currentUserId = User.GetUserId();
-        var actualUserId = userId ?? currentUserId;
-
-        if (actualUserId.Equals(default) || (!userId.Equals(currentUserId) && !User.IsInRole(UserRoles.Administrator)))
-        {
-            return Forbid("Unknown user id");
-        }
+        userId = RequestHelpers.GetUserId(User, userId);
 
         try
         {
-            return await _quickConnect.AuthorizeRequest(actualUserId, code).ConfigureAwait(false);
+            return await _quickConnect.AuthorizeRequest(userId.Value, code).ConfigureAwait(false);
         }
         catch (AuthenticationException)
         {

+ 4 - 1
Jellyfin.Api/Controllers/SearchController.cs

@@ -3,6 +3,8 @@ using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 using System.Globalization;
 using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
@@ -98,6 +100,7 @@ public class SearchController : BaseJellyfinApiController
         [FromQuery] bool includeStudios = true,
         [FromQuery] bool includeArtists = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var result = _searchEngine.GetSearchHints(new SearchQuery
         {
             Limit = limit,
@@ -108,7 +111,7 @@ public class SearchController : BaseJellyfinApiController
             IncludePeople = includePeople,
             IncludeStudios = includeStudios,
             StartIndex = startIndex,
-            UserId = userId ?? Guid.Empty,
+            UserId = userId.Value,
             IncludeItemTypes = includeItemTypes,
             ExcludeItemTypes = excludeItemTypes,
             MediaTypes = mediaTypes,

+ 4 - 2
Jellyfin.Api/Controllers/StudiosController.cs

@@ -86,11 +86,12 @@ public class StudiosController : BaseJellyfinApiController
         [FromQuery] bool? enableImages = true,
         [FromQuery] bool enableTotalRecordCount = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-        User? user = userId is null || userId.Value.Equals(default)
+        User? user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -139,10 +140,11 @@ public class StudiosController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions().AddClientFields(User);
 
         var item = _libraryManager.GetStudio(name);
-        if (userId.HasValue && !userId.Equals(default))
+        if (!userId.Equals(default))
         {
             var user = _userManager.GetUserById(userId.Value);
 

+ 10 - 5
Jellyfin.Api/Controllers/TvShowsController.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations;
 using System.Linq;
 using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
 using Jellyfin.Api.ModelBinders;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
@@ -87,6 +88,7 @@ public class TvShowsController : BaseJellyfinApiController
         [FromQuery] bool disableFirstEpisode = false,
         [FromQuery] bool enableRewatching = false)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var options = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -98,7 +100,7 @@ public class TvShowsController : BaseJellyfinApiController
                 ParentId = parentId,
                 SeriesId = seriesId,
                 StartIndex = startIndex,
-                UserId = userId ?? Guid.Empty,
+                UserId = userId.Value,
                 EnableTotalRecordCount = enableTotalRecordCount,
                 DisableFirstEpisode = disableFirstEpisode,
                 NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
@@ -106,7 +108,7 @@ public class TvShowsController : BaseJellyfinApiController
             },
             options);
 
-        var user = userId is null || userId.Value.Equals(default)
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -144,7 +146,8 @@ public class TvShowsController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -215,7 +218,8 @@ public class TvShowsController : BaseJellyfinApiController
         [FromQuery] bool? enableUserData,
         [FromQuery] string? sortBy)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
@@ -331,7 +335,8 @@ public class TvShowsController : BaseJellyfinApiController
         [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
         [FromQuery] bool? enableUserData)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 

+ 1 - 5
Jellyfin.Api/Controllers/UniversalAudioController.cs

@@ -106,11 +106,7 @@ public class UniversalAudioController : BaseJellyfinApiController
         [FromQuery] bool enableRedirection = true)
     {
         var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
-
-        if (!userId.HasValue || userId.Value.Equals(default))
-        {
-            userId = User.GetUserId();
-        }
+        userId = RequestHelpers.GetUserId(User, userId);
 
         _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile);
 

+ 3 - 2
Jellyfin.Api/Controllers/VideosController.cs

@@ -104,12 +104,13 @@ public class VideosController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
     {
-        var user = userId is null || userId.Value.Equals(default)
+        userId = RequestHelpers.GetUserId(User, userId);
+        var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
 
         var item = itemId.Equals(default)
-            ? (userId is null || userId.Value.Equals(default)
+            ? (userId.Value.Equals(default)
                 ? _libraryManager.RootFolder
                 : _libraryManager.GetUserRootFolder())
             : _libraryManager.GetItemById(itemId);

+ 4 - 2
Jellyfin.Api/Controllers/YearsController.cs

@@ -85,11 +85,12 @@ public class YearsController : BaseJellyfinApiController
         [FromQuery] bool recursive = true,
         [FromQuery] bool? enableImages = true)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var dtoOptions = new DtoOptions { Fields = fields }
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-        User? user = userId is null || userId.Value.Equals(default)
+        User? user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
         BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
@@ -171,6 +172,7 @@ public class YearsController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status404NotFound)]
     public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId)
     {
+        userId = RequestHelpers.GetUserId(User, userId);
         var item = _libraryManager.GetYear(year);
         if (item is null)
         {
@@ -180,7 +182,7 @@ public class YearsController : BaseJellyfinApiController
         var dtoOptions = new DtoOptions()
             .AddClientFields(User);
 
-        if (userId.HasValue && !userId.Value.Equals(default))
+        if (!userId.Value.Equals(default))
         {
             var user = _userManager.GetUserById(userId.Value);
             return _dtoService.GetBaseItemDto(item, dtoOptions, user);

+ 1 - 2
Jellyfin.Api/Extensions/ClaimsPrincipalExtensions.cs

@@ -71,8 +71,7 @@ public static class ClaimsPrincipalExtensions
     public static bool GetIsApiKey(this ClaimsPrincipal user)
     {
         var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey);
-        return !string.IsNullOrEmpty(claimValue)
-               && bool.TryParse(claimValue, out var parsedClaimValue)
+        return bool.TryParse(claimValue, out var parsedClaimValue)
                && parsedClaimValue;
     }
 

+ 27 - 0
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -11,6 +11,7 @@ using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Querying;
@@ -55,6 +56,32 @@ public static class RequestHelpers
         return result;
     }
 
+    /// <summary>
+    /// Checks if the user can access a user.
+    /// </summary>
+    /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param>
+    /// <param name="userId">The user id.</param>
+    /// <returns>A <see cref="bool"/> whether the user can access the user.</returns>
+    internal static Guid GetUserId(ClaimsPrincipal claimsPrincipal, Guid? userId)
+    {
+        var authenticatedUserId = claimsPrincipal.GetUserId();
+
+        // UserId not provided, fall back to authenticated user id.
+        if (userId is null || userId.Value.Equals(default))
+        {
+            return authenticatedUserId;
+        }
+
+        // User must be administrator to access another user.
+        var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator);
+        if (!userId.Value.Equals(authenticatedUserId) && !isAdministrator)
+        {
+            throw new SecurityException("Forbidden");
+        }
+
+        return userId.Value;
+    }
+
     /// <summary>
     /// Checks if the user can update an entry.
     /// </summary>

+ 2 - 2
Jellyfin.Api/Helpers/StreamingHelpers.cs

@@ -337,10 +337,10 @@ public static class StreamingHelpers
         value = index == -1
             ? value.Slice(npt.Length)
             : value.Slice(npt.Length, index - npt.Length);
-        if (value.IndexOf(':') == -1)
+        if (!value.Contains(':'))
         {
             // Parses npt times in the format of '417.33'
-            if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
+            if (double.TryParse(value, CultureInfo.InvariantCulture, out var seconds))
             {
                 return TimeSpan.FromSeconds(seconds).Ticks;
             }

+ 1 - 2
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -457,8 +457,7 @@ public class TranscodingJobHelper : IDisposable
             var videoCodec = state.ActualOutputVideoCodec;
             var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType;
             HardwareEncodingType? hardwareAccelerationType = null;
-            if (!string.IsNullOrEmpty(hardwareAccelerationTypeString)
-                && Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType))
+            if (Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType))
             {
                 hardwareAccelerationType = parsedHardwareAccelerationType;
             }

+ 1 - 1
Jellyfin.Server.Implementations/Users/UserManager.cs

@@ -740,7 +740,7 @@ namespace Jellyfin.Server.Implementations.Users
             throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name));
         }
 
-        private static bool IsValidUsername(string name)
+        private static bool IsValidUsername(ReadOnlySpan<char> name)
         {
             // This is some regex that matches only on unicode "word" characters, as well as -, _ and @
             // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness

+ 2 - 1
Jellyfin.Server/Migrations/MigrationRunner.cs

@@ -21,7 +21,8 @@ namespace Jellyfin.Server.Migrations
         /// </summary>
         private static readonly Type[] _preStartupMigrationTypes =
         {
-            typeof(PreStartupRoutines.CreateNetworkConfiguration)
+            typeof(PreStartupRoutines.CreateNetworkConfiguration),
+            typeof(PreStartupRoutines.MigrateMusicBrainzTimeout)
         };
 
         /// <summary>

+ 89 - 0
Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs

@@ -0,0 +1,89 @@
+using System;
+using System.IO;
+using System.Xml;
+using System.Xml.Serialization;
+using Emby.Server.Implementations;
+using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.PreStartupRoutines;
+
+/// <inheritdoc />
+public class MigrateMusicBrainzTimeout : IMigrationRoutine
+{
+    private readonly ServerApplicationPaths _applicationPaths;
+    private readonly ILogger<MigrateMusicBrainzTimeout> _logger;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MigrateMusicBrainzTimeout"/> class.
+    /// </summary>
+    /// <param name="applicationPaths">An instance of <see cref="ServerApplicationPaths"/>.</param>
+    /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
+    public MigrateMusicBrainzTimeout(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory)
+    {
+        _applicationPaths = applicationPaths;
+        _logger = loggerFactory.CreateLogger<MigrateMusicBrainzTimeout>();
+    }
+
+    /// <inheritdoc />
+    public Guid Id => Guid.Parse("A6DCACF4-C057-4Ef9-80D3-61CEF9DDB4F0");
+
+    /// <inheritdoc />
+    public string Name => nameof(MigrateMusicBrainzTimeout);
+
+    /// <inheritdoc />
+    public bool PerformOnNewInstall => false;
+
+    /// <inheritdoc />
+    public void Perform()
+    {
+        string path = Path.Combine(_applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.MusicBrainz.xml");
+        if (!File.Exists(path))
+        {
+            _logger.LogDebug("No MusicBrainz plugin configuration file found, skipping");
+            return;
+        }
+
+        var serverConfigSerializer = new XmlSerializer(typeof(OldMusicBrainzConfiguration), new XmlRootAttribute("PluginConfiguration"));
+        using var xmlReader = XmlReader.Create(path);
+        var oldPluginConfiguration = serverConfigSerializer.Deserialize(xmlReader) as OldMusicBrainzConfiguration;
+
+        if (oldPluginConfiguration is not null)
+        {
+            var newPluginConfiguration = new PluginConfiguration();
+            newPluginConfiguration.Server = oldPluginConfiguration.Server;
+            newPluginConfiguration.ReplaceArtistName = oldPluginConfiguration.ReplaceArtistName;
+            var newRateLimit = oldPluginConfiguration.RateLimit / 1000.0;
+            newPluginConfiguration.RateLimit = newRateLimit < 1.0 ? 1.0 : newRateLimit;
+
+            var pluginConfigurationSerializer = new XmlSerializer(typeof(PluginConfiguration), new XmlRootAttribute("PluginConfiguration"));
+            var xmlWriterSettings = new XmlWriterSettings { Indent = true };
+            using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings);
+            pluginConfigurationSerializer.Serialize(xmlWriter, newPluginConfiguration);
+        }
+    }
+
+#pragma warning disable
+    public sealed class OldMusicBrainzConfiguration
+    {
+        private string _server = string.Empty;
+
+        private long _rateLimit = 0L;
+
+        public string Server
+        {
+            get => _server;
+            set => _server = value.TrimEnd('/');
+        }
+
+        public long RateLimit
+        {
+            get => _rateLimit;
+            set => _rateLimit = value;
+        }
+
+        public bool ReplaceArtistName { get; set; }
+    }
+#pragma warning restore
+
+}

+ 1 - 1
Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs

@@ -130,7 +130,7 @@ namespace Jellyfin.Server.Migrations.Routines
                         SkipForwardLength = dto.CustomPrefs.TryGetValue("skipForwardLength", out var length) && int.TryParse(length, out var skipForwardLength)
                             ? skipForwardLength
                             : 30000,
-                        SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) && !string.IsNullOrEmpty(length) && int.TryParse(length, out var skipBackwardLength)
+                        SkipBackwardLength = dto.CustomPrefs.TryGetValue("skipBackLength", out length) && int.TryParse(length, out var skipBackwardLength)
                             ? skipBackwardLength
                             : 10000,
                         EnableNextVideoInfoOverlay = dto.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enabled) && !string.IsNullOrEmpty(enabled)

+ 4 - 8
MediaBrowser.Controller/LiveTv/LiveTvChannel.cs

@@ -5,6 +5,7 @@
 using System;
 using System.Collections.Generic;
 using System.Globalization;
+using System.Linq;
 using System.Text.Json.Serialization;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
@@ -105,12 +106,9 @@ namespace MediaBrowser.Controller.LiveTv
 
         protected override string CreateSortName()
         {
-            if (!string.IsNullOrEmpty(Number))
+            if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number))
             {
-                if (double.TryParse(Number, NumberStyles.Any, CultureInfo.InvariantCulture, out double number))
-                {
-                    return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty);
-                }
+                return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty);
             }
 
             return (Number ?? string.Empty) + "-" + (Name ?? string.Empty);
@@ -122,9 +120,7 @@ namespace MediaBrowser.Controller.LiveTv
         }
 
         public IEnumerable<BaseItem> GetTaggedItems()
-        {
-            return new List<BaseItem>();
-        }
+            => Enumerable.Empty<BaseItem>();
 
         public override List<MediaSourceInfo> GetMediaSources(bool enablePathSubstitution)
         {

+ 38 - 67
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -1143,7 +1143,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
         {
-            if (double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out double requestLevel))
+            if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
             {
                 if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
                     || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
@@ -1737,7 +1737,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
                 {
                     // hevc_qsv use -level 51 instead of -level 153.
-                    if (double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out double hevcLevel))
+                    if (double.TryParse(level, CultureInfo.InvariantCulture, out double hevcLevel))
                     {
                         param += " -level " + (hevcLevel / 3);
                     }
@@ -1916,8 +1916,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             // If a specific level was requested, the source must match or be less than
             var level = state.GetRequestedLevel(videoStream.Codec);
-            if (!string.IsNullOrEmpty(level)
-                && double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out var requestLevel))
+            if (double.TryParse(level, CultureInfo.InvariantCulture, out var requestLevel))
             {
                 if (!videoStream.Level.HasValue)
                 {
@@ -2217,85 +2216,57 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             var request = state.BaseRequest;
 
-            var inputChannels = audioStream.Channels;
+            var codec = outputAudioCodec ?? string.Empty;
 
-            if (inputChannels <= 0)
-            {
-                inputChannels = null;
-            }
+            int? resultChannels = state.GetRequestedAudioChannels(codec);
 
-            var codec = outputAudioCodec ?? string.Empty;
+            var inputChannels = audioStream.Channels;
 
-            int? transcoderChannelLimit;
-            if (codec.IndexOf("wma", StringComparison.OrdinalIgnoreCase) != -1)
-            {
-                // wmav2 currently only supports two channel output
-                transcoderChannelLimit = 2;
-            }
-            else if (codec.IndexOf("mp3", StringComparison.OrdinalIgnoreCase) != -1)
+            if (inputChannels > 0)
             {
-                // libmp3lame currently only supports two channel output
-                transcoderChannelLimit = 2;
-            }
-            else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1)
-            {
-                // aac is able to handle 8ch(7.1 layout)
-                transcoderChannelLimit = 8;
-            }
-            else
-            {
-                // If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels
-                transcoderChannelLimit = 6;
+                resultChannels = inputChannels < resultChannels ? inputChannels : resultChannels ?? inputChannels;
             }
 
             var isTranscodingAudio = !IsCopyCodec(codec);
 
-            int? resultChannels = state.GetRequestedAudioChannels(codec);
             if (isTranscodingAudio)
             {
-                resultChannels = GetMinValue(request.TranscodingMaxAudioChannels, resultChannels);
-            }
-
-            if (inputChannels.HasValue)
-            {
-                resultChannels = resultChannels.HasValue
-                    ? Math.Min(resultChannels.Value, inputChannels.Value)
-                    : inputChannels.Value;
-            }
+                // Set max transcoding channels for encoders that can't handle more than a set amount of channels
+                // AAC, FLAC, ALAC, libopus, libvorbis encoders all support at least 8 channels
+                int transcoderChannelLimit = GetAudioEncoder(state) switch
+                {
+                    string audioEncoder when audioEncoder.Equals("wmav2", StringComparison.OrdinalIgnoreCase)
+                                          || audioEncoder.Equals("libmp3lame", StringComparison.OrdinalIgnoreCase) => 2,
+                    string audioEncoder when audioEncoder.Equals("libfdk_aac", StringComparison.OrdinalIgnoreCase)
+                                          || audioEncoder.Equals("aac_at", StringComparison.OrdinalIgnoreCase)
+                                          || audioEncoder.Equals("ac3", StringComparison.OrdinalIgnoreCase)
+                                          || audioEncoder.Equals("eac3", StringComparison.OrdinalIgnoreCase)
+                                          || audioEncoder.Equals("dts", StringComparison.OrdinalIgnoreCase)
+                                          || audioEncoder.Equals("mlp", StringComparison.OrdinalIgnoreCase)
+                                          || audioEncoder.Equals("truehd", StringComparison.OrdinalIgnoreCase) => 6,
+                    // Set default max transcoding channels to 8 to prevent encoding errors due to asking for too many channels
+                    _ => 8,
+                };
 
-            if (isTranscodingAudio && transcoderChannelLimit.HasValue)
-            {
-                resultChannels = resultChannels.HasValue
-                    ? Math.Min(resultChannels.Value, transcoderChannelLimit.Value)
-                    : transcoderChannelLimit.Value;
-            }
+                // Set resultChannels to minimum between resultChannels, TranscodingMaxAudioChannels, transcoderChannelLimit
 
-            // Avoid transcoding to audio channels other than 1ch, 2ch, 6ch (5.1 layout) and 8ch (7.1 layout).
-            // https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices
-            if (isTranscodingAudio
-                && state.TranscodingType != TranscodingJobType.Progressive
-                && resultChannels.HasValue
-                && ((resultChannels.Value > 2 && resultChannels.Value < 6) || resultChannels.Value == 7))
-            {
-                resultChannels = 2;
-            }
-
-            return resultChannels;
-        }
+                resultChannels = transcoderChannelLimit < resultChannels ? transcoderChannelLimit : resultChannels ?? transcoderChannelLimit;
 
-        private int? GetMinValue(int? val1, int? val2)
-        {
-            if (!val1.HasValue)
-            {
-                return val2;
-            }
+                if (request.TranscodingMaxAudioChannels < resultChannels)
+                {
+                    resultChannels = request.TranscodingMaxAudioChannels;
+                }
 
-            if (!val2.HasValue)
-            {
-                return val1;
+                // Avoid transcoding to audio channels other than 1ch, 2ch, 6ch (5.1 layout) and 8ch (7.1 layout).
+                // https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices
+                if (state.TranscodingType != TranscodingJobType.Progressive
+                    && ((resultChannels > 2 && resultChannels < 6) || resultChannels == 7))
+                {
+                    resultChannels = 2;
+                }
             }
 
-            return Math.Min(val1.Value, val2.Value);
+            return resultChannels;
         }
 
         /// <summary>

+ 5 - 10
MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs

@@ -250,8 +250,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 }
 
                 var level = GetRequestedLevel(ActualOutputVideoCodec);
-                if (!string.IsNullOrEmpty(level)
-                    && double.TryParse(level, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
+                if (double.TryParse(level, CultureInfo.InvariantCulture, out var result))
                 {
                     return result;
                 }
@@ -645,8 +644,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             if (!string.IsNullOrEmpty(codec))
             {
                 var value = BaseRequest.GetOption(codec, "maxrefframes");
-                if (!string.IsNullOrEmpty(value)
-                    && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
+                if (int.TryParse(value, CultureInfo.InvariantCulture, out var result))
                 {
                     return result;
                 }
@@ -665,8 +663,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             if (!string.IsNullOrEmpty(codec))
             {
                 var value = BaseRequest.GetOption(codec, "videobitdepth");
-                if (!string.IsNullOrEmpty(value)
-                    && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
+                if (int.TryParse(value, CultureInfo.InvariantCulture, out var result))
                 {
                     return result;
                 }
@@ -685,8 +682,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             if (!string.IsNullOrEmpty(codec))
             {
                 var value = BaseRequest.GetOption(codec, "audiobitdepth");
-                if (!string.IsNullOrEmpty(value)
-                    && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
+                if (int.TryParse(value, CultureInfo.InvariantCulture, out var result))
                 {
                     return result;
                 }
@@ -700,8 +696,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             if (!string.IsNullOrEmpty(codec))
             {
                 var value = BaseRequest.GetOption(codec, "audiochannels");
-                if (!string.IsNullOrEmpty(value)
-                    && int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
+                if (int.TryParse(value, CultureInfo.InvariantCulture, out var result))
                 {
                     return result;
                 }

+ 4 - 4
MediaBrowser.Controller/MediaEncoding/JobLogger.cs

@@ -86,7 +86,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 {
                     var rate = parts[i + 1];
 
-                    if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val))
+                    if (float.TryParse(rate, CultureInfo.InvariantCulture, out var val))
                     {
                         framerate = val;
                     }
@@ -95,7 +95,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 {
                     var rate = part.Split('=', 2)[^1];
 
-                    if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val))
+                    if (float.TryParse(rate, CultureInfo.InvariantCulture, out var val))
                     {
                         framerate = val;
                     }
@@ -127,7 +127,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                     if (scale.HasValue)
                     {
-                        if (long.TryParse(size, NumberStyles.Any, CultureInfo.InvariantCulture, out var val))
+                        if (long.TryParse(size, CultureInfo.InvariantCulture, out var val))
                         {
                             bytesTranscoded = val * scale.Value;
                         }
@@ -146,7 +146,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                     if (scale.HasValue)
                     {
-                        if (float.TryParse(rate, NumberStyles.Any, CultureInfo.InvariantCulture, out var val))
+                        if (float.TryParse(rate, CultureInfo.InvariantCulture, out var val))
                         {
                             bitRate = (int)Math.Ceiling(val * scale.Value);
                         }

+ 0 - 2
MediaBrowser.Controller/Session/ISessionController.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;

+ 2 - 5
MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs

@@ -169,12 +169,9 @@ namespace MediaBrowser.LocalMetadata.Parsers
                 {
                     var text = reader.ReadElementContentAsString();
 
-                    if (!string.IsNullOrEmpty(text))
+                    if (float.TryParse(text, CultureInfo.InvariantCulture, out var value))
                     {
-                        if (float.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
-                        {
-                            item.CriticRating = value;
-                        }
+                        item.CriticRating = value;
                     }
 
                     break;

+ 3 - 3
MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs

@@ -277,7 +277,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
 
             if (match.Success)
             {
-                if (Version.TryParse(match.Groups[1].Value, out var result))
+                if (Version.TryParse(match.Groups[1].ValueSpan, out var result))
                 {
                     return result;
                 }
@@ -327,8 +327,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
                 RegexOptions.Multiline))
             {
                 var version = new Version(
-                    int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture),
-                    int.Parse(match.Groups["minor"].Value, CultureInfo.InvariantCulture));
+                    int.Parse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture),
+                    int.Parse(match.Groups["minor"].ValueSpan, CultureInfo.InvariantCulture));
 
                 map.Add(match.Groups["name"].Value, version);
             }

+ 13 - 20
MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs

@@ -97,12 +97,9 @@ namespace MediaBrowser.MediaEncoding.Probing
             {
                 info.Container = NormalizeFormat(data.Format.FormatName);
 
-                if (!string.IsNullOrEmpty(data.Format.BitRate))
+                if (int.TryParse(data.Format.BitRate, CultureInfo.InvariantCulture, out var value))
                 {
-                    if (int.TryParse(data.Format.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
-                    {
-                        info.Bitrate = value;
-                    }
+                    info.Bitrate = value;
                 }
             }
 
@@ -561,8 +558,8 @@ namespace MediaBrowser.MediaEncoding.Probing
                 }
             }
 
-            if (string.IsNullOrWhiteSpace(name) ||
-                string.IsNullOrWhiteSpace(value))
+            if (string.IsNullOrWhiteSpace(name)
+                || string.IsNullOrWhiteSpace(value))
             {
                 return null;
             }
@@ -674,9 +671,9 @@ namespace MediaBrowser.MediaEncoding.Probing
 
                 stream.Channels = streamInfo.Channels;
 
-                if (int.TryParse(streamInfo.SampleRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
+                if (int.TryParse(streamInfo.SampleRate, CultureInfo.InvariantCulture, out var sampleRate))
                 {
-                    stream.SampleRate = value;
+                    stream.SampleRate = sampleRate;
                 }
 
                 stream.ChannelLayout = ParseChannelLayout(streamInfo.ChannelLayout);
@@ -853,22 +850,18 @@ namespace MediaBrowser.MediaEncoding.Probing
             // Get stream bitrate
             var bitrate = 0;
 
-            if (!string.IsNullOrEmpty(streamInfo.BitRate))
+            if (int.TryParse(streamInfo.BitRate, CultureInfo.InvariantCulture, out var value))
             {
-                if (int.TryParse(streamInfo.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
-                {
-                    bitrate = value;
-                }
+                bitrate = value;
             }
 
             // The bitrate info of FLAC musics and some videos is included in formatInfo.
             if (bitrate == 0
                 && formatInfo is not null
-                && !string.IsNullOrEmpty(formatInfo.BitRate)
                 && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio)))
             {
                 // If the stream info doesn't have a bitrate get the value from the media format info
-                if (int.TryParse(formatInfo.BitRate, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
+                if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value))
                 {
                     bitrate = value;
                 }
@@ -972,8 +965,8 @@ namespace MediaBrowser.MediaEncoding.Probing
 
             var parts = (original ?? string.Empty).Split(':');
             if (!(parts.Length == 2
-                    && int.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out var width)
-                    && int.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out var height)
+                    && int.TryParse(parts[0], CultureInfo.InvariantCulture, out var width)
+                    && int.TryParse(parts[1], CultureInfo.InvariantCulture, out var height)
                     && width > 0
                     && height > 0))
             {
@@ -1117,7 +1110,7 @@ namespace MediaBrowser.MediaEncoding.Probing
             }
 
             var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION");
-            if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration))
+            if (TimeSpan.TryParse(duration, out var parsedDuration))
             {
                 return parsedDuration.TotalSeconds;
             }
@@ -1446,7 +1439,7 @@ namespace MediaBrowser.MediaEncoding.Probing
             // Limit accuracy to milliseconds to match xml saving
             var secondsString = chapter.StartTime;
 
-            if (double.TryParse(secondsString, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds))
+            if (double.TryParse(secondsString, CultureInfo.InvariantCulture, out var seconds))
             {
                 var ms = Math.Round(TimeSpan.FromSeconds(seconds).TotalMilliseconds);
                 info.StartPositionTicks = TimeSpan.FromMilliseconds(ms).Ticks;

+ 2 - 2
MediaBrowser.Model/Dlna/ConditionProcessor.cs

@@ -136,7 +136,7 @@ namespace MediaBrowser.Model.Dlna
                 return !condition.IsRequired;
             }
 
-            if (int.TryParse(condition.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var expected))
+            if (int.TryParse(condition.Value, CultureInfo.InvariantCulture, out var expected))
             {
                 switch (condition.Condition)
                 {
@@ -212,7 +212,7 @@ namespace MediaBrowser.Model.Dlna
                 return !condition.IsRequired;
             }
 
-            if (double.TryParse(condition.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var expected))
+            if (double.TryParse(condition.Value, CultureInfo.InvariantCulture, out var expected))
             {
                 switch (condition.Condition)
                 {

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

@@ -9,7 +9,7 @@ namespace MediaBrowser.Model.Dlna
     {
         public SortCriteria(string sortOrder)
         {
-            if (!string.IsNullOrEmpty(sortOrder) && Enum.TryParse<SortOrder>(sortOrder, true, out var sortOrderValue))
+            if (Enum.TryParse<SortOrder>(sortOrder, true, out var sortOrderValue))
             {
                 SortOrder = sortOrderValue;
             }

+ 11 - 12
MediaBrowser.Model/Dlna/StreamBuilder.cs

@@ -551,8 +551,7 @@ namespace MediaBrowser.Model.Dlna
             }
 
             playlistItem.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo;
-            if (!string.IsNullOrEmpty(transcodingProfile.MaxAudioChannels)
-                && int.TryParse(transcodingProfile.MaxAudioChannels, NumberStyles.Any, CultureInfo.InvariantCulture, out int transcodingMaxAudioChannels))
+            if (int.TryParse(transcodingProfile.MaxAudioChannels, CultureInfo.InvariantCulture, out int transcodingMaxAudioChannels))
             {
                 playlistItem.TranscodingMaxAudioChannels = transcodingMaxAudioChannels;
             }
@@ -1607,7 +1606,7 @@ namespace MediaBrowser.Model.Dlna
                                 continue;
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1633,7 +1632,7 @@ namespace MediaBrowser.Model.Dlna
                                 continue;
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1669,7 +1668,7 @@ namespace MediaBrowser.Model.Dlna
                                 }
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1793,7 +1792,7 @@ namespace MediaBrowser.Model.Dlna
                                 }
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1829,7 +1828,7 @@ namespace MediaBrowser.Model.Dlna
                                 }
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1919,7 +1918,7 @@ namespace MediaBrowser.Model.Dlna
                                 continue;
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1945,7 +1944,7 @@ namespace MediaBrowser.Model.Dlna
                                 continue;
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1971,7 +1970,7 @@ namespace MediaBrowser.Model.Dlna
                                 continue;
                             }
 
-                            if (float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (float.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -1997,7 +1996,7 @@ namespace MediaBrowser.Model.Dlna
                                 continue;
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {
@@ -2023,7 +2022,7 @@ namespace MediaBrowser.Model.Dlna
                                 continue;
                             }
 
-                            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num))
+                            if (int.TryParse(value, CultureInfo.InvariantCulture, out var num))
                             {
                                 if (condition.Condition == ProfileConditionType.Equals)
                                 {

+ 4 - 20
MediaBrowser.Model/Dlna/StreamInfo.cs

@@ -922,12 +922,8 @@ namespace MediaBrowser.Model.Dlna
         public int? GetTargetVideoBitDepth(string codec)
         {
             var value = GetOption(codec, "videobitdepth");
-            if (string.IsNullOrEmpty(value))
-            {
-                return null;
-            }
 
-            if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
+            if (int.TryParse(value, CultureInfo.InvariantCulture, out var result))
             {
                 return result;
             }
@@ -938,12 +934,8 @@ namespace MediaBrowser.Model.Dlna
         public int? GetTargetAudioBitDepth(string codec)
         {
             var value = GetOption(codec, "audiobitdepth");
-            if (string.IsNullOrEmpty(value))
-            {
-                return null;
-            }
 
-            if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
+            if (int.TryParse(value, CultureInfo.InvariantCulture, out var result))
             {
                 return result;
             }
@@ -954,12 +946,8 @@ namespace MediaBrowser.Model.Dlna
         public double? GetTargetVideoLevel(string codec)
         {
             var value = GetOption(codec, "level");
-            if (string.IsNullOrEmpty(value))
-            {
-                return null;
-            }
 
-            if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
+            if (double.TryParse(value, CultureInfo.InvariantCulture, out var result))
             {
                 return result;
             }
@@ -970,12 +958,8 @@ namespace MediaBrowser.Model.Dlna
         public int? GetTargetRefFrames(string codec)
         {
             var value = GetOption(codec, "maxrefframes");
-            if (string.IsNullOrEmpty(value))
-            {
-                return null;
-            }
 
-            if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
+            if (int.TryParse(value, CultureInfo.InvariantCulture, out var result))
             {
                 return result;
             }

+ 5 - 0
MediaBrowser.Model/Net/MimeTypes.cs

@@ -117,7 +117,9 @@ namespace MediaBrowser.Model.Net
 
             // Type image
             { "image/jpeg", ".jpg" },
+            { "image/tiff", ".tiff" },
             { "image/x-png", ".png" },
+            { "image/x-icon", ".ico" },
 
             // Type text
             { "text/plain", ".txt" },
@@ -178,5 +180,8 @@ namespace MediaBrowser.Model.Net
             var extension = Model.MimeTypes.GetMimeTypeExtensions(mimeType).FirstOrDefault();
             return string.IsNullOrEmpty(extension) ? null : "." + extension;
         }
+
+        public static bool IsImage(ReadOnlySpan<char> mimeType)
+            => mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
     }
 }

+ 2 - 2
MediaBrowser.Model/Tasks/ITaskManager.cs

@@ -9,9 +9,9 @@ namespace MediaBrowser.Model.Tasks
 {
     public interface ITaskManager : IDisposable
     {
-        event EventHandler<GenericEventArgs<IScheduledTaskWorker>> TaskExecuting;
+        event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting;
 
-        event EventHandler<TaskCompletionEventArgs> TaskCompleted;
+        event EventHandler<TaskCompletionEventArgs>? TaskCompleted;
 
         /// <summary>
         /// Gets the list of Scheduled Tasks.

+ 0 - 1
MediaBrowser.Providers/Manager/MetadataService.cs

@@ -151,7 +151,6 @@ namespace MediaBrowser.Providers.Manager
                         ApplySearchResult(id, refreshOptions.SearchResult);
                     }
 
-                    // await FindIdentities(id, cancellationToken).ConfigureAwait(false);
                     id.IsAutomated = refreshOptions.IsAutomated;
 
                     var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, cancellationToken).ConfigureAwait(false);

+ 1 - 2
MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs

@@ -1,5 +1,4 @@
 using MediaBrowser.Model.Plugins;
-using MetaBrainz.MusicBrainz;
 
 namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
 
@@ -8,7 +7,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
 /// </summary>
 public class PluginConfiguration : BasePluginConfiguration
 {
-    private const string DefaultServer = "musicbrainz.org";
+    private const string DefaultServer = "https://musicbrainz.org";
 
     private const double DefaultRateLimit = 1.0;
 

+ 11 - 15
MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html

@@ -1,20 +1,16 @@
-<!DOCTYPE html>
-<html>
-<head>
-    <title>MusicBrainz</title>
-</head>
-<body>
-    <div data-role="page" class="page type-interior pluginConfigurationPage musicBrainzConfigPage" data-require="emby-input,emby-button,emby-checkbox">
-        <div data-role="content">
-            <div class="content-primary">
-                <form class="musicBrainzConfigForm">
+<div id="musicBrainzConfigurationPage" data-role="page"
+    class="page type-interior pluginConfigurationPage musicBrainzConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
+    <div data-role="content">
+        <div class="content-primary">
+            <h1>MusicBrainz</h1>
+                <form class="musicBrainzConfigurationForm">
                     <div class="inputContainer">
                         <input is="emby-input" type="text" id="server" required label="Server" />
                         <div class="fieldDescription">This can be a mirror of the official server or even a custom server.</div>
                     </div>
                     <div class="inputContainer">
-                        <input is="emby-input" type="number" id="rateLimit" pattern="[0-9]*" required min="0" max="10000" label="Rate Limit" />
-                        <div class="fieldDescription">Span of time between requests in milliseconds. The official server is limited to one request every two seconds.</div>
+                        <input is="emby-input" type="number" id="rateLimit" required pattern="[0-9]*" min="0" max="10" step=".01" label="Rate Limit" />
+                        <div class="fieldDescription">Span of time between requests in seconds. The official server is limited to one request every seconds.</div>
                     </div>
                     <label class="checkboxContainer">
                         <input is="emby-checkbox" type="checkbox" id="replaceArtistName" />
@@ -32,7 +28,7 @@
                 uniquePluginId: "8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"
             };
 
-            document.querySelector('.musicBrainzConfigPage')
+            document.querySelector('.musicBrainzConfigurationPage')
                 .addEventListener('pageshow', function () {
                     Dashboard.showLoadingMsg();
                     ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) {
@@ -49,14 +45,14 @@
                             bubbles: true,
                             cancelable: false
                         }));
-                        
+
                         document.querySelector('#replaceArtistName').checked = config.ReplaceArtistName;
 
                         Dashboard.hideLoadingMsg();
                     });
                 });
 
-            document.querySelector('.musicBrainzConfigForm')
+            document.querySelector('.musicBrainzConfigurationForm')
                 .addEventListener('submit', function (e) {
                     Dashboard.showLoadingMsg();
 

+ 54 - 36
MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs

@@ -8,8 +8,10 @@ using Jellyfin.Extensions;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Plugins;
 using MediaBrowser.Model.Providers;
 using MediaBrowser.Providers.Music;
+using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
 using MetaBrainz.MusicBrainz;
 using MetaBrainz.MusicBrainz.Interfaces.Entities;
 using MetaBrainz.MusicBrainz.Interfaces.Searches;
@@ -23,8 +25,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz;
 public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable
 {
     private readonly ILogger<MusicBrainzAlbumProvider> _logger;
-    private readonly Query _musicBrainzQuery;
-    private readonly string _musicBrainzDefaultUri = "https://musicbrainz.org";
+    private Query _musicBrainzQuery;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class.
@@ -33,29 +34,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
     public MusicBrainzAlbumProvider(ILogger<MusicBrainzAlbumProvider> logger)
     {
         _logger = logger;
-
-        MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) =>
-            {
-                if (Uri.TryCreate(MusicBrainz.Plugin.Instance.Configuration.Server, UriKind.Absolute, out var server))
-                {
-                    Query.DefaultServer = server.Host;
-                    Query.DefaultPort = server.Port;
-                    Query.DefaultUrlScheme = server.Scheme;
-                }
-                else
-                {
-                    // Fallback to official server
-                    _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
-                    var defaultServer = new Uri(_musicBrainzDefaultUri);
-                    Query.DefaultServer = defaultServer.Host;
-                    Query.DefaultPort = defaultServer.Port;
-                    Query.DefaultUrlScheme = defaultServer.Scheme;
-                }
-
-                Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit;
-            };
-
         _musicBrainzQuery = new Query();
+        ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration);
+        MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig;
     }
 
     /// <inheritdoc />
@@ -64,6 +45,29 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
     /// <inheritdoc />
     public int Order => 0;
 
+    private void ReloadConfig(object? sender, BasePluginConfiguration e)
+    {
+        var configuration = (PluginConfiguration)e;
+        if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server))
+        {
+            Query.DefaultServer = server.DnsSafeHost;
+            Query.DefaultPort = server.Port;
+            Query.DefaultUrlScheme = server.Scheme;
+        }
+        else
+        {
+            // Fallback to official server
+            _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
+            var defaultServer = new Uri(configuration.Server);
+            Query.DefaultServer = defaultServer.Host;
+            Query.DefaultPort = defaultServer.Port;
+            Query.DefaultUrlScheme = defaultServer.Scheme;
+        }
+
+        Query.DelayBetweenRequests = configuration.RateLimit;
+        _musicBrainzQuery = new Query();
+    }
+
     /// <inheritdoc />
     public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
     {
@@ -72,13 +76,13 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
 
         if (!string.IsNullOrEmpty(releaseId))
         {
-            var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
+            var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
             return GetReleaseResult(releaseResult).SingleItemAsEnumerable();
         }
 
         if (!string.IsNullOrEmpty(releaseGroupId))
         {
-            var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
+            var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false);
             return GetReleaseGroupResult(releaseGroupResult.Releases);
         }
 
@@ -133,7 +137,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
 
         foreach (var result in releaseSearchResults)
         {
-            yield return GetReleaseResult(result);
+            // Fetch full release info, otherwise artists are missing
+            var fullResult = _musicBrainzQuery.LookupRelease(result.Id, Include.Artists | Include.ReleaseGroups);
+            yield return GetReleaseResult(fullResult);
         }
     }
 
@@ -143,21 +149,33 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
         {
             Name = releaseSearchResult.Title,
             ProductionYear = releaseSearchResult.Date?.Year,
-            PremiereDate = releaseSearchResult.Date?.NearestDate
+            PremiereDate = releaseSearchResult.Date?.NearestDate,
+            SearchProviderName = Name
         };
 
-        if (releaseSearchResult.ArtistCredit?.Count > 0)
+        // Add artists and use first as album artist
+        var artists = releaseSearchResult.ArtistCredit;
+        if (artists is not null && artists.Count > 0)
         {
-            searchResult.AlbumArtist = new RemoteSearchResult
-            {
-                SearchProviderName = Name,
-                Name = releaseSearchResult.ArtistCredit[0].Name
-            };
+            var artistResults = new List<RemoteSearchResult>();
 
-            if (releaseSearchResult.ArtistCredit[0].Artist?.Id is not null)
+            foreach (var artist in artists)
             {
-                searchResult.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, releaseSearchResult.ArtistCredit[0].Artist!.Id.ToString());
+                var artistResult = new RemoteSearchResult
+                {
+                    Name = artist.Name
+                };
+
+                if (artist.Artist?.Id is not null)
+                {
+                    artistResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Artist!.Id.ToString());
+                }
+
+                artistResults.Add(artistResult);
             }
+
+            searchResult.AlbumArtist = artistResults[0];
+            searchResult.Artists = artistResults.ToArray();
         }
 
         searchResult.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseSearchResult.Id.ToString());

+ 30 - 25
MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs

@@ -8,8 +8,10 @@ using Jellyfin.Extensions;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Plugins;
 using MediaBrowser.Model.Providers;
 using MediaBrowser.Providers.Music;
+using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
 using MetaBrainz.MusicBrainz;
 using MetaBrainz.MusicBrainz.Interfaces.Entities;
 using MetaBrainz.MusicBrainz.Interfaces.Searches;
@@ -23,8 +25,7 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz;
 public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable
 {
     private readonly ILogger<MusicBrainzArtistProvider> _logger;
-    private readonly Query _musicBrainzQuery;
-    private readonly string _musicBrainzDefaultUri = "https://musicbrainz.org";
+    private Query _musicBrainzQuery;
 
     /// <summary>
     /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class.
@@ -33,34 +34,37 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
     public MusicBrainzArtistProvider(ILogger<MusicBrainzArtistProvider> logger)
     {
         _logger = logger;
-
-        MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) =>
-            {
-                if (Uri.TryCreate(MusicBrainz.Plugin.Instance.Configuration.Server, UriKind.Absolute, out var server))
-                {
-                    Query.DefaultServer = server.Host;
-                    Query.DefaultPort = server.Port;
-                    Query.DefaultUrlScheme = server.Scheme;
-                }
-                else
-                {
-                    // Fallback to official server
-                    _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
-                    var defaultServer = new Uri(_musicBrainzDefaultUri);
-                    Query.DefaultServer = defaultServer.Host;
-                    Query.DefaultPort = defaultServer.Port;
-                    Query.DefaultUrlScheme = defaultServer.Scheme;
-                }
-
-                Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit;
-            };
-
         _musicBrainzQuery = new Query();
+        ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration);
+        MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig;
     }
 
     /// <inheritdoc />
     public string Name => "MusicBrainz";
 
+    private void ReloadConfig(object? sender, BasePluginConfiguration e)
+    {
+        var configuration = (PluginConfiguration)e;
+        if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server))
+        {
+            Query.DefaultServer = server.DnsSafeHost;
+            Query.DefaultPort = server.Port;
+            Query.DefaultUrlScheme = server.Scheme;
+        }
+        else
+        {
+            // Fallback to official server
+            _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
+            var defaultServer = new Uri(configuration.Server);
+            Query.DefaultServer = defaultServer.Host;
+            Query.DefaultPort = defaultServer.Port;
+            Query.DefaultUrlScheme = defaultServer.Scheme;
+        }
+
+        Query.DelayBetweenRequests = configuration.RateLimit;
+        _musicBrainzQuery = new Query();
+    }
+
     /// <inheritdoc />
     public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
     {
@@ -112,7 +116,8 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
         {
             Name = artist.Name,
             ProductionYear = artist.LifeSpan?.Begin?.Year,
-            PremiereDate = artist.LifeSpan?.Begin?.NearestDate
+            PremiereDate = artist.LifeSpan?.Begin?.NearestDate,
+            SearchProviderName = Name,
         };
 
         searchResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString());

+ 3 - 5
MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs

@@ -98,8 +98,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
                 // item.VoteCount = voteCount;
             }
 
-            if (!string.IsNullOrEmpty(result.imdbRating)
-                && float.TryParse(result.imdbRating, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbRating)
+            if (float.TryParse(result.imdbRating, CultureInfo.InvariantCulture, out var imdbRating)
                 && imdbRating >= 0)
             {
                 item.CommunityRating = imdbRating;
@@ -209,8 +208,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
                 // item.VoteCount = voteCount;
             }
 
-            if (!string.IsNullOrEmpty(result.imdbRating)
-                && float.TryParse(result.imdbRating, NumberStyles.Any, CultureInfo.InvariantCulture, out var imdbRating)
+            if (float.TryParse(result.imdbRating, CultureInfo.InvariantCulture, out var imdbRating)
                 && imdbRating >= 0)
             {
                 item.CommunityRating = imdbRating;
@@ -552,7 +550,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
                     if (rating?.Value is not null)
                     {
                         var value = rating.Value.TrimEnd('%');
-                        if (float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var score))
+                        if (float.TryParse(value, CultureInfo.InvariantCulture, out var score))
                         {
                             return score;
                         }

+ 28 - 28
MediaBrowser.Providers/Subtitles/SubtitleManager.cs

@@ -193,43 +193,43 @@ namespace MediaBrowser.Providers.Subtitles
                     await stream.CopyToAsync(memoryStream).ConfigureAwait(false);
                     memoryStream.Position = 0;
                 }
-            }
 
-            var savePaths = new List<string>();
-            var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
+                var savePaths = new List<string>();
+                var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
 
-            if (response.IsForced)
-            {
-                saveFileName += ".forced";
-            }
+                if (response.IsForced)
+                {
+                    saveFileName += ".forced";
+                }
 
-            saveFileName += "." + response.Format.ToLowerInvariant();
+                saveFileName += "." + response.Format.ToLowerInvariant();
 
-            if (saveInMediaFolder)
-            {
-                var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName));
-                // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, bad path.");
-                if (mediaFolderPath.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal))
+                if (saveInMediaFolder)
                 {
-                    savePaths.Add(mediaFolderPath);
+                    var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName));
+                    // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, bad path.");
+                    if (mediaFolderPath.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal))
+                    {
+                        savePaths.Add(mediaFolderPath);
+                    }
                 }
-            }
 
-            var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
+                var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
 
-            // TODO: Add some error to the user: return BadRequest("Could not save subtitle, bad path.");
-            if (internalPath.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal))
-            {
-                savePaths.Add(internalPath);
-            }
+                // TODO: Add some error to the user: return BadRequest("Could not save subtitle, bad path.");
+                if (internalPath.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal))
+                {
+                    savePaths.Add(internalPath);
+                }
 
-            if (savePaths.Count > 0)
-            {
-                await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
-            }
-            else
-            {
-                _logger.LogError("An uploaded subtitle could not be saved because the resulting paths were invalid.");
+                if (savePaths.Count > 0)
+                {
+                    await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
+                }
+                else
+                {
+                    _logger.LogError("An uploaded subtitle could not be saved because the resulting paths were invalid.");
+                }
             }
         }
 

+ 2 - 5
MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs

@@ -315,12 +315,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     {
                         var text = reader.ReadElementContentAsString();
 
-                        if (!string.IsNullOrEmpty(text))
+                        if (float.TryParse(text, CultureInfo.InvariantCulture, out var value))
                         {
-                            if (float.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value))
-                            {
-                                item.CriticRating = value;
-                            }
+                            item.CriticRating = value;
                         }
 
                         break;

+ 36 - 0
tests/Jellyfin.Api.Tests/Controllers/ImageControllerTests.cs

@@ -0,0 +1,36 @@
+using System;
+using Jellyfin.Api.Controllers;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+public static class ImageControllerTests
+{
+    [Theory]
+    [InlineData("image/apng", ".apng")]
+    [InlineData("image/avif", ".avif")]
+    [InlineData("image/bmp", ".bmp")]
+    [InlineData("image/gif", ".gif")]
+    [InlineData("image/x-icon", ".ico")]
+    [InlineData("image/jpeg", ".jpg")]
+    [InlineData("image/png", ".png")]
+    [InlineData("image/png; charset=utf-8", ".png")]
+    [InlineData("image/svg+xml", ".svg")]
+    [InlineData("image/tiff", ".tiff")]
+    [InlineData("image/webp", ".webp")]
+    public static void TryGetImageExtensionFromContentType_Valid_True(string contentType, string extension)
+    {
+        Assert.True(ImageController.TryGetImageExtensionFromContentType(contentType, out var ex));
+        Assert.Equal(extension, ex);
+    }
+
+    [Theory]
+    [InlineData(null)]
+    [InlineData("")]
+    [InlineData("text/html")]
+    public static void TryGetImageExtensionFromContentType_InValid_False(string contentType)
+    {
+        Assert.False(ImageController.TryGetImageExtensionFromContentType(contentType, out var ex));
+        Assert.Null(ex);
+    }
+}

+ 80 - 0
tests/Jellyfin.Api.Tests/Helpers/RequestHelpersTests.cs

@@ -1,7 +1,11 @@
 using System;
 using System.Collections.Generic;
+using System.Globalization;
+using System.Security.Claims;
+using Jellyfin.Api.Constants;
 using Jellyfin.Api.Helpers;
 using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Net;
 using Xunit;
 
 namespace Jellyfin.Api.Tests.Helpers
@@ -15,6 +19,82 @@ namespace Jellyfin.Api.Tests.Helpers
             Assert.Equal(expected, RequestHelpers.GetOrderBy(sortBy, requestedSortOrder));
         }
 
+        [Fact]
+        public static void GetUserId_IsAdmin()
+        {
+            Guid? requestUserId = Guid.NewGuid();
+            Guid? authUserId = Guid.NewGuid();
+
+            var claims = new[]
+            {
+                new Claim(InternalClaimTypes.UserId, authUserId.Value.ToString("N", CultureInfo.InvariantCulture)),
+                new Claim(InternalClaimTypes.IsApiKey, bool.FalseString),
+                new Claim(ClaimTypes.Role, UserRoles.Administrator)
+            };
+
+            var identity = new ClaimsIdentity(claims, string.Empty);
+            var principal = new ClaimsPrincipal(identity);
+
+            var userId = RequestHelpers.GetUserId(principal, requestUserId);
+
+            Assert.Equal(requestUserId, userId);
+        }
+
+        [Fact]
+        public static void GetUserId_IsApiKey_EmptyGuid()
+        {
+            Guid? requestUserId = Guid.Empty;
+
+            var claims = new[]
+            {
+                new Claim(InternalClaimTypes.IsApiKey, bool.TrueString)
+            };
+
+            var identity = new ClaimsIdentity(claims, string.Empty);
+            var principal = new ClaimsPrincipal(identity);
+
+            var userId = RequestHelpers.GetUserId(principal, requestUserId);
+
+            Assert.Equal(Guid.Empty, userId);
+        }
+
+        [Fact]
+        public static void GetUserId_IsApiKey_Null()
+        {
+            Guid? requestUserId = null;
+
+            var claims = new[]
+            {
+                new Claim(InternalClaimTypes.IsApiKey, bool.TrueString)
+            };
+
+            var identity = new ClaimsIdentity(claims, string.Empty);
+            var principal = new ClaimsPrincipal(identity);
+
+            var userId = RequestHelpers.GetUserId(principal, requestUserId);
+
+            Assert.Equal(Guid.Empty, userId);
+        }
+
+        [Fact]
+        public static void GetUserId_IsUser()
+        {
+            Guid? requestUserId = Guid.NewGuid();
+            Guid? authUserId = Guid.NewGuid();
+
+            var claims = new[]
+            {
+                new Claim(InternalClaimTypes.UserId, authUserId.Value.ToString("N", CultureInfo.InvariantCulture)),
+                new Claim(InternalClaimTypes.IsApiKey, bool.FalseString),
+                new Claim(ClaimTypes.Role, UserRoles.User)
+            };
+
+            var identity = new ClaimsIdentity(claims, string.Empty);
+            var principal = new ClaimsPrincipal(identity);
+
+            Assert.Throws<SecurityException>(() => RequestHelpers.GetUserId(principal, requestUserId));
+        }
+
         public static TheoryData<IReadOnlyList<string>, IReadOnlyList<SortOrder>, (string, SortOrder)[]> GetOrderBy_Success_TestData()
         {
             var data = new TheoryData<IReadOnlyList<string>, IReadOnlyList<SortOrder>, (string, SortOrder)[]>();

+ 2 - 1
tests/Jellyfin.Model.Tests/Net/MimeTypesTests.cs

@@ -127,9 +127,10 @@ namespace Jellyfin.Model.Tests.Net
         [InlineData("image/jpeg", ".jpg")]
         [InlineData("image/png", ".png")]
         [InlineData("image/svg+xml", ".svg")]
-        [InlineData("image/tiff", ".tif")]
+        [InlineData("image/tiff", ".tiff")]
         [InlineData("image/vnd.microsoft.icon", ".ico")]
         [InlineData("image/webp", ".webp")]
+        [InlineData("image/x-icon", ".ico")]
         [InlineData("image/x-png", ".png")]
         [InlineData("text/css", ".css")]
         [InlineData("text/csv", ".csv")]

+ 0 - 2
tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs

@@ -12,8 +12,6 @@ namespace Jellyfin.Naming.Tests.Common
 
             Assert.NotEmpty(options.CleanDateTimeRegexes);
             Assert.NotEmpty(options.CleanStringRegexes);
-            Assert.NotEmpty(options.EpisodeWithoutSeasonRegexes);
-            Assert.NotEmpty(options.EpisodeMultiPartRegexes);
         }
 
         [Fact]

+ 5 - 0
tests/Jellyfin.Naming.Tests/TV/EpisodeNumberTests.cs

@@ -73,6 +73,11 @@ namespace Jellyfin.Naming.Tests.TV
         [InlineData("[BBT-RMX] Ranma ½ - 154 [50AC421A].mkv", 154)] // hyphens in the pre-name info, triple digit episode number
         [InlineData("Season 2/Episode 21 - 94 Meetings.mp4", 21)] // Title starts with a number
         [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", 7)]
+        [InlineData("Season 3/The Series Season 3 Episode 9 - The title.avi", 9)]
+        [InlineData("Season 3/The Series S3 E9 - The title.avi", 9)]
+        [InlineData("Season 3/S003 E009.avi", 9)]
+        [InlineData("Season 3/Season 3 Episode 9.avi", 9)]
+
         // [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 317)] // triple digit episode number
         // TODO: [InlineData("Season 2/16 12 Some Title.avi", 16)]
         // TODO: [InlineData("Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", 3)]

+ 1 - 0
tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs

@@ -30,6 +30,7 @@ namespace Jellyfin.Naming.Tests.TV
         [InlineData("/Season 02/Elementary - 02x03-E15 - Ep Name.mp4", false, "Elementary", 2, 3)]
         [InlineData("/Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", false, "Elementary", 1, 23)]
         [InlineData("/The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH/The Wonder Years s04e07 Christmas Party NTSC PDTV.avi", false, "The Wonder Years", 4, 7)]
+        [InlineData("/The.Sopranos/Season 3/The Sopranos Season 3 Episode 09 - The Telltale Moozadell.avi", false, "The Sopranos", 3, 9)]
         // TODO: [InlineData("/Castle Rock 2x01 Que el rio siga su curso [WEB-DL HULU 1080p h264 Dual DD5.1 Subs].mkv", "Castle Rock", 2, 1)]
         // TODO: [InlineData("/After Life 1x06 Episodio 6 [WEB-DL NF 1080p h264 Dual DD 5.1 Sub].mkv", "After Life", 1, 6)]
         // TODO: [InlineData("/Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", "Uchuu Senkan Yamoto 2199", 4, 3)]

+ 19 - 0
tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs

@@ -323,6 +323,25 @@ namespace Jellyfin.Naming.Tests.Video
             Assert.Single(result[0].AlternateVersions);
         }
 
+        [Fact]
+        public void TestMultiVersion12()
+        {
+            var files = new[]
+            {
+                @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv",
+                @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv"
+            };
+
+            var result = VideoListResolver.Resolve(
+                files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+                _namingOptions).ToList();
+
+            Assert.Single(result);
+            Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", result[0].Files[0].Path);
+            Assert.Single(result[0].AlternateVersions);
+            Assert.Equal("/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", result[0].AlternateVersions[0].Path);
+        }
+
         [Fact]
         public void Resolve_GivenFolderNameWithBracketsAndHyphens_GroupsBasedOnFolderName()
         {

+ 2 - 2
tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs

@@ -22,13 +22,13 @@ public sealed class ItemsControllerTests : IClassFixture<JellyfinApplicationFact
     }
 
     [Fact]
-    public async Task GetItems_NoApiKeyOrUserId_BadRequest()
+    public async Task GetItems_NoApiKeyOrUserId_Success()
     {
         var client = _factory.CreateClient();
         client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false));
 
         var response = await client.GetAsync("Items").ConfigureAwait(false);
-        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
     }
 
     [Theory]