Explorar el Código

Merge branch 'master' into fix-hwa-video-rotation

Nyanmisaka hace 10 meses
padre
commit
2aa9cf4007
Se han modificado 60 ficheros con 868 adiciones y 736 borrados
  1. 1 1
      .github/ISSUE_TEMPLATE/issue report.yml
  2. 3 3
      .github/workflows/ci-codeql-analysis.yml
  3. 2 2
      .github/workflows/ci-openapi.yml
  4. 1 0
      CONTRIBUTORS.md
  5. 5 4
      Directory.Packages.props
  6. 6 9
      Emby.Naming/TV/SeasonPathParser.cs
  7. 1 1
      Emby.Photos/PhotoProvider.cs
  8. 4 4
      Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
  9. 34 28
      Emby.Server.Implementations/Dto/DtoService.cs
  10. 2 3
      Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
  11. 4 3
      Emby.Server.Implementations/Library/MediaSourceManager.cs
  12. 1 5
      Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs
  13. 2 10
      Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
  14. 7 28
      Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
  15. 7 1
      Emby.Server.Implementations/Localization/Core/es-AR.json
  16. 1 1
      Emby.Server.Implementations/Localization/Core/fil.json
  17. 5 2
      Emby.Server.Implementations/Localization/Core/hr.json
  18. 6 1
      Emby.Server.Implementations/Localization/Core/ko.json
  19. 3 3
      Emby.Server.Implementations/Localization/Core/lv.json
  20. 1 1
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  21. 8 8
      Jellyfin.Api/Controllers/LiveTvController.cs
  22. 0 2
      Jellyfin.Api/Extensions/DtoExtensions.cs
  23. 1 1
      Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
  24. 12 31
      Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
  25. 0 2
      MediaBrowser.Controller/Dto/DtoOptions.cs
  26. 1 9
      MediaBrowser.Controller/Entities/UserItemData.cs
  27. 74 0
      MediaBrowser.Controller/MediaEncoding/DownMixAlgorithmsHelper.cs
  28. 27 32
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  29. 0 31
      MediaBrowser.Controller/Providers/MetadataResult.cs
  30. 2 8
      MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs
  31. 5 20
      MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
  32. 14 3
      MediaBrowser.Model/Dlna/StreamBuilder.cs
  33. 2 3
      MediaBrowser.Model/Dto/UserItemDataDto.cs
  34. 15 3
      MediaBrowser.Model/Entities/DownMixStereoAlgorithms.cs
  35. 7 2
      MediaBrowser.Model/Entities/MediaStream.cs
  36. 180 143
      MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
  37. 49 36
      MediaBrowser.Model/Querying/QueryResult.cs
  38. 3 3
      MediaBrowser.Model/Session/UserDataChangeInfo.cs
  39. 1 1
      MediaBrowser.Providers/MediaBrowser.Providers.csproj
  40. 79 83
      MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
  41. 1 4
      MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
  42. 1 5
      MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
  43. 2 9
      MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
  44. 3 14
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
  45. 1 4
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
  46. 5 22
      MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
  47. 2 8
      MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
  48. 57 59
      MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
  49. 3 13
      MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs
  50. 4 17
      MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
  51. 0 5
      MediaBrowser.XbmcMetadata/Providers/BaseVideoNfoProvider.cs
  52. 22 0
      jellyfin.ruleset
  53. 1 4
      src/Jellyfin.LiveTv/Guide/GuideManager.cs
  54. 12 5
      tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
  55. 0 35
      tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs
  56. 37 0
      tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
  57. 24 0
      tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
  58. 4 1
      tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
  59. 24 0
      tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising.nfo
  60. 89 0
      tests/Jellyfin.XbmcMetadata.Tests/Test Data/Stargate Atlantis S01E01-E04.nfo

+ 1 - 1
.github/ISSUE_TEMPLATE/issue report.yml

@@ -86,7 +86,7 @@ body:
       label: Jellyfin Server version
       description: What version of Jellyfin are you using?
       options:
-        - 10.9.7
+        - 10.9.8+
         - Master
         - Unstable
         - Older*

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

@@ -27,11 +27,11 @@ jobs:
         dotnet-version: '8.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12
+      uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12
+      uses: github/codeql-action/autobuild@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12
+      uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15

+ 2 - 2
.github/workflows/ci-openapi.yml

@@ -27,7 +27,7 @@ jobs:
       - name: Generate openapi.json
         run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
       - name: Upload openapi.json
-        uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
+        uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
         with:
           name: openapi-head
           retention-days: 14
@@ -61,7 +61,7 @@ jobs:
       - name: Generate openapi.json
         run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
       - name: Upload openapi.json
-        uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
+        uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
         with:
           name: openapi-base
           retention-days: 14

+ 1 - 0
CONTRIBUTORS.md

@@ -185,6 +185,7 @@
  - [Vedant](https://github.com/viktory36/)
  - [NotSaifA](https://github.com/NotSaifA)
  - [HonestlyWhoKnows](https://github.com/honestlywhoknows)
+ - [TheMelmacian](https://github.com/TheMelmacian)
  - [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
 
 # Emby Contributors

+ 5 - 4
Directory.Packages.props

@@ -4,7 +4,7 @@
   </PropertyGroup>
   <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
   <ItemGroup Label="Package Dependencies">
-    <PackageVersion Include="AsyncKeyedLock" Version="6.4.2" />
+    <PackageVersion Include="AsyncKeyedLock" Version="7.0.0" />
     <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
     <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
     <PackageVersion Include="AutoFixture" Version="4.18.1" />
@@ -22,7 +22,7 @@
     <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
     <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
     <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
-    <PackageVersion Include="libse" Version="4.0.5" />
+    <PackageVersion Include="libse" Version="4.0.7" />
     <PackageVersion Include="LrcParser" Version="2023.524.0" />
     <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
     <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.7" />
@@ -58,7 +58,7 @@
     <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
     <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
     <PackageVersion Include="prometheus-net" Version="8.2.1" />
-    <PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
+    <PackageVersion Include="Serilog.AspNetCore" Version="8.0.2" />
     <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
     <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.2" />
     <PackageVersion Include="Serilog.Sinks.Async" Version="2.0.0" />
@@ -72,7 +72,7 @@
     <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.8" />
     <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
     <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
-    <PackageVersion Include="Svg.Skia" Version="1.0.0.18" />
+    <PackageVersion Include="Svg.Skia" Version="2.0.0" />
     <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
     <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
     <PackageVersion Include="System.Globalization" Version="4.3.0" />
@@ -81,6 +81,7 @@
     <PackageVersion Include="System.Text.Json" Version="8.0.4" />
     <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.1" />
     <PackageVersion Include="TagLibSharp" Version="2.3.0" />
+    <PackageVersion Include="z440.atl.core" Version="5.25.0" />
     <PackageVersion Include="TMDbLib" Version="2.2.0" />
     <PackageVersion Include="UTF.Unknown" Version="2.5.1" />
     <PackageVersion Include="Xunit.Priority" Version="1.1.6" />

+ 6 - 9
Emby.Naming/TV/SeasonPathParser.cs

@@ -24,6 +24,8 @@ namespace Emby.Naming.TV
             "stagione"
         };
 
+        private static readonly char[] _splitChars = ['.', '_', ' ', '-'];
+
         /// <summary>
         /// Attempts to parse season number from path.
         /// </summary>
@@ -83,14 +85,9 @@ namespace Emby.Naming.TV
                 }
             }
 
-            if (filename.StartsWith("s", StringComparison.OrdinalIgnoreCase))
+            if (TryGetSeasonNumberFromPart(filename, out int seasonNumber))
             {
-                var testFilename = filename.AsSpan().Slice(1);
-
-                if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
-                {
-                    return (val, true);
-                }
+                return (seasonNumber, true);
             }
 
             // Look for one of the season folder names
@@ -108,10 +105,10 @@ namespace Emby.Naming.TV
                 }
             }
 
-            var parts = filename.Split(new[] { '.', '_', ' ', '-' }, StringSplitOptions.RemoveEmptyEntries);
+            var parts = filename.Split(_splitChars, StringSplitOptions.RemoveEmptyEntries);
             foreach (var part in parts)
             {
-                if (TryGetSeasonNumberFromPart(part, out int seasonNumber))
+                if (TryGetSeasonNumberFromPart(part, out seasonNumber))
                 {
                     return (seasonNumber, true);
                 }

+ 1 - 1
Emby.Photos/PhotoProvider.cs

@@ -26,7 +26,7 @@ public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IH
     private readonly ILogger<PhotoProvider> _logger;
     private readonly IImageProcessor _imageProcessor;
 
-    // These are causing taglib to hang
+    // Other extensions might cause taglib to hang
     private readonly string[] _includeExtensions = [".jpg", ".jpeg", ".png", ".tiff", ".cr2", ".webp", ".avif"];
 
     /// <summary>

+ 4 - 4
Emby.Server.Implementations/Data/SqliteUserDataRepository.cs

@@ -333,10 +333,10 @@ namespace Emby.Server.Implementations.Data
         /// <returns>The user item data.</returns>
         private UserItemData ReadRow(SqliteDataReader reader)
         {
-            var userData = new UserItemData();
-
-            userData.Key = reader[0].ToString();
-            // userData.UserId = reader[1].ReadGuidFromBlob();
+            var userData = new UserItemData
+            {
+                Key = reader.GetString(0)
+            };
 
             if (reader.TryGetDouble(2, out var rating))
             {

+ 34 - 28
Emby.Server.Implementations/Dto/DtoService.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -83,12 +81,12 @@ namespace Emby.Server.Implementations.Dto
         private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
 
         /// <inheritdoc />
-        public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User user = null, BaseItem owner = null)
+        public IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null)
         {
             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;
+            List<(BaseItem, BaseItemDto)>? programTuples = null;
+            List<(BaseItemDto, LiveTvChannel)>? channelTuples = null;
 
             for (int index = 0; index < accessibleItems.Count; index++)
             {
@@ -137,7 +135,7 @@ namespace Emby.Server.Implementations.Dto
             return returnItems;
         }
 
-        public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User user = null, BaseItem owner = null)
+        public BaseItemDto GetBaseItemDto(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
         {
             var dto = GetBaseItemDtoInternal(item, options, user, owner);
             if (item is LiveTvChannel tvChannel)
@@ -167,7 +165,7 @@ namespace Emby.Server.Implementations.Dto
             return dto;
         }
 
-        private static IList<BaseItem> GetTaggedItems(IItemByName byName, User user, DtoOptions options)
+        private static IList<BaseItem> GetTaggedItems(IItemByName byName, User? user, DtoOptions options)
         {
             return byName.GetTaggedItems(
                 new InternalItemsQuery(user)
@@ -177,7 +175,7 @@ namespace Emby.Server.Implementations.Dto
                 });
         }
 
-        private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User user = null, BaseItem owner = null)
+        private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
         {
             var dto = new BaseItemDto
             {
@@ -292,7 +290,7 @@ namespace Emby.Server.Implementations.Dto
                 }
 
                 var path = mediaSource.Path;
-                string fileExtensionContainer = null;
+                string? fileExtensionContainer = null;
 
                 if (!string.IsNullOrEmpty(path))
                 {
@@ -316,7 +314,8 @@ namespace Emby.Server.Implementations.Dto
             }
         }
 
-        public BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem> taggedItems, User user = null)
+        /// <inheritdoc />
+        public BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem>? taggedItems, User? user = null)
         {
             var dto = GetBaseItemDtoInternal(item, options, user);
 
@@ -486,10 +485,10 @@ namespace Emby.Server.Implementations.Dto
             return images
                 .Select(p => GetImageCacheTag(item, p))
                 .Where(i => i is not null)
-                .ToArray();
+                .ToArray()!; // null values got filtered out
         }
 
-        private string GetImageCacheTag(BaseItem item, ItemImageInfo image)
+        private string? GetImageCacheTag(BaseItem item, ItemImageInfo image)
         {
             try
             {
@@ -508,7 +507,7 @@ namespace Emby.Server.Implementations.Dto
         /// <param name="dto">The dto.</param>
         /// <param name="item">The item.</param>
         /// <param name="user">The requesting user.</param>
-        private void AttachPeople(BaseItemDto dto, BaseItem item, User user = null)
+        private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null)
         {
             // Ordering by person type to ensure actors and artists are at the front.
             // This is taking advantage of the fact that they both begin with A
@@ -552,7 +551,7 @@ namespace Emby.Server.Implementations.Dto
 
             var list = new List<BaseItemPerson>();
 
-            var dictionary = people.Select(p => p.Name)
+            Dictionary<string, Person> dictionary = people.Select(p => p.Name)
                 .Distinct(StringComparer.OrdinalIgnoreCase).Select(c =>
                 {
                     try
@@ -565,9 +564,9 @@ namespace Emby.Server.Implementations.Dto
                         return null;
                     }
                 }).Where(i => i is not null)
-                .Where(i => user is null || i.IsVisible(user))
-                .DistinctBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
-                .ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase);
+                .Where(i => user is null || i!.IsVisible(user))
+                .DistinctBy(x => x!.Name, StringComparer.OrdinalIgnoreCase)
+                .ToDictionary(i => i!.Name, StringComparer.OrdinalIgnoreCase)!; // null values got filtered out
 
             for (var i = 0; i < people.Count; i++)
             {
@@ -580,7 +579,7 @@ namespace Emby.Server.Implementations.Dto
                     Type = person.Type
                 };
 
-                if (dictionary.TryGetValue(person.Name, out Person entity))
+                if (dictionary.TryGetValue(person.Name, out Person? entity))
                 {
                     baseItemPerson.PrimaryImageTag = GetTagAndFillBlurhash(dto, entity, ImageType.Primary);
                     baseItemPerson.Id = entity.Id;
@@ -650,7 +649,7 @@ namespace Emby.Server.Implementations.Dto
             return _libraryManager.GetGenreId(name);
         }
 
-        private string GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ImageType imageType, int imageIndex = 0)
+        private string? GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ImageType imageType, int imageIndex = 0)
         {
             var image = item.GetImageInfo(imageType, imageIndex);
             if (image is not null)
@@ -661,9 +660,14 @@ namespace Emby.Server.Implementations.Dto
             return null;
         }
 
