Browse Source

Merge branch 'master' into network-rewrite

Shadowghost 2 years ago
parent
commit
80b8661008
100 changed files with 1165 additions and 652 deletions
  1. 3 3
      .github/workflows/codeql-analysis.yml
  2. 1 1
      .github/workflows/openapi.yml
  3. 1 0
      CONTRIBUTORS.md
  4. 5 5
      Directory.Packages.props
  5. 1 3
      Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs
  6. 1 3
      Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs
  7. 18 8
      Emby.Dlna/Server/DescriptionXmlBuilder.cs
  8. 1 1
      Emby.Naming/AudioBook/AudioBookFilePathParser.cs
  9. 10 10
      Emby.Naming/AudioBook/AudioBookListResolver.cs
  10. 1 1
      Emby.Naming/AudioBook/AudioBookNameParser.cs
  11. 1 1
      Emby.Naming/TV/SeriesResolver.cs
  12. 1 1
      Emby.Naming/Video/FileStackRule.cs
  13. 23 6
      Emby.Naming/Video/VideoListResolver.cs
  14. 1 1
      Emby.Server.Implementations/Channels/ChannelManager.cs
  15. 11 7
      Emby.Server.Implementations/Collections/CollectionManager.cs
  16. 112 170
      Emby.Server.Implementations/Data/SqliteItemRepository.cs
  17. 10 9
      Emby.Server.Implementations/Dto/DtoService.cs
  18. 15 18
      Emby.Server.Implementations/Library/LibraryManager.cs
  19. 1 2
      Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs
  20. 1 1
      Emby.Server.Implementations/Library/UserViewManager.cs
  21. 1 2
      Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs
  22. 1 2
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  23. 1 1
      Emby.Server.Implementations/Localization/Core/es.json
  24. 7 1
      Emby.Server.Implementations/Localization/Core/hi.json
  25. 2 2
      Emby.Server.Implementations/Localization/Core/nl.json
  26. 1 1
      Emby.Server.Implementations/Localization/Core/uk.json
  27. 65 15
      Emby.Server.Implementations/Localization/LocalizationManager.cs
  28. 11 0
      Emby.Server.Implementations/Localization/Ratings/0-prefer.csv
  29. 13 7
      Emby.Server.Implementations/Localization/Ratings/au.csv
  30. 11 6
      Emby.Server.Implementations/Localization/Ratings/be.csv
  31. 8 6
      Emby.Server.Implementations/Localization/Ratings/br.csv
  32. 20 6
      Emby.Server.Implementations/Localization/Ratings/ca.csv
  33. 7 8
      Emby.Server.Implementations/Localization/Ratings/co.csv
  34. 12 10
      Emby.Server.Implementations/Localization/Ratings/de.csv
  35. 7 4
      Emby.Server.Implementations/Localization/Ratings/dk.csv
  36. 24 6
      Emby.Server.Implementations/Localization/Ratings/es.csv
  37. 10 10
      Emby.Server.Implementations/Localization/Ratings/fi.csv
  38. 12 5
      Emby.Server.Implementations/Localization/Ratings/fr.csv
  39. 22 7
      Emby.Server.Implementations/Localization/Ratings/gb.csv
  40. 9 6
      Emby.Server.Implementations/Localization/Ratings/ie.csv
  41. 11 4
      Emby.Server.Implementations/Localization/Ratings/jp.csv
  42. 6 7
      Emby.Server.Implementations/Localization/Ratings/kz.csv
  43. 6 6
      Emby.Server.Implementations/Localization/Ratings/mx.csv
  44. 8 6
      Emby.Server.Implementations/Localization/Ratings/nl.csv
  45. 9 6
      Emby.Server.Implementations/Localization/Ratings/no.csv
  46. 15 11
      Emby.Server.Implementations/Localization/Ratings/nz.csv
  47. 6 1
      Emby.Server.Implementations/Localization/Ratings/ro.csv
  48. 6 5
      Emby.Server.Implementations/Localization/Ratings/ru.csv
  49. 10 5
      Emby.Server.Implementations/Localization/Ratings/se.csv
  50. 22 7
      Emby.Server.Implementations/Localization/Ratings/uk.csv
  51. 50 23
      Emby.Server.Implementations/Localization/Ratings/us.csv
  52. 3 6
      Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
  53. 2 5
      Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
  54. 8 1
      Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
  55. 3 6
      Jellyfin.Api/Controllers/GenresController.cs
  56. 45 4
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  57. 7 0
      Jellyfin.Api/Controllers/ItemsController.cs
  58. 139 20
      Jellyfin.Api/Controllers/UserLibraryController.cs
  59. 2 1
      Jellyfin.Server/Migrations/MigrationRunner.cs
  60. 86 0
      Jellyfin.Server/Migrations/PreStartupRoutines/MigrateRatingLevels.cs
  61. 7 12
      MediaBrowser.Controller/Entities/BaseItem.cs
  62. 5 0
      MediaBrowser.Controller/Entities/TV/Episode.cs
  63. 25 21
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  64. 5 0
      MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
  65. 1 1
      MediaBrowser.Controller/Persistence/IItemRepository.cs
  66. 3 0
      MediaBrowser.Controller/Providers/EpisodeInfo.cs
  67. 1 0
      MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs
  68. 0 2
      MediaBrowser.Controller/Subtitles/ISubtitleManager.cs
  69. 2 2
      MediaBrowser.Model/Entities/ParentalRating.cs
  70. 1 0
      MediaBrowser.Model/Users/UserPolicy.cs
  71. 2 2
      MediaBrowser.Providers/Manager/ProviderManager.cs
  72. 1 1
      MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
  73. 3 2
      MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs
  74. 3 2
      MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html
  75. 9 3
      MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs
  76. 13 8
      MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
  77. 6 6
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
  78. 1 1
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
  79. 5 4
      MediaBrowser.Providers/Plugins/Omdb/Configuration/config.html
  80. 1 4
      MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs
  81. 2 1
      MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html
  82. 1 3
      MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
  83. 2 4
      MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
  84. 2 1
      MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html
  85. 1 3
      MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
  86. 28 25
      MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
  87. 1 4
      MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
  88. 6 4
      MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
  89. 2 7
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
  90. 1 3
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
  91. 1 4
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
  92. 1 1
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
  93. 7 4
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
  94. 25 27
      MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
  95. 3 1
      MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
  96. 3 5
      MediaBrowser.Providers/Subtitles/SubtitleManager.cs
  97. 0 2
      MediaBrowser.Providers/TV/SeriesMetadataService.cs
  98. 1 1
      src/Jellyfin.Extensions/StringExtensions.cs
  99. 31 1
      tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs
  100. 47 0
      tests/Jellyfin.Dlna.Tests/Server/DescriptionXmlBuilderTests.cs

+ 3 - 3
.github/workflows/codeql-analysis.yml

@@ -27,11 +27,11 @@ jobs:
         dotnet-version: '7.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2
+      uses: github/codeql-action/init@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2
+      uses: github/codeql-action/autobuild@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2
+      uses: github/codeql-action/analyze@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2

+ 1 - 1
.github/workflows/openapi.yml

@@ -103,7 +103,7 @@ jobs:
           body="${body//$'\r'/'%0D'}"
           echo ::set-output name=body::$body
       - name: Find difference comment
-        uses: peter-evans/find-comment@85a676a52594b4481e0532825a2d8906ef96dac2 # v2
+        uses: peter-evans/find-comment@034abe94d3191f9c89d870519735beae326f2bdb # v2
         id: find-comment
         with:
           issue-number: ${{ github.event.pull_request.number }}

+ 1 - 0
CONTRIBUTORS.md

@@ -163,6 +163,7 @@
  - [vgambier](https://github.com/vgambier)
  - [MinecraftPlaye](https://github.com/MinecraftPlaye)
  - [RealGreenDragon](https://github.com/RealGreenDragon)
+ - [ipitio](https://github.com/ipitio)
 
 # Emby Contributors
 

+ 5 - 5
Directory.Packages.props

@@ -6,9 +6,9 @@
   <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
 
   <ItemGroup Label="Package Dependencies">
-    <PackageVersion Include="AutoFixture.AutoMoq" Version="4.17.0" />
-    <PackageVersion Include="AutoFixture.Xunit2" Version="4.17.0" />
-    <PackageVersion Include="AutoFixture" Version="4.17.0" />
+    <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.0" />
+    <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.0" />
+    <PackageVersion Include="AutoFixture" Version="4.18.0" />
     <PackageVersion Include="BDInfo" Version="0.7.6.2" />
     <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.2.0" />
     <PackageVersion Include="BlurHashSharp" Version="1.2.0" />
@@ -17,7 +17,7 @@
     <PackageVersion Include="Diacritics" Version="3.3.14" />
     <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
     <PackageVersion Include="DotNet.Glob" Version="3.1.3" />
-    <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.3" />
+    <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.5" />
     <PackageVersion Include="FsCheck.Xunit" Version="2.16.5" />
     <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
     <PackageVersion Include="libse" Version="3.6.10" />
@@ -46,7 +46,7 @@
     <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Logging" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Options" Version="7.0.1" />
-    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
+    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
     <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" />
     <PackageVersion Include="MimeTypes" Version="2.4.0" />
     <PackageVersion Include="Mono.Nat" Version="3.0.4" />

+ 1 - 3
Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs

@@ -27,7 +27,7 @@ namespace Emby.Dlna.ConnectionManager
         /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
         private static IEnumerable<StateVariable> GetStateVariables()
         {
-            var list = new List<StateVariable>
+            return new StateVariable[]
             {
                 new StateVariable
                 {
@@ -114,8 +114,6 @@ namespace Emby.Dlna.ConnectionManager
                     SendsEvents = false
                 }
             };
-
-            return list;
         }
     }
 }

+ 1 - 3
Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs

@@ -27,7 +27,7 @@ namespace Emby.Dlna.ContentDirectory
         /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
         private static IEnumerable<StateVariable> GetStateVariables()
         {
-            var list = new List<StateVariable>
+            return new StateVariable[]
             {
                 new StateVariable
                 {
@@ -154,8 +154,6 @@ namespace Emby.Dlna.ContentDirectory
                     SendsEvents = false
                 }
             };
-
-            return list;
         }
     }
 }

+ 18 - 8
Emby.Dlna/Server/DescriptionXmlBuilder.cs

@@ -147,11 +147,16 @@ namespace Emby.Dlna.Server
             }
         }
 
-        private string GetFriendlyName()
+        internal string GetFriendlyName()
         {
             if (string.IsNullOrEmpty(_profile.FriendlyName))
             {
-                return "Jellyfin - " + _serverName;
+                return _serverName;
+            }
+
+            if (!_profile.FriendlyName.Contains("${HostName}", StringComparison.OrdinalIgnoreCase))
+            {
+                return _profile.FriendlyName;
             }
 
             var characterList = new List<char>();
@@ -164,13 +169,18 @@ namespace Emby.Dlna.Server
                 }
             }
 
-            var characters = characterList.ToArray();
-
-            var serverName = new string(characters);
-
-            var name = _profile.FriendlyName?.Replace("${HostName}", serverName, StringComparison.OrdinalIgnoreCase);
+            var serverName = string.Create(
+                characterList.Count,
+                characterList,
+                (dest, source) =>
+                {
+                    for (int i = 0; i < dest.Length; i++)
+                    {
+                        dest[i] = source[i];
+                    }
+                });
 
-            return name ?? string.Empty;
+            return _profile.FriendlyName.Replace("${HostName}", serverName, StringComparison.OrdinalIgnoreCase);
         }
 
         private void AppendIconList(StringBuilder builder)

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

@@ -32,7 +32,7 @@ namespace Emby.Naming.AudioBook
             var fileName = Path.GetFileNameWithoutExtension(path);
             foreach (var expression in _options.AudioBookPartsExpressions)
             {
-                var match = new Regex(expression, RegexOptions.IgnoreCase).Match(fileName);
+                var match = Regex.Match(fileName, expression, RegexOptions.IgnoreCase);
                 if (match.Success)
                 {
                     if (!result.ChapterNumber.HasValue)

+ 10 - 10
Emby.Naming/AudioBook/AudioBookListResolver.cs

@@ -79,25 +79,25 @@ namespace Emby.Naming.AudioBook
                 {
                     if (group.Count() > 1 || haveChaptersOrPages)
                     {
-                        var ex = new List<AudioBookFileInfo>();
-                        var alt = new List<AudioBookFileInfo>();
+                        List<AudioBookFileInfo>? ex = null;
+                        List<AudioBookFileInfo>? alt = null;
 
                         foreach (var audioFile in group)
                         {
-                            var name = Path.GetFileNameWithoutExtension(audioFile.Path);
-                            if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase) ||
-                                name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) ||
-                                name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase))
+                            var name = Path.GetFileNameWithoutExtension(audioFile.Path.AsSpan());
+                            if (name.Equals("audiobook", StringComparison.OrdinalIgnoreCase)
+                                || name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase)
+                                || name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase))
                             {
-                                alt.Add(audioFile);
+                                (alt ??= new()).Add(audioFile);
                             }
                             else
                             {
-                                ex.Add(audioFile);
+                                (ex ??= new()).Add(audioFile);
                             }
                         }
 
-                        if (ex.Count > 0)
+                        if (ex is not null)
                         {
                             var extra = ex
                                 .OrderBy(x => x.Container)
@@ -108,7 +108,7 @@ namespace Emby.Naming.AudioBook
                             extras.AddRange(extra);
                         }
 
-                        if (alt.Count > 0)
+                        if (alt is not null)
                         {
                             var alternatives = alt
                                 .OrderBy(x => x.Container)

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

@@ -30,7 +30,7 @@ namespace Emby.Naming.AudioBook
             AudioBookNameParserResult result = default;
             foreach (var expression in _options.AudioBookNamesExpressions)
             {
-                var match = new Regex(expression, RegexOptions.IgnoreCase).Match(name);
+                var match = Regex.Match(name, expression, RegexOptions.IgnoreCase);
                 if (match.Success)
                 {
                     if (result.Name is null)

+ 1 - 1
Emby.Naming/TV/SeriesResolver.cs

@@ -14,7 +14,7 @@ namespace Emby.Naming.TV
         /// Used for removing separators between words, i.e turns "The_show" into "The show" while
         /// preserving namings like "S.H.O.W".
         /// </summary>
-        private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))");
+        private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))", RegexOptions.Compiled);
 
         /// <summary>
         /// Resolve information about series from path.

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