-        private string GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ItemImageInfo image)
+        private string? GetTagAndFillBlurhash(BaseItemDto dto, BaseItem item, ItemImageInfo image)
         {
             var tag = GetImageCacheTag(item, image);
+            if (tag is null)
+            {
+                return null;
+            }
+
             if (!string.IsNullOrEmpty(image.BlurHash))
             {
                 dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>();
@@ -716,7 +720,7 @@ namespace Emby.Server.Implementations.Dto
         /// <param name="item">The item.</param>
         /// <param name="owner">The owner.</param>
         /// <param name="options">The options.</param>
-        private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem owner, DtoOptions options)
+        private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options)
         {
             if (options.ContainsField(ItemFields.DateCreated))
             {
@@ -1097,7 +1101,7 @@ namespace Emby.Server.Implementations.Dto
                 }
             }
 
-            BaseItem[] allExtras = null;
+            BaseItem[]? allExtras = null;
 
             if (options.ContainsField(ItemFields.SpecialFeatureCount))
             {
@@ -1134,7 +1138,7 @@ namespace Emby.Server.Implementations.Dto
                 dto.SeasonId = episode.SeasonId;
                 dto.SeriesId = episode.SeriesId;
 
-                Series episodeSeries = null;
+                Series? episodeSeries = null;
 
                 // this block will add the series poster for episodes without a poster
                 // TODO maybe remove the if statement entirely
@@ -1162,8 +1166,10 @@ namespace Emby.Server.Implementations.Dto
             }
 
             // Add SeriesInfo
-            if (item is Series series)
+            Series? series;
+            if (item is Series tmp)
             {
+                series = tmp;
                 dto.AirDays = series.AirDays;
                 dto.AirTime = series.AirTime;
                 dto.Status = series.Status?.ToString();
@@ -1264,7 +1270,7 @@ namespace Emby.Server.Implementations.Dto
             }
         }
 
-        private BaseItem GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
+        private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
         {
             if (currentItem is MusicAlbum musicAlbum)
             {
@@ -1285,7 +1291,7 @@ namespace Emby.Server.Implementations.Dto
             return parent;
         }
 
-        private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem owner)
+        private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
         {
             if (!item.SupportsInheritedParentImages)
             {
@@ -1305,7 +1311,7 @@ namespace Emby.Server.Implementations.Dto
                 return;
             }
 
-            BaseItem parent = null;
+            BaseItem? parent = null;
             var isFirst = true;
 
             var imageTags = dto.ImageTags;
@@ -1378,7 +1384,7 @@ namespace Emby.Server.Implementations.Dto
             }
         }
 
-        private string GetMappedPath(BaseItem item, BaseItem ownerItem)
+        private string GetMappedPath(BaseItem item, BaseItem? ownerItem)
         {
             var path = item.Path;
 

+ 2 - 3
Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs

@@ -1,6 +1,5 @@
 using System;
 using System.Collections.Generic;
-using System.Globalization;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
@@ -138,13 +137,13 @@ namespace Emby.Server.Implementations.EntryPoints
 
             return new UserDataChangeInfo
             {
-                UserId = userId.ToString("N", CultureInfo.InvariantCulture),
+                UserId = userId,
                 UserDataList = changedItems
                     .DistinctBy(x => x.Id)
                     .Select(i =>
                     {
                         var dto = _userDataManager.GetUserDataDto(i, user);
-                        dto.ItemId = i.Id.ToString("N", CultureInfo.InvariantCulture);
+                        dto.ItemId = i.Id;
                         return dto;
                     })
                     .ToArray()

+ 4 - 3
Emby.Server.Implementations/Library/MediaSourceManager.cs

@@ -379,7 +379,8 @@ namespace Emby.Server.Implementations.Library
 
         private void SetDefaultSubtitleStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
         {
-            if (userData.SubtitleStreamIndex.HasValue
+            if (userData is not null
+                && userData.SubtitleStreamIndex.HasValue
                 && user.RememberSubtitleSelections
                 && user.SubtitleMode != SubtitlePlaybackMode.None
                 && allowRememberingSelection)
@@ -411,7 +412,7 @@ namespace Emby.Server.Implementations.Library
 
         private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
         {
-            if (userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
+            if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
             {
                 var index = userData.AudioStreamIndex.Value;
                 // Make sure the saved index is still valid
@@ -434,7 +435,7 @@ namespace Emby.Server.Implementations.Library
 
             if (mediaType == MediaType.Video)
             {
-                var userData = item is null ? new UserItemData() : _userDataManager.GetUserData(user, item);
+                var userData = item is null ? null : _userDataManager.GetUserData(user, item);
 
                 var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
 

+ 1 - 5
Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs

@@ -68,11 +68,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
             var justName = Path.GetFileName(item.Path.AsSpan());
 
             var id = justName.GetAttributeValue("tmdbid");
-
-            if (!string.IsNullOrEmpty(id))
-            {
-                item.SetProviderId(MetadataProvider.Tmdb, id);
-            }
+            item.TrySetProviderId(MetadataProvider.Tmdb, id);
         }
     }
 }

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

@@ -373,22 +373,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                 {
                     // Check for TMDb id
                     var tmdbid = justName.GetAttributeValue("tmdbid");
-
-                    if (!string.IsNullOrWhiteSpace(tmdbid))
-                    {
-                        item.SetProviderId(MetadataProvider.Tmdb, tmdbid);
-                    }
+                    item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
                 }
 
                 if (!string.IsNullOrEmpty(item.Path))
                 {
                     // Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether  id in parent dir or in file name)
                     var imdbid = item.Path.AsSpan().GetAttributeValue("imdbid");
-
-                    if (!string.IsNullOrWhiteSpace(imdbid))
-                    {
-                        item.SetProviderId(MetadataProvider.Imdb, imdbid);
-                    }
+                    item.TrySetProviderId(MetadataProvider.Imdb, imdbid);
                 }
             }
         }

+ 7 - 28
Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs

@@ -186,46 +186,25 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
             var justName = Path.GetFileName(path.AsSpan());
 
             var imdbId = justName.GetAttributeValue("imdbid");
-            if (!string.IsNullOrEmpty(imdbId))
-            {
-                item.SetProviderId(MetadataProvider.Imdb, imdbId);
-            }
+            item.TrySetProviderId(MetadataProvider.Imdb, imdbId);
 
             var tvdbId = justName.GetAttributeValue("tvdbid");
-            if (!string.IsNullOrEmpty(tvdbId))
-            {
-                item.SetProviderId(MetadataProvider.Tvdb, tvdbId);
-            }
+            item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
 
             var tvmazeId = justName.GetAttributeValue("tvmazeid");
-            if (!string.IsNullOrEmpty(tvmazeId))
-            {
-                item.SetProviderId(MetadataProvider.TvMaze, tvmazeId);
-            }
+            item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
 
             var tmdbId = justName.GetAttributeValue("tmdbid");
-            if (!string.IsNullOrEmpty(tmdbId))
-            {
-                item.SetProviderId(MetadataProvider.Tmdb, tmdbId);
-            }
+            item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
 
             var anidbId = justName.GetAttributeValue("anidbid");
-            if (!string.IsNullOrEmpty(anidbId))
-            {
-                item.SetProviderId("AniDB", anidbId);
-            }
+            item.TrySetProviderId("AniDB", anidbId);
 
             var aniListId = justName.GetAttributeValue("anilistid");
-            if (!string.IsNullOrEmpty(aniListId))
-            {
-                item.SetProviderId("AniList", aniListId);
-            }
+            item.TrySetProviderId("AniList", aniListId);
 
             var aniSearchId = justName.GetAttributeValue("anisearchid");
-            if (!string.IsNullOrEmpty(aniSearchId))
-            {
-                item.SetProviderId("AniSearch", aniSearchId);
-            }
+            item.TrySetProviderId("AniSearch", aniSearchId);
         }
     }
 }

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

@@ -124,5 +124,11 @@
     "External": "Externo",
     "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reprodución HLS más precisas. Esta tarea puede durar mucho tiempo.",
     "TaskKeyframeExtractor": "Extractor de Fotogramas Clave",
-    "HearingImpaired": "Discapacidad Auditiva"
+    "HearingImpaired": "Discapacidad Auditiva",
+    "TaskRefreshTrickplayImages": "Generar imágenes de Trickplay",
+    "TaskRefreshTrickplayImagesDescription": "Crea vistas previas de reproducción engañosa para videos en bibliotecas habilitadas.",
+    "TaskAudioNormalization": "Normalización de audio",
+    "TaskAudioNormalizationDescription": "Escanea archivos en busca de datos de normalización de audio.",
+    "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción",
+    "TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de colecciones y listas de reproducción que ya no existen."
 }

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

@@ -69,7 +69,7 @@
     "HeaderLiveTV": "Live TV",
     "HeaderFavoriteSongs": "Mga Paboritong Kanta",
     "HeaderFavoriteShows": "Mga Paboritong Pelikula",
-    "HeaderFavoriteEpisodes": "Mga Paboritong Episode",
+    "HeaderFavoriteEpisodes": "Mga Paboritong Yugto",
     "HeaderFavoriteArtists": "Mga Paboritong Artista",
     "HeaderFavoriteAlbums": "Mga Paboritong Album",
     "HeaderContinueWatching": "Magpatuloy sa Panonood",

+ 5 - 2
Emby.Server.Implementations/Localization/Core/hr.json

@@ -11,7 +11,7 @@
     "Collections": "Kolekcije",
     "DeviceOfflineWithName": "{0} je prekinuo vezu",
     "DeviceOnlineWithName": "{0} je povezan",
-    "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave od {0}",
+    "FailedLoginAttemptWithUserName": "Neuspješan pokušaj prijave od {0}",
     "Favorites": "Favoriti",
     "Folders": "Mape",
     "Genres": "Žanrovi",
@@ -127,5 +127,8 @@
     "HearingImpaired": "Oštećen sluh",
     "TaskRefreshTrickplayImages": "Generiraj Trickplay Slike",
     "TaskRefreshTrickplayImagesDescription": "Kreira trickplay pretpreglede za videe u omogućenim knjižnicama.",
-    "TaskAudioNormalization": "Normalizacija zvuka"
+    "TaskAudioNormalization": "Normalizacija zvuka",
+    "TaskAudioNormalizationDescription": "Skenira datoteke u potrazi za podacima o normalizaciji zvuka.",
+    "TaskCleanCollectionsAndPlaylistsDescription": "Uklanja stavke iz zbirki i popisa za reprodukciju koje više ne postoje.",
+    "TaskCleanCollectionsAndPlaylists": "Očisti zbirke i popise za reprodukciju"
 }

+ 6 - 1
Emby.Server.Implementations/Localization/Core/ko.json

@@ -125,5 +125,10 @@
     "TaskKeyframeExtractor": "키프레임 추출",
     "External": "외부",
     "HearingImpaired": "청각 장애",
-    "TaskCleanCollectionsAndPlaylists": "컬렉션과 재생목록 정리"
+    "TaskCleanCollectionsAndPlaylists": "컬렉션과 재생목록 정리",
+    "TaskAudioNormalization": "오디오의 볼륨 수준을 일정하게 조정",
+    "TaskAudioNormalizationDescription": "오디오의 볼륨 수준을 일정하게 조정하기 위해 파일을 스캔합니다.",
+    "TaskRefreshTrickplayImages": "비디오 탐색용 미리보기 썸네일 생성",
+    "TaskRefreshTrickplayImagesDescription": "활성화된 라이브러리에서 비디오의 트릭플레이 미리보기를 생성합니다.",
+    "TaskCleanCollectionsAndPlaylistsDescription": "더 이상 존재하지 않는 컬렉션 및 재생 목록에서 항목을 제거합니다."
 }

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

@@ -76,7 +76,7 @@
     "Genres": "Žanri",
     "Folders": "Mapes",
     "Favorites": "Izlase",
-    "FailedLoginAttemptWithUserName": "Neveiksmīgs ielogošanos mēģinājums no {0}",
+    "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}",
     "DeviceOnlineWithName": "Savienojums ar {0} ir izveidots",
     "DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts",
     "Collections": "Kolekcijas",
@@ -95,7 +95,7 @@
     "TaskRefreshChapterImages": "Izvilkt nodaļu attēlus",
     "TasksApplicationCategory": "Lietotne",
     "TasksLibraryCategory": "Bibliotēka",
-    "TaskDownloadMissingSubtitlesDescription": "Meklē trūkstošus subtitrus internēta balstoties uz metadatu uzstādījumiem.",
+    "TaskDownloadMissingSubtitlesDescription": "Meklē internetā trūkstošos subtitrus, pamatojoties uz metadatu konfigurāciju.",
     "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošos subtitrus",
     "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
     "TaskRefreshChannels": "Atjaunot kanālus",
@@ -127,7 +127,7 @@
     "TaskRefreshTrickplayImages": "Ģenerēt partīšanas attēlus",
     "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās.",
     "TaskAudioNormalization": "Audio normalizācija",
-    "TaskCleanCollectionsAndPlaylistsDescription": "Noņem elemēntus no kolekcijām un atskaņošanas sarakstiem, kuri vairs neeksistē.",
+    "TaskCleanCollectionsAndPlaylistsDescription": "Noņem vairs neeksistējošus vienumus no kolekcijām un atskaņošanas sarakstiem.",
     "TaskAudioNormalizationDescription": "Skanē failus priekš audio normālizācijas informācijas.",
     "TaskCleanCollectionsAndPlaylists": "Notīrīt kolekcijas un atskaņošanas sarakstus"
 }

+ 1 - 1
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -1733,7 +1733,7 @@ public class DynamicHlsController : BaseJellyfinApiController
 
         var channels = state.OutputAudioChannels;
 
-        var useDownMixAlgorithm = state.AudioStream.Channels is 6 && _encodingOptions.DownMixStereoAlgorithm != DownMixStereoAlgorithms.None;
+        var useDownMixAlgorithm = DownMixAlgorithmsHelper.AlgorithmFilterStrings.ContainsKey((_encodingOptions.DownMixStereoAlgorithm, DownMixAlgorithmsHelper.InferChannelLayout(state.AudioStream)));
 
         if (channels.HasValue
             && (channels.Value != 2

+ 8 - 8
Jellyfin.Api/Controllers/LiveTvController.cs

@@ -656,7 +656,7 @@ public class LiveTvController : BaseJellyfinApiController
 
         var query = new InternalItemsQuery(user)
         {
-            ChannelIds = body.ChannelIds,
+            ChannelIds = body.ChannelIds ?? [],
             HasAired = body.HasAired,
             IsAiring = body.IsAiring,
             EnableTotalRecordCount = body.EnableTotalRecordCount,
@@ -666,31 +666,31 @@ public class LiveTvController : BaseJellyfinApiController
             MaxEndDate = body.MaxEndDate,
             StartIndex = body.StartIndex,
             Limit = body.Limit,
-            OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder),
+            OrderBy = RequestHelpers.GetOrderBy(body.SortBy ?? [], body.SortOrder ?? []),
             IsNews = body.IsNews,
             IsMovie = body.IsMovie,
             IsSeries = body.IsSeries,
             IsKids = body.IsKids,
             IsSports = body.IsSports,
             SeriesTimerId = body.SeriesTimerId,
-            Genres = body.Genres,
-            GenreIds = body.GenreIds
+            Genres = body.Genres ?? [],
+            GenreIds = body.GenreIds ?? []
         };
 
-        if (!body.LibrarySeriesId.IsEmpty())
+        if (!body.LibrarySeriesId.IsNullOrEmpty())
         {
             query.IsSeries = true;
 
-            var series = _libraryManager.GetItemById<Series>(body.LibrarySeriesId);
+            var series = _libraryManager.GetItemById<Series>(body.LibrarySeriesId.Value);
             if (series is not null)
             {
                 query.Name = series.Name;
             }
         }
 
-        var dtoOptions = new DtoOptions { Fields = body.Fields }
+        var dtoOptions = new DtoOptions { Fields = body.Fields ?? [] }
             .AddClientFields(User)
-            .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes);
+            .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes ?? []);
         return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
     }
 

+ 0 - 2
Jellyfin.Api/Extensions/DtoExtensions.cs

@@ -26,8 +26,6 @@ public static class DtoExtensions
     internal static DtoOptions AddClientFields(
         this DtoOptions dtoOptions, ClaimsPrincipal user)
     {
-        dtoOptions.Fields ??= Array.Empty<ItemFields>();
-
         string? client = user.GetClient();
 
         // No client in claim

+ 1 - 1
Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs

@@ -38,7 +38,7 @@ public static class FileStreamResponseHelpers
         }
 
         // Can't dispose the response as it's required up the call chain.
-        var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false);
+        var response = await httpClient.GetAsync(new Uri(state.MediaPath), HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
         var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain;
 
         httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";

+ 12 - 31
Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.ComponentModel;
 using System.Text.Json.Serialization;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions.Json.Converters;
@@ -17,7 +18,7 @@ public class GetProgramsDto
     /// Gets or sets the channels to return guide information for.
     /// </summary>
     [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
-    public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>();
+    public IReadOnlyList<Guid>? ChannelIds { get; set; }
 
     /// <summary>
     /// Gets or sets optional. Filter by user id.
@@ -26,153 +27,133 @@ public class GetProgramsDto
 
     /// <summary>
     /// Gets or sets the minimum premiere start date.
-    /// Optional.
     /// </summary>
     public DateTime? MinStartDate { get; set; }
 
     /// <summary>
     /// Gets or sets filter by programs that have completed airing, or not.
-    /// Optional.
     /// </summary>
     public bool? HasAired { get; set; }
 
     /// <summary>
     /// Gets or sets filter by programs that are currently airing, or not.
-    /// Optional.
     /// </summary>
     public bool? IsAiring { get; set; }
 
     /// <summary>
     /// Gets or sets the maximum premiere start date.
-    /// Optional.
     /// </summary>
     public DateTime? MaxStartDate { get; set; }
 
     /// <summary>
     /// Gets or sets the minimum premiere end date.
-    /// Optional.
     /// </summary>
     public DateTime? MinEndDate { get; set; }
 
     /// <summary>
     /// Gets or sets the maximum premiere end date.
-    /// Optional.
     /// </summary>
     public DateTime? MaxEndDate { get; set; }
 
     /// <summary>
     /// Gets or sets filter for movies.
-    /// Optional.
     /// </summary>
     public bool? IsMovie { get; set; }
 
     /// <summary>
     /// Gets or sets filter for series.
-    /// Optional.
     /// </summary>
     public bool? IsSeries { get; set; }
 
     /// <summary>
     /// Gets or sets filter for news.
-    /// Optional.
     /// </summary>
     public bool? IsNews { get; set; }
 
     /// <summary>
     /// Gets or sets filter for kids.
-    /// Optional.
     /// </summary>
     public bool? IsKids { get; set; }
 
     /// <summary>
     /// Gets or sets filter for sports.
-    /// Optional.
     /// </summary>
     public bool? IsSports { get; set; }
 
     /// <summary>
     /// Gets or sets the record index to start at. All items with a lower index will be dropped from the results.
-    /// Optional.
     /// </summary>
     public int? StartIndex { get; set; }
 
     /// <summary>
     /// Gets or sets the maximum number of records to return.
-    /// Optional.
     /// </summary>
     public int? Limit { get; set; }
 
     /// <summary>
     /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate.
-    /// Optional.
     /// </summary>
     [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
-    public IReadOnlyList<ItemSortBy> SortBy { get; set; } = Array.Empty<ItemSortBy>();
+    public IReadOnlyList<ItemSortBy>? SortBy { get; set; }
 
     /// <summary>
-    /// Gets or sets sort Order - Ascending,Descending.
+    /// Gets or sets sort order.
     /// </summary>
     [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
-    public IReadOnlyList<SortOrder> SortOrder { get; set; } = Array.Empty<SortOrder>();
+    public IReadOnlyList<SortOrder>? SortOrder { get; set; }
 
     /// <summary>
     /// Gets or sets the genres to return guide information for.
     /// </summary>
     [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))]
-    public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>();
+    public IReadOnlyList<string>? Genres { get; set; }
 
     /// <summary>
     /// Gets or sets the genre ids to return guide information for.
     /// </summary>
     [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
-    public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>();
+    public IReadOnlyList<Guid>? GenreIds { get; set; }
 
     /// <summary>
     /// Gets or sets include image information in output.
-    /// Optional.
     /// </summary>
     public bool? EnableImages { get; set; }
 
     /// <summary>
     /// Gets or sets a value indicating whether retrieve total record count.
     /// </summary>
+    [DefaultValue(true)]
     public bool EnableTotalRecordCount { get; set; } = true;
 
     /// <summary>
     /// Gets or sets the max number of images to return, per image type.
-    /// Optional.
     /// </summary>
     public int? ImageTypeLimit { get; set; }
 
     /// <summary>
     /// Gets or sets the image types to include in the output.
-    /// Optional.
     /// </summary>
     [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
-    public IReadOnlyList<ImageType> EnableImageTypes { get; set; } = Array.Empty<ImageType>();
+    public IReadOnlyList<ImageType>? EnableImageTypes { get; set; }
 
     /// <summary>
     /// Gets or sets include user data.
-    /// Optional.
     /// </summary>
     public bool? EnableUserData { get; set; }
 
     /// <summary>
     /// Gets or sets filter by series timer id.
-    /// Optional.
     /// </summary>
     public string? SeriesTimerId { get; set; }
 
     /// <summary>
     /// Gets or sets filter by library series id.
-    /// Optional.
     /// </summary>
-    public Guid LibrarySeriesId { get; set; }
+    public Guid? LibrarySeriesId { get; set; }
 
     /// <summary>
-    /// Gets or sets specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.
-    /// Optional.
+    /// Gets or sets specify additional fields of information to return in the output.
     /// </summary>
     [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
-    public IReadOnlyList<ItemFields> Fields { get; set; } = Array.Empty<ItemFields>();
+    public IReadOnlyList<ItemFields>? Fields { get; set; }
 }

+ 0 - 2
MediaBrowser.Controller/Dto/DtoOptions.cs

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

+ 1 - 9
MediaBrowser.Controller/Entities/UserItemData.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -19,17 +17,11 @@ namespace MediaBrowser.Controller.Entities
         /// </summary>
         private double? _rating;
 
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        public Guid UserId { get; set; }
-
         /// <summary>
         /// Gets or sets the key.
         /// </summary>
         /// <value>The key.</value>
-        public string Key { get; set; }
+        public required string Key { get; set; }
 
         /// <summary>
         /// Gets or sets the users 0-10 rating.

+ 74 - 0
MediaBrowser.Controller/MediaEncoding/DownMixAlgorithmsHelper.cs

@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Controller.MediaEncoding;
+
+/// <summary>
+/// Describes the downmix algorithms capabilities.
+/// </summary>
+public static class DownMixAlgorithmsHelper
+{
+    /// <summary>
+    /// The filter string of the DownMixStereoAlgorithms.
+    /// The index is the tuple of (algorithm, layout).
+    /// </summary>
+    public static readonly Dictionary<(DownMixStereoAlgorithms, string), string> AlgorithmFilterStrings = new()
+    {
+        { (DownMixStereoAlgorithms.Dave750, "5.1"), "pan=stereo|c0=0.5*c2+0.707*c0+0.707*c4+0.5*c3|c1=0.5*c2+0.707*c1+0.707*c5+0.5*c3" },
+        // Use AC-4 algorithm to downmix 7.1 inputs to 5.1 first
+        { (DownMixStereoAlgorithms.Dave750, "7.1"), "pan=5.1(side)|c0=c0|c1=c1|c2=c2|c3=c3|c4=0.707*c4+0.707*c6|c5=0.707*c5+0.707*c7,pan=stereo|c0=0.5*c2+0.707*c0+0.707*c4+0.5*c3|c1=0.5*c2+0.707*c1+0.707*c5+0.5*c3" },
+        { (DownMixStereoAlgorithms.NightmodeDialogue, "5.1"), "pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5" },
+        // Use AC-4 algorithm to downmix 7.1 inputs to 5.1 first
+        { (DownMixStereoAlgorithms.NightmodeDialogue, "7.1"), "pan=5.1(side)|c0=c0|c1=c1|c2=c2|c3=c3|c4=0.707*c4+0.707*c6|c5=0.707*c5+0.707*c7,pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5" },
+        { (DownMixStereoAlgorithms.Rfc7845, "3.0"), "pan=stereo|c0=0.414214*c2+0.585786*c0|c1=0.414214*c2+0.585786*c1" },
+        { (DownMixStereoAlgorithms.Rfc7845, "quad"), "pan=stereo|c0=0.422650*c0+0.366025*c2+0.211325*c3|c1=0.422650*c1+0.366025*c3+0.211325*c2" },
+        { (DownMixStereoAlgorithms.Rfc7845, "5.0"), "pan=stereo|c0=0.460186*c2+0.650802*c0+0.563611*c3+0.325401*c4|c1=0.460186*c2+0.650802*c1+0.563611*c4+0.325401*c3" },
+        { (DownMixStereoAlgorithms.Rfc7845, "5.1"), "pan=stereo|c0=0.374107*c2+0.529067*c0+0.458186*c4+0.264534*c5+0.374107*c3|c1=0.374107*c2+0.529067*c1+0.458186*c5+0.264534*c4+0.374107*c3" },
+        { (DownMixStereoAlgorithms.Rfc7845, "6.1"), "pan=stereo|c0=0.321953*c2+0.455310*c0+0.394310*c5+0.227655*c6+0.278819*c4+0.321953*c3|c1=0.321953*c2+0.455310*c1+0.394310*c6+0.227655*c5+0.278819*c4+0.321953*c3" },
+        { (DownMixStereoAlgorithms.Rfc7845, "7.1"), "pan=stereo|c0=0.274804*c2+0.388631*c0+0.336565*c6+0.194316*c7+0.336565*c4+0.194316*c5+0.274804*c3|c1=0.274804*c2+0.388631*c1+0.336565*c7+0.194316*c6+0.336565*c5+0.194316*c4+0.274804*c3" },
+        { (DownMixStereoAlgorithms.Ac4, "3.0"), "pan=stereo|c0=c0+0.707*c2|c1=c1+0.707*c2" },
+        { (DownMixStereoAlgorithms.Ac4, "5.0"), "pan=stereo|c0=c0+0.707*c2+0.707*c3|c1=c1+0.707*c2+0.707*c4" },
+        { (DownMixStereoAlgorithms.Ac4, "5.1"), "pan=stereo|c0=c0+0.707*c2+0.707*c4|c1=c1+0.707*c2+0.707*c5" },
+        { (DownMixStereoAlgorithms.Ac4, "7.0"), "pan=5.0(side)|c0=c0|c1=c1|c2=c2|c3=0.707*c3+0.707*c5|c4=0.707*c4+0.707*c6,pan=stereo|c0=c0+0.707*c2+0.707*c3|c1=c1+0.707*c2+0.707*c4" },
+        { (DownMixStereoAlgorithms.Ac4, "7.1"), "pan=5.1(side)|c0=c0|c1=c1|c2=c2|c3=c3|c4=0.707*c4+0.707*c6|c5=0.707*c5+0.707*c7,pan=stereo|c0=c0+0.707*c2+0.707*c4|c1=c1+0.707*c2+0.707*c5" },
+    };
+
+    /// <summary>
+    /// Get the audio channel layout string from the audio stream
+    /// If the input audio string does not have a valid layout string, guess from channel count.
+    /// </summary>
+    /// <param name="audioStream">The audio stream to get layout.</param>
+    /// <returns>Channel Layout string.</returns>
+    public static string InferChannelLayout(MediaStream audioStream)
+    {
+        if (!string.IsNullOrWhiteSpace(audioStream.ChannelLayout))
+        {
+            // Note: BDMVs do not derive this string from ffmpeg, which would cause ambiguity with 4-channel audio
+            // "quad" => 2 front and 2 rear, "4.0" => 3 front and 1 rear
+            // BDMV will always use "4.0" in this case
+            // Because the quad layout is super rare in BDs, we will use "4.0" as is here
+            return audioStream.ChannelLayout;
+        }
+
+        if (audioStream.Channels is null)
+        {
+            return string.Empty;
+        }
+
+        // When we don't have definitive channel layout, we have to guess from the channel count
+        // Guessing is not always correct, but for most videos we don't have to guess like this as the definitive layout is recorded during scan
+        var inferredLayout = audioStream.Channels.Value switch
+        {
+            1 => "mono",
+            2 => "stereo",
+            3 => "2.1", // Could also be 3.0, prefer 2.1
+            4 => "4.0", // Could also be quad (with rear left and rear right) and 3.1 with LFE. prefer 4.0 with front center and back center
+            5 => "5.0",
+            6 => "5.1", // Could also be 6.0 or hexagonal, prefer 5.1
+            7 => "6.1", // Could also be 7.0, prefer 6.1
+            8 => "7.1", // Could also be 8.0, prefer 7.1
+            _ => string.Empty // Return empty string for not supported layout
+        };
+        return inferredLayout;
+    }
+}

+ 27 - 32
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -64,6 +64,7 @@ namespace MediaBrowser.Controller.MediaEncoding
         private readonly Version _minFFmpegSvtAv1Params = new Version(5, 1);
         private readonly Version _minFFmpegVaapiH26xEncA53CcSei = new Version(6, 0);
         private readonly Version _minFFmpegReadrateOption = new Version(5, 0);
+        private readonly Version _minFFmpegWorkingVtHwSurface = new Version(7, 0, 1);
         private readonly Version _minFFmpegDisplayRotationOption = new Version(6, 0);
 
         private static readonly Regex _validationRegex = new(ValidationRegex, RegexOptions.Compiled);
@@ -2673,28 +2674,17 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             var filters = new List<string>();
 
-            if (channels.HasValue
-                && channels.Value == 2
-                && state.AudioStream is not null
-                && state.AudioStream.Channels.HasValue
-                && state.AudioStream.Channels.Value == 6)
+            if (channels is 2 && state.AudioStream?.Channels is > 2)
             {
-                if (!encodingOptions.DownMixAudioBoost.Equals(1))
+                var hasDownMixFilter = DownMixAlgorithmsHelper.AlgorithmFilterStrings.TryGetValue((encodingOptions.DownMixStereoAlgorithm, DownMixAlgorithmsHelper.InferChannelLayout(state.AudioStream)), out var downMixFilterString);
+                if (hasDownMixFilter)
                 {
-                    filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture));
+                    filters.Add(downMixFilterString);
                 }
 
-                switch (encodingOptions.DownMixStereoAlgorithm)
+                if (!encodingOptions.DownMixAudioBoost.Equals(1))
                 {
-                    case DownMixStereoAlgorithms.Dave750:
-                        filters.Add("pan=stereo|c0=0.5*c2+0.707*c0+0.707*c4+0.5*c3|c1=0.5*c2+0.707*c1+0.707*c5+0.5*c3");
-                        break;
-                    case DownMixStereoAlgorithms.NightmodeDialogue:
-                        filters.Add("pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5");
-                        break;
-                    case DownMixStereoAlgorithms.None:
-                    default:
-                        break;
+                    filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture));
                 }
             }
 