@@ -17,7 +17,7 @@ public class FileStackRule
     /// <param name="isNumerical">Whether the file stack rule uses numerical or alphabetical numbering.</param>
     public FileStackRule(string token, bool isNumerical)
     {
-        _tokenRegex = new Regex(token, RegexOptions.IgnoreCase);
+        _tokenRegex = new Regex(token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
         IsNumerical = isNumerical;
     }
 

+ 23 - 6
Emby.Naming/Video/VideoListResolver.cs

@@ -4,6 +4,7 @@ using System.IO;
 using System.Linq;
 using System.Text.RegularExpressions;
 using Emby.Naming.Common;
+using Jellyfin.Extensions;
 using MediaBrowser.Model.IO;
 
 namespace Emby.Naming.Video
@@ -13,6 +14,8 @@ namespace Emby.Naming.Video
     /// </summary>
     public static class VideoListResolver
     {
+        private static readonly Regex _resolutionRegex = new Regex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
         /// <summary>
         /// Resolves alternative versions and extras from list of video files.
         /// </summary>
@@ -115,19 +118,34 @@ namespace Emby.Naming.Video
                     continue;
                 }
 
-                if (!IsEligibleForMultiVersion(folderName, video.Files[0].Path, namingOptions))
+                if (!IsEligibleForMultiVersion(folderName, video.Files[0].FileNameWithoutExtension, namingOptions))
                 {
                     return videos;
                 }
 
-                if (folderName.Equals(Path.GetFileNameWithoutExtension(video.Files[0].Path.AsSpan()), StringComparison.Ordinal))
+                if (folderName.Equals(video.Files[0].FileNameWithoutExtension, 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));
+            if (videos.Count > 1)
+            {
+                var groups = videos.GroupBy(x => _resolutionRegex.IsMatch(x.Files[0].FileNameWithoutExtension)).ToList();
+                videos.Clear();
+                foreach (var group in groups)
+                {
+                    if (group.Key)
+                    {
+                        videos.InsertRange(0, group.OrderByDescending(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
+                    }
+                    else
+                    {
+                        videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
+                    }
+                }
+            }
+
             primary ??= videos[0];
             videos.Remove(primary);
 
@@ -161,9 +179,8 @@ namespace Emby.Naming.Video
             return true;
         }
 
-        private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, string testFilePath, NamingOptions namingOptions)
+        private static bool IsEligibleForMultiVersion(ReadOnlySpan<char> folderName, ReadOnlySpan<char> testFilename, NamingOptions namingOptions)
         {
-            var testFilename = Path.GetFileNameWithoutExtension(testFilePath.AsSpan());
             if (!testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase))
             {
                 return false;

+ 1 - 1
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -401,7 +401,7 @@ namespace Emby.Server.Implementations.Channels
             }
             else
             {
-                results = new List<MediaSourceInfo>();
+                results = Enumerable.Empty<MediaSourceInfo>();
             }
 
             return results

+ 11 - 7
Emby.Server.Implementations/Collections/CollectionManager.cs

@@ -206,8 +206,7 @@ namespace Emby.Server.Implementations.Collections
                 throw new ArgumentException("No collection exists with the supplied Id");
             }
 
-            var list = new List<LinkedChild>();
-            var itemList = new List<BaseItem>();
+            List<BaseItem>? itemList = null;
 
             var linkedChildrenList = collection.GetLinkedChildren();
             var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList();
@@ -223,18 +222,23 @@ namespace Emby.Server.Implementations.Collections
 
                 if (!currentLinkedChildrenIds.Contains(id))
                 {
-                    itemList.Add(item);
+                    (itemList ??= new()).Add(item);
 
-                    list.Add(LinkedChild.Create(item));
                     linkedChildrenList.Add(item);
                 }
             }
 
-            if (list.Count > 0)
+            if (itemList is not null)
             {
-                LinkedChild[] newChildren = new LinkedChild[collection.LinkedChildren.Length + list.Count];
+                var originalLen = collection.LinkedChildren.Length;
+                var newItemCount = itemList.Count;
+                LinkedChild[] newChildren = new LinkedChild[originalLen + newItemCount];
                 collection.LinkedChildren.CopyTo(newChildren, 0);
-                list.CopyTo(newChildren, collection.LinkedChildren.Length);
+                for (int i = 0; i < newItemCount; i++)
+                {
+                    newChildren[originalLen + i] = LinkedChild.Create(itemList[i]);
+                }
+
                 collection.LinkedChildren = newChildren;
                 collection.UpdateRatingToItems(linkedChildrenList);
 

+ 112 - 170
Emby.Server.Implementations/Data/SqliteItemRepository.cs

@@ -586,7 +586,7 @@ namespace Emby.Server.Implementations.Data
         /// <exception cref="ArgumentNullException">
         /// <paramref name="items"/> or <paramref name="cancellationToken"/> is <c>null</c>.
         /// </exception>
-        public void SaveItems(IEnumerable<BaseItem> items, CancellationToken cancellationToken)
+        public void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken)
         {
             ArgumentNullException.ThrowIfNull(items);
 
@@ -594,9 +594,11 @@ namespace Emby.Server.Implementations.Data
 
             CheckDisposed();
 
-            var tuples = new List<(BaseItem, List<Guid>, BaseItem, string, List<string>)>();
-            foreach (var item in items)
+            var itemsLen = items.Count;
+            var tuples = new ValueTuple<BaseItem, List<Guid>, BaseItem, string, List<string>>[itemsLen];
+            for (int i = 0; i < itemsLen; i++)
             {
+                var item = items[i];
                 var ancestorIds = item.SupportsAncestors ?
                     item.GetAncestorIds().Distinct().ToList() :
                     null;
@@ -606,7 +608,7 @@ namespace Emby.Server.Implementations.Data
                 var userdataKey = item.GetUserDataKeys().FirstOrDefault();
                 var inheritedTags = item.GetInheritedTags();
 
-                tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags));
+                tuples[i] = (item, ancestorIds, topParent, userdataKey, inheritedTags);
             }
 
             using (var connection = GetConnection())
@@ -3202,7 +3204,8 @@ namespace Emby.Server.Implementations.Data
             return IsAlphaNumeric(value);
         }
 
-        private List<string> GetWhereClauses(InternalItemsQuery query, IStatement statement)
+#nullable enable
+        private List<string> GetWhereClauses(InternalItemsQuery query, IStatement? statement)
         {
             if (query.IsResumable ?? false)
             {
@@ -3677,7 +3680,6 @@ namespace Emby.Server.Implementations.Data
                 if (statement is not null)
                 {
                     nameContains = FixUnicodeChars(nameContains);
-
                     statement.TryBind("@NameContains", "%" + GetCleanValue(nameContains) + "%");
                 }
             }
@@ -3803,13 +3805,8 @@ namespace Emby.Server.Implementations.Data
                 foreach (var artistId in query.ArtistIds)
                 {
                     var paramName = "@ArtistIds" + index;
-
                     clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))");
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, artistId);
-                    }
-
+                    statement?.TryBind(paramName, artistId);
                     index++;
                 }
 
@@ -3824,13 +3821,8 @@ namespace Emby.Server.Implementations.Data
                 foreach (var artistId in query.AlbumArtistIds)
                 {
                     var paramName = "@ArtistIds" + index;
-
                     clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=1))");
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, artistId);
-                    }
-
+                    statement?.TryBind(paramName, artistId);
                     index++;
                 }
 
@@ -3845,13 +3837,8 @@ namespace Emby.Server.Implementations.Data
                 foreach (var artistId in query.ContributingArtistIds)
                 {
                     var paramName = "@ArtistIds" + index;
-
                     clauses.Add("((select CleanName from TypedBaseItems where guid=" + paramName + ") in (select CleanValue from ItemValues where ItemId=Guid and Type=0) AND (select CleanName from TypedBaseItems where guid=" + paramName + ") not in (select CleanValue from ItemValues where ItemId=Guid and Type=1))");
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, artistId);
-                    }
-
+                    statement?.TryBind(paramName, artistId);
                     index++;
                 }
 
@@ -3866,13 +3853,8 @@ namespace Emby.Server.Implementations.Data
                 foreach (var albumId in query.AlbumIds)
                 {
                     var paramName = "@AlbumIds" + index;
-
                     clauses.Add("Album in (select Name from typedbaseitems where guid=" + paramName + ")");
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, albumId);
-                    }
-
+                    statement?.TryBind(paramName, albumId);
                     index++;
                 }
 
@@ -3887,13 +3869,8 @@ namespace Emby.Server.Implementations.Data
                 foreach (var artistId in query.ExcludeArtistIds)
                 {
                     var paramName = "@ExcludeArtistId" + index;
-
                     clauses.Add("(guid not in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))");
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, artistId);
-                    }
-
+                    statement?.TryBind(paramName, artistId);
                     index++;
                 }
 
@@ -3908,13 +3885,8 @@ namespace Emby.Server.Implementations.Data
                 foreach (var genreId in query.GenreIds)
                 {
                     var paramName = "@GenreId" + index;
-
                     clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=2))");
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, genreId);
-                    }
-
+                    statement?.TryBind(paramName, genreId);
                     index++;
                 }
 
@@ -3929,11 +3901,7 @@ namespace Emby.Server.Implementations.Data
                 foreach (var item in query.Genres)
                 {
                     clauses.Add("@Genre" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=2)");
-                    if (statement is not null)
-                    {
-                        statement.TryBind("@Genre" + index, GetCleanValue(item));
-                    }
-
+                    statement?.TryBind("@Genre" + index, GetCleanValue(item));
                     index++;
                 }
 
@@ -3948,11 +3916,7 @@ namespace Emby.Server.Implementations.Data
                 foreach (var item in tags)
                 {
                     clauses.Add("@Tag" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=4)");
-                    if (statement is not null)
-                    {
-                        statement.TryBind("@Tag" + index, GetCleanValue(item));
-                    }
-
+                    statement?.TryBind("@Tag" + index, GetCleanValue(item));
                     index++;
                 }
 
@@ -3967,11 +3931,7 @@ namespace Emby.Server.Implementations.Data
                 foreach (var item in excludeTags)
                 {
                     clauses.Add("@ExcludeTag" + index + " not in (select CleanValue from ItemValues where ItemId=Guid and Type=4)");
-                    if (statement is not null)
-                    {
-                        statement.TryBind("@ExcludeTag" + index, GetCleanValue(item));
-                    }
-
+                    statement?.TryBind("@ExcludeTag" + index, GetCleanValue(item));
                     index++;
                 }
 
@@ -3986,14 +3946,8 @@ namespace Emby.Server.Implementations.Data
                 foreach (var studioId in query.StudioIds)
                 {
                     var paramName = "@StudioId" + index;
-
                     clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=3))");
-
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, studioId);
-                    }
-
+                    statement?.TryBind(paramName, studioId);
                     index++;
                 }
 
@@ -4008,11 +3962,7 @@ namespace Emby.Server.Implementations.Data
                 foreach (var item in query.OfficialRatings)
                 {
                     clauses.Add("OfficialRating=@OfficialRating" + index);
-                    if (statement is not null)
-                    {
-                        statement.TryBind("@OfficialRating" + index, item);
-                    }
-
+                    statement?.TryBind("@OfficialRating" + index, item);
                     index++;
                 }
 
@@ -4020,34 +3970,96 @@ namespace Emby.Server.Implementations.Data
                 whereClauses.Add(clause);
             }
 
-            if (query.MinParentalRating.HasValue)
+            var ratingClauseBuilder = new StringBuilder("(");
+            if (query.HasParentalRating ?? false)
             {
-                whereClauses.Add("InheritedParentalRatingValue>=@MinParentalRating");
-                if (statement is not null)
+                ratingClauseBuilder.Append("InheritedParentalRatingValue not null");
+                if (query.MinParentalRating.HasValue)
                 {
-                    statement.TryBind("@MinParentalRating", query.MinParentalRating.Value);
+                    ratingClauseBuilder.Append(" AND InheritedParentalRatingValue >= @MinParentalRating");
+                    statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value);
                 }
-            }
 
-            if (query.MaxParentalRating.HasValue)
+                if (query.MaxParentalRating.HasValue)
+                {
+                    ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating");
+                    statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value);
+                }
+            }
+            else if (query.BlockUnratedItems.Length > 0)
             {
-                whereClauses.Add("InheritedParentalRatingValue<=@MaxParentalRating");
+                var paramName = "@UnratedType";
+                var index = 0;
+                string blockedUnratedItems = string.Join(',', query.BlockUnratedItems.Select(_ => paramName + index++));
+                ratingClauseBuilder.Append("(InheritedParentalRatingValue is null AND UnratedType not in (" + blockedUnratedItems + "))");
+
                 if (statement is not null)
                 {
-                    statement.TryBind("@MaxParentalRating", query.MaxParentalRating.Value);
+                    for (var ind = 0; ind < query.BlockUnratedItems.Length; ind++)
+                    {
+                        statement.TryBind(paramName + ind, query.BlockUnratedItems[ind].ToString());
+                    }
                 }
-            }
 
-            if (query.HasParentalRating.HasValue)
-            {
-                if (query.HasParentalRating.Value)
+                if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue)
                 {
-                    whereClauses.Add("InheritedParentalRatingValue > 0");
+                    ratingClauseBuilder.Append(" OR (");
                 }
-                else
+
+                if (query.MinParentalRating.HasValue)
+                {
+                    ratingClauseBuilder.Append("InheritedParentalRatingValue >= @MinParentalRating");
+                    statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value);
+                }
+
+                if (query.MaxParentalRating.HasValue)
+                {
+                    if (query.MinParentalRating.HasValue)
+                    {
+                        ratingClauseBuilder.Append(" AND ");
+                    }
+
+                    ratingClauseBuilder.Append("InheritedParentalRatingValue <= @MaxParentalRating");
+                    statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value);
+                }
+
+                if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue)
+                {
+                    ratingClauseBuilder.Append(")");
+                }
+
+                if (!(query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue))
+                {
+                    ratingClauseBuilder.Append(" OR InheritedParentalRatingValue not null");
+                }
+            }
+            else if (query.MinParentalRating.HasValue)
+            {
+                ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR (InheritedParentalRatingValue >= @MinParentalRating");
+                statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value);
+
+                if (query.MaxParentalRating.HasValue)
                 {
-                    whereClauses.Add("InheritedParentalRatingValue = 0");
+                    ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating");
+                    statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value);
                 }
+
+                ratingClauseBuilder.Append(")");
+            }
+            else if (query.MaxParentalRating.HasValue)
+            {
+                ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR InheritedParentalRatingValue <= @MaxParentalRating");
+                statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value);
+            }
+            else if (!query.HasParentalRating ?? false)
+            {
+                ratingClauseBuilder.Append("InheritedParentalRatingValue is null");
+            }
+
+            var ratingClauseString = ratingClauseBuilder.ToString();
+            if (!string.Equals(ratingClauseString, "(", StringComparison.OrdinalIgnoreCase))
+            {
+                whereClauses.Add(ratingClauseString + ")");
             }
 
             if (query.HasOfficialRating.HasValue)
@@ -4089,37 +4101,25 @@ namespace Emby.Server.Implementations.Data
             if (!string.IsNullOrWhiteSpace(query.HasNoAudioTrackWithLanguage))
             {
                 whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Audio' and MediaStreams.Language=@HasNoAudioTrackWithLanguage limit 1) is null)");
-                if (statement is not null)
-                {
-                    statement.TryBind("@HasNoAudioTrackWithLanguage", query.HasNoAudioTrackWithLanguage);
-                }
+                statement?.TryBind("@HasNoAudioTrackWithLanguage", query.HasNoAudioTrackWithLanguage);
             }
 
             if (!string.IsNullOrWhiteSpace(query.HasNoInternalSubtitleTrackWithLanguage))
             {
                 whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=0 and MediaStreams.Language=@HasNoInternalSubtitleTrackWithLanguage limit 1) is null)");
-                if (statement is not null)
-                {
-                    statement.TryBind("@HasNoInternalSubtitleTrackWithLanguage", query.HasNoInternalSubtitleTrackWithLanguage);
-                }
+                statement?.TryBind("@HasNoInternalSubtitleTrackWithLanguage", query.HasNoInternalSubtitleTrackWithLanguage);
             }
 
             if (!string.IsNullOrWhiteSpace(query.HasNoExternalSubtitleTrackWithLanguage))
             {
                 whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.IsExternal=1 and MediaStreams.Language=@HasNoExternalSubtitleTrackWithLanguage limit 1) is null)");
-                if (statement is not null)
-                {
-                    statement.TryBind("@HasNoExternalSubtitleTrackWithLanguage", query.HasNoExternalSubtitleTrackWithLanguage);
-                }
+                statement?.TryBind("@HasNoExternalSubtitleTrackWithLanguage", query.HasNoExternalSubtitleTrackWithLanguage);
             }
 
             if (!string.IsNullOrWhiteSpace(query.HasNoSubtitleTrackWithLanguage))
             {
                 whereClauses.Add("((select language from MediaStreams where MediaStreams.ItemId=A.Guid and MediaStreams.StreamType='Subtitle' and MediaStreams.Language=@HasNoSubtitleTrackWithLanguage limit 1) is null)");
-                if (statement is not null)
-                {
-                    statement.TryBind("@HasNoSubtitleTrackWithLanguage", query.HasNoSubtitleTrackWithLanguage);
-                }
+                statement?.TryBind("@HasNoSubtitleTrackWithLanguage", query.HasNoSubtitleTrackWithLanguage);
             }
 
             if (query.HasSubtitles.HasValue)
@@ -4169,15 +4169,11 @@ namespace Emby.Server.Implementations.Data
             if (query.Years.Length == 1)
             {
                 whereClauses.Add("ProductionYear=@Years");
-                if (statement is not null)
-                {
-                    statement.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture));
-                }
+                statement?.TryBind("@Years", query.Years[0].ToString(CultureInfo.InvariantCulture));
             }
             else if (query.Years.Length > 1)
             {
                 var val = string.Join(',', query.Years);
-
                 whereClauses.Add("ProductionYear in (" + val + ")");
             }
 
@@ -4185,10 +4181,7 @@ namespace Emby.Server.Implementations.Data
             if (isVirtualItem.HasValue)
             {
                 whereClauses.Add("IsVirtualItem=@IsVirtualItem");
-                if (statement is not null)
-                {
-                    statement.TryBind("@IsVirtualItem", isVirtualItem.Value);
-                }
+                statement?.TryBind("@IsVirtualItem", isVirtualItem.Value);
             }
 
             if (query.IsSpecialSeason.HasValue)
@@ -4219,31 +4212,22 @@ namespace Emby.Server.Implementations.Data
             if (queryMediaTypes.Length == 1)
             {
                 whereClauses.Add("MediaType=@MediaTypes");
-                if (statement is not null)
-                {
-                    statement.TryBind("@MediaTypes", queryMediaTypes[0]);
-                }
+                statement?.TryBind("@MediaTypes", queryMediaTypes[0]);
             }
             else if (queryMediaTypes.Length > 1)
             {
                 var val = string.Join(',', queryMediaTypes.Select(i => "'" + i + "'"));
-
                 whereClauses.Add("MediaType in (" + val + ")");
             }
 
             if (query.ItemIds.Length > 0)
             {
                 var includeIds = new List<string>();
-
                 var index = 0;
                 foreach (var id in query.ItemIds)
                 {
                     includeIds.Add("Guid = @IncludeId" + index);
-                    if (statement is not null)
-                    {
-                        statement.TryBind("@IncludeId" + index, id);
-                    }
-
+                    statement?.TryBind("@IncludeId" + index, id);
                     index++;
                 }
 
@@ -4253,16 +4237,11 @@ namespace Emby.Server.Implementations.Data
             if (query.ExcludeItemIds.Length > 0)
             {
                 var excludeIds = new List<string>();
-
                 var index = 0;
                 foreach (var id in query.ExcludeItemIds)
                 {
                     excludeIds.Add("Guid <> @ExcludeId" + index);
-                    if (statement is not null)
-                    {
-                        statement.TryBind("@ExcludeId" + index, id);
-                    }
-
+                    statement?.TryBind("@ExcludeId" + index, id);
                     index++;
                 }
 
@@ -4283,11 +4262,7 @@ namespace Emby.Server.Implementations.Data
 
                     var paramName = "@ExcludeProviderId" + index;
                     excludeIds.Add("(ProviderIds is null or ProviderIds not like " + paramName + ")");
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%");
-                    }
-
+                    statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%");
                     index++;
 
                     break;
@@ -4312,7 +4287,7 @@ namespace Emby.Server.Implementations.Data
                     }
 
                     // TODO this seems to be an idea for a better schema where ProviderIds are their own table
-                    //      buut this is not implemented
+                    //      but this is not implemented
                     // hasProviderIds.Add("(COALESCE((select value from ProviderIds where ItemId=Guid and Name = '" + pair.Key + "'), '') <> " + paramName + ")");
 
                     // TODO this is a really BAD way to do it since the pair:
@@ -4326,11 +4301,7 @@ namespace Emby.Server.Implementations.Data
                     hasProviderIds.Add("ProviderIds like " + paramName);
 
                     // this replaces the placeholder with a value, here: %key=val%
-                    if (statement is not null)
-                    {
-                        statement.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%");
-                    }
-
+                    statement?.TryBind(paramName, "%" + pair.Key + "=" + pair.Value + "%");
                     index++;
 
                     break;
@@ -4407,11 +4378,7 @@ namespace Emby.Server.Implementations.Data
             if (query.AncestorIds.Length == 1)
             {
                 whereClauses.Add("Guid in (select itemId from AncestorIds where AncestorId=@AncestorId)");
-
-                if (statement is not null)
-                {
-                    statement.TryBind("@AncestorId", query.AncestorIds[0]);
-                }
+                statement?.TryBind("@AncestorId", query.AncestorIds[0]);
             }
 
             if (query.AncestorIds.Length > 1)
@@ -4424,39 +4391,13 @@ namespace Emby.Server.Implementations.Data
             {
                 var inClause = "select guid from TypedBaseItems where PresentationUniqueKey=@AncestorWithPresentationUniqueKey";
                 whereClauses.Add(string.Format(CultureInfo.InvariantCulture, "Guid in (select itemId from AncestorIds where AncestorId in ({0}))", inClause));
-                if (statement is not null)
-                {
-                    statement.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey);
-                }
+                statement?.TryBind("@AncestorWithPresentationUniqueKey", query.AncestorWithPresentationUniqueKey);
             }
 
             if (!string.IsNullOrWhiteSpace(query.SeriesPresentationUniqueKey))
             {
                 whereClauses.Add("SeriesPresentationUniqueKey=@SeriesPresentationUniqueKey");
-
-                if (statement is not null)
-                {
-                    statement.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey);
-                }
-            }
-
-            if (query.BlockUnratedItems.Length == 1)
-            {
-                whereClauses.Add("(InheritedParentalRatingValue > 0 or UnratedType <> @UnratedType)");
-                if (statement is not null)
-                {
-                    statement.TryBind("@UnratedType", query.BlockUnratedItems[0].ToString());
-                }
-            }
-
-            if (query.BlockUnratedItems.Length > 1)
-            {
-                var inClause = string.Join(',', query.BlockUnratedItems.Select(i => "'" + i.ToString() + "'"));
-                whereClauses.Add(
-                    string.Format(
-                        CultureInfo.InvariantCulture,
-                        "(InheritedParentalRatingValue > 0 or UnratedType not in ({0}))",
-                        inClause));
+                statement?.TryBind("@SeriesPresentationUniqueKey", query.SeriesPresentationUniqueKey);
             }
 
             if (query.ExcludeInheritedTags.Length > 0)
@@ -4605,6 +4546,7 @@ namespace Emby.Server.Implementations.Data
 
             return whereClauses;
         }
+#nullable disable
 
         /// <summary>
         /// Formats a where clause for the specified provider.

+ 10 - 9
Emby.Server.Implementations/Dto/DtoService.cs

@@ -83,22 +83,23 @@ namespace Emby.Server.Implementations.Dto
         /// <inheritdoc />
         public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null)
         {
-            var returnItems = new BaseItemDto[items.Count];
-            var programTuples = new List<(BaseItem, BaseItemDto)>();
-            var channelTuples = new List<(BaseItemDto, LiveTvChannel)>();
+            var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
+            var returnItems = new BaseItemDto[accessibleItems.Count];
+            List<(BaseItem, BaseItemDto)> programTuples = null;
+            List<(BaseItemDto, LiveTvChannel)> channelTuples = null;
 
-            for (int index = 0; index < items.Count; index++)
+            for (int index = 0; index < accessibleItems.Count; index++)
             {
-                var item = items[index];
+                var item = accessibleItems[index];
                 var dto = GetBaseItemDtoInternal(item, options, user, owner);
 
                 if (item is LiveTvChannel tvChannel)
                 {
-                    channelTuples.Add((dto, tvChannel));
+                    (channelTuples ??= new()).Add((dto, tvChannel));
                 }
                 else if (item is LiveTvProgram)
                 {
-                    programTuples.Add((item, dto));
+                    (programTuples ??= new()).Add((item, dto));
                 }
 
                 if (item is IItemByName byName)
@@ -121,12 +122,12 @@ namespace Emby.Server.Implementations.Dto
                 returnItems[index] = dto;
             }
 
-            if (programTuples.Count > 0)
+            if (programTuples is not null)
             {
                 LivetvManager.AddInfoToProgramDto(programTuples, options.Fields, user).GetAwaiter().GetResult();
             }
 
-            if (channelTuples.Count > 0)
+            if (channelTuples is not null)
             {
                 LivetvManager.AddChannelInfo(channelTuples, options, user);
             }

+ 15 - 18
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -356,8 +356,8 @@ namespace Emby.Server.Implementations.Library
             }
 
             var children = item.IsFolder
-                ? ((Folder)item).GetRecursiveChildren(false).ToList()
-                : new List<BaseItem>();
+                ? ((Folder)item).GetRecursiveChildren(false)
+                : Enumerable.Empty<BaseItem>();
 
             foreach (var metadataPath in GetMetadataPaths(item, children))
             {
@@ -1253,7 +1253,7 @@ namespace Emby.Server.Implementations.Library
                 var parent = GetItemById(query.ParentId);
                 if (parent is not null)
                 {
-                    SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent });
+                    SetTopParentIdsOrAncestors(query, new[] { parent });
                 }
             }
 
@@ -1277,7 +1277,7 @@ namespace Emby.Server.Implementations.Library
                 var parent = GetItemById(query.ParentId);
                 if (parent is not null)
                 {
-                    SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent });
+                    SetTopParentIdsOrAncestors(query, new[] { parent });
                 }
             }
 
@@ -1435,7 +1435,7 @@ namespace Emby.Server.Implementations.Library
                 var parent = GetItemById(query.ParentId);
                 if (parent is not null)
                 {
-                    SetTopParentIdsOrAncestors(query, new List<BaseItem> { parent });
+                    SetTopParentIdsOrAncestors(query, new[] { parent });
                 }
             }
 
@@ -1455,7 +1455,7 @@ namespace Emby.Server.Implementations.Library
                 _itemRepository.GetItemList(query));
         }
 