@@ -5300,6 +5290,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             string vidEncoder)
         {
             var isVtEncoder = vidEncoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase);
+            var isVtDecoder = vidDecoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase);
 
             if (!isVtEncoder)
             {
@@ -5320,6 +5311,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             var doDeintH2645 = doDeintH264 || doDeintHevc;
             var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
             var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
+            var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
 
             var rotation = state.VideoStream?.Rotation ?? 0;
             var tranposeDir = rotation == 0 ? string.Empty : GetVideoTransposeDirection(state);
@@ -5414,23 +5406,25 @@ namespace MediaBrowser.Controller.MediaEncoding
                     subFilters.Add(subTextSubtitlesFilter);
                 }
 
-                subFilters.Add("hwupload=derive_device=videotoolbox");
+                subFilters.Add("hwupload");
                 overlayFilters.Add("overlay_videotoolbox=eof_action=pass:repeatlast=0");
             }
 
+            if (usingHwSurface)
+            {
+                return (mainFilters, subFilters, overlayFilters);
+            }
+
+            // For old jellyfin-ffmpeg that has broken hwsurface, add a hwupload
             var needFiltering = mainFilters.Any(f => !string.IsNullOrEmpty(f)) ||
                                 subFilters.Any(f => !string.IsNullOrEmpty(f)) ||
                                 overlayFilters.Any(f => !string.IsNullOrEmpty(f));
-
-            // This is a workaround for ffmpeg's hwupload implementation
-            // For VideoToolbox encoders, a hwupload without a valid filter actually consuming its frame
-            // will cause the encoder to produce incorrect frames.
             if (needFiltering)
             {
                 // INPUT videotoolbox/memory surface(vram/uma)
                 // this will pass-through automatically if in/out format matches.
                 mainFilters.Insert(0, "format=nv12|p010le|videotoolbox_vld");
-                mainFilters.Insert(0, "hwupload=derive_device=videotoolbox");
+                mainFilters.Insert(0, "hwupload");
             }
 
             return (mainFilters, subFilters, overlayFilters);
@@ -6458,22 +6452,20 @@ namespace MediaBrowser.Controller.MediaEncoding
                                     || string.Equals("yuvj420p", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase);
             var is8_10bitSwFormatsVt = is8bitSwFormatsVt || string.Equals("yuv420p10le", videoStream.PixelFormat, StringComparison.OrdinalIgnoreCase);
 
-            // VideoToolbox's Hardware surface in ffmpeg is not only slower than hwupload, but also breaks HDR in many cases.
-            // For example: https://trac.ffmpeg.org/ticket/10884
-            // Disable it for now.
-            const bool UseHwSurface = false;
+            // The related patches make videotoolbox hardware surface working is only available in jellyfin-ffmpeg 7.0.1 at the moment.
+            bool useHwSurface = (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface) && IsVideoToolboxFullSupported();
 
             if (is8bitSwFormatsVt)
             {
                 if (string.Equals("avc", videoStream.Codec, StringComparison.OrdinalIgnoreCase)
                     || string.Equals("h264", videoStream.Codec, StringComparison.OrdinalIgnoreCase))
                 {
-                    return GetHwaccelType(state, options, "h264", bitDepth, UseHwSurface);
+                    return GetHwaccelType(state, options, "h264", bitDepth, useHwSurface);
                 }
 
                 if (string.Equals("vp8", videoStream.Codec, StringComparison.OrdinalIgnoreCase))
                 {
-                    return GetHwaccelType(state, options, "vp8", bitDepth, UseHwSurface);
+                    return GetHwaccelType(state, options, "vp8", bitDepth, useHwSurface);
                 }
             }
 
@@ -6482,12 +6474,12 @@ namespace MediaBrowser.Controller.MediaEncoding
                 if (string.Equals("hevc", videoStream.Codec, StringComparison.OrdinalIgnoreCase)
                     || string.Equals("h265", videoStream.Codec, StringComparison.OrdinalIgnoreCase))
                 {
-                    return GetHwaccelType(state, options, "hevc", bitDepth, UseHwSurface);
+                    return GetHwaccelType(state, options, "hevc", bitDepth, useHwSurface);
                 }
 
                 if (string.Equals("vp9", videoStream.Codec, StringComparison.OrdinalIgnoreCase))
                 {
-                    return GetHwaccelType(state, options, "vp9", bitDepth, UseHwSurface);
+                    return GetHwaccelType(state, options, "vp9", bitDepth, useHwSurface);
                 }
             }
 
@@ -7172,7 +7164,10 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             var channels = state.OutputAudioChannels;
 
-            if (channels.HasValue && ((channels.Value != 2 && state.AudioStream?.Channels != 6) || encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))
+            var useDownMixAlgorithm = state.AudioStream is not null
+                                      && DownMixAlgorithmsHelper.AlgorithmFilterStrings.ContainsKey((encodingOptions.DownMixStereoAlgorithm, DownMixAlgorithmsHelper.InferChannelLayout(state.AudioStream)));
+
+            if (channels.HasValue && !useDownMixAlgorithm)
             {
                 args += " -ac " + channels.Value;
             }

+ 0 - 31
MediaBrowser.Controller/Providers/MetadataResult.cs

@@ -2,9 +2,7 @@
 
 #pragma warning disable CA1002, CA2227, CS1591
 
-using System;
 using System.Collections.Generic;
-using System.Globalization;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Model.Entities;
 
@@ -33,8 +31,6 @@ namespace MediaBrowser.Controller.Providers
             set => _remoteImages = value;
         }
 
-        public List<UserItemData> UserDataList { get; set; }
-
         public List<PersonInfo> People { get; set; }
 
         public bool HasMetadata { get; set; }
@@ -68,32 +64,5 @@ namespace MediaBrowser.Controller.Providers
                 People.Clear();
             }
         }
-
-        public UserItemData GetOrAddUserData(string userId)
-        {
-            UserDataList ??= new List<UserItemData>();
-
-            UserItemData userData = null;
-
-            foreach (var i in UserDataList)
-            {
-                if (string.Equals(userId, i.UserId.ToString("N", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase))
-                {
-                    userData = i;
-                }
-            }
-
-            if (userData is null)
-            {
-                userData = new UserItemData()
-                {
-                    UserId = new Guid(userId)
-                };
-
-                UserDataList.Add(userData);
-            }
-
-            return userData;
-        }
     }
 }

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

@@ -365,10 +365,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
                     break;
                 case "CollectionNumber":
                     var tmdbCollection = reader.ReadNormalizedString();
-                    if (!string.IsNullOrEmpty(tmdbCollection))
-                    {
-                        item.SetProviderId(MetadataProvider.TmdbCollection, tmdbCollection);
-                    }
+                    item.TrySetProviderId(MetadataProvider.TmdbCollection, tmdbCollection);
 
                     break;
 
@@ -502,10 +499,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
                     if (_validProviderIds!.TryGetValue(readerName, out string? providerIdValue))
                     {
                         var id = reader.ReadElementContentAsString();
-                        if (!string.IsNullOrWhiteSpace(id))
-                        {
-                            item.SetProviderId(providerIdValue, id);
-                        }
+                        item.TrySetProviderId(providerIdValue, id);
                     }
                     else
                     {

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

@@ -1325,38 +1325,23 @@ namespace MediaBrowser.MediaEncoding.Probing
             // These support multiple values, but for now we only store the first.
             var mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Artist Id"))
                 ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMARTISTID"));
-            if (!string.IsNullOrEmpty(mb))
-            {
-                audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb);
-            }
+            audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb);
 
             mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Artist Id"))
                 ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ARTISTID"));
-            if (!string.IsNullOrEmpty(mb))
-            {
-                audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb);
-            }
+            audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, mb);
 
             mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Id"))
                 ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMID"));
-            if (!string.IsNullOrEmpty(mb))
-            {
-                audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb);
-            }
+            audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, mb);
 
             mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Group Id"))
                  ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASEGROUPID"));
-            if (!string.IsNullOrEmpty(mb))
-            {
-                audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb);
-            }
+            audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb);
 
             mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Track Id"))
                  ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASETRACKID"));
-            if (!string.IsNullOrEmpty(mb))
-            {
-                audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb);
-            }
+            audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, mb);
         }
 
         private string GetMultipleMusicBrainzId(string value)

+ 14 - 3
MediaBrowser.Model/Dlna/StreamBuilder.cs

@@ -908,7 +908,18 @@ namespace MediaBrowser.Model.Dlna
                 }
             }
 
-            var directAudioStream = candidateAudioStreams.FirstOrDefault(stream => ContainerProfile.ContainsContainer(audioCodecs, stream.Codec));
+            var audioStreamWithSupportedCodec = candidateAudioStreams.Where(stream => ContainerProfile.ContainsContainer(audioCodecs, stream.Codec)).FirstOrDefault();
+
+            var directAudioStream = audioStreamWithSupportedCodec?.Channels is not null && audioStreamWithSupportedCodec.Channels.Value <= (playlistItem.TranscodingMaxAudioChannels ?? int.MaxValue) ? audioStreamWithSupportedCodec : null;
+
+            var channelsExceedsLimit = audioStreamWithSupportedCodec is not null && directAudioStream is null;
+
+            if (channelsExceedsLimit && playlistItem.TargetAudioStream is not null)
+            {
+                playlistItem.TranscodeReasons |= TranscodeReason.AudioChannelsNotSupported;
+                playlistItem.TargetAudioStream.Channels = playlistItem.TranscodingMaxAudioChannels;
+            }
+
             playlistItem.AudioCodecs = audioCodecs;
             if (directAudioStream is not null)
             {
@@ -971,7 +982,7 @@ namespace MediaBrowser.Model.Dlna
             }
 
             // Honor requested max channels
-            playlistItem.GlobalMaxAudioChannels = options.MaxAudioChannels;
+            playlistItem.GlobalMaxAudioChannels = channelsExceedsLimit ? playlistItem.TranscodingMaxAudioChannels : options.MaxAudioChannels;
 
             int audioBitrate = GetAudioBitrate(options.GetMaxBitrate(true) ?? 0, playlistItem.TargetAudioCodec, audioStream, playlistItem);
             playlistItem.AudioBitrate = Math.Min(playlistItem.AudioBitrate ?? audioBitrate, audioBitrate);
@@ -1293,7 +1304,7 @@ namespace MediaBrowser.Model.Dlna
 
                     // Check audio codec
                     MediaStream? selectedAudioStream = null;