-        private void SetTopParentIdsOrAncestors(InternalItemsQuery query, List<BaseItem> parents)
+        private void SetTopParentIdsOrAncestors(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents)
         {
             if (parents.All(i => i is ICollectionFolder || i is UserView))
             {
@@ -1602,7 +1602,7 @@ namespace Emby.Server.Implementations.Library
             {
                 _logger.LogError(ex, "Error getting intros");
 
-                return new List<IntroInfo>();
+                return Enumerable.Empty<IntroInfo>();
             }
         }
 
@@ -2876,7 +2876,7 @@ namespace Emby.Server.Implementations.Library
 
         private async Task SavePeopleMetadataAsync(IEnumerable<PersonInfo> people, CancellationToken cancellationToken)
         {
-            var personsToSave = new List<BaseItem>();
+            List<BaseItem> personsToSave = null;
 
             foreach (var person in people)
             {
@@ -2918,12 +2918,12 @@ namespace Emby.Server.Implementations.Library
 
                 if (saveEntity)
                 {
-                    personsToSave.Add(personEntity);
+                    (personsToSave ??= new()).Add(personEntity);
                     await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
                 }
             }
 
-            if (personsToSave.Count > 0)
+            if (personsToSave is not null)
             {
                 CreateItems(personsToSave, null, CancellationToken.None);
             }
@@ -3085,22 +3085,19 @@ namespace Emby.Server.Implementations.Library
                 throw new ArgumentNullException(nameof(path));
             }
 
-            var removeList = new List<NameValuePair>();
+            List<NameValuePair> removeList = null;
 
             foreach (var contentType in _configurationManager.Configuration.ContentTypes)
             {
-                if (string.IsNullOrWhiteSpace(contentType.Name))
-                {
-                    removeList.Add(contentType);
-                }
-                else if (_fileSystem.AreEqual(path, contentType.Name)
+                if (string.IsNullOrWhiteSpace(contentType.Name)
+                    || _fileSystem.AreEqual(path, contentType.Name)
                     || _fileSystem.ContainsSubPath(path, contentType.Name))
                 {
-                    removeList.Add(contentType);
+                    (removeList ??= new()).Add(contentType);
                 }
             }
 
-            if (removeList.Count > 0)
+            if (removeList is not null)
             {
                 _configurationManager.Configuration.ContentTypes = _configurationManager.Configuration.ContentTypes
                     .Except(removeList)

+ 1 - 2
Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs

@@ -158,7 +158,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
         private MultiItemResolverResult ResolveMultipleAudio(Folder parent, IEnumerable<FileSystemMetadata> fileSystemEntries, bool parseName)
         {
             var files = new List<FileSystemMetadata>();
-            var items = new List<BaseItem>();
             var leftOver = new List<FileSystemMetadata>();
 
             // Loop through each child file/folder and see if we find a video
@@ -180,7 +179,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
             var result = new MultiItemResolverResult
             {
                 ExtraFiles = leftOver,
-                Items = items
+                Items = new List<BaseItem>()
             };
 
             var isInMixedFolder = resolverResult.Count > 1 || (parent is not null && parent.IsTopParent);

+ 1 - 1
Emby.Server.Implementations/Library/UserViewManager.cs

@@ -286,7 +286,7 @@ namespace Emby.Server.Implementations.Library
 
             if (parents.Count == 0)
             {
-                return new List<BaseItem>();
+                return Array.Empty<BaseItem>();
             }
 
             if (includeItemTypes.Length == 0)

+ 1 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs

@@ -13,8 +13,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
         public LegacyHdHomerunChannelCommands(string url)
         {
             // parse url for channel and program
-            var regExp = new Regex(@"\/ch([0-9]+)-?([0-9]*)");
-            var match = regExp.Match(url);
+            var match = Regex.Match(url, @"\/ch([0-9]+)-?([0-9]*)");
             if (match.Success)
             {
                 _channel = match.Groups[1].Value;

+ 1 - 2
Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs

@@ -308,8 +308,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
         {
             var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 
-            var reg = new Regex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase);
-            var matches = reg.Matches(line);
+            var matches = Regex.Matches(line, @"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase);
 
             remaining = line;
 

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

@@ -31,7 +31,7 @@
     "ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca",
     "LabelIpAddressValue": "Dirección IP: {0}",
     "LabelRunningTimeValue": "Tiempo de funcionamiento: {0}",
-    "Latest": "Últimos",
+    "Latest": "Último contenido en",
     "MessageApplicationUpdated": "Se ha actualizado el servidor Jellyfin",
     "MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}",
     "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de configuración del servidor ha sido actualizada",

+ 7 - 1
Emby.Server.Implementations/Localization/Core/hi.json

@@ -67,5 +67,11 @@
     "Plugin": "प्लग-इन",
     "Playlists": "प्लेलिस्ट",
     "Photos": "तस्वीरें",
-    "External": "बाहरी"
+    "External": "बाहरी",
+    "PluginUpdatedWithName": "{0} अपडेट हुए",
+    "ScheduledTaskStartedWithName": "{0} शुरू हुए",
+    "Songs": "गाने",
+    "UserStartedPlayingItemWithValues": "{0} {2} पर {1} खेल रहे हैं",
+    "UserStoppedPlayingItemWithValues": "{0} ने {2} पर {1} खेलना खत्म किया",
+    "StartupEmbyServerIsLoading": "जेलीफ़िन सर्वर लोड हो रहा है। कृपया शीघ्र ही पुन: प्रयास करें।"
 }

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

@@ -95,13 +95,13 @@
     "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": "Kanalen vernieuwen",
     "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 voor acteurs en regisseurs in de media bibliotheek.",
+    "TaskRefreshPeopleDescription": "Updatet metadata voor acteurs en regisseurs in je mediabibliotheek.",
     "TaskRefreshPeople": "Personen vernieuwen",
     "TaskCleanLogsDescription": "Verwijdert log bestanden ouder dan {0} dagen.",
     "TaskRefreshLibraryDescription": "Scant de mediabibliotheek op nieuwe bestanden en vernieuwt de metadata.",

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

@@ -86,7 +86,7 @@
     "Shows": "Шоу",
     "ServerNameNeedsToBeRestarted": "{0} потрібно перезапустити",
     "ScheduledTaskStartedWithName": "{0} розпочато",
-    "ScheduledTaskFailedWithName": "Помилка {0}",
+    "ScheduledTaskFailedWithName": "{0} незавершено, збій",
     "ProviderValue": "Постачальник: {0}",
     "PluginUpdatedWithName": "{0} оновлено",
     "PluginUninstalledWithName": "{0} видалено",

+ 65 - 15
Emby.Server.Implementations/Localization/LocalizationManager.cs

@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
+using System.Linq;
 using System.Reflection;
 using System.Text.Json;
 using System.Threading.Tasks;
@@ -25,7 +26,7 @@ namespace Emby.Server.Implementations.Localization
         private const string CulturesPath = "Emby.Server.Implementations.Localization.iso6392.txt";
         private const string CountriesPath = "Emby.Server.Implementations.Localization.countries.json";
         private static readonly Assembly _assembly = typeof(LocalizationManager).Assembly;
-        private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated" };
+        private static readonly string[] _unratedValues = { "n/a", "unrated", "not rated", "nr" };
 
         private readonly IServerConfigurationManager _configurationManager;
         private readonly ILogger<LocalizationManager> _logger;
@@ -86,12 +87,10 @@ namespace Emby.Server.Implementations.Localization
                         var name = parts[0];
                         dict.Add(name, new ParentalRating(name, value));
                     }
-#if DEBUG
                     else
                     {
                         _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode);
                     }
-#endif
                 }
 
                 _allParentalRatings[countryCode] = dict;
@@ -184,7 +183,56 @@ namespace Emby.Server.Implementations.Localization
 
         /// <inheritdoc />
         public IEnumerable<ParentalRating> GetParentalRatings()
-            => GetParentalRatingsDictionary().Values;
+        {
+            var ratings = GetParentalRatingsDictionary().Values.ToList();
+
+            // Add common ratings to ensure them being available for selection.
+            // Based on the US rating system due to it being the main source of rating in the metadata providers
+            // Minimum rating possible
+            if (!ratings.Any(x => x.Value == 0))
+            {
+                ratings.Add(new ParentalRating("Approved", 0));
+            }
+
+            // Matches PG (this has different age restrictions depending on country)
+            if (!ratings.Any(x => x.Value == 10))
+            {
+                ratings.Add(new ParentalRating("10", 10));
+            }
+
+            // Matches PG-13
+            if (!ratings.Any(x => x.Value == 13))
+            {
+                ratings.Add(new ParentalRating("13", 13));
+            }
+
+            // Matches TV-14
+            if (!ratings.Any(x => x.Value == 14))
+            {
+                ratings.Add(new ParentalRating("14", 14));
+            }
+
+            // Catchall if max rating of country is less than 21
+            // Using 21 instead of 18 to be sure to allow access to all rated content except adult and banned
+            if (!ratings.Any(x => x.Value >= 21))
+            {
+                ratings.Add(new ParentalRating("21", 21));
+            }
+
+            // A lot of countries don't excplicitly have a seperate rating for adult content
+            if (!ratings.Any(x => x.Value == 1000))
+            {
+                ratings.Add(new ParentalRating("XXX", 1000));
+            }
+
+            // A lot of countries don't excplicitly have a seperate rating for banned content
+            if (!ratings.Any(x => x.Value == 1001))
+            {
+                ratings.Add(new ParentalRating("Banned", 1001));
+            }
+
+            return ratings.OrderBy(r => r.Value);
+        }
 
         /// <summary>
         /// Gets the parental ratings dictionary.
@@ -194,6 +242,7 @@ namespace Emby.Server.Implementations.Localization
         {
             var countryCode = _configurationManager.Configuration.MetadataCountryCode;
 
+            // Fall back to US ratings if no country code is specified or country code does not exist.
             if (string.IsNullOrEmpty(countryCode))
             {
                 countryCode = "us";
@@ -205,15 +254,15 @@ namespace Emby.Server.Implementations.Localization
         }
 
         /// <summary>
-        /// Gets the ratings.
+        /// Gets the ratings for a country.
         /// </summary>
         /// <param name="countryCode">The country code.</param>
         /// <returns>The ratings.</returns>
         private Dictionary<string, ParentalRating>? GetRatings(string countryCode)
         {
-            _allParentalRatings.TryGetValue(countryCode, out var value);
+            _allParentalRatings.TryGetValue(countryCode, out var countryValue);
 
-            return value;
+            return countryValue;
         }
 
         /// <inheritdoc />
@@ -221,12 +270,14 @@ namespace Emby.Server.Implementations.Localization
         {
             ArgumentException.ThrowIfNullOrEmpty(rating);
 
+            // Handle unrated content
             if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
             {
                 return null;
             }
 
             // Fairly common for some users to have "Rated R" in their rating field
+            rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase);
             rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase);
 
             var ratingsDictionary = GetParentalRatingsDictionary();
@@ -246,18 +297,17 @@ namespace Emby.Server.Implementations.Localization
             }
 
             // Try splitting by : to handle "Germany: FSK 18"
-            var index = rating.IndexOf(':', StringComparison.Ordinal);
-            if (index != -1)
+            if (rating.Contains(':', StringComparison.OrdinalIgnoreCase))
             {
-                var trimmedRating = rating.AsSpan(index).TrimStart(':').Trim();
+                return GetRatingLevel(rating.AsSpan().RightPart(':').ToString());
+            }
 
-                if (!trimmedRating.IsEmpty)
-                {
-                    return GetRatingLevel(trimmedRating.ToString());
-                }
+            // Remove prefix country code to handle "DE-18"
+            if (rating.Contains('-', StringComparison.OrdinalIgnoreCase))
+            {
+                return GetRatingLevel(rating.AsSpan().RightPart('-').ToString());
             }
 
-            // TODO: Further improve by normalizing out all spaces and dashes
             return null;
         }
 

+ 11 - 0
Emby.Server.Implementations/Localization/Ratings/0-prefer.csv

@@ -0,0 +1,11 @@
+E,0
+EC,0
+T,7
+M,18
+AO,18
+UR,18
+RP,18
+X,1000
+XX,1000
+XXX,1000
+XXXX,1000

+ 13 - 7
Emby.Server.Implementations/Localization/Ratings/au.csv

@@ -1,7 +1,13 @@
-AU-G,1
-AU-PG,5
-AU-M,6
-AU-MA15+,7
-AU-R18+,9
-AU-X18+,10
-AU-RC,11
+Exempt,0
+G,0
+7+,7
+M,15
+MA,15
+MA15+,15
+PG,16
+16+,16
+R,18
+R18+,18
+X18+,18
+18+,18
+X,1000

+ 11 - 6
Emby.Server.Implementations/Localization/Ratings/be.csv

@@ -1,6 +1,11 @@
-BE-AL,1
-BE-MG6,2
-BE-6,3
-BE-9,5
-BE-12,6
-BE-16,8
+AL,0
+KT,0
+TOUS,0
+MG6,6
+6,6
+9,9
+KNT,12
+12,12
+14,14
+16,16
+18,18

+ 8 - 6
Emby.Server.Implementations/Localization/Ratings/br.csv

@@ -1,6 +1,8 @@
-BR-L,1
-BR-10,5
-BR-12,7
-BR-14,8
-BR-16,8
-BR-18,9
+Livre,0
+L,0
+ER,9
+10,10
+12,12
+14,14
+16,16
+18,18

+ 20 - 6
Emby.Server.Implementations/Localization/Ratings/ca.csv

@@ -1,6 +1,20 @@
-CA-G,1
-CA-PG,5
-CA-14A,7
-CA-A,8
-CA-18A,9
-CA-R,10
+E,0
+G,0
+TV-Y,0
+TV-G,0
+TV-Y7,7
+TV-Y7-FV,7
+PG,9
+TV-PG,9
+PG-13,13
+13+,13
+TV-14,14
+14A,14
+16+,16
+NC-17,17
+R,18
+TV-MA,18
+18A,18
+18+,18
+A,1000
+Prohibited,1001

+ 7 - 8
Emby.Server.Implementations/Localization/Ratings/co.csv

@@ -1,8 +1,7 @@
-CO-T,1
-CO-7,5
-CO-12,7
-CO-15,8
-CO-18,10
-CO-X,100
-CO-BANNED,15
-CO-E,15
+T,0
+7,7
+12,12
+15,15
+18,18
+X,1000
+Prohibited,1001

+ 12 - 10
Emby.Server.Implementations/Localization/Ratings/de.csv

@@ -1,10 +1,12 @@
-DE-0,1
-FSK-0,1
-DE-6,5
-FSK-6,5
-DE-12,7
-FSK-12,7
-DE-16,8
-FSK-16,8
-DE-18,9
-FSK-18,9
+Educational,0
+Infoprogramm,0
+FSK-0,0
+0,0
+FSK-6,6
+6,6
+FSK-12,12
+12,12
+FSK-16,16
+16,16
+FSK-18,18
+18,18

+ 7 - 4
Emby.Server.Implementations/Localization/Ratings/dk.csv

@@ -1,4 +1,7 @@
-DA-A,1
-DA-7,5
-DA-11,6
-DA-15,8
+F,0
+A,0
+7,7
+11,11
+12,12
+15,15
+16,16

+ 24 - 6
Emby.Server.Implementations/Localization/Ratings/es.csv

@@ -1,6 +1,24 @@
-ES-A,1
-ES-APTA,1
-ES-7,3
-ES-12,6
-ES-16,8
-ES-18,11
+A,0
+A/fig,0
+A/i,0
+A/fig/i,0
+APTA,0
+TP,0
+0+,0
+6+,6
+7/fig,7
+7/i,7
+7/i/fig,7
+7,7
+9+,9
+10,10
+12,12
+12/fig,12
+13,13
+14,14
+16,16
+16/fig,16
+18,18
+18/fig,18
+X,1000
+Banned,1001

+ 10 - 10
Emby.Server.Implementations/Localization/Ratings/fi.csv

@@ -1,10 +1,10 @@
-FI-S,1
-FI-T,1
-FI-7,4
-FI-12,5
-FI-16,8
-FI-18,9
-FI-K7,4
-FI-K12,5
-FI-K16,8
-FI-K18,9
+S,0
+T,0
+K7,7
+7,7
+K12,12
+12,12
+K16,16
+16,16
+K18,18
+18,18

+ 12 - 5
Emby.Server.Implementations/Localization/Ratings/fr.csv

@@ -1,5 +1,12 @@
-FR-U,1
-FR-10,5
-FR-12,7
-FR-16,9
-FR-18,10
+Public Averti,0
+Tous Publics,0
+U,0
+0+,0
+6+,6
+9+,9
+10,10
+12,12
+14+,14
+16,16
+18,18
+X,1000

+ 22 - 7
Emby.Server.Implementations/Localization/Ratings/gb.csv

@@ -1,7 +1,22 @@
-GB-U,1
-GB-PG,5
-GB-12,6
-GB-12A,7
-GB-15,8
-GB-18,9
-GB-R18,15
+All,0
+E,0
+G,0
+U,0
+0+,0
+6+,6
+7+,7
+PG,8
+9+,9
+12,12
+12+,12
+12A,12
+Teen,13
+13+,13
+14+,14
+15,15
+16,16
+Caution,18
+18,18
+Mature,1000
+Adult,1000
+R18,1000

+ 9 - 6
Emby.Server.Implementations/Localization/Ratings/ie.csv

@@ -1,6 +1,9 @@
-IE-G,1
-IE-PG,5
-IE-12A,7
-IE-15A,8
-IE-16,9
-IE-18,10
+G,4
+PG,12
+12,12
+12A,12
+12PG,12
+15,15
+15A,15
+16,16
+18,18

+ 11 - 4
Emby.Server.Implementations/Localization/Ratings/jp.csv

@@ -1,4 +1,11 @@
-JP-G,1
-JP-PG12,7
-JP-15+,8
-JP-18+,10
+A,0
+G,0
+B,12
+PG12,12
+C,15
+15+,15
+R15+,15
+16+,16
+D,17
+Z,18
+18+,18

+ 6 - 7
Emby.Server.Implementations/Localization/Ratings/kz.csv

@@ -1,7 +1,6 @@
-KZ-6-,0
-KZ-6+,6
-KZ-12+,12
-KZ-14+,14
-KZ-16+,16
-KZ-18+,18
-KZ-21+,21
+K,0
+БА,12
+Б14,14
+E16,16
+E18,18
+HA,18

+ 6 - 6
Emby.Server.Implementations/Localization/Ratings/mx.csv

@@ -1,6 +1,6 @@
-MX-AA,1
-MX-A,5
-MX-B,7
-MX-B-15,8
-MX-C,9
-MX-D,10
+A,0
+AA,0
+B,12
+B-15,15
+C,18
+D,1000

+ 8 - 6
Emby.Server.Implementations/Localization/Ratings/nl.csv

@@ -1,6 +1,8 @@
-NL-AL,1
-NL-MG6,2
-NL-6,3
-NL-9,5
-NL-12,6
-NL-16,8
+AL,0
+MG6,6
+6,6
+9,9
+12,12
+14,14
+16,16
+18,18

+ 9 - 6
Emby.Server.Implementations/Localization/Ratings/no.csv

@@ -1,6 +1,9 @@
-NO-A,1
-NO-6,3
-NO-9,4
-NO-12,5
-NO-15,8
-NO-18,9
+A,0
+6,6
+7,7
+9,9
+11,11
+12,12
+15,15
+18,18
+Not approved,1001

+ 15 - 11
Emby.Server.Implementations/Localization/Ratings/nz.csv

@@ -1,11 +1,15 @@
-NZ-G,1
-NZ-PG,5
-NZ-M,6
-NZ-R13,7
-NZ-RP13,7
-NZ-R15,8
-NZ-RP16,9
-NZ-R16,9
-NZ-R18,10
-NZ-R,10
-NZ-MA,10
+Exempt,0
+G,0
+GY,13
+PG,13
+R13,13
+RP13,13
+R15,15
+M,16
+R16,16
+RP16,16
+GA,18
+R18,18
+MA,1000
+R,1001
+Objectionable,1001

+ 6 - 1
Emby.Server.Implementations/Localization/Ratings/ro.csv

@@ -1 +1,6 @@
-RO-AG,1
+AG,0
+AP-12,12
+N-15,15
+IM-18,18
+IM-18-XXX,1000
+IC,1001

+ 6 - 5
Emby.Server.Implementations/Localization/Ratings/ru.csv

@@ -1,5 +1,6 @@
-RU-0+,1
-RU-6+,3
-RU-12+,7
-RU-16+,9
-RU-18+,10
+0+,0
+6+,6
+12+,12
+16+,16
+18+,18
+Refused classification,1001

+ 10 - 5
Emby.Server.Implementations/Localization/Ratings/se.csv

@@ -1,5 +1,10 @@
-SE-Btl,1
-SE-Barntillåten,1
-SE-7,3
-SE-11,5
-SE-15,8
+Alla,0
+Barntillåten,0
+Btl,0
+0+,0
+7,7
+9+,9
+10+,10
+11,11
+14,14
+15,15

+ 22 - 7
Emby.Server.Implementations/Localization/Ratings/uk.csv

@@ -1,7 +1,22 @@
-UK-U,1
-UK-PG,5
-UK-12,7
-UK-12A,7
-UK-15,9
-UK-18,10
-UK-R18,15
+All,0
+E,0
+G,0
+U,0
+0+,0
+6+,6
+7+,7
+PG,8
+9+,9
+12,12
+12+,12
+12A,12
+Teen,13
+13+,13
+14+,14
+15,15
+16,16
+Caution,18
+18,18
+Mature,1000
+Adult,1000
+R18,1000

+ 50 - 23
Emby.Server.Implementations/Localization/Ratings/us.csv

@@ -1,23 +1,50 @@
-TV-Y,1
-APPROVED,1
-G,1
-E,1
-EC,1
-TV-G,1
-TV-Y7,3
-TV-Y7-FV,4
-PG,5
-TV-PG,5
-PG-13,7
-T,7
-TV-14,8
-R,9
-M,9
-TV-MA,9
-NC-17,10
-AO,15
-RP,15
-UR,15
-NR,15
-X,15
-XXX,100
+Approved,0
+G,0
+TV-G,0
+TV-Y,0
+TV-Y7,7
+TV-Y7-FV,7
+PG,10
+PG-13,13
+TV-PG,13
+TV-PG-D,13
+TV-PG-L,13
+TV-PG-S,13
+TV-PG-V,13
+TV-PG-DL,13
+TV-PG-DS,13
+TV-PG-DV,13
+TV-PG-LS,13
+TV-PG-LV,13
+TV-PG-SV,13
+TV-PG-DLS,13
+TV-PG-DLV,13
+TV-PG-DSV,13
+TV-PG-LSV,13
+TV-PG-DLSV,13
+TV-14,14
+TV-14-D,14
+TV-14-L,14
+TV-14-S,14
+TV-14-V,14
+TV-14-DL,14
+TV-14-DS,14
+TV-14-DV,14
+TV-14-LS,14
+TV-14-LV,14
+TV-14-SV,14
+TV-14-DLS,14
+TV-14-DLV,14
+TV-14-DSV,14
+TV-14-LSV,14
+TV-14-DLSV,14
+NC-17,17
+R,17
+TV-MA,17
+TV-MA-L,17
+TV-MA-S,17
+TV-MA-V,17
+TV-MA-LS,17
+TV-MA-LV,17
+TV-MA-SV,17
+TV-MA-LSV,17

+ 3 - 6
Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs

@@ -93,11 +93,8 @@ namespace Emby.Server.Implementations.ScheduledTasks
         public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger)
         {
             ArgumentNullException.ThrowIfNull(scheduledTask);
-
             ArgumentNullException.ThrowIfNull(applicationPaths);
-
             ArgumentNullException.ThrowIfNull(taskManager);
-
             ArgumentNullException.ThrowIfNull(logger);
 
             ScheduledTask = scheduledTask;
@@ -332,7 +329,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
                 return;
             }
 