-                    if (candidateAudioStreams.Any())
+                    if (candidateAudioStreams.Count != 0)
                     {
                         selectedAudioStream = candidateAudioStreams.FirstOrDefault(audioStream => directPlayProfile.SupportsAudioCodec(audioStream.Codec));
                         if (selectedAudioStream is null)

+ 2 - 3
MediaBrowser.Model/Dto/UserItemDataDto.cs

@@ -1,4 +1,3 @@
-#nullable disable
 using System;
 
 namespace MediaBrowser.Model.Dto
@@ -66,12 +65,12 @@ namespace MediaBrowser.Model.Dto
         /// Gets or sets the key.
         /// </summary>
         /// <value>The key.</value>
-        public string Key { get; set; }
+        public required string Key { get; set; }
 
         /// <summary>
         /// Gets or sets the item identifier.
         /// </summary>
         /// <value>The item identifier.</value>
-        public string ItemId { get; set; }
+        public Guid ItemId { get; set; }
     }
 }

+ 15 - 3
MediaBrowser.Model/Entities/DownMixStereoAlgorithms.cs

@@ -1,8 +1,7 @@
 namespace MediaBrowser.Model.Entities;
 
 /// <summary>
-/// An enum representing an algorithm to downmix 6ch+ to stereo.
-/// Algorithms sourced from https://superuser.com/questions/852400/properly-downmix-5-1-to-stereo-using-ffmpeg/1410620#1410620.
+/// An enum representing an algorithm to downmix surround sound to stereo.
 /// </summary>
 public enum DownMixStereoAlgorithms
 {
@@ -13,11 +12,24 @@ public enum DownMixStereoAlgorithms
 
     /// <summary>
     /// Algorithm by Dave_750.
+    /// Sourced from https://superuser.com/questions/852400/properly-downmix-5-1-to-stereo-using-ffmpeg/1410620#1410620.
     /// </summary>
     Dave750 = 1,
 
     /// <summary>
     /// Nightmode Dialogue algorithm.
+    /// Sourced from https://superuser.com/questions/852400/properly-downmix-5-1-to-stereo-using-ffmpeg/1410620#1410620.
     /// </summary>
-    NightmodeDialogue = 2
+    NightmodeDialogue = 2,
+
+    /// <summary>
+    /// RFC7845 Section 5.1.1.5 defined algorithm.
+    /// </summary>
+    Rfc7845 = 3,
+
+    /// <summary>
+    /// AC-4 standard algorithm with its default gain values.
+    /// Defined in ETSI TS 103 190 Section 6.2.17.
+    /// </summary>
+    Ac4 = 4
 }

+ 7 - 2
MediaBrowser.Model/Entities/MediaStream.cs

@@ -202,7 +202,7 @@ namespace MediaBrowser.Model.Entities
                         || dvProfile == 8
                         || dvProfile == 9))
                 {
-                    var title = "DV Profile " + dvProfile;
+                    var title = "Dolby Vision Profile " + dvProfile;
 
                     if (dvBlCompatId > 0)
                     {
@@ -214,6 +214,7 @@ namespace MediaBrowser.Model.Entities
                         1 => title + " (HDR10)",
                         2 => title + " (SDR)",
                         4 => title + " (HLG)",
+                        6 => title + " (HDR10)", // Technically means Blu-ray, but practically always HDR10
                         _ => title
                     };
                 }
@@ -336,7 +337,11 @@ namespace MediaBrowser.Model.Entities
                             attributes.Add(Codec.ToUpperInvariant());
                         }
 
-                        if (VideoRange != VideoRange.Unknown)
+                        if (VideoDoViTitle is not null)
+                        {
+                            attributes.Add(VideoDoViTitle);
+                        }
+                        else if (VideoRange != VideoRange.Unknown)
                         {
                             attributes.Add(VideoRange.ToString());
                         }

+ 180 - 143
MediaBrowser.Model/Entities/ProviderIdsExtensions.cs

@@ -3,177 +3,214 @@ using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.Linq;
 
-namespace MediaBrowser.Model.Entities
+namespace MediaBrowser.Model.Entities;
+
+/// <summary>
+/// Class ProviderIdsExtensions.
+/// </summary>
+public static class ProviderIdsExtensions
 {
     /// <summary>
-    /// Class ProviderIdsExtensions.
+    /// Case insensitive dictionary of <see cref="MetadataProvider"/> string representation.
+    /// </summary>
+    private static readonly Dictionary<string, string> _metadataProviderEnumDictionary =
+        Enum.GetValues<MetadataProvider>()
+            .ToDictionary(
+                enumValue => enumValue.ToString(),
+                enumValue => enumValue.ToString(),
+                StringComparer.OrdinalIgnoreCase);
+
+    /// <summary>
+    /// Checks if this instance has an id for the given provider.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="name">The of the provider name.</param>
+    /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
+    public static bool HasProviderId(this IHasProviderIds instance, string name)
+        => instance.TryGetProviderId(name, out _);
+
+    /// <summary>
+    /// Checks if this instance has an id for the given provider.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="provider">The provider.</param>
+    /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
+    public static bool HasProviderId(this IHasProviderIds instance, MetadataProvider provider)
+        => instance.HasProviderId(provider.ToString());
+
+    /// <summary>
+    /// Gets a provider id.
     /// </summary>
-    public static class ProviderIdsExtensions
+    /// <param name="instance">The instance.</param>
+    /// <param name="name">The name.</param>
+    /// <param name="id">The provider id.</param>
+    /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
+    public static bool TryGetProviderId(this IHasProviderIds instance, string name, [NotNullWhen(true)] out string? id)
     {
-        /// <summary>
-        /// Case insensitive dictionary of <see cref="MetadataProvider"/> string representation.
-        /// </summary>
-        private static readonly Dictionary<string, string> _metadataProviderEnumDictionary =
-            Enum.GetValues<MetadataProvider>()
-                .ToDictionary(
-                    enumValue => enumValue.ToString(),
-                    enumValue => enumValue.ToString(),
-                    StringComparer.OrdinalIgnoreCase);
-
-        /// <summary>
-        /// Checks if this instance has an id for the given provider.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="name">The of the provider name.</param>
-        /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
-        public static bool HasProviderId(this IHasProviderIds instance, string name)
-        {
-            ArgumentNullException.ThrowIfNull(instance);
+        ArgumentNullException.ThrowIfNull(instance);
 
-            return instance.TryGetProviderId(name, out _);
+        if (instance.ProviderIds is null)
+        {
+            id = null;
+            return false;
         }
 
-        /// <summary>
-        /// Checks if this instance has an id for the given provider.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="provider">The provider.</param>
-        /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
-        public static bool HasProviderId(this IHasProviderIds instance, MetadataProvider provider)
+        var foundProviderId = instance.ProviderIds.TryGetValue(name, out id);
+        // This occurs when searching with Identify (and possibly in other places)
+        if (string.IsNullOrEmpty(id))
         {
-            return instance.HasProviderId(provider.ToString());
+            id = null;
+            foundProviderId = false;
         }
 
-        /// <summary>
-        /// Gets a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="name">The name.</param>
-        /// <param name="id">The provider id.</param>
-        /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
-        public static bool TryGetProviderId(this IHasProviderIds instance, string name, [NotNullWhen(true)] out string? id)
+        return foundProviderId;
+    }
+
+    /// <summary>
+    /// Gets a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="provider">The provider.</param>
+    /// <param name="id">The provider id.</param>
+    /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
+    public static bool TryGetProviderId(this IHasProviderIds instance, MetadataProvider provider, [NotNullWhen(true)] out string? id)
+    {
+        return instance.TryGetProviderId(provider.ToString(), out id);
+    }
+
+    /// <summary>
+    /// Gets a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="name">The name.</param>
+    /// <returns>System.String.</returns>
+    public static string? GetProviderId(this IHasProviderIds instance, string name)
+    {
+        instance.TryGetProviderId(name, out string? id);
+        return id;
+    }
+
+    /// <summary>
+    /// Gets a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="provider">The provider.</param>
+    /// <returns>System.String.</returns>
+    public static string? GetProviderId(this IHasProviderIds instance, MetadataProvider provider)
+    {
+        return instance.GetProviderId(provider.ToString());
+    }
+
+    /// <summary>
+    /// Sets a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="name">The name, this should not contain a '=' character.</param>
+    /// <param name="value">The value.</param>
+    /// <remarks>Due to how deserialization from the database works the name can not contain '='.</remarks>
+    /// <returns><c>true</c> if the provider id got set successfully; otherwise, <c>false</c>.</returns>
+    public static bool TrySetProviderId(this IHasProviderIds instance, string? name, string? value)
+    {
+        ArgumentNullException.ThrowIfNull(instance);
+
+        // When name contains a '=' it can't be deserialized from the database
+        if (string.IsNullOrWhiteSpace(name)
+            || string.IsNullOrWhiteSpace(value)
+            || name.Contains('=', StringComparison.Ordinal))
         {
-            ArgumentNullException.ThrowIfNull(instance);
-
-            if (instance.ProviderIds is null)
-            {
-                id = null;
-                return false;
-            }
-
-            var foundProviderId = instance.ProviderIds.TryGetValue(name, out id);
-            // This occurs when searching with Identify (and possibly in other places)
-            if (string.IsNullOrEmpty(id))
-            {
-                id = null;
-                foundProviderId = false;
-            }
-
-            return foundProviderId;
+            return false;
         }
 
-        /// <summary>
-        /// Gets a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="provider">The provider.</param>
-        /// <param name="id">The provider id.</param>
-        /// <returns><c>true</c> if a provider id with the given name was found; otherwise <c>false</c>.</returns>
-        public static bool TryGetProviderId(this IHasProviderIds instance, MetadataProvider provider, [NotNullWhen(true)] out string? id)
+        // Ensure it exists
+        instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        // Match on internal MetadataProvider enum string values before adding arbitrary providers
+        if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
         {
-            return instance.TryGetProviderId(provider.ToString(), out id);
+            instance.ProviderIds[enumValue] = value;
         }
-
-        /// <summary>
-        /// Gets a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="name">The name.</param>
-        /// <returns>System.String.</returns>
-        public static string? GetProviderId(this IHasProviderIds instance, string name)
+        else
         {
-            instance.TryGetProviderId(name, out string? id);
-            return id;
+            instance.ProviderIds[name] = value;
         }
 
-        /// <summary>
-        /// Gets a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="provider">The provider.</param>
-        /// <returns>System.String.</returns>
-        public static string? GetProviderId(this IHasProviderIds instance, MetadataProvider provider)
+        return true;
+    }
+
+    /// <summary>
+    /// Sets a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="provider">The provider.</param>
+    /// <param name="value">The value.</param>
+    /// <returns><c>true</c> if the provider id got set successfully; otherwise, <c>false</c>.</returns>
+    public static bool TrySetProviderId(this IHasProviderIds instance, MetadataProvider provider, string? value)
+        => instance.TrySetProviderId(provider.ToString(), value);
+
+    /// <summary>
+    /// Sets a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="name">The name, this should not contain a '=' character.</param>
+    /// <param name="value">The value.</param>
+    /// <remarks>Due to how deserialization from the database works the name can not contain '='.</remarks>
+    public static void SetProviderId(this IHasProviderIds instance, string name, string value)
+    {
+        ArgumentNullException.ThrowIfNull(instance);
+        ArgumentException.ThrowIfNullOrWhiteSpace(name);
+        ArgumentException.ThrowIfNullOrWhiteSpace(value);
+
+        // When name contains a '=' it can't be deserialized from the database
+        if (name.Contains('=', StringComparison.Ordinal))
         {
-            return instance.GetProviderId(provider.ToString());
+            throw new ArgumentException("Provider id name cannot contain '='", nameof(name));
         }
 
-        /// <summary>
-        /// Sets a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="name">The name, this should not contain a '=' character.</param>
-        /// <param name="value">The value.</param>
-        /// <remarks>Due to how deserialization from the database works the name can not contain '='.</remarks>
-        public static void SetProviderId(this IHasProviderIds instance, string name, string value)
+        // Ensure it exists
+        instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+        // Match on internal MetadataProvider enum string values before adding arbitrary providers
+        if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
         {
-            ArgumentNullException.ThrowIfNull(instance);
-            ArgumentException.ThrowIfNullOrEmpty(name);
-            ArgumentException.ThrowIfNullOrEmpty(value);
-
-            // When name contains a '=' it can't be deserialized from the database
-            if (name.Contains('=', StringComparison.Ordinal))
-            {
-                throw new ArgumentException("Provider id name cannot contain '='", nameof(name));
-            }
-
-            // Ensure it exists
-            instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
-
-            // Match on internal MetadataProvider enum string values before adding arbitrary providers
-            if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
-            {
-                instance.ProviderIds[enumValue] = value;
-            }
-            else
-            {
-                instance.ProviderIds[name] = value;
-            }
+            instance.ProviderIds[enumValue] = value;
         }
-
-        /// <summary>
-        /// Sets a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="provider">The provider.</param>
-        /// <param name="value">The value.</param>
-        public static void SetProviderId(this IHasProviderIds instance, MetadataProvider provider, string value)
+        else
         {
-            instance.SetProviderId(provider.ToString(), value);
+            instance.ProviderIds[name] = value;
         }
+    }
 
-        /// <summary>
-        /// Removes a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="name">The name.</param>
-        public static void RemoveProviderId(this IHasProviderIds instance, string name)
-        {
-            ArgumentNullException.ThrowIfNull(instance);
-            ArgumentException.ThrowIfNullOrEmpty(name);
+    /// <summary>
+    /// Sets a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="provider">The provider.</param>
+    /// <param name="value">The value.</param>
+    public static void SetProviderId(this IHasProviderIds instance, MetadataProvider provider, string value)
+        => instance.SetProviderId(provider.ToString(), value);
 
-            instance.ProviderIds?.Remove(name);
-        }
+    /// <summary>
+    /// Removes a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="name">The name.</param>
+    public static void RemoveProviderId(this IHasProviderIds instance, string name)
+    {
+        ArgumentNullException.ThrowIfNull(instance);
+        ArgumentException.ThrowIfNullOrEmpty(name);
 
-        /// <summary>
-        /// Removes a provider id.
-        /// </summary>
-        /// <param name="instance">The instance.</param>
-        /// <param name="provider">The provider.</param>
-        public static void RemoveProviderId(this IHasProviderIds instance, MetadataProvider provider)
-        {
-            ArgumentNullException.ThrowIfNull(instance);
+        instance.ProviderIds?.Remove(name);
+    }
 
-            instance.ProviderIds?.Remove(provider.ToString());
-        }
+    /// <summary>
+    /// Removes a provider id.
+    /// </summary>
+    /// <param name="instance">The instance.</param>
+    /// <param name="provider">The provider.</param>
+    public static void RemoveProviderId(this IHasProviderIds instance, MetadataProvider provider)
+    {
+        ArgumentNullException.ThrowIfNull(instance);
+
+        instance.ProviderIds?.Remove(provider.ToString());
     }
 }

+ 49 - 36
MediaBrowser.Model/Querying/QueryResult.cs

@@ -1,47 +1,60 @@
-#nullable disable
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 
-namespace MediaBrowser.Model.Querying
+namespace MediaBrowser.Model.Querying;
+
+/// <summary>
+/// Query result container.
+/// </summary>
+/// <typeparam name="T">The type of item contained in the query result.</typeparam>
+public class QueryResult<T>
 {
-    public class QueryResult<T>
+    /// <summary>
+    /// Initializes a new instance of the <see cref="QueryResult{T}" /> class.
+    /// </summary>
+    public QueryResult()
     {
-        public QueryResult()
-        {
-            Items = Array.Empty<T>();
-        }
+        Items = Array.Empty<T>();
+    }
 
-        public QueryResult(IReadOnlyList<T> items)
-        {
-            Items = items;
-            TotalRecordCount = items.Count;
-        }
+    /// <summary>
+    /// Initializes a new instance of the <see cref="QueryResult{T}" /> class.
+    /// </summary>
+    /// <param name="items">The list of items.</param>
+    public QueryResult(IReadOnlyList<T> items)
+    {
+        Items = items;
+        TotalRecordCount = items.Count;
+    }
 
-        public QueryResult(int? startIndex, int? totalRecordCount, IReadOnlyList<T> items)
-        {
-            StartIndex = startIndex ?? 0;
-            TotalRecordCount = totalRecordCount ?? items.Count;
-            Items = items;
-        }
+    /// <summary>
+    /// Initializes a new instance of the <see cref="QueryResult{T}" /> class.
+    /// </summary>
+    /// <param name="startIndex">The start index that was used to build the item list.</param>
+    /// <param name="totalRecordCount">The total count of items.</param>
+    /// <param name="items">The list of items.</param>
+    public QueryResult(int? startIndex, int? totalRecordCount, IReadOnlyList<T> items)
+    {
+        StartIndex = startIndex ?? 0;
+        TotalRecordCount = totalRecordCount ?? items.Count;
+        Items = items;
+    }
 
-        /// <summary>
-        /// Gets or sets the items.
-        /// </summary>
-        /// <value>The items.</value>
-        public IReadOnlyList<T> Items { get; set; }
+    /// <summary>
+    /// Gets or sets the items.
+    /// </summary>
+    /// <value>The items.</value>
+    public IReadOnlyList<T> Items { get; set; }
 
-        /// <summary>
-        /// Gets or sets the total number of records available.
-        /// </summary>
-        /// <value>The total record count.</value>
-        public int TotalRecordCount { get; set; }
+    /// <summary>
+    /// Gets or sets the total number of records available.
+    /// </summary>
+    /// <value>The total record count.</value>
+    public int TotalRecordCount { get; set; }
 
-        /// <summary>
-        /// Gets or sets the index of the first record in Items.
-        /// </summary>
-        /// <value>First record index.</value>
-        public int StartIndex { get; set; }
-    }
+    /// <summary>
+    /// Gets or sets the index of the first record in Items.
+    /// </summary>
+    /// <value>First record index.</value>
+    public int StartIndex { get; set; }
 }