-            _logger.LogInformation("{0} fired for task: {1}", trigger.GetType().Name, Name);
+            _logger.LogDebug("{0} fired for task: {1}", trigger.GetType().Name, Name);
 
             trigger.Stop();
 
@@ -378,7 +375,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
 
             CurrentCancellationTokenSource = new CancellationTokenSource();
 
-            _logger.LogInformation("Executing {0}", Name);
+            _logger.LogDebug("Executing {0}", Name);
 
             ((TaskManager)_taskManager).OnTaskExecuting(this);
 
@@ -406,7 +403,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
             }
             catch (Exception ex)
             {
-                _logger.LogError(ex, "Error");
+                _logger.LogError(ex, "Error executing Scheduled Task");
 
                 failureException = ex;
 

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

@@ -132,7 +132,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
             {
                 var type = scheduledTask.ScheduledTask.GetType();
 
-                _logger.LogInformation("Queuing task {0}", type.Name);
+                _logger.LogDebug("Queuing task {0}", type.Name);
 
                 lock (_taskQueue)
                 {
@@ -172,7 +172,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
         {
             var type = task.ScheduledTask.GetType();
 
-            _logger.LogInformation("Queuing task {0}", type.Name);
+            _logger.LogDebug("Queuing task {0}", type.Name);
 
             lock (_taskQueue)
             {
@@ -254,9 +254,6 @@ namespace Emby.Server.Implementations.ScheduledTasks
         /// </summary>
         private void ExecuteQueuedTasks()
         {
-            _logger.LogInformation("ExecuteQueuedTasks");
-
-            // Execute queued tasks
             lock (_taskQueue)
             {
                 var list = new List<Tuple<Type, TaskOptions>>();

+ 8 - 1
Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs

@@ -46,6 +46,13 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
                 return Task.CompletedTask;
             }
 
+            if (isApiKey)
+            {
+                // Api keys are unrestricted.
+                context.Succeed(requirement);
+                return Task.CompletedTask;
+            }
+
             var isInLocalNetwork = _httpContextAccessor.HttpContext is not null
                                    && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIP());
             var user = _userManager.GetUserById(userId);
@@ -62,7 +69,7 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
             }
 
             // Admins can do everything
-            if (isApiKey || context.User.IsInRole(UserRoles.Administrator))
+            if (context.User.IsInRole(UserRoles.Administrator))
             {
                 context.Succeed(requirement);
                 return Task.CompletedTask;

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

@@ -172,12 +172,9 @@ public class GenresController : BaseJellyfinApiController
 
         item ??= new Genre();
 
-        if (userId.Value.Equals(default))
-        {
-            return _dtoService.GetBaseItemDto(item, dtoOptions);
-        }
-
-        var user = _userManager.GetUserById(userId.Value);
+        var user = userId.Value.Equals(default)
+            ? null
+            : _userManager.GetUserById(userId.Value);
 
         return _dtoService.GetBaseItemDto(item, dtoOptions, user);
     }

+ 45 - 4
Jellyfin.Api/Controllers/ItemUpdateController.cs

@@ -98,7 +98,7 @@ public class ItemUpdateController : BaseJellyfinApiController
                 }).ToList());
         }
 
-        UpdateItem(request, item);
+        await UpdateItem(request, item).ConfigureAwait(false);
 
         item.OnMetadataChanged();
 
@@ -147,7 +147,7 @@ public class ItemUpdateController : BaseJellyfinApiController
 
         var info = new MetadataEditorInfo
         {
-            ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(),
+            ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(),
             ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
             Countries = _localizationManager.GetCountries().ToArray(),
             Cultures = _localizationManager.GetCultures().ToArray()
@@ -224,7 +224,7 @@ public class ItemUpdateController : BaseJellyfinApiController
         return NoContent();
     }
 
-    private void UpdateItem(BaseItemDto request, BaseItem item)
+    private async Task UpdateItem(BaseItemDto request, BaseItem item)
     {
         item.Name = request.Name;
         item.ForcedSortName = request.ForcedSortName;
@@ -266,9 +266,50 @@ public class ItemUpdateController : BaseJellyfinApiController
         item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
         item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
         item.ProductionYear = request.ProductionYear;
-        item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
+
+        request.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
+        item.OfficialRating = request.OfficialRating;
         item.CustomRating = request.CustomRating;
 
+        if (item is Series rseries)
+        {
+            foreach (Season season in rseries.Children)
+            {
+                season.OfficialRating = request.OfficialRating;
+                season.CustomRating = request.CustomRating;
+                season.OnMetadataChanged();
+                await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+
+                foreach (Episode ep in season.Children)
+                {
+                    ep.OfficialRating = request.OfficialRating;
+                    ep.CustomRating = request.CustomRating;
+                    ep.OnMetadataChanged();
+                    await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+                }
+            }
+        }
+        else if (item is Season season)
+        {
+            foreach (Episode ep in season.Children)
+            {
+                ep.OfficialRating = request.OfficialRating;
+                ep.CustomRating = request.CustomRating;
+                ep.OnMetadataChanged();
+                await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+            }
+        }
+        else if (item is MusicAlbum album)
+        {
+            foreach (BaseItem track in album.Children)
+            {
+                track.OfficialRating = request.OfficialRating;
+                track.CustomRating = request.CustomRating;
+                track.OnMetadataChanged();
+                await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+            }
+        }
+
         if (request.ProductionLocations is not null)
         {
             item.ProductionLocations = request.ProductionLocations;

+ 7 - 0
Jellyfin.Api/Controllers/ItemsController.cs

@@ -411,6 +411,13 @@ public class ItemsController : BaseJellyfinApiController
                 query.SeriesStatuses = seriesStatus;
             }
 
+            // Exclude Blocked Unrated Items
+            var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
+            if (blockedUnratedItems is not null)
+            {
+                query.BlockUnratedItems = blockedUnratedItems;
+            }
+
             // ExcludeLocationTypes
             if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
             {

+ 139 - 20
Jellyfin.Api/Controllers/UserLibraryController.cs

@@ -6,6 +6,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using Jellyfin.Api.Extensions;
 using Jellyfin.Api.ModelBinders;
+using Jellyfin.Data.Entities;
 using Jellyfin.Data.Enums;
 using MediaBrowser.Controller.Dto;
 using MediaBrowser.Controller.Entities;
@@ -72,7 +73,7 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <param name="userId">User id.</param>
     /// <param name="itemId">Item id.</param>
     /// <response code="200">Item returned.</response>
-    /// <returns>An <see cref="OkResult"/> containing the d item.</returns>
+    /// <returns>An <see cref="OkResult"/> containing the item.</returns>
     [HttpGet("Users/{userId}/Items/{itemId}")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
@@ -86,11 +87,19 @@ public class UserLibraryController : BaseJellyfinApiController
         var item = itemId.Equals(default)
             ? _libraryManager.GetUserRootFolder()
             : _libraryManager.GetItemById(itemId);
+
         if (item is null)
         {
             return NotFound();
         }
 
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
         await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
 
         var dtoOptions = new DtoOptions().AddClientFields(User);
@@ -139,11 +148,19 @@ public class UserLibraryController : BaseJellyfinApiController
         var item = itemId.Equals(default)
             ? _libraryManager.GetUserRootFolder()
             : _libraryManager.GetItemById(itemId);
+
         if (item is null)
         {
             return NotFound();
         }
 
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
         var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
         var dtoOptions = new DtoOptions().AddClientFields(User);
         var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
@@ -162,7 +179,29 @@ public class UserLibraryController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
     {
-        return MarkFavorite(userId, itemId, true);
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
+        {
+            return NotFound();
+        }
+
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
+
+        if (item is null)
+        {
+            return NotFound();
+        }
+
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
+        return MarkFavorite(user, item, true);
     }
 
     /// <summary>
@@ -176,7 +215,29 @@ public class UserLibraryController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
     {
-        return MarkFavorite(userId, itemId, false);
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
+        {
+            return NotFound();
+        }
+
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
+
+        if (item is null)
+        {
+            return NotFound();
+        }
+
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
+        return MarkFavorite(user, item, false);
     }
 
     /// <summary>
@@ -190,7 +251,29 @@ public class UserLibraryController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
     {
-        return UpdateUserItemRatingInternal(userId, itemId, null);
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
+        {
+            return NotFound();
+        }
+
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
+
+        if (item is null)
+        {
+            return NotFound();
+        }
+
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
+        return UpdateUserItemRatingInternal(user, item, null);
     }
 
     /// <summary>
@@ -205,7 +288,29 @@ public class UserLibraryController : BaseJellyfinApiController
     [ProducesResponseType(StatusCodes.Status200OK)]
     public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes)
     {
-        return UpdateUserItemRatingInternal(userId, itemId, likes);
+        var user = _userManager.GetUserById(userId);
+        if (user is null)
+        {
+            return NotFound();
+        }
+
+        var item = itemId.Equals(default)
+            ? _libraryManager.GetUserRootFolder()
+            : _libraryManager.GetItemById(itemId);
+
+        if (item is null)
+        {
+            return NotFound();
+        }
+
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
+        return UpdateUserItemRatingInternal(user, item, likes);
     }
 
     /// <summary>
@@ -228,13 +333,20 @@ public class UserLibraryController : BaseJellyfinApiController
         var item = itemId.Equals(default)
             ? _libraryManager.GetUserRootFolder()
             : _libraryManager.GetItemById(itemId);
+
         if (item is null)
         {
             return NotFound();
         }
 
-        var dtoOptions = new DtoOptions().AddClientFields(User);
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
 
+        var dtoOptions = new DtoOptions().AddClientFields(User);
         if (item is IHasTrailers hasTrailers)
         {
             var trailers = hasTrailers.LocalTrailers;
@@ -266,11 +378,19 @@ public class UserLibraryController : BaseJellyfinApiController
         var item = itemId.Equals(default)
             ? _libraryManager.GetUserRootFolder()
             : _libraryManager.GetItemById(itemId);
+
         if (item is null)
         {
             return NotFound();
         }
 
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
         var dtoOptions = new DtoOptions().AddClientFields(User);
 
         return Ok(item
@@ -385,15 +505,11 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <summary>
     /// Marks the favorite.
     /// </summary>
-    /// <param name="userId">The user id.</param>
-    /// <param name="itemId">The item id.</param>
+    /// <param name="user">The user.</param>
+    /// <param name="item">The item.</param>
     /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param>
-    private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite)
+    private UserItemDataDto MarkFavorite(User user, BaseItem item, bool isFavorite)
     {
-        var user = _userManager.GetUserById(userId);
-
-        var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
-
         // Get the user data for this item
         var data = _userDataRepository.GetUserData(user, item);
 
@@ -408,15 +524,11 @@ public class UserLibraryController : BaseJellyfinApiController
     /// <summary>
     /// Updates the user item rating.
     /// </summary>
-    /// <param name="userId">The user id.</param>
-    /// <param name="itemId">The item id.</param>
+    /// <param name="user">The user.</param>
+    /// <param name="item">The item.</param>
     /// <param name="likes">if set to <c>true</c> [likes].</param>
-    private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes)
+    private UserItemDataDto UpdateUserItemRatingInternal(User user, BaseItem item, bool? likes)
     {
-        var user = _userManager.GetUserById(userId);
-
-        var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
-
         // Get the user data for this item
         var data = _userDataRepository.GetUserData(user, item);
 
@@ -455,6 +567,13 @@ public class UserLibraryController : BaseJellyfinApiController
             return NotFound();
         }
 
+        if (item is not UserRootFolder
+            // Check the item is visible for the user
+            && !item.IsVisible(user))
+        {
+            return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
+        }
+
         var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false);
         if (result is not null)
         {

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

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

+ 86 - 0
Jellyfin.Server/Migrations/PreStartupRoutines/MigrateRatingLevels.cs

@@ -0,0 +1,86 @@
+using System;
+using System.Globalization;
+using System.IO;
+
+using Emby.Server.Implementations;
+using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging;
+using SQLitePCL.pretty;
+
+namespace Jellyfin.Server.Migrations.PreStartupRoutines
+{
+    /// <summary>
+    /// Migrate rating levels to new rating level system.
+    /// </summary>
+    internal class MigrateRatingLevels : IMigrationRoutine
+    {
+        private const string DbFilename = "library.db";
+        private readonly ILogger<MigrateRatingLevels> _logger;
+        private readonly IServerApplicationPaths _applicationPaths;
+
+        public MigrateRatingLevels(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory)
+        {
+            _applicationPaths = applicationPaths;
+            _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
+        }
+
+        /// <inheritdoc/>
+        public Guid Id => Guid.Parse("{67445D54-B895-4B24-9F4C-35CE0690EA07}");
+
+        /// <inheritdoc/>
+        public string Name => "MigrateRatingLevels";
+
+        /// <inheritdoc/>
+        public bool PerformOnNewInstall => false;
+
+        /// <inheritdoc/>
+        public void Perform()
+        {
+            var dataPath = _applicationPaths.DataPath;
+            var dbPath = Path.Combine(dataPath, DbFilename);
+            using (var connection = SQLite3.Open(
+                dbPath,
+                ConnectionFlags.ReadWrite,
+                null))
+            {
+                // Back up the database before deleting any entries
+                for (int i = 1; ; i++)
+                {
+                    var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
+                    if (!File.Exists(bakPath))
+                    {
+                        try
+                        {
+                            File.Copy(dbPath, bakPath);
+                            _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
+                            break;
+                        }
+                        catch (Exception ex)
+                        {
+                            _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+                            throw;
+                        }
+                    }
+                }
+
+                // Migrate parental rating levels to new schema
+                _logger.LogInformation("Migrating parental rating levels.");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating = 'NR'");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE InheritedParentalRatingValue = ''");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE InheritedParentalRatingValue = 0");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 1000 WHERE InheritedParentalRatingValue = 100");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 1000 WHERE InheritedParentalRatingValue = 15");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 18 WHERE InheritedParentalRatingValue = 10");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 18 WHERE InheritedParentalRatingValue = 9");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 16 WHERE InheritedParentalRatingValue = 8");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 7");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 6");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 12 WHERE InheritedParentalRatingValue = 5");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 7 WHERE InheritedParentalRatingValue = 4");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 6 WHERE InheritedParentalRatingValue = 3");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 6 WHERE InheritedParentalRatingValue = 2");
+                connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = 0 WHERE InheritedParentalRatingValue = 1");
+            }
+        }
+    }
+}

+ 7 - 12
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -554,7 +554,7 @@ namespace MediaBrowser.Controller.Entities
         public string OfficialRating { get; set; }
 
         [JsonIgnore]
-        public int InheritedParentalRatingValue { get; set; }
+        public int? InheritedParentalRatingValue { get; set; }
 
         /// <summary>
         /// Gets or sets the critic rating.
@@ -1534,12 +1534,6 @@ namespace MediaBrowser.Controller.Entities
             }
 
             var maxAllowedRating = user.MaxParentalAgeRating;
-
-            if (maxAllowedRating is null)
-            {
-                return true;
-            }
-
             var rating = CustomRatingForComparison;
 
             if (string.IsNullOrEmpty(rating))
@@ -1549,12 +1543,13 @@ namespace MediaBrowser.Controller.Entities
 
             if (string.IsNullOrEmpty(rating))
             {
+                Logger.LogDebug("{0} has no parental rating set.", Name);
                 return !GetBlockUnratedValue(user);
             }
 
             var value = LocalizationManager.GetRatingLevel(rating);
 
-            // Could not determine the integer value
+            // Could not determine rating level
             if (!value.HasValue)
             {
                 var isAllowed = !GetBlockUnratedValue(user);
@@ -1567,7 +1562,7 @@ namespace MediaBrowser.Controller.Entities
                 return isAllowed;
             }
 
-            return value.Value <= maxAllowedRating.Value;
+            return !maxAllowedRating.HasValue || value.Value <= maxAllowedRating.Value;
         }
 
         public int? GetInheritedParentalRatingValue()
@@ -1627,10 +1622,10 @@ namespace MediaBrowser.Controller.Entities
         }
 
         /// <summary>
-        /// Gets the block unrated value.
+        /// Gets a bool indicating if access to the unrated item is blocked or not.
         /// </summary>
         /// <param name="user">The configuration.</param>
-        /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
+        /// <returns><c>true</c> if blocked, <c>false</c> otherwise.</returns>
         protected virtual bool GetBlockUnratedValue(User user)
         {
             // Don't block plain folders that are unrated. Let the media underneath get blocked
@@ -2517,7 +2512,7 @@ namespace MediaBrowser.Controller.Entities
 
             var item = this;
 
-            var inheritedParentalRatingValue = item.GetInheritedParentalRatingValue() ?? 0;
+            var inheritedParentalRatingValue = item.GetInheritedParentalRatingValue() ?? null;
             if (inheritedParentalRatingValue != item.InheritedParentalRatingValue)
             {
                 item.InheritedParentalRatingValue = inheritedParentalRatingValue;

+ 5 - 0
MediaBrowser.Controller/Entities/TV/Episode.cs

@@ -308,6 +308,11 @@ namespace MediaBrowser.Controller.Entities.TV
                 id.SeriesDisplayOrder = series.DisplayOrder;
             }
 
+            if (Season is not null)
+            {
+                id.SeasonProviderIds = Season.ProviderIds;
+            }
+
             id.IsMissingEpisode = IsMissingEpisode;
             id.IndexNumberEnd = IndexNumberEnd;
 

+ 25 - 21
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -71,6 +71,21 @@ namespace MediaBrowser.Controller.MediaEncoding
             "m4v",
         };
 
+        // 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
+        private static readonly Dictionary<string, int> _audioTranscodeChannelLookup = new(StringComparer.OrdinalIgnoreCase)
+        {
+            { "wmav2", 2 },
+            { "libmp3lame", 2 },
+            { "libfdk_aac", 6 },
+            { "aac_at", 6 },
+            { "ac3", 6 },
+            { "eac3", 6 },
+            { "dca", 6 },
+            { "mlp", 6 },
+            { "truehd", 6 },
+        };
+
         public EncodingHelper(
             IApplicationPaths appPaths,
             IMediaEncoder mediaEncoder,
@@ -2231,25 +2246,14 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             if (isTranscodingAudio)
             {
-                // 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,
-                };
+                var audioEncoder = GetAudioEncoder(state);
+                if (!_audioTranscodeChannelLookup.TryGetValue(audioEncoder, out var transcoderChannelLimit))
+                {
+                    // Set default max transcoding channels to 8 to prevent encoding errors due to asking for too many channels.
+                    transcoderChannelLimit = 8;
+                }
 
                 // Set resultChannels to minimum between resultChannels, TranscodingMaxAudioChannels, transcoderChannelLimit
-
                 resultChannels = transcoderChannelLimit < resultChannels ? transcoderChannelLimit : resultChannels ?? transcoderChannelLimit;
 
                 if (request.TranscodingMaxAudioChannels < resultChannels)
@@ -4228,12 +4232,12 @@ namespace MediaBrowser.Controller.MediaEncoding
                         subFilters.Add(subTextSubtitlesFilter);
                     }
 
-                    subFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16");
+                    // prefer vaapi hwupload to vulkan hwupload,
+                    // Mesa RADV does not support a dedicated transfer queue.
+                    subFilters.Add("hwupload=derive_device=vaapi,format=vaapi,hwmap=derive_device=vulkan");
 
                     overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0");
-
-                    // explicitly sync using libplacebo.
-                    overlayFilters.Add("libplacebo=format=nv12:upscaler=none:downscaler=none");
+                    overlayFilters.Add("scale_vulkan=format=nv12");
 
                     // OUTPUT vaapi(nv12/bgra) surface(vram)
                     // reverse-mapping via vaapi-vulkan interop.

+ 5 - 0
MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs

@@ -232,6 +232,11 @@ namespace MediaBrowser.Controller.Net
                 // TODO Investigate and properly fix.
                 Logger.LogError(ex, "Object Disposed");
             }