+ 3 - 3
MediaBrowser.Model/Session/UserDataChangeInfo.cs

@@ -1,4 +1,4 @@
-#nullable disable
+using System;
 using MediaBrowser.Model.Dto;
 
 namespace MediaBrowser.Model.Session
@@ -12,12 +12,12 @@ namespace MediaBrowser.Model.Session
         /// Gets or sets the user id.
         /// </summary>
         /// <value>The user id.</value>
-        public string UserId { get; set; }
+        public Guid UserId { get; set; }
 
         /// <summary>
         /// Gets or sets the user data list.
         /// </summary>
         /// <value>The user data list.</value>
-        public UserItemDataDto[] UserDataList { get; set; }
+        public required UserItemDataDto[] UserDataList { get; set; }
     }
 }

+ 1 - 1
MediaBrowser.Providers/MediaBrowser.Providers.csproj

@@ -23,7 +23,7 @@
     <PackageReference Include="Microsoft.Extensions.Http" />
     <PackageReference Include="Newtonsoft.Json" />
     <PackageReference Include="PlaylistsNET" />
-    <PackageReference Include="TagLibSharp" />
+    <PackageReference Include="z440.atl.core"/>
     <PackageReference Include="TMDbLib" />
   </ItemGroup>
 

+ 79 - 83
MediaBrowser.Providers/MediaInfo/AudioFileProber.cs

@@ -4,6 +4,7 @@ using System.Globalization;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using ATL;
 using Jellyfin.Data.Enums;
 using Jellyfin.Extensions;
 using MediaBrowser.Controller.Entities;
@@ -18,7 +19,6 @@ using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.MediaInfo;
 using Microsoft.Extensions.Logging;
-using TagLib;
 
 namespace MediaBrowser.Providers.MediaInfo
 {
@@ -27,6 +27,7 @@ namespace MediaBrowser.Providers.MediaInfo
     /// </summary>
     public class AudioFileProber
     {
+        private const char InternalValueSeparator = '\u001F';
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IItemRepository _itemRepo;
         private readonly ILibraryManager _libraryManager;
@@ -61,6 +62,7 @@ namespace MediaBrowser.Providers.MediaInfo
             _mediaSourceManager = mediaSourceManager;
             _lyricResolver = lyricResolver;
             _lyricManager = lyricManager;
+            ATL.Settings.DisplayValueSeparator = InternalValueSeparator;
         }
 
         /// <summary>
@@ -127,7 +129,6 @@ namespace MediaBrowser.Providers.MediaInfo
 
             audio.RunTimeTicks = mediaInfo.RunTimeTicks;
             audio.Size = mediaInfo.Size;
-            audio.PremiereDate = mediaInfo.PremiereDate;
 
             // Add external lyrics first to prevent the lrc file get overwritten on first scan
             var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
@@ -157,60 +158,23 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param>
         private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, bool tryExtractEmbeddedLyrics)
         {
-            Tag? tags = null;
-            try
-            {
-                using var file = TagLib.File.Create(audio.Path);
-                var tagTypes = file.TagTypesOnDisk;
+            Track track = new Track(audio.Path);
 
-                if (tagTypes.HasFlag(TagTypes.Id3v2))
-                {
-                    tags = file.GetTag(TagTypes.Id3v2);
-                }
-                else if (tagTypes.HasFlag(TagTypes.Ape))
-                {
-                    tags = file.GetTag(TagTypes.Ape);
-                }
-                else if (tagTypes.HasFlag(TagTypes.FlacMetadata))
-                {
-                    tags = file.GetTag(TagTypes.FlacMetadata);
-                }
-                else if (tagTypes.HasFlag(TagTypes.Apple))
-                {
-                    tags = file.GetTag(TagTypes.Apple);
-                }
-                else if (tagTypes.HasFlag(TagTypes.Xiph))
-                {
-                    tags = file.GetTag(TagTypes.Xiph);
-                }
-                else if (tagTypes.HasFlag(TagTypes.AudibleMetadata))
-                {
-                    tags = file.GetTag(TagTypes.AudibleMetadata);
-                }
-                else if (tagTypes.HasFlag(TagTypes.Id3v1))
-                {
-                    tags = file.GetTag(TagTypes.Id3v1);
-                }
-            }
-            catch (Exception e)
+            // ATL will fall back to filename as title when it does not understand the metadata
+            if (track.MetadataFormats.All(mf => mf.Equals(ATL.Factory.UNKNOWN_FORMAT)))
             {
-                _logger.LogWarning(e, "TagLib-Sharp does not support this audio");
+                track.Title = mediaInfo.Name;
             }
 
-            tags ??= new TagLib.Id3v2.Tag();
-            tags.AlbumArtists ??= mediaInfo.AlbumArtists;
-            tags.Album ??= mediaInfo.Album;
-            tags.Title ??= mediaInfo.Name;
-            tags.Year = tags.Year == 0U ? Convert.ToUInt32(mediaInfo.ProductionYear, CultureInfo.InvariantCulture) : tags.Year;
-            tags.Performers ??= mediaInfo.Artists;
-            tags.Genres ??= mediaInfo.Genres;
-            tags.Track = tags.Track == 0U ? Convert.ToUInt32(mediaInfo.IndexNumber, CultureInfo.InvariantCulture) : tags.Track;
-            tags.Disc = tags.Disc == 0U ? Convert.ToUInt32(mediaInfo.ParentIndexNumber, CultureInfo.InvariantCulture) : tags.Disc;
+            track.Album = string.IsNullOrEmpty(track.Album) ? mediaInfo.Album : track.Album;
+            track.Year ??= mediaInfo.ProductionYear;
+            track.TrackNumber ??= mediaInfo.IndexNumber;
+            track.DiscNumber ??= mediaInfo.ParentIndexNumber;
 
             if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
             {
                 var people = new List<PersonInfo>();
-                var albumArtists = tags.AlbumArtists;
+                var albumArtists = string.IsNullOrEmpty(track.AlbumArtist) ? mediaInfo.AlbumArtists : track.AlbumArtist.Split(InternalValueSeparator);
                 foreach (var albumArtist in albumArtists)
                 {
                     if (!string.IsNullOrEmpty(albumArtist))
@@ -223,7 +187,7 @@ namespace MediaBrowser.Providers.MediaInfo
                     }
                 }
 
-                var performers = tags.Performers;
+                var performers = string.IsNullOrEmpty(track.Artist) ? mediaInfo.Artists : track.Artist.Split(InternalValueSeparator);
                 foreach (var performer in performers)
                 {
                     if (!string.IsNullOrEmpty(performer))
@@ -236,7 +200,7 @@ namespace MediaBrowser.Providers.MediaInfo
                     }
                 }
 
-                foreach (var composer in tags.Composers)
+                foreach (var composer in track.Composer.Split(InternalValueSeparator))
                 {
                     if (!string.IsNullOrEmpty(composer))
                     {
@@ -277,27 +241,32 @@ namespace MediaBrowser.Providers.MediaInfo
                 }
             }
 
-            if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title))
+            if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(track.Title))
             {
-                audio.Name = tags.Title;
+                audio.Name = track.Title;
             }
 
             if (options.ReplaceAllMetadata)
             {
-                audio.Album = tags.Album;
-                audio.IndexNumber = Convert.ToInt32(tags.Track);
-                audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+                audio.Album = track.Album;
+                audio.IndexNumber = track.TrackNumber;
+                audio.ParentIndexNumber = track.DiscNumber;
             }
             else
             {
-                audio.Album ??= tags.Album;
-                audio.IndexNumber ??= Convert.ToInt32(tags.Track);
-                audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
+                audio.Album ??= track.Album;
+                audio.IndexNumber ??= track.TrackNumber;
+                audio.ParentIndexNumber ??= track.DiscNumber;
             }
 
-            if (tags.Year != 0)
+            if (track.Date.HasValue)
             {
-                var year = Convert.ToInt32(tags.Year);
+                audio.PremiereDate = track.Date;
+            }
+
+            if (track.Year.HasValue)
+            {
+                var year = track.Year.Value;
                 audio.ProductionYear = year;
 
                 if (!audio.PremiereDate.HasValue)
@@ -308,64 +277,91 @@ namespace MediaBrowser.Providers.MediaInfo
                     }
                     catch (ArgumentOutOfRangeException ex)
                     {
-                        _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, tags.Year);
+                        _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, track.Year);
                     }
                 }
             }
 
             if (!audio.LockedFields.Contains(MetadataField.Genres))
             {
+                var genres = string.IsNullOrEmpty(track.Genre) ? mediaInfo.Genres : track.Genre.Split(InternalValueSeparator).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
                 audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
-                    ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
+                    ? genres
                     : audio.Genres;
             }
 
-            if (!double.IsNaN(tags.ReplayGainTrackGain))
+            track.AdditionalFields.TryGetValue("REPLAYGAIN_TRACK_GAIN", out var trackGainTag);
+
+            if (trackGainTag is not null)
             {
-                audio.NormalizationGain = (float)tags.ReplayGainTrackGain;
+                if (trackGainTag.EndsWith("db", StringComparison.OrdinalIgnoreCase))
+                {
+                    trackGainTag = trackGainTag[..^2].Trim();
+                }
+
+                if (float.TryParse(trackGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+                {
+                    audio.NormalizationGain = value;
+                }
             }
 
-            if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
-                && !string.IsNullOrEmpty(tags.MusicBrainzArtistId))
+            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
             {
-                audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
+                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
+                     || track.AdditionalFields.TryGetValue("MusicBrainz Artist Id", out musicBrainzArtistTag))
+                    && !string.IsNullOrEmpty(musicBrainzArtistTag))
+                {
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzArtistTag);
+                }
             }
 
-            if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
-                && !string.IsNullOrEmpty(tags.MusicBrainzReleaseArtistId))
+            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
             {
-                audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
+                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtistIdTag)
+                     || track.AdditionalFields.TryGetValue("MusicBrainz Album Artist Id", out musicBrainzReleaseArtistIdTag))
+                    && !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag))
+                {
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbumArtist, musicBrainzReleaseArtistIdTag);
+                }
             }
 
-            if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
-                && !string.IsNullOrEmpty(tags.MusicBrainzReleaseId))
+            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
             {
-                audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
+                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag)
+                     || track.AdditionalFields.TryGetValue("MusicBrainz Album Id", out musicBrainzReleaseIdTag))
+                    && !string.IsNullOrEmpty(musicBrainzReleaseIdTag))
+                {
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzAlbum, musicBrainzReleaseIdTag);
+                }
             }
 