+            catch (Exception ex)
+            {
+                // TODO Investigate and properly fix.
+                Logger.LogError(ex, "Error disposing websocket");
+            }
 
             lock (_activeConnections)
             {

+ 1 - 1
MediaBrowser.Controller/Persistence/IItemRepository.cs

@@ -28,7 +28,7 @@ namespace MediaBrowser.Controller.Persistence
         /// </summary>
         /// <param name="items">The items.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
-        void SaveItems(IEnumerable<BaseItem> items, CancellationToken cancellationToken);
+        void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken);
 
         void SaveImages(BaseItem item);
 

+ 3 - 0
MediaBrowser.Controller/Providers/EpisodeInfo.cs

@@ -12,10 +12,13 @@ namespace MediaBrowser.Controller.Providers
         public EpisodeInfo()
         {
             SeriesProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            SeasonProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
         }
 
         public Dictionary<string, string> SeriesProviderIds { get; set; }
 
+        public Dictionary<string, string> SeasonProviderIds { get; set; }
+
         public int? IndexNumberEnd { get; set; }
 
         public bool IsMissingEpisode { get; set; }

+ 1 - 0
MediaBrowser.Controller/Providers/MetadataRefreshOptions.cs

@@ -26,6 +26,7 @@ namespace MediaBrowser.Controller.Providers
             ReplaceAllMetadata = copy.ReplaceAllMetadata;
             EnableRemoteContentProbe = copy.EnableRemoteContentProbe;
 
+            IsAutomated = copy.IsAutomated;
             ImageRefreshMode = copy.ImageRefreshMode;
             ReplaceAllImages = copy.ReplaceAllImages;
             ReplaceImages = copy.ReplaceImages;

+ 0 - 2
MediaBrowser.Controller/Subtitles/ISubtitleManager.cs

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

+ 2 - 2
MediaBrowser.Model/Entities/ParentalRating.cs

@@ -12,7 +12,7 @@ namespace MediaBrowser.Model.Entities
         {
         }
 
-        public ParentalRating(string name, int value)
+        public ParentalRating(string name, int? value)
         {
             Name = name;
             Value = value;
@@ -28,6 +28,6 @@ namespace MediaBrowser.Model.Entities
         /// Gets or sets the value.
         /// </summary>
         /// <value>The value.</value>
-        public int Value { get; set; }
+        public int? Value { get; set; }
     }
 }

+ 1 - 0
MediaBrowser.Model/Users/UserPolicy.cs

@@ -46,6 +46,7 @@ namespace MediaBrowser.Model.Users
             LoginAttemptsBeforeLockout = -1;
 
             MaxActiveSessions = 0;
+            MaxParentalRating = null;
 
             EnableAllChannels = true;
             EnabledChannels = Array.Empty<Guid>();

+ 2 - 2
MediaBrowser.Providers/Manager/ProviderManager.cs

@@ -284,12 +284,12 @@ namespace MediaBrowser.Providers.Manager
             }
             catch (OperationCanceledException)
             {
-                return new List<RemoteImageInfo>();
+                return Enumerable.Empty<RemoteImageInfo>();
             }
             catch (Exception ex)
             {
                 _logger.LogError(ex, "{ProviderName} failed in GetImageInfos for type {ItemType} at {ItemPath}", provider.GetType().Name, item.GetType().Name, item.Path);
-                return new List<RemoteImageInfo>();
+                return Enumerable.Empty<RemoteImageInfo>();
             }
         }
 

+ 1 - 1
MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs

@@ -87,7 +87,7 @@ namespace MediaBrowser.Providers.Playlists
                 return GetPlsItems(stream);
             }
 
-            return new List<LinkedChild>();
+            return Enumerable.Empty<LinkedChild>();
         }
 
         private IEnumerable<LinkedChild> GetPlsItems(Stream stream)

+ 3 - 2
MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs

@@ -4,6 +4,7 @@
 
 using System.Collections.Generic;
 using System.IO;
+using System.Linq;
 using System.Net.Http;
 using System.Text.Json;
 using System.Threading;
@@ -42,7 +43,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
+            return new ImageType[]
             {
                 ImageType.Primary,
                 ImageType.Logo,
@@ -74,7 +75,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
                 }
             }
 
-            return new List<RemoteImageInfo>();
+            return Enumerable.Empty<RemoteImageInfo>();
         }
 
         private IEnumerable<RemoteImageInfo> GetImages(AudioDbArtistProvider.Artist item)

+ 3 - 2
MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html

@@ -1,12 +1,13 @@
 <!DOCTYPE html>
 <html>
 <head>
-    <title>AudioDB</title>
+    <title>TheAudioDB</title>
 </head>
 <body>
-    <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
+    <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
         <div data-role="content">
             <div class="content-primary">
+                <h1>TheAudioDB</h1>
                 <form class="configForm">
                     <label class="checkboxContainer">
                         <input is="emby-checkbox" type="checkbox" id="replaceAlbumName" />

+ 9 - 3
MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs

@@ -7,16 +7,22 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
 /// </summary>
 public class PluginConfiguration : BasePluginConfiguration
 {
-    private const string DefaultServer = "https://musicbrainz.org";
+    /// <summary>
+    /// The default server URL.
+    /// </summary>
+    public const string DefaultServer = "https://musicbrainz.org";
 
-    private const double DefaultRateLimit = 1.0;
+    /// <summary>
+    /// The default rate limit.
+    /// </summary>
+    public const double DefaultRateLimit = 1.0;
 
     private string _server = DefaultServer;
 
     private double _rateLimit = DefaultRateLimit;
 
     /// <summary>
-    /// Gets or sets the server url.
+    /// Gets or sets the server URL.
     /// </summary>
     public string Server
     {

+ 13 - 8
MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html

@@ -1,9 +1,14 @@
-<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">
+<!DOCTYPE html>
+<html>
+<head>
+    <title>MusicBrainz</title>
+</head>
+<body>
+    <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
+        <div data-role="content">
+            <div class="content-primary">
+                <h1>MusicBrainz</h1>
+                <form class="configForm">
                     <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>
@@ -28,7 +33,7 @@
                 uniquePluginId: "8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"
             };
 
-            document.querySelector('.musicBrainzConfigurationPage')
+            document.querySelector('.configPage')
                 .addEventListener('pageshow', function () {
                     Dashboard.showLoadingMsg();
                     ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) {
@@ -52,7 +57,7 @@
                     });
                 });
 
-            document.querySelector('.musicBrainzConfigurationForm')
+            document.querySelector('.configForm')
                 .addEventListener('submit', function (e) {
                     Dashboard.showLoadingMsg();
 

+ 6 - 6
MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs

@@ -58,7 +58,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
         {
             // Fallback to official server
             _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
-            var defaultServer = new Uri(configuration.Server);
+            var defaultServer = new Uri(PluginConfiguration.DefaultServer);
             Query.DefaultServer = defaultServer.Host;
             Query.DefaultPort = defaultServer.Port;
             Query.DefaultUrlScheme = defaultServer.Scheme;
@@ -157,10 +157,10 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
         var artists = releaseSearchResult.ArtistCredit;
         if (artists is not null && artists.Count > 0)
         {
-            var artistResults = new List<RemoteSearchResult>();
-
-            foreach (var artist in artists)
+            var artistResults = new RemoteSearchResult[artists.Count];
+            for (int i = 0; i < artists.Count; i++)
             {
+                var artist = artists[i];
                 var artistResult = new RemoteSearchResult
                 {
                     Name = artist.Name
@@ -171,11 +171,11 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
                     artistResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Artist!.Id.ToString());
                 }
 
-                artistResults.Add(artistResult);
+                artistResults[i] = artistResult;
             }
 
             searchResult.AlbumArtist = artistResults[0];
-            searchResult.Artists = artistResults.ToArray();
+            searchResult.Artists = artistResults;
         }
 
         searchResult.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseSearchResult.Id.ToString());

+ 1 - 1
MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs

@@ -55,7 +55,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
         {
             // Fallback to official server
             _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
-            var defaultServer = new Uri(configuration.Server);
+            var defaultServer = new Uri(PluginConfiguration.DefaultServer);
             Query.DefaultServer = defaultServer.Host;
             Query.DefaultPort = defaultServer.Port;
             Query.DefaultUrlScheme = defaultServer.Scheme;

+ 5 - 4
MediaBrowser.Providers/Plugins/Omdb/Configuration/config.html

@@ -4,9 +4,10 @@
     <title>OMDb</title>
 </head>
 <body>
-    <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
+    <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
         <div data-role="content">
             <div class="content-primary">
+                <h1>OMDb</h1>
                 <form class="configForm">
                     <label class="checkboxContainer">
                         <input is="emby-checkbox" type="checkbox" id="castAndCrew" />
@@ -33,16 +34,16 @@
                     });
                 });
 
-            
+
             document.querySelector('.configForm')
                 .addEventListener('submit', function (e) {
                     Dashboard.showLoadingMsg();
-    
+
                     ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
                         config.CastAndCrew = document.querySelector('#castAndCrew').checked;
                         ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
                     });
-                    
+
                     e.preventDefault();
                     return false;
                 });

+ 1 - 4
MediaBrowser.Providers/Plugins/Omdb/OmdbImageProvider.cs

@@ -38,10 +38,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
 
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
-            {
-                ImageType.Primary
-            };
+            yield return ImageType.Primary;
         }
 
         public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)

+ 2 - 1
MediaBrowser.Providers/Plugins/StudioImages/Configuration/config.html

@@ -4,9 +4,10 @@
     <title>Studio Images</title>
 </head>
 <body>
-    <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
+    <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
         <div data-role="content">
             <div class="content-primary">
+                <h1>Studio Images</h1>
                 <form class="configForm">
                     <div class="inputContainer">
                         <input is="emby-input" type="text" id="repository" label="Repository" />

+ 1 - 3
MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -50,7 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
+            return new ImageType[]
             {
                 ImageType.Primary,
                 ImageType.Backdrop

+ 2 - 4
MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -74,7 +72,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
 
             var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, cancellationToken).ConfigureAwait(false);
 
-            var collections = new List<RemoteSearchResult>();
+            var collections = new RemoteSearchResult[collectionSearchResults.Count];
             for (var i = 0; i < collectionSearchResults.Count; i++)
             {
                 var collection = new RemoteSearchResult
@@ -84,7 +82,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
                 };
                 collection.SetProviderId(MetadataProvider.Tmdb, collectionSearchResults[i].Id.ToString(CultureInfo.InvariantCulture));
 
-                collections.Add(collection);
+                collections[i] = collection;
             }
 
             return collections;

+ 2 - 1
MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html

@@ -4,9 +4,10 @@
     <title>TMDb</title>
 </head>
 <body>
-    <div data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
+    <div id="configPage" data-role="page" class="page type-interior pluginConfigurationPage configPage" data-require="emby-input,emby-button,emby-checkbox">
         <div data-role="content">
             <div class="content-primary">
+                <h1>TMDb</h1>
                 <form class="configForm">
                     <label class="checkboxContainer">
                         <input is="emby-checkbox" type="checkbox" id="includeAdult" />

+ 1 - 3
MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -51,7 +49,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
+            return new ImageType[]
             {
                 ImageType.Primary,
                 ImageType.Backdrop,

+ 28 - 25
MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -64,32 +62,35 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
                         cancellationToken)
                     .ConfigureAwait(false);
 
-                var remoteResult = new RemoteSearchResult
+                if (movie is not null)
                 {
-                    Name = movie.Title ?? movie.OriginalTitle,
-                    SearchProviderName = Name,
-                    ImageUrl = _tmdbClientManager.GetPosterUrl(movie.PosterPath),
-                    Overview = movie.Overview
-                };
+                    var remoteResult = new RemoteSearchResult
+                    {
+                        Name = movie.Title ?? movie.OriginalTitle,
+                        SearchProviderName = Name,
+                        ImageUrl = _tmdbClientManager.GetPosterUrl(movie.PosterPath),
+                        Overview = movie.Overview
+                    };
 
-                if (movie.ReleaseDate is not null)
-                {
-                    var releaseDate = movie.ReleaseDate.Value.ToUniversalTime();
-                    remoteResult.PremiereDate = releaseDate;
-                    remoteResult.ProductionYear = releaseDate.Year;
-                }
+                    if (movie.ReleaseDate is not null)
+                    {
+                        var releaseDate = movie.ReleaseDate.Value.ToUniversalTime();
+                        remoteResult.PremiereDate = releaseDate;
+                        remoteResult.ProductionYear = releaseDate.Year;
+                    }
 
-                remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture));
+                    remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture));
 
-                if (!string.IsNullOrWhiteSpace(movie.ImdbId))
-                {
-                    remoteResult.SetProviderId(MetadataProvider.Imdb, movie.ImdbId);
-                }
+                    if (!string.IsNullOrWhiteSpace(movie.ImdbId))
+                    {
+                        remoteResult.SetProviderId(MetadataProvider.Imdb, movie.ImdbId);
+                    }
 
-                return new[] { remoteResult };
+                    return new[] { remoteResult };
+                }
             }
 
-            IReadOnlyList<SearchMovie> movieResults;
+            IReadOnlyList<SearchMovie>? movieResults = null;
             if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id))
             {
                 var result = await _tmdbClientManager.FindByExternalIdAsync(
@@ -97,18 +98,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
                     FindExternalSource.Imdb,
                     TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage),
                     cancellationToken).ConfigureAwait(false);
-                movieResults = result.MovieResults;
+                movieResults = result?.MovieResults;
             }
-            else if (searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out id))
+
+            if (movieResults is null && searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out id))
             {
                 var result = await _tmdbClientManager.FindByExternalIdAsync(
                     id,
                     FindExternalSource.TvDb,
                     TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage),
                     cancellationToken).ConfigureAwait(false);
-                movieResults = result.MovieResults;
+                movieResults = result?.MovieResults;
             }
-            else
+
+            if (movieResults is null)
             {
                 movieResults = await _tmdbClientManager
                     .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, cancellationToken)

+ 1 - 4
MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs

@@ -46,10 +46,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
-            {
-                ImageType.Primary
-            };
+            yield return ImageType.Primary;
         }
 
         /// <inheritdoc />

+ 6 - 4
MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -69,7 +67,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
 
             var personSearchResult = await _tmdbClientManager.SearchPersonAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false);
 
-            var remoteSearchResults = new List<RemoteSearchResult>();
+            var remoteSearchResults = new RemoteSearchResult[personSearchResult.Count];
             for (var i = 0; i < personSearchResult.Count; i++)
             {
                 var person = personSearchResult[i];
@@ -81,7 +79,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
                 };
 
                 remoteSearchResult.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture));