-            if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
-                && !string.IsNullOrEmpty(tags.MusicBrainzReleaseGroupId))
+            if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
             {
-                audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
+                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGroupIdTag)
+                     || track.AdditionalFields.TryGetValue("MusicBrainz Release Group Id", out musicBrainzReleaseGroupIdTag))
+                    && !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag))
+                {
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzReleaseGroup, musicBrainzReleaseGroupIdTag);
+                }
             }
 
             if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
             {
-                // Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`.
-                // See https://github.com/mono/taglib-sharp/issues/304
-                var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack);
-                if (trackMbId is not null)
+                if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASETRACKID", out var trackMbId)
+                     || track.AdditionalFields.TryGetValue("MusicBrainz Release Track Id", out trackMbId))
+                    && !string.IsNullOrEmpty(trackMbId))
                 {
-                    audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
+                    audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
                 }
             }
 
             // Save extracted lyrics if they exist,
             // and if the audio doesn't yet have lyrics.
-            if (!string.IsNullOrWhiteSpace(tags.Lyrics)
+            var lyrics = track.Lyrics.SynchronizedLyrics.Count > 0 ? track.Lyrics.FormatSynchToLRC() : track.Lyrics.UnsynchronizedLyrics;
+            if (!string.IsNullOrWhiteSpace(lyrics)
                 && tryExtractEmbeddedLyrics)
             {
-                await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
+                await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
             }
         }
 

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

@@ -220,10 +220,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
                 item.HomePageUrl = result.Website;
             }
 
-            if (!string.IsNullOrWhiteSpace(result.imdbID))
-            {
-                item.SetProviderId(MetadataProvider.Imdb, result.imdbID);
-            }
+            item.TrySetProviderId(MetadataProvider.Imdb, result.imdbID);
 
             ParseAdditionalMetadata(itemResult, result, isEnglishRequested);
 

+ 1 - 5
MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs

@@ -81,11 +81,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
                     }
 
                     remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture));
-
-                    if (!string.IsNullOrWhiteSpace(movie.ImdbId))
-                    {
-                        remoteResult.SetProviderId(MetadataProvider.Imdb, movie.ImdbId);
-                    }
+                    remoteResult.TrySetProviderId(MetadataProvider.Imdb, movie.ImdbId);
 
                     return new[] { remoteResult };
                 }

+ 2 - 9
MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs

@@ -56,10 +56,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
                     }
 
                     result.SetProviderId(MetadataProvider.Tmdb, personResult.Id.ToString(CultureInfo.InvariantCulture));
-                    if (!string.IsNullOrEmpty(personResult.ExternalIds.ImdbId))
-                    {
-                        result.SetProviderId(MetadataProvider.Imdb, personResult.ExternalIds.ImdbId);
-                    }
+                    result.TrySetProviderId(MetadataProvider.Imdb, personResult.ExternalIds.ImdbId);
 
                     return new[] { result };
                 }
@@ -129,11 +126,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
                 }
 
                 item.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture));
-
-                if (!string.IsNullOrEmpty(person.ImdbId))
-                {
-                    item.SetProviderId(MetadataProvider.Imdb, person.ImdbId);
-                }
+                item.TrySetProviderId(MetadataProvider.Imdb, person.ImdbId);
 
                 result.HasMetadata = true;
                 result.Item = item;

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

@@ -187,20 +187,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
             };
 
             var externalIds = episodeResult.ExternalIds;
-            if (!string.IsNullOrEmpty(externalIds?.TvdbId))
-            {
-                item.SetProviderId(MetadataProvider.Tvdb, externalIds.TvdbId);
-            }
-
-            if (!string.IsNullOrEmpty(externalIds?.ImdbId))
-            {
-                item.SetProviderId(MetadataProvider.Imdb, externalIds.ImdbId);
-            }
-
-            if (!string.IsNullOrEmpty(externalIds?.TvrageId))
-            {
-                item.SetProviderId(MetadataProvider.TvRage, externalIds.TvrageId);
-            }
+            item.TrySetProviderId(MetadataProvider.Tvdb, externalIds?.TvdbId);
+            item.TrySetProviderId(MetadataProvider.Imdb, externalIds?.ImdbId);
+            item.TrySetProviderId(MetadataProvider.TvRage, externalIds?.TvrageId);
 
             if (episodeResult.Videos?.Results is not null)
             {

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

@@ -73,10 +73,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
                 result.Item.Name = seasonResult.Name;
             }
 
-            if (!string.IsNullOrEmpty(seasonResult.ExternalIds?.TvdbId))
-            {
-                result.Item.SetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds.TvdbId);
-            }
+            result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds.TvdbId);
 
             // TODO why was this disabled?
             var credits = seasonResult.Credits;

+ 5 - 22
MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs

@@ -135,15 +135,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
             remoteResult.SetProviderId(MetadataProvider.Tmdb, series.Id.ToString(CultureInfo.InvariantCulture));
             if (series.ExternalIds is not null)
             {
-                if (!string.IsNullOrEmpty(series.ExternalIds.ImdbId))
-                {
-                    remoteResult.SetProviderId(MetadataProvider.Imdb, series.ExternalIds.ImdbId);
-                }
+                remoteResult.TrySetProviderId(MetadataProvider.Imdb, series.ExternalIds.ImdbId);
 
-                if (!string.IsNullOrEmpty(series.ExternalIds.TvdbId))
-                {
-                    remoteResult.SetProviderId(MetadataProvider.Tvdb, series.ExternalIds.TvdbId);
-                }
+                remoteResult.TrySetProviderId(MetadataProvider.Tvdb, series.ExternalIds.TvdbId);
             }
 
             remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime();
@@ -289,20 +283,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
             var ids = seriesResult.ExternalIds;
             if (ids is not null)
             {
-                if (!string.IsNullOrWhiteSpace(ids.ImdbId))
-                {
-                    series.SetProviderId(MetadataProvider.Imdb, ids.ImdbId);
-                }
-
-                if (!string.IsNullOrEmpty(ids.TvrageId))
-                {
-                    series.SetProviderId(MetadataProvider.TvRage, ids.TvrageId);
-                }
-
-                if (!string.IsNullOrEmpty(ids.TvdbId))
-                {
-                    series.SetProviderId(MetadataProvider.Tvdb, ids.TvdbId);
-                }
+                series.TrySetProviderId(MetadataProvider.Imdb, ids.ImdbId);
+                series.TrySetProviderId(MetadataProvider.TvRage, ids.TvrageId);
+                series.TrySetProviderId(MetadataProvider.Tvdb, ids.TvdbId);
             }
 
             var contentRatings = seriesResult.ContentRatings.Results ?? new List<ContentRating>();

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

@@ -572,10 +572,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
 
                     var provider = reader.GetAttribute("type");
                     var providerId = reader.ReadElementContentAsString();
-                    if (!string.IsNullOrWhiteSpace(provider) && !string.IsNullOrWhiteSpace(providerId))
-                    {
-                        item.SetProviderId(provider, providerId);
-                    }
+                    item.TrySetProviderId(provider, providerId);
 
                     break;
                 case "thumb":
@@ -604,10 +601,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     if (_validProviderIds.TryGetValue(readerName, out string? providerIdValue))
                     {
                         var id = reader.ReadElementContentAsString();
-                        if (!string.IsNullOrWhiteSpace(providerIdValue) && !string.IsNullOrWhiteSpace(id))
-                        {
-                            item.SetProviderId(providerIdValue, id);
-                        }
+                        item.TrySetProviderId(providerIdValue, id);
                     }
                     else
                     {

+ 57 - 59
MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs

@@ -59,80 +59,50 @@ namespace MediaBrowser.XbmcMetadata.Parsers
             try
             {
                 // Extract episode details from the first episodedetails block
-                using (var stringReader = new StringReader(xml))
-                using (var reader = XmlReader.Create(stringReader, settings))
-                {
-                    reader.MoveToContent();
-                    reader.Read();
-
-                    // Loop through each element
-                    while (!reader.EOF && reader.ReadState == ReadState.Interactive)
-                    {
-                        cancellationToken.ThrowIfCancellationRequested();
-
-                        if (reader.NodeType == XmlNodeType.Element)
-                        {
-                            FetchDataFromXmlNode(reader, item);
-                        }
-                        else
-                        {
-                            reader.Read();
-                        }
-                    }
-                }
+                ReadEpisodeDetailsFromXml(item, xml, settings, cancellationToken);
 
                 // Extract the last episode number from nfo
-                // Retrieves all title and plot tags from the rest of the nfo and concatenates them with the first episode
+                // Retrieves all additional episodedetails blocks from the rest of the nfo and concatenates the name, originalTitle and overview tags with the first episode
                 // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag
                 var name = new StringBuilder(item.Item.Name);
+                var originalTitle = new StringBuilder(item.Item.OriginalTitle);
                 var overview = new StringBuilder(item.Item.Overview);
                 while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1)
                 {
                     xml = xmlFile.Substring(0, index + srch.Length);
                     xmlFile = xmlFile.Substring(index + srch.Length);
 
-                    using (var stringReader = new StringReader(xml))
-                    using (var reader = XmlReader.Create(stringReader, settings))
+                    var additionalEpisode = new MetadataResult<Episode>()
+                    {
+                        Item = new Episode()
+                    };
+
+                    // Extract episode details from additional episodedetails block
+                    ReadEpisodeDetailsFromXml(additionalEpisode, xml, settings, cancellationToken);
+
+                    if (!string.IsNullOrEmpty(additionalEpisode.Item.Name))
+                    {
+                        name.Append(" / ").Append(additionalEpisode.Item.Name);
+                    }
+
+                    if (!string.IsNullOrEmpty(additionalEpisode.Item.Overview))
+                    {
+                        overview.Append(" / ").Append(additionalEpisode.Item.Overview);
+                    }
+
+                    if (!string.IsNullOrEmpty(additionalEpisode.Item.OriginalTitle))
+                    {
+                        originalTitle.Append(" / ").Append(additionalEpisode.Item.OriginalTitle);
+                    }
+
+                    if (additionalEpisode.Item.IndexNumber != null)
                     {
-                        reader.MoveToContent();
-
-                        while (!reader.EOF && reader.ReadState == ReadState.Interactive)
-                        {
-                            cancellationToken.ThrowIfCancellationRequested();
-
-                            if (reader.NodeType == XmlNodeType.Element)
-                            {
-                                switch (reader.Name)
-                                {
-                                    case "name":
-                                    case "title":
-                                    case "localtitle":
-                                        name.Append(" / ").Append(reader.ReadElementContentAsString());
-                                        break;
-                                    case "episode":
-                                        {
-                                            if (int.TryParse(reader.ReadElementContentAsString(), out var num))
-                                            {
-                                                item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num);
-                                            }
-
-                                            break;
-                                        }
-
-                                    case "biography":
-                                    case "plot":
-                                    case "review":
-                                        overview.Append(" / ").Append(reader.ReadElementContentAsString());
-                                        break;
-                                }
-                            }
-
-                            reader.Read();
-                        }
+                        item.Item.IndexNumberEnd = Math.Max((int)additionalEpisode.Item.IndexNumber, item.Item.IndexNumberEnd ?? (int)additionalEpisode.Item.IndexNumber);
                     }
                 }
 
                 item.Item.Name = name.ToString();
+                item.Item.OriginalTitle = originalTitle.ToString();
                 item.Item.Overview = overview.ToString();
             }
             catch (XmlException)
@@ -200,5 +170,33 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     break;
             }
         }
+
+        /// <summary>
+        /// Reads the episode details from the given xml and saves the result in the provided result item.
+        /// </summary>
+        private void ReadEpisodeDetailsFromXml(MetadataResult<Episode> item, string xml, XmlReaderSettings settings, CancellationToken cancellationToken)
+        {
+            using (var stringReader = new StringReader(xml))
+            using (var reader = XmlReader.Create(stringReader, settings))
+            {
+                reader.MoveToContent();
+                reader.Read();
+
+                // Loop through each element
+                while (!reader.EOF && reader.ReadState == ReadState.Interactive)
+                {
+                    cancellationToken.ThrowIfCancellationRequested();
+
+                    if (reader.NodeType == XmlNodeType.Element)
+                    {
+                        FetchDataFromXmlNode(reader, item);
+                    }
+                    else
+                    {
+                        reader.Read();
+                    }
+                }
+            }
+        }
     }
 }

+ 3 - 13
MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs

@@ -65,15 +65,8 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                             tmdbId = contentId;
                         }
 
-                        if (!string.IsNullOrWhiteSpace(imdbId))
-                        {
-                            item.SetProviderId(MetadataProvider.Imdb, imdbId);
-                        }
-
-                        if (!string.IsNullOrWhiteSpace(tmdbId))
-                        {
-                            item.SetProviderId(MetadataProvider.Tmdb, tmdbId);
-                        }
+                        item.TrySetProviderId(MetadataProvider.Imdb, imdbId);
+                        item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
 
                         break;
                     }
@@ -83,10 +76,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                         var movie = item as Movie;
 
                         var tmdbcolid = reader.GetAttribute("tmdbcolid");
-                        if (!string.IsNullOrWhiteSpace(tmdbcolid) && movie is not null)
-                        {
-                            movie.SetProviderId(MetadataProvider.TmdbCollection, tmdbcolid);
-                        }
+                        movie?.TrySetProviderId(MetadataProvider.TmdbCollection, tmdbcolid);
 
                         var val = reader.ReadInnerXml();
 

+ 4 - 17
MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs

@@ -48,29 +48,16 @@ namespace MediaBrowser.XbmcMetadata.Parsers
             {
                 case "id":
                     {
-                        string? imdbId = reader.GetAttribute("IMDB");
-                        string? tmdbId = reader.GetAttribute("TMDB");
-                        string? tvdbId = reader.GetAttribute("TVDB");
+                        item.TrySetProviderId(MetadataProvider.Imdb, reader.GetAttribute("IMDB"));
+                        item.TrySetProviderId(MetadataProvider.Tmdb, reader.GetAttribute("TMDB"));
 
+                        string? tvdbId = reader.GetAttribute("TVDB");
                         if (string.IsNullOrWhiteSpace(tvdbId))
                         {
                             tvdbId = reader.ReadElementContentAsString();
                         }
 
-                        if (!string.IsNullOrWhiteSpace(imdbId))
-                        {
-                            item.SetProviderId(MetadataProvider.Imdb, imdbId);
-                        }
-
-                        if (!string.IsNullOrWhiteSpace(tmdbId))
-                        {
-                            item.SetProviderId(MetadataProvider.Tmdb, tmdbId);
-                        }
-
-                        if (!string.IsNullOrWhiteSpace(tvdbId))
-                        {
-                            item.SetProviderId(MetadataProvider.Tvdb, tvdbId);
-                        }
+                        item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
 
                         break;
                     }

+ 0 - 5
MediaBrowser.XbmcMetadata/Providers/BaseVideoNfoProvider.cs

@@ -54,11 +54,6 @@ namespace MediaBrowser.XbmcMetadata.Providers
             result.People = tmpItem.People;
             result.Images = tmpItem.Images;
             result.RemoteImages = tmpItem.RemoteImages;
-
-            if (tmpItem.UserDataList is not null)
-            {
-                result.UserDataList = tmpItem.UserDataList;
-            }
         }
 
         /// <inheritdoc />

+ 22 - 0
jellyfin.ruleset

@@ -105,6 +105,28 @@
     <Rule Id="CA1851" Action="Error" />
     <!-- error on CA1854: Prefer a 'TryGetValue' call over a Dictionary indexer access guarded by a 'ContainsKey' check to avoid double lookup -->
     <Rule Id="CA1854" Action="Error" />
+    <!-- error on CA1860: Avoid using 'Enumerable.Any()' extension method -->
+    <Rule Id="CA1860" Action="Error" />
+    <!-- error on CA1862: Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -->
+    <Rule Id="CA1862" Action="Error" />
+    <!-- error on CA1863: Use 'CompositeFormat' -->
+    <Rule Id="CA1863" Action="Error" />
+    <!-- error on CA1864: Prefer the 'IDictionary.TryAdd(TKey, TValue)' method -->
+    <Rule Id="CA1864" Action="Error" />
+    <!-- error on CA1865-CA1867: Use 'string.Method(char)' instead of 'string.Method(string)' for string with single char -->
+    <Rule Id="CA1865" Action="Error" />
+    <Rule Id="CA1866" Action="Error" />
+    <Rule Id="CA1867" Action="Error" />
+    <!-- error on CA1868: Unnecessary call to 'Contains' for sets -->
+    <Rule Id="CA1868" Action="Error" />
+    <!-- error on CA1869: Cache and reuse 'JsonSerializerOptions' instances -->
+    <Rule Id="CA1869" Action="Error" />
+    <!-- error on CA1870: Use a cached 'SearchValues' instance -->
+    <Rule Id="CA1870" Action="Error" />
+    <!-- error on CA1871: Do not pass a nullable struct to 'ArgumentNullException.ThrowIfNull' -->
+    <Rule Id="CA1871" Action="Error" />
+    <!-- error on CA1872: Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString' -->
+    <Rule Id="CA1872" Action="Error" />
     <!-- error on CA2016: Forward the CancellationToken parameter to methods that take one
         or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token -->
     <Rule Id="CA2016" Action="Error" />

+ 1 - 4
src/Jellyfin.LiveTv/Guide/GuideManager.cs

@@ -479,10 +479,7 @@ public class GuideManager : IGuideManager
                 DateModified = DateTime.UtcNow
             };
 
-            if (!string.IsNullOrEmpty(info.Etag))
-            {
-                item.SetProviderId(EtagKey, info.Etag);
-            }
+            item.TrySetProviderId(EtagKey, info.Etag);
         }
 
         if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))

+ 12 - 5
tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs

@@ -51,8 +51,8 @@ namespace Jellyfin.Model.Tests
         [InlineData("SafariNext", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectPlay)] // #6450
         [InlineData("SafariNext", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450
         [InlineData("SafariNext", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450
-        [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450
-        [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450
+        [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450
+        [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450
         // AndroidPixel
         [InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450
         [InlineData("AndroidPixel", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -205,8 +205,8 @@ namespace Jellyfin.Model.Tests
         [InlineData("SafariNext", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectPlay)] // #6450
         [InlineData("SafariNext", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450
         [InlineData("SafariNext", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450
-        [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450
-        [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Remux", "HLS.mp4")] // #6450
+        [InlineData("SafariNext", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450
+        [InlineData("SafariNext", "mp4-hevc-ac3-aacExt-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioChannelsNotSupported, "DirectStream", "HLS.mp4")] // #6450
         // AndroidPixel
         [InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450
         [InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450
@@ -432,7 +432,14 @@ namespace Jellyfin.Model.Tests
                         if (targetAudioStream?.IsExternal == false)
                         {
                             // Check expected audio codecs (1)
-                            Assert.DoesNotContain(targetAudioStream.Codec, streamInfo.AudioCodecs);
+                            if ((why & TranscodeReason.AudioChannelsNotSupported) == 0)
+                            {
+                                Assert.DoesNotContain(targetAudioStream.Codec, streamInfo.AudioCodecs);
+                            }
+                            else
+                            {
+                                Assert.Equal(targetAudioStream.Channels, streamInfo.TranscodingMaxAudioChannels);
+                            }
                         }
                     }
                     else if (transcodeMode.Equals("Remux", StringComparison.Ordinal))

+ 0 - 35
tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs

@@ -1,35 +0,0 @@
-using Emby.Naming.TV;
-using Xunit;
-
-namespace Jellyfin.Naming.Tests.TV
-{
-    public class SeasonFolderTests
-    {
-        [Theory]
-        [InlineData("/Drive/Season 1", 1, true)]
-        [InlineData("/Drive/Season 2", 2, true)]
-        [InlineData("/Drive/Season 02", 2, true)]
-        [InlineData("/Drive/Seinfeld/S02", 2, true)]
-        [InlineData("/Drive/Seinfeld/2", 2, true)]
-        [InlineData("/Drive/Season 2009", 2009, true)]
-        [InlineData("/Drive/Season1", 1, true)]
-        [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)]
-        [InlineData("/Drive/Season 7 (2016)", 7, false)]
-        [InlineData("/Drive/Staffel 7 (2016)", 7, false)]
-        [InlineData("/Drive/Stagione 7 (2016)", 7, false)]
-        [InlineData("/Drive/Season (8)", null, false)]
-        [InlineData("/Drive/3.Staffel", 3, false)]
-        [InlineData("/Drive/s06e05", null, false)]
-        [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)]
-        [InlineData("/Drive/extras", 0, true)]
-        [InlineData("/Drive/specials", 0, true)]
-        public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory)
-        {
-            var result = SeasonPathParser.Parse(path, true, true);
-
-            Assert.Equal(result.SeasonNumber is not null, result.Success);
-            Assert.Equal(result.SeasonNumber, seasonNumber);
-            Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
-        }
-    }
-}

+ 37 - 0
tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs

@@ -0,0 +1,37 @@
+using Emby.Naming.TV;
+using Xunit;
+
+namespace Jellyfin.Naming.Tests.TV;
+
+public class SeasonPathParserTests
+{
+    [Theory]
+    [InlineData("/Drive/Season 1", 1, true)]
+    [InlineData("/Drive/s1", 1, true)]
+    [InlineData("/Drive/S1", 1, true)]
+    [InlineData("/Drive/Season 2", 2, true)]
+    [InlineData("/Drive/Season 02", 2, true)]
+    [InlineData("/Drive/Seinfeld/S02", 2, true)]
+    [InlineData("/Drive/Seinfeld/2", 2, true)]
+    [InlineData("/Drive/Seinfeld - S02", 2, true)]
+    [InlineData("/Drive/Season 2009", 2009, true)]
+    [InlineData("/Drive/Season1", 1, true)]
+    [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)]
+    [InlineData("/Drive/Season 7 (2016)", 7, false)]
+    [InlineData("/Drive/Staffel 7 (2016)", 7, false)]
+    [InlineData("/Drive/Stagione 7 (2016)", 7, false)]
+    [InlineData("/Drive/Season (8)", null, false)]
+    [InlineData("/Drive/3.Staffel", 3, false)]
+    [InlineData("/Drive/s06e05", null, false)]
+    [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)]
+    [InlineData("/Drive/extras", 0, true)]
+    [InlineData("/Drive/specials", 0, true)]
+    public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory)
+    {
+        var result = SeasonPathParser.Parse(path, true, true);
+
+        Assert.Equal(result.SeasonNumber is not null, result.Success);
+        Assert.Equal(result.SeasonNumber, seasonNumber);
+        Assert.Equal(isSeasonDirectory, result.IsSeasonFolder);
+    }
+}

+ 24 - 0
tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs

@@ -123,6 +123,30 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
             Assert.Equal(2004, item.ProductionYear);
         }
 
+        [Fact]
+        public void Fetch_Valid_MultiEpisode_With_Missing_Tags_Success()
+        {
+            var result = new MetadataResult<Episode>()
+            {
+                Item = new Episode()
+            };
+
+            _parser.Fetch(result, "Test Data/Stargate Atlantis S01E01-E04.nfo", CancellationToken.None);
+
+            var item = result.Item;
+            // <title> provided for episode 1, 3 and 4
+            Assert.Equal("Rising / Hide and Seek / Thirty-Eight Minutes", item.Name);
+            // <originaltitle> provided for all episodes
+            Assert.Equal("Rising (1) / Rising (2) / Hide and Seek / Thirty-Eight Minutes", item.OriginalTitle);
+            Assert.Equal(1, item.IndexNumber);
+            Assert.Equal(4, item.IndexNumberEnd);
+            Assert.Equal(1, item.ParentIndexNumber);
+            // <plot> only provided for episode 1
+            Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.", item.Overview);
+            Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate);
+            Assert.Equal(2004, item.ProductionYear);
+        }
+
         [Fact]
         public void Parse_GivenFileWithThumbWithoutAspect_Success()
         {

+ 4 - 1
tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs

@@ -53,7 +53,10 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
 
             var userData = new Mock<IUserDataManager>();
             userData.Setup(x => x.GetUserData(_testUser, It.IsAny<BaseItem>()))
-                .Returns(new UserItemData());
+                .Returns(new UserItemData()
+                {
+                    Key = "Something"
+                });
 
             var directoryService = new Mock<IDirectoryService>();
             _localImageFileMetadata = new FileSystemMetadata()

+ 24 - 0
tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising.nfo

@@ -7,6 +7,18 @@
   <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25333.jpg</thumb>
   <watched>false</watched>
   <rating>8.0</rating>
+  <actor>
+    <name>Joe Flanigan</name>
+    <role>John Sheppard</role>
+    <order>0</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+  </actor>
+  <actor>
+    <name>David Hewlett</name>
+    <role>Rodney McKay</role>
+    <order>1</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+  </actor>
 </episodedetails>
 <episodedetails>
   <title>Rising (2)</title>
@@ -17,4 +29,16 @@
   <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25334.jpg</thumb>
   <watched>false</watched>
   <rating>7.9</rating>
+  <actor>
+    <name>Joe Flanigan</name>
+    <role>John Sheppard</role>
+    <order>0</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+  </actor>
+  <actor>
+    <name>David Hewlett</name>
+    <role>Rodney McKay</role>
+    <order>1</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+  </actor>
 </episodedetails>

+ 89 - 0
tests/Jellyfin.XbmcMetadata.Tests/Test Data/Stargate Atlantis S01E01-E04.nfo

@@ -0,0 +1,89 @@
+<episodedetails>
+  <title>Rising</title>
+  <originaltitle>Rising (1)</originaltitle>
+  <season>1</season>
+  <episode>1</episode>
+  <aired>2004-07-16</aired>
+  <plot>A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.</plot>
+  <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25333.jpg</thumb>
+  <watched>false</watched>
+  <rating>8.0</rating>
+  <actor>
+    <name>Joe Flanigan</name>
+    <role>John Sheppard</role>
+    <order>0</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+  </actor>
+  <actor>
+    <name>David Hewlett</name>
+    <role>Rodney McKay</role>
+    <order>1</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+  </actor>
+</episodedetails>
+<episodedetails>
+  <originaltitle>Rising (2)</originaltitle>
+  <season>1</season>
+  <episode>2</episode>
+  <aired>2004-07-16</aired>
+  <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25334.jpg</thumb>
+  <watched>false</watched>
+  <rating>7.9</rating>
+  <actor>
+    <name>Joe Flanigan</name>
+    <role>John Sheppard</role>
+    <order>0</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+  </actor>
+  <actor>
+    <name>David Hewlett</name>
+    <role>Rodney McKay</role>
+    <order>1</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+  </actor>
+</episodedetails>
+<episodedetails>
+  <title>Hide and Seek</title>
+  <originaltitle>Hide and Seek</originaltitle>
+  <season>1</season>
+  <episode>3</episode>
+  <aired>2004-07-23</aired>
+  <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25335.jpg</thumb>
+  <watched>false</watched>
+  <rating>7.5</rating>
+  <actor>
+    <name>Joe Flanigan</name>
+    <role>John Sheppard</role>
+    <order>0</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+  </actor>
+  <actor>
+    <name>David Hewlett</name>
+    <role>Rodney McKay</role>
+    <order>1</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+  </actor>
+</episodedetails>
+<episodedetails>
+  <title>Thirty-Eight Minutes</title>
+  <originaltitle>Thirty-Eight Minutes</originaltitle>
+  <season>1</season>
+  <episode>4</episode>
+  <aired>2004-07-23</aired>
+  <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25336.jpg</thumb>
+  <watched>false</watched>
+  <rating>7.5</rating>
+  <actor>
+    <name>Joe Flanigan</name>
+    <role>John Sheppard</role>
+    <order>0</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+  </actor>
+  <actor>
+    <name>David Hewlett</name>
+    <role>Rodney McKay</role>
+    <order>1</order>
+    <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+  </actor>
+</episodedetails>
+