-                remoteSearchResults.Add(remoteSearchResult);
+                remoteSearchResults[i] = remoteSearchResult;
             }
 
             return remoteSearchResults;
@@ -107,6 +105,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
             if (personTmdbId > 0)
             {
                 var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
+                if (person is null)
+                {
+                    return result;
+                }
 
                 result.HasMetadata = true;
 

+ 2 - 7
MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -49,10 +47,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
-            {
-                ImageType.Primary
-            };
+            yield return ImageType.Primary;
         }
 
         /// <inheritdoc />
@@ -63,7 +58,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
 
             var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
 
-            if (seriesTmdbId <= 0)
+            if (series is null || seriesTmdbId <= 0)
             {
                 return Enumerable.Empty<RemoteImageInfo>();
             }

+ 1 - 3
MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -87,7 +85,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
                 return metadataResult;
             }
 
-            info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string tmdbId);
+            info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? tmdbId);
 
             var seriesTmdbId = Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture);
             if (seriesTmdbId <= 0)

+ 1 - 4
MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs

@@ -48,10 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
-            {
-                ImageType.Primary
-            };
+            yield return ImageType.Primary;
         }
 
         /// <inheritdoc />

+ 1 - 1
MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs

@@ -48,7 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
+            return new ImageType[]
             {
                 ImageType.Primary,
                 ImageType.Backdrop,

+ 7 - 4
MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -211,7 +209,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
                 }
             }
 
-            if (string.IsNullOrEmpty(tmdbId))
+            if (!int.TryParse(tmdbId, CultureInfo.InvariantCulture, out int tmdbIdInt))
             {
                 return result;
             }
@@ -219,9 +217,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
             cancellationToken.ThrowIfCancellationRequested();
 
             var tvShow = await _tmdbClientManager
-                .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
+                .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
                 .ConfigureAwait(false);
 
+            if (tvShow is null)
+            {
+                return result;
+            }
+
             result = new MetadataResult<Series>
             {
                 Item = MapTvShowToSeries(tvShow, info.MetadataCountryCode),

+ 25 - 27
MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs

@@ -1,6 +1,4 @@
-#nullable disable
-
-using System;
+using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Threading;
@@ -50,10 +48,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="imageLanguages">A comma-separated list of image languages.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb movie or null if not found.</returns>
-        public async Task<Movie> GetMovieAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken)
+        public async Task<Movie?> GetMovieAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken)
         {
             var key = $"movie-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
-            if (_memoryCache.TryGetValue(key, out Movie movie))
+            if (_memoryCache.TryGetValue(key, out Movie? movie))
             {
                 return movie;
             }
@@ -89,10 +87,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="imageLanguages">A comma-separated list of image languages.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb collection or null if not found.</returns>
-        public async Task<Collection> GetCollectionAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken)
+        public async Task<Collection?> GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken)
         {
             var key = $"collection-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
-            if (_memoryCache.TryGetValue(key, out Collection collection))
+            if (_memoryCache.TryGetValue(key, out Collection? collection))
             {
                 return collection;
             }
@@ -122,10 +120,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="imageLanguages">A comma-separated list of image languages.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb tv show information or null if not found.</returns>
-        public async Task<TvShow> GetSeriesAsync(int tmdbId, string language, string imageLanguages, CancellationToken cancellationToken)
+        public async Task<TvShow?> GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken)
         {
             var key = $"series-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
-            if (_memoryCache.TryGetValue(key, out TvShow series))
+            if (_memoryCache.TryGetValue(key, out TvShow? series))
             {
                 return series;
             }
@@ -162,7 +160,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="imageLanguages">A comma-separated list of image languages.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb tv show episode group information or null if not found.</returns>
-        private async Task<TvGroupCollection> GetSeriesGroupAsync(int tvShowId, string displayOrder, string language, string imageLanguages, CancellationToken cancellationToken)
+        private async Task<TvGroupCollection?> GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken)
         {
             TvGroupType? groupType =
                 string.Equals(displayOrder, "originalAirDate", StringComparison.Ordinal) ? TvGroupType.OriginalAirDate :
@@ -180,7 +178,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
             }
 
             var key = $"group-{tvShowId.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}";
-            if (_memoryCache.TryGetValue(key, out TvGroupCollection group))
+            if (_memoryCache.TryGetValue(key, out TvGroupCollection? group))
             {
                 return group;
             }
@@ -217,10 +215,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="imageLanguages">A comma-separated list of image languages.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb tv season information or null if not found.</returns>
-        public async Task<TvSeason> GetSeasonAsync(int tvShowId, int seasonNumber, string language, string imageLanguages, CancellationToken cancellationToken)
+        public async Task<TvSeason?> GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, CancellationToken cancellationToken)
         {
             var key = $"season-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}";
-            if (_memoryCache.TryGetValue(key, out TvSeason season))
+            if (_memoryCache.TryGetValue(key, out TvSeason? season))
             {
                 return season;
             }
@@ -254,10 +252,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="imageLanguages">A comma-separated list of image languages.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb tv episode information or null if not found.</returns>
-        public async Task<TvEpisode> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string language, string imageLanguages, CancellationToken cancellationToken)
+        public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken)
         {
             var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}";
-            if (_memoryCache.TryGetValue(key, out TvEpisode episode))
+            if (_memoryCache.TryGetValue(key, out TvEpisode? episode))
             {
                 return episode;
             }
@@ -301,10 +299,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="language">The episode's language.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb person information or null if not found.</returns>
-        public async Task<Person> GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken)
+        public async Task<Person?> GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken)
         {
             var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
-            if (_memoryCache.TryGetValue(key, out Person person))
+            if (_memoryCache.TryGetValue(key, out Person? person))
             {
                 return person;
             }
@@ -333,14 +331,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="language">The item's language.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The TMDb item or null if not found.</returns>
-        public async Task<FindContainer> FindByExternalIdAsync(
+        public async Task<FindContainer?> FindByExternalIdAsync(
             string externalId,
             FindExternalSource source,
             string language,
             CancellationToken cancellationToken)
         {
             var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}";
-            if (_memoryCache.TryGetValue(key, out FindContainer result))
+            if (_memoryCache.TryGetValue(key, out FindContainer? result))
             {
                 return result;
             }
@@ -372,7 +370,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default)
         {
             var key = $"searchseries-{name}-{language}";
-            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv> series))
+            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null)
             {
                 return series.Results;
             }
@@ -400,7 +398,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         public async Task<IReadOnlyList<SearchPerson>> SearchPersonAsync(string name, CancellationToken cancellationToken)
         {
             var key = $"searchperson-{name}";
-            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson> person))
+            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson>? person) && person is not null)
             {
                 return person.Results;
             }
@@ -442,7 +440,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, CancellationToken cancellationToken)
         {
             var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}";
-            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie> movies))
+            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie>? movies) && movies is not null)
             {
                 return movies.Results;
             }
@@ -471,7 +469,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, CancellationToken cancellationToken)
         {
             var key = $"collectionsearch-{name}-{language}";
-            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection> collections))
+            if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection>? collections) && collections is not null)
             {
                 return collections.Results;
             }
@@ -496,7 +494,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="size">The image size to fetch.</param>
         /// <param name="path">The relative URL of the image.</param>
         /// <returns>The absolute URL.</returns>
-        private string GetUrl(string size, string path)
+        private string? GetUrl(string? size, string path)
         {
             if (string.IsNullOrEmpty(path))
             {
@@ -511,7 +509,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// </summary>
         /// <param name="posterPath">The relative URL of the poster.</param>
         /// <returns>The absolute URL.</returns>
-        public string GetPosterUrl(string posterPath)
+        public string? GetPosterUrl(string posterPath)
         {
             return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath);
         }
@@ -521,7 +519,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// </summary>
         /// <param name="actorProfilePath">The relative URL of the profile image.</param>
         /// <returns>The absolute URL.</returns>
-        public string GetProfileUrl(string actorProfilePath)
+        public string? GetProfileUrl(string actorProfilePath)
         {
             return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath);
         }
@@ -579,7 +577,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// <param name="type">The type of the image.</param>
         /// <param name="requestLanguage">The requested language.</param>
         /// <returns>The remote images.</returns>
-        private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string size, ImageType type, string requestLanguage)
+        private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string? size, ImageType type, string requestLanguage)
         {
             // sizes provided are for original resolution, don't store them when downloading scaled images
             var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase);

+ 3 - 1
MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using System.Text.RegularExpressions;
 using MediaBrowser.Model.Entities;
 using TMDbLib.Objects.General;
@@ -128,7 +129,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
         /// </summary>
         /// <param name="language">The language code.</param>
         /// <returns>The normalized language code.</returns>
-        public static string NormalizeLanguage(string language)
+        [return: NotNullIfNotNull(nameof(language))]
+        public static string? NormalizeLanguage(string? language)
         {
             if (string.IsNullOrEmpty(language))
             {

+ 3 - 5
MediaBrowser.Providers/Subtitles/SubtitleManager.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -56,7 +54,7 @@ namespace MediaBrowser.Providers.Subtitles
         }
 
         /// <inheritdoc />
-        public event EventHandler<SubtitleDownloadFailureEventArgs> SubtitleDownloadFailure;
+        public event EventHandler<SubtitleDownloadFailureEventArgs>? SubtitleDownloadFailure;
 
         /// <inheritdoc />
         public async Task<RemoteSubtitleInfo[]> SearchSubtitles(SubtitleSearchRequest request, CancellationToken cancellationToken)
@@ -235,7 +233,7 @@ namespace MediaBrowser.Providers.Subtitles
 
         private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
         {
-            List<Exception> exs = null;
+            List<Exception>? exs = null;
 
             foreach (var savePath in savePaths)
             {
@@ -245,7 +243,7 @@ namespace MediaBrowser.Providers.Subtitles
 
                 try
                 {
-                    Directory.CreateDirectory(Path.GetDirectoryName(savePath));
+                    Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
 
                     var fileOptions = AsyncFile.WriteOptions;
                     fileOptions.Mode = FileMode.CreateNew;

+ 0 - 2
MediaBrowser.Providers/TV/SeriesMetadataService.cs

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

+ 1 - 1
src/Jellyfin.Extensions/StringExtensions.cs

@@ -12,7 +12,7 @@ namespace Jellyfin.Extensions
     {
         // Matches non-conforming unicode chars
         // https://mnaoumov.wordpress.com/2014/06/14/stripping-invalid-characters-from-utf-16-strings/
-        private static readonly Regex _nonConformingUnicode = new Regex("([\ud800-\udbff](?![\udc00-\udfff]))|((?<![\ud800-\udbff])[\udc00-\udfff])|(\ufffd)");
+        private static readonly Regex _nonConformingUnicode = new Regex("([\ud800-\udbff](?![\udc00-\udfff]))|((?<![\ud800-\udbff])[\udc00-\udfff])|(\ufffd)", RegexOptions.Compiled);
 
         /// <summary>
         /// Removes the diacritics character from the strings.

+ 31 - 1
tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs

@@ -1,9 +1,13 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Security.Claims;
 using System.Threading.Tasks;
 using AutoFixture;
 using AutoFixture.AutoMoq;
 using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
 using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
 using Jellyfin.Server.Implementations.Security;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Library;
@@ -51,6 +55,32 @@ namespace Jellyfin.Api.Tests.Auth.DefaultAuthorizationPolicy
             Assert.True(context.HasSucceeded);
         }
 
+        [Fact]
+        public async Task ShouldSucceedOnApiKey()
+        {
+            TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+
+            _httpContextAccessor
+                .Setup(h => h.HttpContext!.Connection.RemoteIpAddress)
+                .Returns(new IPAddress(0));
+
+            _userManagerMock
+                .Setup(u => u.GetUserById(It.IsAny<Guid>()))
+                .Returns<User>(null);
+
+            var claims = new[]
+            {
+                new Claim(InternalClaimTypes.IsApiKey, bool.TrueString)
+            };
+
+            var identity = new ClaimsIdentity(claims, string.Empty);
+            var principal = new ClaimsPrincipal(identity);
+            var context = new AuthorizationHandlerContext(_requirements, principal, null);
+
+            await _sut.HandleAsync(context);
+            Assert.True(context.HasSucceeded);
+        }
+
         [Theory]
         [MemberData(nameof(GetParts_ValidAuthHeader_Success_Data))]
         public void GetParts_ValidAuthHeader_Success(string input, Dictionary<string, string> parts)

+ 47 - 0
tests/Jellyfin.Dlna.Tests/Server/DescriptionXmlBuilderTests.cs

@@ -0,0 +1,47 @@
+using Emby.Dlna.Server;
+using MediaBrowser.Model.Dlna;
+using Xunit;
+
+namespace Jellyfin.Dlna.Server.Tests;
+
+public class DescriptionXmlBuilderTests
+{
+    [Fact]
+    public void GetFriendlyName_EmptyProfile_ReturnsServerName()
+    {
+        const string ServerName = "Test Server Name";
+        var builder = new DescriptionXmlBuilder(new DeviceProfile(), "serverUdn", "localhost", ServerName, string.Empty);
+        Assert.Equal(ServerName, builder.GetFriendlyName());
+    }
+
+    [Fact]
+    public void GetFriendlyName_FriendlyName_ReturnsFriendlyName()
+    {
+        const string FriendlyName = "Friendly Neighborhood Test Server";
+        var builder = new DescriptionXmlBuilder(
+            new DeviceProfile()
+            {
+                FriendlyName = FriendlyName
+            },
+            "serverUdn",
+            "localhost",
+            "Test Server Name",
+            string.Empty);
+        Assert.Equal(FriendlyName, builder.GetFriendlyName());
+    }
+
+    [Fact]
+    public void GetFriendlyName_FriendlyNameInterpolation_ReturnsFriendlyName()
+    {
+        var builder = new DescriptionXmlBuilder(
+            new DeviceProfile()
+            {
+                FriendlyName = "Friendly Neighborhood ${HostName}"
+            },
+            "serverUdn",
+            "localhost",
+            "Test Server Name",
+            string.Empty);
+        Assert.Equal("Friendly Neighborhood TestServerName", builder.GetFriendlyName());
+    }
+}

Some files were not shown because too many files changed in this diff