Przeglądaj źródła

Merge branch 'master' into network-rewrite

Shadowghost 2 lat temu
rodzic
commit
520c07e8ca
78 zmienionych plików z 1551 dodań i 515 usunięć
  1. 4 4
      .github/workflows/codeql-analysis.yml
  2. 2 2
      .github/workflows/commands.yml
  3. 2 2
      .github/workflows/openapi.yml
  4. 13 13
      Directory.Packages.props
  5. 1 0
      Dockerfile
  6. 1 0
      Dockerfile.arm
  7. 1 0
      Dockerfile.arm64
  8. 35 55
      Emby.Dlna/PlayTo/PlayToController.cs
  9. 2 3
      Emby.Dlna/PlayTo/PlayToManager.cs
  10. 4 0
      Emby.Server.Implementations/ApplicationHost.cs
  11. 14 10
      Emby.Server.Implementations/Channels/ChannelManager.cs
  12. 13 5
      Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
  13. 7 6
      Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
  14. 5 3
      Emby.Server.Implementations/Library/LibraryManager.cs
  15. 2 1
      Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs
  16. 5 2
      Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs
  17. 9 6
      Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
  18. 19 4
      Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs
  19. 5 3
      Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs
  20. 4 2
      Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs
  21. 7 6
      Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
  22. 14 4
      Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs
  23. 4 2
      Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
  24. 2 2
      Emby.Server.Implementations/Library/UserViewManager.cs
  25. 16 16
      Emby.Server.Implementations/LiveTv/LiveTvManager.cs
  26. 6 2
      Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
  27. 10 1
      Emby.Server.Implementations/Localization/Core/is.json
  28. 49 31
      Emby.Server.Implementations/Localization/LocalizationManager.cs
  29. 3 3
      Jellyfin.Api/Controllers/ChannelsController.cs
  30. 9 3
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  31. 5 0
      Jellyfin.Api/Controllers/ItemUpdateController.cs
  32. 5 5
      Jellyfin.Api/Controllers/LiveTvController.cs
  33. 13 1
      Jellyfin.Api/Helpers/TranscodingJobHelper.cs
  34. 1 0
      Jellyfin.Api/Jellyfin.Api.csproj
  35. 2 2
      Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
  36. 2 2
      MediaBrowser.Controller/Channels/IChannelManager.cs
  37. 1 1
      MediaBrowser.Controller/Entities/AggregateFolder.cs
  38. 1 1
      MediaBrowser.Controller/Entities/BaseItem.cs
  39. 1 1
      MediaBrowser.Controller/Entities/CollectionFolder.cs
  40. 8 17
      MediaBrowser.Controller/Library/ItemResolveArgs.cs
  41. 2 2
      MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
  42. 140 22
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  43. 30 0
      MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
  44. 3 2
      MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
  45. 123 0
      MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs
  46. 187 0
      MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs
  47. 68 0
      MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs
  48. 24 5
      MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
  49. 141 28
      MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
  50. 1 0
      MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
  51. 12 1
      MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
  52. 2 1
      MediaBrowser.Model/Configuration/EncodingOptions.cs
  53. 2 8
      MediaBrowser.Model/Configuration/ServerConfiguration.cs
  54. 34 5
      MediaBrowser.Model/Dlna/ConditionProcessor.cs
  55. 1 1
      MediaBrowser.Model/Dlna/ContainerProfile.cs
  56. 3 3
      MediaBrowser.Model/Dlna/DirectPlayProfile.cs
  57. 0 18
      MediaBrowser.Model/Dlna/ITranscoderSupport.cs
  58. 4 6
      MediaBrowser.Model/Dlna/MediaOptions.cs
  59. 74 72
      MediaBrowser.Model/Dlna/StreamBuilder.cs
  60. 2 3
      MediaBrowser.Model/Dlna/StreamInfo.cs
  61. 2 1
      MediaBrowser.Model/Globalization/ILocalizationManager.cs
  62. 41 0
      MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs
  63. 14 0
      MediaBrowser.Model/MediaInfo/IBlurayExaminer.cs
  64. 172 34
      MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
  65. 3 0
      MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
  66. 1 1
      MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs
  67. 40 67
      MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
  68. 1 1
      deployment/Dockerfile.centos.amd64
  69. 1 1
      deployment/Dockerfile.fedora.amd64
  70. 1 1
      deployment/Dockerfile.ubuntu.amd64
  71. 1 1
      deployment/Dockerfile.ubuntu.arm64
  72. 1 1
      deployment/Dockerfile.ubuntu.armhf
  73. 4 0
      tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
  74. 24 0
      tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
  75. 76 0
      tests/Jellyfin.Server.Implementations.Tests/Library/AudioResolverTests.cs
  76. 5 5
      tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
  77. 2 2
      tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs
  78. 2 2
      tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs

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

@@ -20,18 +20,18 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
+      uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3
     - name: Setup .NET
       uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3
       with:
         dotnet-version: '7.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
+      uses: github/codeql-action/init@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
+      uses: github/codeql-action/autobuild@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
+      uses: github/codeql-action/analyze@168b99b3c22180941ae7dbdd5f5c9678ede476ba # v2

+ 2 - 2
.github/workflows/commands.yml

@@ -24,7 +24,7 @@ jobs:
           reactions: '+1'
 
       - name: Checkout the latest code
-        uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
+        uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0
@@ -51,7 +51,7 @@ jobs:
           reactions: eyes
 
       - name: Checkout the latest code
-        uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
+        uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0

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

@@ -14,7 +14,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
+        uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -39,7 +39,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
+        uses: actions/checkout@24cb9080177205b6e8c946b17badbe402adc938f # v3
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}

+ 13 - 13
Directory.Packages.props

@@ -17,30 +17,30 @@
     <PackageVersion Include="Diacritics" Version="3.3.14" />
     <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
     <PackageVersion Include="DotNet.Glob" Version="3.1.3" />
-    <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.5" />
+    <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.8.6" />
     <PackageVersion Include="FsCheck.Xunit" Version="2.16.5" />
     <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
-    <PackageVersion Include="libse" Version="3.6.10" />
-    <PackageVersion Include="LrcParser" Version="2022.529.1" />
+    <PackageVersion Include="libse" Version="3.6.11" />
+    <PackageVersion Include="LrcParser" Version="2023.308.0" />
     <PackageVersion Include="MetaBrainz.MusicBrainz" Version="5.0.0" />
-    <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.4" />
     <PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
-    <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.4" />
     <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.3" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.3" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.3" />
-    <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.4" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.4" />
+    <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.4" />
     <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
-    <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.4" />
     <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
-    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.3" />
-    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.3" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.4" />
+    <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.4" />
     <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Http" Version="7.0.0" />
     <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
@@ -52,7 +52,7 @@
     <PackageVersion Include="Mono.Nat" Version="3.0.4" />
     <PackageVersion Include="Moq" Version="4.18.4" />
     <PackageVersion Include="NEbml" Version="0.11.0" />
-    <PackageVersion Include="Newtonsoft.Json" Version="13.0.2" />
+    <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
     <PackageVersion Include="PlaylistsNET" Version="1.3.1" />
     <PackageVersion Include="prometheus-net.AspNetCore" Version="8.0.0" />
     <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />

+ 1 - 0
Dockerfile

@@ -10,6 +10,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
  && npm ci --no-audit --unsafe-perm \
+ && npm run build:production \
  && mv dist /dist
 
 FROM debian:stable-slim as app

+ 1 - 0
Dockerfile.arm

@@ -11,6 +11,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
  && npm ci --no-audit --unsafe-perm \
+ && npm run build:production \
  && mv dist /dist
 
 FROM multiarch/qemu-user-static:x86_64-arm as qemu

+ 1 - 0
Dockerfile.arm64

@@ -11,6 +11,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-
  && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \
  && cd jellyfin-web-* \
  && npm ci --no-audit --unsafe-perm \
+ && npm run build:production \
  && mv dist /dist
 
 FROM multiarch/qemu-user-static:x86_64-aarch64 as qemu

+ 35 - 55
Emby.Dlna/PlayTo/PlayToController.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 #pragma warning disable CS1591
 
 using System;
@@ -66,7 +64,8 @@ namespace Emby.Dlna.PlayTo
             IUserDataManager userDataManager,
             ILocalizationManager localization,
             IMediaSourceManager mediaSourceManager,
-            IMediaEncoder mediaEncoder)
+            IMediaEncoder mediaEncoder,
+            Device device)
         {
             _session = session;
             _sessionManager = sessionManager;
@@ -82,14 +81,7 @@ namespace Emby.Dlna.PlayTo
             _localization = localization;
             _mediaSourceManager = mediaSourceManager;
             _mediaEncoder = mediaEncoder;
-        }
-
-        public bool IsSessionActive => !_disposed && _device is not null;
 
-        public bool SupportsMediaControl => IsSessionActive;
-
-        public void Init(Device device)
-        {
             _device = device;
             _device.OnDeviceUnavailable = OnDeviceUnavailable;
             _device.PlaybackStart += OnDevicePlaybackStart;
@@ -102,6 +94,10 @@ namespace Emby.Dlna.PlayTo
             _deviceDiscovery.DeviceLeft += OnDeviceDiscoveryDeviceLeft;
         }
 
+        public bool IsSessionActive => !_disposed;
+
+        public bool SupportsMediaControl => IsSessionActive;
+
         /*
          * Send a message to the DLNA device to notify what is the next track in the playlist.
          */
@@ -131,22 +127,22 @@ namespace Emby.Dlna.PlayTo
             }
         }
 
-        private void OnDeviceDiscoveryDeviceLeft(object sender, GenericEventArgs<UpnpDeviceInfo> e)
+        private void OnDeviceDiscoveryDeviceLeft(object? sender, GenericEventArgs<UpnpDeviceInfo> e)
         {
             var info = e.Argument;
 
             if (!_disposed
-                && info.Headers.TryGetValue("USN", out string usn)
+                && info.Headers.TryGetValue("USN", out string? usn)
                 && usn.IndexOf(_device.Properties.UUID, StringComparison.OrdinalIgnoreCase) != -1
                 && (usn.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) != -1
-                    || (info.Headers.TryGetValue("NT", out string nt)
+                    || (info.Headers.TryGetValue("NT", out string? nt)
                         && nt.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) != -1)))
             {
                 OnDeviceUnavailable();
             }
         }
 
-        private async void OnDeviceMediaChanged(object sender, MediaChangedEventArgs e)
+        private async void OnDeviceMediaChanged(object? sender, MediaChangedEventArgs e)
         {
             if (_disposed || string.IsNullOrEmpty(e.OldMediaInfo.Url))
             {
@@ -188,7 +184,7 @@ namespace Emby.Dlna.PlayTo
             }
         }
 
-        private async void OnDevicePlaybackStopped(object sender, PlaybackStoppedEventArgs e)
+        private async void OnDevicePlaybackStopped(object? sender, PlaybackStoppedEventArgs e)
         {
             if (_disposed)
             {
@@ -257,7 +253,7 @@ namespace Emby.Dlna.PlayTo
             }
         }
 
-        private async void OnDevicePlaybackStart(object sender, PlaybackStartEventArgs e)
+        private async void OnDevicePlaybackStart(object? sender, PlaybackStartEventArgs e)
         {
             if (_disposed)
             {
@@ -281,7 +277,7 @@ namespace Emby.Dlna.PlayTo
             }
         }
 
-        private async void OnDevicePlaybackProgress(object sender, PlaybackProgressEventArgs e)
+        private async void OnDevicePlaybackProgress(object? sender, PlaybackProgressEventArgs e)
         {
             if (_disposed)
             {
@@ -486,9 +482,9 @@ namespace Emby.Dlna.PlayTo
 
         private PlaylistItem CreatePlaylistItem(
             BaseItem item,
-            User user,
+            User? user,
             long startPostionTicks,
-            string mediaSourceId,
+            string? mediaSourceId,
             int? audioStreamIndex,
             int? subtitleStreamIndex)
         {
@@ -525,7 +521,7 @@ namespace Emby.Dlna.PlayTo
             return playlistItem;
         }
 
-        private string GetDlnaHeaders(PlaylistItem item)
+        private string? GetDlnaHeaders(PlaylistItem item)
         {
             var profile = item.Profile;
             var streamInfo = item.StreamInfo;
@@ -579,7 +575,7 @@ namespace Emby.Dlna.PlayTo
             return null;
         }
 
-        private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
+        private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
         {
             if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
             {
@@ -696,7 +692,6 @@ namespace Emby.Dlna.PlayTo
             _device.MediaChanged -= OnDeviceMediaChanged;
             _deviceDiscovery.DeviceLeft -= OnDeviceDiscoveryDeviceLeft;
             _device.OnDeviceUnavailable = null;
-            _device = null;
 
             _disposed = true;
         }
@@ -716,7 +711,7 @@ namespace Emby.Dlna.PlayTo
                 case GeneralCommandType.ToggleMute:
                     return _device.ToggleMute(cancellationToken);
                 case GeneralCommandType.SetAudioStreamIndex:
-                    if (command.Arguments.TryGetValue("Index", out string index))
+                    if (command.Arguments.TryGetValue("Index", out string? index))
                     {
                         if (int.TryParse(index, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
                         {
@@ -740,7 +735,7 @@ namespace Emby.Dlna.PlayTo
 
                     throw new ArgumentException("SetSubtitleStreamIndex argument cannot be null");
                 case GeneralCommandType.SetVolume:
-                    if (command.Arguments.TryGetValue("Volume", out string vol))
+                    if (command.Arguments.TryGetValue("Volume", out string? vol))
                     {
                         if (int.TryParse(vol, NumberStyles.Integer, CultureInfo.InvariantCulture, out var volume))
                         {
@@ -865,34 +860,19 @@ namespace Emby.Dlna.PlayTo
                 throw new ObjectDisposedException(GetType().Name);
             }
 
-            if (_device is null)
-            {
-                return Task.CompletedTask;
-            }
-
-            if (name == SessionMessageType.Play)
-            {
-                return SendPlayCommand(data as PlayRequest, cancellationToken);
-            }
-
-            if (name == SessionMessageType.Playstate)
+            return name switch
             {
-                return SendPlaystateCommand(data as PlaystateRequest, cancellationToken);
-            }
-
-            if (name == SessionMessageType.GeneralCommand)
-            {
-                return SendGeneralCommand(data as GeneralCommand, cancellationToken);
-            }
-
-            // Not supported or needed right now
-            return Task.CompletedTask;
+                SessionMessageType.Play => SendPlayCommand((data as PlayRequest)!, cancellationToken),
+                SessionMessageType.Playstate => SendPlaystateCommand((data as PlaystateRequest)!, cancellationToken),
+                SessionMessageType.GeneralCommand => SendGeneralCommand((data as GeneralCommand)!, cancellationToken),
+                _ => Task.CompletedTask // Not supported or needed right now
+            };
         }
 
         private class StreamParams
         {
-            private MediaSourceInfo _mediaSource;
-            private IMediaSourceManager _mediaSourceManager;
+            private MediaSourceInfo? _mediaSource;
+            private IMediaSourceManager? _mediaSourceManager;
 
             public Guid ItemId { get; set; }
 
@@ -904,17 +884,17 @@ namespace Emby.Dlna.PlayTo
 
             public int? SubtitleStreamIndex { get; set; }
 
-            public string DeviceProfileId { get; set; }
+            public string? DeviceProfileId { get; set; }
 
-            public string DeviceId { get; set; }
+            public string? DeviceId { get; set; }
 
-            public string MediaSourceId { get; set; }
+            public string? MediaSourceId { get; set; }
 
-            public string LiveStreamId { get; set; }
+            public string? LiveStreamId { get; set; }
 
-            public BaseItem Item { get; set; }
+            public BaseItem? Item { get; set; }
 
-            public async Task<MediaSourceInfo> GetMediaSource(CancellationToken cancellationToken)
+            public async Task<MediaSourceInfo?> GetMediaSource(CancellationToken cancellationToken)
             {
                 if (_mediaSource is not null)
                 {
@@ -944,8 +924,8 @@ namespace Emby.Dlna.PlayTo
                 {
                     var part = parts[i];
 
-                    if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase) ||
-                        string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase))
+                    if (string.Equals(part, "audio", StringComparison.OrdinalIgnoreCase)
+                        || string.Equals(part, "videos", StringComparison.OrdinalIgnoreCase))
                     {
                         if (Guid.TryParse(parts[i + 1], out var result))
                         {

+ 2 - 3
Emby.Dlna/PlayTo/PlayToManager.cs

@@ -205,12 +205,11 @@ namespace Emby.Dlna.PlayTo
                     _userDataManager,
                     _localization,
                     _mediaSourceManager,
-                    _mediaEncoder);
+                    _mediaEncoder,
+                    device);
 
                 sessionInfo.AddController(controller);
 
-                controller.Init(device);
-
                 var profile = _dlnaManager.GetProfile(device.Properties.ToDeviceIdentification()) ??
                               _dlnaManager.GetDefaultProfile();
 

+ 4 - 0
Emby.Server.Implementations/ApplicationHost.cs

@@ -80,11 +80,13 @@ using MediaBrowser.Controller.Subtitles;
 using MediaBrowser.Controller.SyncPlay;
 using MediaBrowser.Controller.TV;
 using MediaBrowser.LocalMetadata.Savers;
+using MediaBrowser.MediaEncoding.BdInfo;
 using MediaBrowser.MediaEncoding.Subtitles;
 using MediaBrowser.Model.Cryptography;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Globalization;
 using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
 using MediaBrowser.Model.Net;
 using MediaBrowser.Model.Serialization;
 using MediaBrowser.Model.System;
@@ -529,6 +531,8 @@ namespace Emby.Server.Implementations
 
             serviceCollection.AddSingleton<ILocalizationManager, LocalizationManager>();
 
+            serviceCollection.AddSingleton<IBlurayExaminer, BdInfoExaminer>();
+
             serviceCollection.AddSingleton<IUserDataRepository, SqliteUserDataRepository>();
             serviceCollection.AddSingleton<IUserDataManager, UserDataManager>();
 

+ 14 - 10
Emby.Server.Implementations/Channels/ChannelManager.cs

@@ -157,16 +157,16 @@ namespace Emby.Server.Implementations.Channels
         }
 
         /// <inheritdoc />
-        public QueryResult<Channel> GetChannelsInternal(ChannelQuery query)
+        public async Task<QueryResult<Channel>> GetChannelsInternalAsync(ChannelQuery query)
         {
             var user = query.UserId.Equals(default)
                 ? null
                 : _userManager.GetUserById(query.UserId);
 
-            var channels = GetAllChannels()
-                .Select(GetChannelEntity)
+            var channels = await GetAllChannelEntitiesAsync()
                 .OrderBy(i => i.SortName)
-                .ToList();
+                .ToListAsync()
+                .ConfigureAwait(false);
 
             if (query.IsRecordingsFolder.HasValue)
             {
@@ -226,6 +226,7 @@ namespace Emby.Server.Implementations.Channels
 
             if (user is not null)
             {
+                var userId = user.Id.ToString("N", CultureInfo.InvariantCulture);
                 channels = channels.Where(i =>
                 {
                     if (!i.IsVisible(user))
@@ -235,7 +236,7 @@ namespace Emby.Server.Implementations.Channels
 
                     try
                     {
-                        return GetChannelProvider(i).IsEnabledFor(user.Id.ToString("N", CultureInfo.InvariantCulture));
+                        return GetChannelProvider(i).IsEnabledFor(userId);
                     }
                     catch
                     {
@@ -258,7 +259,7 @@ namespace Emby.Server.Implementations.Channels
             {
                 foreach (var item in all)
                 {
-                    RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult();
+                    await RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).ConfigureAwait(false);
                 }
             }
 
@@ -269,13 +270,13 @@ namespace Emby.Server.Implementations.Channels
         }
 
         /// <inheritdoc />
-        public QueryResult<BaseItemDto> GetChannels(ChannelQuery query)
+        public async Task<QueryResult<BaseItemDto>> GetChannelsAsync(ChannelQuery query)
         {
             var user = query.UserId.Equals(default)
                 ? null
                 : _userManager.GetUserById(query.UserId);
 
-            var internalResult = GetChannelsInternal(query);
+            var internalResult = await GetChannelsInternalAsync(query).ConfigureAwait(false);
 
             var dtoOptions = new DtoOptions();
 
@@ -327,9 +328,12 @@ namespace Emby.Server.Implementations.Channels
             progress.Report(100);
         }
 
-        private Channel GetChannelEntity(IChannel channel)
+        private async IAsyncEnumerable<Channel> GetAllChannelEntitiesAsync()
         {
-            return GetChannel(GetInternalChannelId(channel.Name)) ?? GetChannel(channel, CancellationToken.None).GetAwaiter().GetResult();
+            foreach (IChannel channel in GetAllChannels())
+            {
+                yield return GetChannel(GetInternalChannelId(channel.Name)) ?? await GetChannel(channel, CancellationToken.None).ConfigureAwait(false);
+            }
         }
 
         private MediaSourceInfo[] GetSavedMediaSources(BaseItem item)

+ 13 - 5
Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs

@@ -276,25 +276,31 @@ namespace Emby.Server.Implementations.EntryPoints
         /// Libraries the update timer callback.
         /// </summary>
         /// <param name="state">The state.</param>
-        private void LibraryUpdateTimerCallback(object state)
+        private async void LibraryUpdateTimerCallback(object state)
         {
+            List<Folder> foldersAddedTo;
+            List<Folder> foldersRemovedFrom;
+            List<BaseItem> itemsUpdated;
+            List<BaseItem> itemsAdded;
+            List<BaseItem> itemsRemoved;
             lock (_libraryChangedSyncLock)
             {
                 // Remove dupes in case some were saved multiple times
-                var foldersAddedTo = _foldersAddedTo
+                foldersAddedTo = _foldersAddedTo
                                         .DistinctBy(x => x.Id)
                                         .ToList();
 
-                var foldersRemovedFrom = _foldersRemovedFrom
+                foldersRemovedFrom = _foldersRemovedFrom
                                             .DistinctBy(x => x.Id)
                                             .ToList();
 
-                var itemsUpdated = _itemsUpdated
+                itemsUpdated = _itemsUpdated
                                     .Where(i => !_itemsAdded.Contains(i))
                                     .DistinctBy(x => x.Id)
                                     .ToList();
 
-                SendChangeNotifications(_itemsAdded.ToList(), itemsUpdated, _itemsRemoved.ToList(), foldersAddedTo, foldersRemovedFrom, CancellationToken.None).GetAwaiter().GetResult();
+                itemsAdded = _itemsAdded.ToList();
+                itemsRemoved = _itemsRemoved.ToList();
 
                 if (LibraryUpdateTimer is not null)
                 {
@@ -308,6 +314,8 @@ namespace Emby.Server.Implementations.EntryPoints
                 _foldersAddedTo.Clear();
                 _foldersRemovedFrom.Clear();
             }
+
+            await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
         }
 
         /// <summary>

+ 7 - 6
Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs

@@ -87,29 +87,30 @@ namespace Emby.Server.Implementations.EntryPoints
             }
         }
 
-        private void UpdateTimerCallback(object? state)
+        private async void UpdateTimerCallback(object? state)
         {
+            List<KeyValuePair<Guid, List<BaseItem>>> changes;
             lock (_syncLock)
             {
                 // Remove dupes in case some were saved multiple times
-                var changes = _changedItems.ToList();
+                changes = _changedItems.ToList();
                 _changedItems.Clear();
 
-                SendNotifications(changes, CancellationToken.None).GetAwaiter().GetResult();
-
                 if (_updateTimer is not null)
                 {
                     _updateTimer.Dispose();
                     _updateTimer = null;
                 }
             }
+
+            await SendNotifications(changes, CancellationToken.None).ConfigureAwait(false);
         }
 
         private async Task SendNotifications(List<KeyValuePair<Guid, List<BaseItem>>> changes, CancellationToken cancellationToken)
         {
-            foreach (var pair in changes)
+            foreach ((var key, var value) in changes)
             {
-                await SendNotifications(pair.Key, pair.Value, cancellationToken).ConfigureAwait(false);
+                await SendNotifications(key, value, cancellationToken).ConfigureAwait(false);
             }
         }
 

+ 5 - 3
Emby.Server.Implementations/Library/LibraryManager.cs

@@ -113,6 +113,7 @@ namespace Emby.Server.Implementations.Library
         /// <param name="imageProcessor">The image processor.</param>
         /// <param name="memoryCache">The memory cache.</param>
         /// <param name="namingOptions">The naming options.</param>
+        /// <param name="directoryService">The directory service.</param>
         public LibraryManager(
             IServerApplicationHost appHost,
             ILoggerFactory loggerFactory,
@@ -128,7 +129,8 @@ namespace Emby.Server.Implementations.Library
             IItemRepository itemRepository,
             IImageProcessor imageProcessor,
             IMemoryCache memoryCache,
-            NamingOptions namingOptions)
+            NamingOptions namingOptions,
+            IDirectoryService directoryService)
         {
             _appHost = appHost;
             _logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -146,7 +148,7 @@ namespace Emby.Server.Implementations.Library
             _memoryCache = memoryCache;
             _namingOptions = namingOptions;
 
-            _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions);
+            _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
 
             _configurationManager.ConfigurationUpdated += ConfigurationUpdated;
 
@@ -537,7 +539,7 @@ namespace Emby.Server.Implementations.Library
                 collectionType = GetContentTypeOverride(fullPath, true);
             }
 
-            var args = new ItemResolveArgs(_configurationManager.ApplicationPaths, directoryService)
+            var args = new ItemResolveArgs(_configurationManager.ApplicationPaths, this)
             {
                 Parent = parent,
                 FileInfo = fileInfo,

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

@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
                     continue;
                 }
 
-                if (resolvedItem.Files.Count == 0)
+                // Until multi-part books are handled letting files stack hides them from browsing in the client
+                if (resolvedItem.Files.Count == 0 || resolvedItem.Extras.Count > 0 || resolvedItem.AlternateVersions.Count > 0)
                 {
                     continue;
                 }

+ 5 - 2
Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs

@@ -25,16 +25,19 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
     {
         private readonly ILogger<MusicAlbumResolver> _logger;
         private readonly NamingOptions _namingOptions;
+        private readonly IDirectoryService _directoryService;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="MusicAlbumResolver"/> class.
         /// </summary>
         /// <param name="logger">The logger.</param>
         /// <param name="namingOptions">The naming options.</param>
-        public MusicAlbumResolver(ILogger<MusicAlbumResolver> logger, NamingOptions namingOptions)
+        /// <param name="directoryService">The directory service.</param>
+        public MusicAlbumResolver(ILogger<MusicAlbumResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
         {
             _logger = logger;
             _namingOptions = namingOptions;
+            _directoryService = directoryService;
         }
 
         /// <summary>
@@ -109,7 +112,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
                 }
 
                 // If args contains music it's a music album
-                if (ContainsMusic(args.FileSystemChildren, true, args.DirectoryService))
+                if (ContainsMusic(args.FileSystemChildren, true, _directoryService))
                 {
                     return true;
                 }

+ 9 - 6
Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
 using Emby.Naming.Common;
 using MediaBrowser.Controller.Entities.Audio;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Resolvers;
 using MediaBrowser.Model.Entities;
 using Microsoft.Extensions.Logging;
@@ -18,19 +19,23 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
     public class MusicArtistResolver : ItemResolver<MusicArtist>
     {
         private readonly ILogger<MusicAlbumResolver> _logger;
-        private NamingOptions _namingOptions;
+        private readonly NamingOptions _namingOptions;
+        private readonly IDirectoryService _directoryService;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="MusicArtistResolver"/> class.
         /// </summary>
         /// <param name="logger">Instance of the <see cref="MusicAlbumResolver"/> interface.</param>
         /// <param name="namingOptions">The <see cref="NamingOptions"/>.</param>
+        /// <param name="directoryService">The directory service.</param>
         public MusicArtistResolver(
             ILogger<MusicAlbumResolver> logger,
-            NamingOptions namingOptions)
+            NamingOptions namingOptions,
+            IDirectoryService directoryService)
         {
             _logger = logger;
             _namingOptions = namingOptions;
+            _directoryService = directoryService;
         }
 
         /// <summary>
@@ -78,9 +83,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
                 return null;
             }
 
-            var directoryService = args.DirectoryService;
-
-            var albumResolver = new MusicAlbumResolver(_logger, _namingOptions);
+            var albumResolver = new MusicAlbumResolver(_logger, _namingOptions, _directoryService);
 
             var directories = args.FileSystemChildren.Where(i => i.IsDirectory);
 
@@ -97,7 +100,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
                 }
 
                 // If we contain a music album assume we are an artist folder
-                if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, directoryService))
+                if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, _directoryService))
                 {
                     // Stop once we see a music album
                     state.Stop();

+ 19 - 4
Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs

@@ -25,14 +25,17 @@ namespace Emby.Server.Implementations.Library.Resolvers
     {
         private readonly ILogger _logger;
 
-        protected BaseVideoResolver(ILogger logger, NamingOptions namingOptions)
+        protected BaseVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
         {
             _logger = logger;
             NamingOptions = namingOptions;
+            DirectoryService = directoryService;
         }
 
         protected NamingOptions NamingOptions { get; }
 
+        protected IDirectoryService DirectoryService { get; }
+
         /// <summary>
         /// Resolves the specified args.
         /// </summary>
@@ -65,13 +68,25 @@ namespace Emby.Server.Implementations.Library.Resolvers
                     var filename = child.Name;
                     if (child.IsDirectory)
                     {
-                        if (IsDvdDirectory(child.FullName, filename, args.DirectoryService))
+                        if (IsDvdDirectory(child.FullName, filename, DirectoryService))
                         {
-                            videoType = VideoType.Dvd;
+                            var videoTmp = new TVideoType
+                            {
+                                Path = args.Path,
+                                VideoType = VideoType.Dvd
+                            };
+                            Set3DFormat(videoTmp);
+                            return videoTmp;
                         }
                         else if (IsBluRayDirectory(filename))
                         {
-                            videoType = VideoType.BluRay;
+                            var videoTmp = new TVideoType
+                            {
+                                Path = args.Path,
+                                VideoType = VideoType.BluRay
+                            };
+                            Set3DFormat(videoTmp);
+                            return videoTmp;
                         }
                     }
                     else if (IsDvdFile(filename))

+ 5 - 3
Emby.Server.Implementations/Library/Resolvers/ExtraResolver.cs

@@ -4,6 +4,7 @@ using System.IO;
 using Emby.Naming.Common;
 using Emby.Naming.Video;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Resolvers;
 using MediaBrowser.Model.Entities;
 using Microsoft.Extensions.Logging;
@@ -25,11 +26,12 @@ namespace Emby.Server.Implementations.Library.Resolvers
         /// </summary>
         /// <param name="logger">The logger.</param>
         /// <param name="namingOptions">An instance of <see cref="NamingOptions"/>.</param>
-        public ExtraResolver(ILogger<ExtraResolver> logger, NamingOptions namingOptions)
+        /// <param name="directoryService">The directory service.</param>
+        public ExtraResolver(ILogger<ExtraResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
         {
             _namingOptions = namingOptions;
-            _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions) };
-            _videoResolvers = new IItemResolver[] { new GenericVideoResolver<Video>(logger, namingOptions) };
+            _trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) };
+            _videoResolvers = new IItemResolver[] { new GenericVideoResolver<Video>(logger, namingOptions, directoryService) };
         }
 
         /// <summary>

+ 4 - 2
Emby.Server.Implementations/Library/Resolvers/GenericVideoResolver.cs

@@ -2,6 +2,7 @@
 
 using Emby.Naming.Common;
 using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.Library.Resolvers
@@ -18,8 +19,9 @@ namespace Emby.Server.Implementations.Library.Resolvers
         /// </summary>
         /// <param name="logger">The logger.</param>
         /// <param name="namingOptions">The naming options.</param>
-        public GenericVideoResolver(ILogger logger, NamingOptions namingOptions)
-            : base(logger, namingOptions)
+        /// <param name="directoryService">The directory service.</param>
+        public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
+            : base(logger, namingOptions, directoryService)
         {
         }
     }

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

@@ -43,8 +43,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
         /// <param name="imageProcessor">The image processor.</param>
         /// <param name="logger">The logger.</param>
         /// <param name="namingOptions">The naming options.</param>
-        public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions)
-            : base(logger, namingOptions)
+        /// <param name="directoryService">The directory service.</param>
+        public MovieResolver(IImageProcessor imageProcessor, ILogger<MovieResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
+            : base(logger, namingOptions, directoryService)
         {
             _imageProcessor = imageProcessor;
         }
@@ -97,12 +98,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
 
                 if (string.Equals(collectionType, CollectionType.MusicVideos, StringComparison.OrdinalIgnoreCase))
                 {
-                    movie = FindMovie<MusicVideo>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
+                    movie = FindMovie<MusicVideo>(args, args.Path, args.Parent, files, DirectoryService, collectionType, false);
                 }
 
                 if (string.Equals(collectionType, CollectionType.HomeVideos, StringComparison.OrdinalIgnoreCase))
                 {
-                    movie = FindMovie<Video>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, false);
+                    movie = FindMovie<Video>(args, args.Path, args.Parent, files, DirectoryService, collectionType, false);
                 }
 
                 if (string.IsNullOrEmpty(collectionType))
@@ -118,12 +119,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
                         return null;
                     }
 
-                    movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
+                    movie = FindMovie<Movie>(args, args.Path, args.Parent, files, DirectoryService, collectionType, true);
                 }
 
                 if (string.Equals(collectionType, CollectionType.Movies, StringComparison.OrdinalIgnoreCase))
                 {
-                    movie = FindMovie<Movie>(args, args.Path, args.Parent, files, args.DirectoryService, collectionType, true);
+                    movie = FindMovie<Movie>(args, args.Path, args.Parent, files, DirectoryService, collectionType, true);
                 }
 
                 // ignore extras

+ 14 - 4
Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs

@@ -1,7 +1,5 @@
 #nullable disable
 
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
 using System.IO;
@@ -12,15 +10,20 @@ using Jellyfin.Extensions;
 using MediaBrowser.Controller.Drawing;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Resolvers;
 using MediaBrowser.Model.Entities;
 
 namespace Emby.Server.Implementations.Library.Resolvers
 {
+    /// <summary>
+    /// Class PhotoResolver.
+    /// </summary>
     public class PhotoResolver : ItemResolver<Photo>
     {
         private readonly IImageProcessor _imageProcessor;
         private readonly NamingOptions _namingOptions;
+        private readonly IDirectoryService _directoryService;
 
         private static readonly HashSet<string> _ignoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
         {
@@ -35,10 +38,17 @@ namespace Emby.Server.Implementations.Library.Resolvers
             "default"
         };
 
-        public PhotoResolver(IImageProcessor imageProcessor, NamingOptions namingOptions)
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PhotoResolver"/> class.
+        /// </summary>
+        /// <param name="imageProcessor">The image processor.</param>
+        /// <param name="namingOptions">The naming options.</param>
+        /// <param name="directoryService">The directory service.</param>
+        public PhotoResolver(IImageProcessor imageProcessor, NamingOptions namingOptions, IDirectoryService directoryService)
         {
             _imageProcessor = imageProcessor;
             _namingOptions = namingOptions;
+            _directoryService = directoryService;
         }
 
         /// <summary>
@@ -61,7 +71,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
                         var filename = Path.GetFileNameWithoutExtension(args.Path);
 
                         // Make sure the image doesn't belong to a video file
-                        var files = args.DirectoryService.GetFiles(Path.GetDirectoryName(args.Path));
+                        var files = _directoryService.GetFiles(Path.GetDirectoryName(args.Path));
 
                         foreach (var file in files)
                         {

+ 4 - 2
Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs

@@ -5,6 +5,7 @@ using System.Linq;
 using Emby.Naming.Common;
 using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Entities;
 using Microsoft.Extensions.Logging;
 
@@ -20,8 +21,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
         /// </summary>
         /// <param name="logger">The logger.</param>
         /// <param name="namingOptions">The naming options.</param>
-        public EpisodeResolver(ILogger<EpisodeResolver> logger, NamingOptions namingOptions)
-            : base(logger, namingOptions)
+        /// <param name="directoryService">The directory service.</param>
+        public EpisodeResolver(ILogger<EpisodeResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService)
+            : base(logger, namingOptions, directoryService)
         {
         }
 

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

@@ -111,10 +111,10 @@ namespace Emby.Server.Implementations.Library
 
             if (query.IncludeExternalContent)
             {
-                var channelResult = _channelManager.GetChannelsInternal(new ChannelQuery
+                var channelResult = _channelManager.GetChannelsInternalAsync(new ChannelQuery
                 {
                     UserId = query.UserId
-                });
+                }).GetAwaiter().GetResult();
 
                 var channels = channelResult.Items;
 

+ 16 - 16
Emby.Server.Implementations/LiveTv/LiveTvManager.cs

@@ -1312,20 +1312,19 @@ namespace Emby.Server.Implementations.LiveTv
             return 7;
         }
 
-        private QueryResult<BaseItem> GetEmbyRecordings(RecordingQuery query, DtoOptions dtoOptions, User user)
+        private async Task<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user)
         {
             if (user is null)
             {
                 return new QueryResult<BaseItem>();
             }
 
-            var folderIds = GetRecordingFolders(user, true)
-                .Select(i => i.Id)
-                .ToList();
+            var folders = await GetRecordingFoldersAsync(user, true).ConfigureAwait(false);
+            var folderIds = Array.ConvertAll(folders, x => x.Id);
 
             var excludeItemTypes = new List<BaseItemKind>();
 
-            if (folderIds.Count == 0)
+            if (folderIds.Length == 0)
             {
                 return new QueryResult<BaseItem>();
             }
@@ -1392,7 +1391,7 @@ namespace Emby.Server.Implementations.LiveTv
             {
                 MediaTypes = new[] { MediaType.Video },
                 Recursive = true,
-                AncestorIds = folderIds.ToArray(),
+                AncestorIds = folderIds,
                 IsFolder = false,
                 IsVirtualItem = false,
                 Limit = limit,
@@ -1528,7 +1527,7 @@ namespace Emby.Server.Implementations.LiveTv
             }
         }
 
-        public QueryResult<BaseItemDto> GetRecordings(RecordingQuery query, DtoOptions options)
+        public async Task<QueryResult<BaseItemDto>> GetRecordingsAsync(RecordingQuery query, DtoOptions options)
         {
             var user = query.UserId.Equals(default)
                 ? null
@@ -1536,7 +1535,7 @@ namespace Emby.Server.Implementations.LiveTv
 
             RemoveFields(options);
 
-            var internalResult = GetEmbyRecordings(query, options, user);
+            var internalResult = await GetEmbyRecordingsAsync(query, options, user).ConfigureAwait(false);
 
             var returnArray = _dtoService.GetBaseItemDtos(internalResult.Items, options, user);
 
@@ -2379,12 +2378,11 @@ namespace Emby.Server.Implementations.LiveTv
             return _tvDtoService.GetInternalProgramId(externalId);
         }
 
-        public List<BaseItem> GetRecordingFolders(User user)
-        {
-            return GetRecordingFolders(user, false);
-        }
+        /// <inheritdoc />
+        public Task<BaseItem[]> GetRecordingFoldersAsync(User user)
+            => GetRecordingFoldersAsync(user, false);
 
-        private List<BaseItem> GetRecordingFolders(User user, bool refreshChannels)
+        private async Task<BaseItem[]> GetRecordingFoldersAsync(User user, bool refreshChannels)
         {
             var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders()
                 .SelectMany(i => i.Locations)
@@ -2396,14 +2394,16 @@ namespace Emby.Server.Implementations.LiveTv
                 .OrderBy(i => i.SortName)
                 .ToList();
 
-            folders.AddRange(_channelManager.GetChannelsInternal(new MediaBrowser.Model.Channels.ChannelQuery
+            var channels = await _channelManager.GetChannelsInternalAsync(new MediaBrowser.Model.Channels.ChannelQuery
             {
                 UserId = user.Id,
                 IsRecordingsFolder = true,
                 RefreshLatestChannelItems = refreshChannels
-            }).Items);
+            }).ConfigureAwait(false);
+
+            folders.AddRange(channels.Items);
 
-            return folders.Cast<BaseItem>().ToList();
+            return folders.Cast<BaseItem>().ToArray();
         }
     }
 }

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

@@ -122,9 +122,13 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
             var attributes = ParseExtInf(extInf, out string remaining);
             extInf = remaining;
 
-            if (attributes.TryGetValue("tvg-logo", out string value))
+            if (attributes.TryGetValue("tvg-logo", out string tvgLogo))
             {
-                channel.ImageUrl = value;
+                channel.ImageUrl = tvgLogo;
+            }
+            else if (attributes.TryGetValue("logo", out string logo))
+            {
+                channel.ImageUrl = logo;
             }
 
             if (attributes.TryGetValue("group-title", out string groupTitle))

+ 10 - 1
Emby.Server.Implementations/Localization/Core/is.json

@@ -107,5 +107,14 @@
     "TasksApplicationCategory": "Forrit",
     "TasksLibraryCategory": "Miðlasafn",
     "TasksMaintenanceCategory": "Viðhald",
-    "Default": "Sjálfgefið"
+    "Default": "Sjálfgefið",
+    "TaskCleanActivityLog": "Hreinsa athafnaskrá",
+    "TaskRefreshPeople": "Endurnýja fólk",
+    "TaskDownloadMissingSubtitles": "Sækja texta sem vantar",
+    "TaskOptimizeDatabase": "Fínstilla gagnagrunn",
+    "Undefined": "Óskilgreint",
+    "TaskCleanLogsDescription": "Eyðir færslu skrám sem eru meira en {0} gömul.",
+    "TaskCleanLogs": "Hreinsa færslu skrá",
+    "TaskDownloadMissingSubtitlesDescription": "Leitar á netinu að texta sem vantar miðað við uppsetningu lýsigagna.",
+    "HearingImpaired": "Heyrnarskertur"
 }

+ 49 - 31
Emby.Server.Implementations/Localization/LocalizationManager.cs

@@ -184,10 +184,19 @@ namespace Emby.Server.Implementations.Localization
         /// <inheritdoc />
         public IEnumerable<ParentalRating> GetParentalRatings()
         {
-            var ratings = GetParentalRatingsDictionary().Values.ToList();
+            // Use server default language for ratings
+            // Fall back to empty list if there are no parental ratings for that language
+            var ratings = GetParentalRatingsDictionary()?.Values.ToList()
+                ?? new List<ParentalRating>();
 
-            // Add common ratings to ensure them being available for selection.
+            // Add common ratings to ensure them being available for selection
             // Based on the US rating system due to it being the main source of rating in the metadata providers
+            // Unrated
+            if (!ratings.Any(x => x.Value is null))
+            {
+                ratings.Add(new ParentalRating("Unrated", null));
+            }
+
             // Minimum rating possible
             if (!ratings.Any(x => x.Value == 0))
             {
@@ -237,36 +246,26 @@ namespace Emby.Server.Implementations.Localization
         /// <summary>
         /// Gets the parental ratings dictionary.
         /// </summary>
+        /// <param name="countryCode">The optional two letter ISO language string.</param>
         /// <returns><see cref="Dictionary{String, ParentalRating}" />.</returns>
-        private Dictionary<string, ParentalRating> GetParentalRatingsDictionary()
+        private Dictionary<string, ParentalRating>? GetParentalRatingsDictionary(string? countryCode = null)
         {
-            var countryCode = _configurationManager.Configuration.MetadataCountryCode;
-
-            // Fall back to US ratings if no country code is specified or country code does not exist.
+            // Fallback to server default if no country code is specified.
             if (string.IsNullOrEmpty(countryCode))
             {
-                countryCode = "us";
+                countryCode = _configurationManager.Configuration.MetadataCountryCode;
             }
 
-            return GetRatings(countryCode)
-                ?? GetRatings("us")
-                ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
-        }
-
-        /// <summary>
-        /// Gets the ratings for a country.
-        /// </summary>
-        /// <param name="countryCode">The country code.</param>
-        /// <returns>The ratings.</returns>
-        private Dictionary<string, ParentalRating>? GetRatings(string countryCode)
-        {
-            _allParentalRatings.TryGetValue(countryCode, out var countryValue);
+            if (_allParentalRatings.TryGetValue(countryCode, out var countryValue))
+            {
+                return countryValue;
+            }
 
-            return countryValue;
+            return null;
         }
 
         /// <inheritdoc />
-        public int? GetRatingLevel(string rating)
+        public int? GetRatingLevel(string rating, string? countryCode = null)
         {
             ArgumentException.ThrowIfNullOrEmpty(rating);
 
@@ -280,32 +279,51 @@ namespace Emby.Server.Implementations.Localization
             rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase);
             rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase);
 
-            var ratingsDictionary = GetParentalRatingsDictionary();
-
-            if (ratingsDictionary.TryGetValue(rating, out ParentalRating? value))
+            // Use rating system matching the language
+            if (!string.IsNullOrEmpty(countryCode))
+            {
+                var ratingsDictionary = GetParentalRatingsDictionary(countryCode);
+                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value))
+                {
+                    return value.Value;
+                }
+            }
+            else
             {
-                return value.Value;
+                // Fall back to server default language for ratings check
+                // If it has no ratings, use the US ratings
+                var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
+                if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRating? value))
+                {
+                    return value.Value;
+                }
             }
 
-            // If we don't find anything check all ratings systems
+            // If we don't find anything, check all ratings systems
             foreach (var dictionary in _allParentalRatings.Values)
             {
-                if (dictionary.TryGetValue(rating, out value))
+                if (dictionary.TryGetValue(rating, out var value))
                 {
                     return value.Value;
                 }
             }
 
-            // Try splitting by : to handle "Germany: FSK 18"
+            // Try splitting by : to handle "Germany: FSK-18"
             if (rating.Contains(':', StringComparison.OrdinalIgnoreCase))
             {
                 return GetRatingLevel(rating.AsSpan().RightPart(':').ToString());
             }
 
-            // Remove prefix country code to handle "DE-18"
+            // Handle prefix country code to handle "DE-18"
             if (rating.Contains('-', StringComparison.OrdinalIgnoreCase))
             {
-                return GetRatingLevel(rating.AsSpan().RightPart('-').ToString());
+                var ratingSpan = rating.AsSpan();
+
+                // Extract culture from country prefix
+                var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString());
+
+                // Check rating system of culture
+                return GetRatingLevel(ratingSpan.RightPart('-').ToString(), culture?.TwoLetterISOLanguageName);
             }
 
             return null;

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

@@ -52,7 +52,7 @@ public class ChannelsController : BaseJellyfinApiController
     /// <returns>An <see cref="OkResult"/> containing the channels.</returns>
     [HttpGet]
     [ProducesResponseType(StatusCodes.Status200OK)]
-    public ActionResult<QueryResult<BaseItemDto>> GetChannels(
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannels(
         [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
         [FromQuery] int? limit,
@@ -61,7 +61,7 @@ public class ChannelsController : BaseJellyfinApiController
         [FromQuery] bool? isFavorite)
     {
         userId = RequestHelpers.GetUserId(User, userId);
-        return _channelManager.GetChannels(new ChannelQuery
+        return await _channelManager.GetChannelsAsync(new ChannelQuery
         {
             Limit = limit,
             StartIndex = startIndex,
@@ -69,7 +69,7 @@ public class ChannelsController : BaseJellyfinApiController
             SupportsLatestItems = supportsLatestItems,
             SupportsMediaDeletion = supportsMediaDeletion,
             IsFavorite = isFavorite
-        });
+        }).ConfigureAwait(false);
     }
 
     /// <summary>

+ 9 - 3
Jellyfin.Api/Controllers/DynamicHlsController.cs

@@ -19,6 +19,8 @@ using MediaBrowser.Controller.Devices;
 using MediaBrowser.Controller.Dlna;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.MediaEncoding.Encoder;
 using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.Dlna;
 using MediaBrowser.Model.Entities;
@@ -1654,8 +1656,8 @@ public class DynamicHlsController : BaseJellyfinApiController
             startNumber.ToString(CultureInfo.InvariantCulture),
             baseUrlParam,
             isEventPlaylist ? "event" : "vod",
-            outputTsArg,
-            outputPath).Trim();
+            EncodingUtils.NormalizePath(outputTsArg),
+            EncodingUtils.NormalizePath(outputPath)).Trim();
     }
 
     /// <summary>
@@ -1840,7 +1842,11 @@ public class DynamicHlsController : BaseJellyfinApiController
             // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
 
             // video processing filters.
-            args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec);
+            var videoProcessParam = _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec);
+
+            var negativeMapArgs = _encodingHelper.GetNegativeMapArgsByFilters(state, videoProcessParam);
+
+            args = negativeMapArgs + args + videoProcessParam;
 
             // -start_at_zero is necessary to use with -ss when seeking,
             // otherwise the target position cannot be determined.

+ 5 - 0
Jellyfin.Api/Controllers/ItemUpdateController.cs

@@ -246,6 +246,11 @@ public class ItemUpdateController : BaseJellyfinApiController
             episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber;
         }
 
+        if (request.Height is not null && item is LiveTvChannel channel)
+        {
+            channel.Height = request.Height.Value;
+        }
+
         item.Tags = request.Tags;
 
         if (request.Taglines is not null)

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

@@ -252,7 +252,7 @@ public class LiveTvController : BaseJellyfinApiController
     [HttpGet("Recordings")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [Authorize(Policy = Policies.LiveTvAccess)]
-    public ActionResult<QueryResult<BaseItemDto>> GetRecordings(
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecordings(
         [FromQuery] string? channelId,
         [FromQuery] Guid? userId,
         [FromQuery] int? startIndex,
@@ -278,7 +278,7 @@ public class LiveTvController : BaseJellyfinApiController
             .AddClientFields(User)
             .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
 
-        return _liveTvManager.GetRecordings(
+        return await _liveTvManager.GetRecordingsAsync(
             new RecordingQuery
             {
                 ChannelId = channelId,
@@ -299,7 +299,7 @@ public class LiveTvController : BaseJellyfinApiController
                 ImageTypeLimit = imageTypeLimit,
                 EnableImages = enableImages
             },
-            dtoOptions);
+            dtoOptions).ConfigureAwait(false);
     }
 
     /// <summary>
@@ -383,13 +383,13 @@ public class LiveTvController : BaseJellyfinApiController
     [HttpGet("Recordings/Folders")]
     [ProducesResponseType(StatusCodes.Status200OK)]
     [Authorize(Policy = Policies.LiveTvAccess)]
-    public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId)
+    public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecordingFolders([FromQuery] Guid? userId)
     {
         userId = RequestHelpers.GetUserId(User, userId);
         var user = userId.Value.Equals(default)
             ? null
             : _userManager.GetUserById(userId.Value);
-        var folders = _liveTvManager.GetRecordingFolders(user);
+        var folders = await _liveTvManager.GetRecordingFoldersAsync(user).ConfigureAwait(false);
 
         var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user);
 

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

@@ -323,6 +323,15 @@ public class TranscodingJobHelper : IDisposable
         if (delete(job.Path!))
         {
             await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
+            if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay)
+            {
+                var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat");
+                if (File.Exists(concatFilePath))
+                {
+                    _logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath);
+                    File.Delete(concatFilePath);
+                }
+            }
         }
 
         if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
@@ -524,7 +533,10 @@ public class TranscodingJobHelper : IDisposable
         if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
         {
             var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
-            await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
+            if (state.VideoType != VideoType.Dvd)
+            {
+                await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
+            }
 
             if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase))
             {

+ 1 - 0
Jellyfin.Api/Jellyfin.Api.csproj

@@ -22,6 +22,7 @@
   <ItemGroup>
     <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" />
     <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+    <ProjectReference Include="..\MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj" />
     <ProjectReference Include="..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" />
   </ItemGroup>
 

+ 2 - 2
Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs

@@ -56,8 +56,8 @@ public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<Activi
         base.Dispose(dispose);
     }
 
-    private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e)
+    private async void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e)
     {
-        SendData(true).GetAwaiter().GetResult();
+        await SendData(true).ConfigureAwait(false);
     }
 }

+ 2 - 2
MediaBrowser.Controller/Channels/IChannelManager.cs

@@ -46,14 +46,14 @@ namespace MediaBrowser.Controller.Channels
         /// </summary>
         /// <param name="query">The query.</param>
         /// <returns>The channels.</returns>
-        QueryResult<Channel> GetChannelsInternal(ChannelQuery query);
+        Task<QueryResult<Channel>> GetChannelsInternalAsync(ChannelQuery query);
 
         /// <summary>
         /// Gets the channels.
         /// </summary>
         /// <param name="query">The query.</param>
         /// <returns>The channels.</returns>
-        QueryResult<BaseItemDto> GetChannels(ChannelQuery query);
+        Task<QueryResult<BaseItemDto>> GetChannelsAsync(ChannelQuery query);
 
         /// <summary>
         /// Gets the latest channel items.

+ 1 - 1
MediaBrowser.Controller/Entities/AggregateFolder.cs

@@ -120,7 +120,7 @@ namespace MediaBrowser.Controller.Entities
 
             var path = ContainingFolderPath;
 
-            var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService)
+            var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, LibraryManager)
             {
                 FileInfo = FileSystem.GetDirectoryInfo(path)
             };

+ 1 - 1
MediaBrowser.Controller/Entities/BaseItem.cs

@@ -47,7 +47,7 @@ namespace MediaBrowser.Controller.Entities
         /// The supported image extensions.
         /// </summary>
         public static readonly string[] SupportedImageExtensions
-            = new[] { ".png", ".jpg", ".jpeg", ".tbn", ".gif" };
+            = new[] { ".png", ".jpg", ".jpeg", ".webp", ".tbn", ".gif" };
 
         private static readonly List<string> _supportedExtensions = new List<string>(SupportedImageExtensions)
         {

+ 1 - 1
MediaBrowser.Controller/Entities/CollectionFolder.cs

@@ -288,7 +288,7 @@ namespace MediaBrowser.Controller.Entities
         {
             var path = ContainingFolderPath;
 
-            var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, directoryService)
+            var args = new ItemResolveArgs(ConfigurationManager.ApplicationPaths, LibraryManager)
             {
                 FileInfo = FileSystem.GetDirectoryInfo(path),
                 Parent = GetParent() as Folder,

+ 8 - 17
MediaBrowser.Controller/Library/ItemResolveArgs.cs

@@ -1,12 +1,11 @@
 #nullable disable
 
-#pragma warning disable CA1721, CA1819, CS1591
+#pragma warning disable CS1591
 
 using System;
 using System.Collections.Generic;
 using System.Linq;
 using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Configuration;
 using MediaBrowser.Model.IO;
 
@@ -23,22 +22,20 @@ namespace MediaBrowser.Controller.Library
         /// </summary>
         private readonly IServerApplicationPaths _appPaths;
 
+        private readonly ILibraryManager _libraryManager;
         private LibraryOptions _libraryOptions;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="ItemResolveArgs" /> class.
         /// </summary>
         /// <param name="appPaths">The app paths.</param>
-        /// <param name="directoryService">The directory service.</param>
-        public ItemResolveArgs(IServerApplicationPaths appPaths, IDirectoryService directoryService)
+        /// <param name="libraryManager">The library manager.</param>
+        public ItemResolveArgs(IServerApplicationPaths appPaths, ILibraryManager libraryManager)
         {
             _appPaths = appPaths;
-            DirectoryService = directoryService;
+            _libraryManager = libraryManager;
         }
 
-        // TODO remove dependencies as properties, they should be injected where it makes sense
-        public IDirectoryService DirectoryService { get; }
-
         /// <summary>
         /// Gets or sets the file system children.
         /// </summary>
@@ -47,7 +44,7 @@ namespace MediaBrowser.Controller.Library
 
         public LibraryOptions LibraryOptions
         {
-            get => _libraryOptions ??= Parent is null ? new LibraryOptions() : BaseItem.LibraryManager.GetLibraryOptions(Parent);
+            get => _libraryOptions ??= Parent is null ? new LibraryOptions() : _libraryManager.GetLibraryOptions(Parent);
             set => _libraryOptions = value;
         }
 
@@ -231,21 +228,15 @@ namespace MediaBrowser.Controller.Library
         /// <summary>
         /// Gets the configured content type for the path.
         /// </summary>
-        /// <remarks>
-        /// This is subject to future refactoring as it relies on a static property in BaseItem.
-        /// </remarks>
         /// <returns>The configured content type.</returns>
         public string GetConfiguredContentType()
         {
-            return BaseItem.LibraryManager.GetConfiguredContentType(Path);
+            return _libraryManager.GetConfiguredContentType(Path);
         }
 
         /// <summary>
         /// Gets the file system children that do not hit the ignore file check.
         /// </summary>
-        /// <remarks>
-        /// This is subject to future refactoring as it relies on a static property in BaseItem.
-        /// </remarks>
         /// <returns>The file system children that are not ignored.</returns>
         public IEnumerable<FileSystemMetadata> GetActualFileSystemChildren()
         {
@@ -253,7 +244,7 @@ namespace MediaBrowser.Controller.Library
             for (var i = 0; i < numberOfChildren; i++)
             {
                 var child = FileSystemChildren[i];
-                if (BaseItem.LibraryManager.IgnoreFile(child, Parent))
+                if (_libraryManager.IgnoreFile(child, Parent))
                 {
                     continue;
                 }

+ 2 - 2
MediaBrowser.Controller/LiveTv/ILiveTvManager.cs

@@ -97,7 +97,7 @@ namespace MediaBrowser.Controller.LiveTv
         /// <param name="query">The query.</param>
         /// <param name="options">The options.</param>
         /// <returns>A recording.</returns>
-        QueryResult<BaseItemDto> GetRecordings(RecordingQuery query, DtoOptions options);
+        Task<QueryResult<BaseItemDto>> GetRecordingsAsync(RecordingQuery query, DtoOptions options);
 
         /// <summary>
         /// Gets the timers.
@@ -308,6 +308,6 @@ namespace MediaBrowser.Controller.LiveTv
 
         void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null);
 
-        List<BaseItem> GetRecordingFolders(User user);
+        Task<BaseItem[]> GetRecordingFoldersAsync(User user);
     }
 }

+ 140 - 22
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -43,6 +43,9 @@ namespace MediaBrowser.Controller.MediaEncoding
         private readonly Version _maxKerneli915Hang = new Version(6, 1, 3);
         private readonly Version _minFixedKernel60i915Hang = new Version(6, 0, 18);
 
+        private readonly Version _minFFmpegImplictHwaccel = new Version(6, 0);
+        private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0);
+
         private static readonly string[] _videoProfilesH264 = new[]
         {
             "ConstrainedBaseline",
@@ -558,9 +561,12 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         public string GetInputPathArgument(EncodingJobInfo state)
         {
-            var mediaPath = state.MediaPath ?? string.Empty;
-
-            return _mediaEncoder.GetInputArgument(mediaPath, state.MediaSource);
+            return state.MediaSource.VideoType switch
+            {
+                VideoType.Dvd => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistVobFiles(state.MediaPath, null).ToList(), state.MediaSource),
+                VideoType.BluRay => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistM2tsFiles(state.MediaPath).ToList(), state.MediaSource),
+                _ => _mediaEncoder.GetInputArgument(state.MediaPath, state.MediaSource)
+            };
         }
 
         /// <summary>
@@ -639,6 +645,26 @@ namespace MediaBrowser.Controller.MediaEncoding
                 deviceIndex);
         }
 
+        private string GetVulkanDeviceArgs(int deviceIndex, string deviceName, string srcDeviceAlias, string alias)
+        {
+            alias ??= VulkanAlias;
+            deviceIndex = deviceIndex >= 0
+                ? deviceIndex
+                : 0;
+            var vendorOpts = string.IsNullOrEmpty(deviceName)
+                ? ":" + deviceIndex
+                : ":" + "\"" + deviceName + "\"";
+            var options = string.IsNullOrEmpty(srcDeviceAlias)
+                ? vendorOpts
+                : "@" + srcDeviceAlias;
+
+            return string.Format(
+                CultureInfo.InvariantCulture,
+                " -init_hw_device vulkan={0}{1}",
+                alias,
+                options);
+        }
+
         private string GetOpenclDeviceArgs(int deviceIndex, string deviceVendorName, string srcDeviceAlias, string alias)
         {
             alias ??= OpenclAlias;
@@ -821,6 +847,12 @@ namespace MediaBrowser.Controller.MediaEncoding
                             args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias));
                             filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias);
                         }
+                        else
+                        {
+                            // libplacebo wants an explicitly set vulkan filter device.
+                            args.Append(GetVulkanDeviceArgs(0, null, VaapiAlias, VulkanAlias));
+                            filterDevArgs = GetFilterHwDeviceArgs(VulkanAlias);
+                        }
                     }
                     else
                     {
@@ -962,8 +994,18 @@ namespace MediaBrowser.Controller.MediaEncoding
                 arg.Append(canvasArgs);
             }
 
-            arg.Append(" -i ")
-                .Append(GetInputPathArgument(state));
+            if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
+            {
+                var tmpConcatPath = Path.Join(options.TranscodingTempPath, state.MediaSource.Id + ".concat");
+                _mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath);
+                arg.Append(" -f concat -safe 0 -i ")
+                    .Append(tmpConcatPath);
+            }
+            else
+            {
+                arg.Append(" -i ")
+                    .Append(GetInputPathArgument(state));
+            }
 
             // sub2video for external graphical subtitles
             if (state.SubtitleStream is not null
@@ -2074,14 +2116,20 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         private static double GetVideoBitrateScaleFactor(string codec)
         {
+            // hevc & vp9 - 40% more efficient than h.264
             if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
                 || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)
-                || string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase))
+                || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
             {
                 return .6;
             }
 
+            // av1 - 50% more efficient than h.264
+            if (string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase))
+            {
+                return .5;
+            }
+
             return 1;
         }
 
@@ -2089,7 +2137,9 @@ namespace MediaBrowser.Controller.MediaEncoding
         {
             var inputScaleFactor = GetVideoBitrateScaleFactor(inputVideoCodec);
             var outputScaleFactor = GetVideoBitrateScaleFactor(outputVideoCodec);
-            var scaleFactor = outputScaleFactor / inputScaleFactor;
+
+            // Don't scale the real bitrate lower than the requested bitrate
+            var scaleFactor = Math.Min(outputScaleFactor / inputScaleFactor, 1);
 
             if (bitrate <= 500000)
             {
@@ -2429,6 +2479,30 @@ namespace MediaBrowser.Controller.MediaEncoding
             return args;
         }
 
+        /// <summary>
+        /// Gets the negative map args by filters.
+        /// </summary>
+        /// <param name="state">The state.</param>
+        /// <param name="videoProcessFilters">The videoProcessFilters.</param>
+        /// <returns>System.String.</returns>
+        public string GetNegativeMapArgsByFilters(EncodingJobInfo state, string videoProcessFilters)
+        {
+            string args = string.Empty;
+
+            // http://ffmpeg.org/ffmpeg-all.html#toc-Complex-filtergraphs-1
+            if (state.VideoStream != null && videoProcessFilters.Contains("-filter_complex", StringComparison.Ordinal))
+            {
+                int videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream);
+
+                args += string.Format(
+                    CultureInfo.InvariantCulture,
+                    "-map -0:{0} ",
+                    videoStreamIndex);
+            }
+
+            return args;
+        }
+
         /// <summary>
         /// Determines which stream will be used for playback.
         /// </summary>
@@ -3271,7 +3345,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                 // OUTPUT nv12 surface(memory)
                 // prefer hwmap to hwdownload on opencl.
-                var hwTransferFilter = hasGraphicalSubs ? "hwdownload" : "hwmap";
+                var hwTransferFilter = hasGraphicalSubs ? "hwdownload" : "hwmap=mode=read";
                 mainFilters.Add(hwTransferFilter);
                 mainFilters.Add("format=nv12");
             }
@@ -3514,7 +3588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 // OUTPUT nv12 surface(memory)
                 // prefer hwmap to hwdownload on opencl.
                 // qsv hwmap is not fully implemented for the time being.
-                mainFilters.Add(isHwmapUsable ? "hwmap" : "hwdownload");
+                mainFilters.Add(isHwmapUsable ? "hwmap=mode=read" : "hwdownload");
                 mainFilters.Add("format=nv12");
             }
 
@@ -3672,6 +3746,13 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                 var outFormat = doTonemap ? string.Empty : "nv12";
                 var hwScaleFilter = GetHwScaleFilter(isVaapiDecoder ? "vaapi" : "qsv", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH);
+
+                // allocate extra pool sizes for vaapi vpp
+                if (!string.IsNullOrEmpty(hwScaleFilter) && isVaapiDecoder)
+                {
+                    hwScaleFilter += ":extra_hw_frames=24";
+                }
+
                 // hw scale
                 mainFilters.Add(hwScaleFilter);
             }
@@ -3718,7 +3799,7 @@ namespace MediaBrowser.Controller.MediaEncoding
                 // OUTPUT nv12 surface(memory)
                 // prefer hwmap to hwdownload on opencl/vaapi.
                 // qsv hwmap is not fully implemented for the time being.
-                mainFilters.Add(isHwmapUsable ? "hwmap" : "hwdownload");
+                mainFilters.Add(isHwmapUsable ? "hwmap=mode=read" : "hwdownload");
                 mainFilters.Add("format=nv12");
             }
 
@@ -3947,6 +4028,13 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                 var outFormat = doTonemap ? string.Empty : "nv12";
                 var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH);
+
+                // allocate extra pool sizes for vaapi vpp
+                if (!string.IsNullOrEmpty(hwScaleFilter))
+                {
+                    hwScaleFilter += ":extra_hw_frames=24";
+                }
+
                 // hw scale
                 mainFilters.Add(hwScaleFilter);
             }
@@ -3988,7 +4076,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                 // OUTPUT nv12 surface(memory)
                 // prefer hwmap to hwdownload on opencl/vaapi.
-                mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap");
+                mainFilters.Add(isHwmapNotUsable ? "hwdownload" : "hwmap=mode=read");
                 mainFilters.Add("format=nv12");
             }
 
@@ -4126,7 +4214,9 @@ namespace MediaBrowser.Controller.MediaEncoding
                 // sw => hw
                 if (doVkTonemap)
                 {
-                    mainFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16");
+                    mainFilters.Add("hwupload_vaapi");
+                    mainFilters.Add("hwmap=derive_device=vulkan");
+                    mainFilters.Add("format=vulkan");
                 }
             }
             else if (isVaapiDecoder)
@@ -4156,6 +4246,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             {
                 // map from vaapi to vulkan via vaapi-vulkan interop (Vega/gfx9+).
                 mainFilters.Add("hwmap=derive_device=vulkan");
+                mainFilters.Add("format=vulkan");
             }
 
             // vk tonemap
@@ -4234,7 +4325,9 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                     // prefer vaapi hwupload to vulkan hwupload,
                     // Mesa RADV does not support a dedicated transfer queue.
-                    subFilters.Add("hwupload=derive_device=vaapi,format=vaapi,hwmap=derive_device=vulkan");
+                    subFilters.Add("hwupload_vaapi");
+                    subFilters.Add("hwmap=derive_device=vulkan");
+                    subFilters.Add("format=vulkan");
 
                     overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0");
                     overlayFilters.Add("scale_vulkan=format=nv12");
@@ -4336,6 +4429,13 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                 outFormat = doOclTonemap ? string.Empty : "nv12";
                 var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH);
+
+                // allocate extra pool sizes for vaapi vpp
+                if (!string.IsNullOrEmpty(hwScaleFilter))
+                {
+                    hwScaleFilter += ":extra_hw_frames=24";
+                }
+
                 // hw scale
                 mainFilters.Add(hwScaleFilter);
             }
@@ -4713,7 +4813,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             }
 
             // HWA decoders can handle both video files and video folders.
-            var videoType = mediaSource.VideoType;
+            var videoType = state.VideoType;
             if (videoType != VideoType.VideoFile
                 && videoType != VideoType.Iso
                 && videoType != VideoType.Dvd
@@ -4854,8 +4954,18 @@ namespace MediaBrowser.Controller.MediaEncoding
             var isVideotoolboxSupported = isMacOS && _mediaEncoder.SupportsHwaccel("videotoolbox");
             var isCodecAvailable = options.HardwareDecodingCodecs.Contains(videoCodec, StringComparison.OrdinalIgnoreCase);
 
+            var ffmpegVersion = _mediaEncoder.EncoderVersion;
+
             // Set the av1 codec explicitly to trigger hw accelerator, otherwise libdav1d will be used.
-            var isAv1 = string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase);
+            var isAv1 = ffmpegVersion < _minFFmpegImplictHwaccel
+                && string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase);
+
+            // Allow profile mismatch if decoding H.264 baseline with d3d11va and vaapi hwaccels.
+            var profileMismatch = string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)
+                && string.Equals(state.VideoStream?.Profile, "baseline", StringComparison.OrdinalIgnoreCase);
+
+            // Disable the extra internal copy in nvdec. We already handle it in filter chain.
+            var nvdecNoInternalCopy = ffmpegVersion >= _minFFmpegHwaUnsafeOutput;
 
             if (bitDepth == 10 && isCodecAvailable)
             {
@@ -4881,14 +4991,16 @@ namespace MediaBrowser.Controller.MediaEncoding
                 {
                     if (isVaapiSupported && isCodecAvailable)
                     {
-                        return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty);
+                        return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty)
+                            + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty);
                     }
 
                     if (isD3d11Supported && isCodecAvailable)
                     {
                         // set -threads 3 to intel d3d11va decoder explicitly. Lower threads may result in dead lock.
                         // on newer devices such as Xe, the larger the init_pool_size, the longer the initialization time for opencl to derive from d3d11.
-                        return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + " -threads 3" + (isAv1 ? " -c:v av1" : string.Empty);
+                        return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty)
+                            + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + " -threads 3" + (isAv1 ? " -c:v av1" : string.Empty);
                     }
                 }
                 else
@@ -4908,7 +5020,8 @@ namespace MediaBrowser.Controller.MediaEncoding
                     if (options.EnableEnhancedNvdecDecoder)
                     {
                         // set -threads 1 to nvdec decoder explicitly since it doesn't implement threading support.
-                        return " -hwaccel cuda" + (outputHwSurface ? " -hwaccel_output_format cuda" : string.Empty) + " -threads 1" + (isAv1 ? " -c:v av1" : string.Empty);
+                        return " -hwaccel cuda" + (outputHwSurface ? " -hwaccel_output_format cuda" : string.Empty)
+                            + (nvdecNoInternalCopy ? " -hwaccel_flags +unsafe_output" : string.Empty) + " -threads 1" + (isAv1 ? " -c:v av1" : string.Empty);
                     }
                     else
                     {
@@ -4923,7 +5036,8 @@ namespace MediaBrowser.Controller.MediaEncoding
             {
                 if (isD3d11Supported && isCodecAvailable)
                 {
-                    return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty);
+                    return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty)
+                        + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty);
                 }
             }
 
@@ -4932,9 +5046,11 @@ namespace MediaBrowser.Controller.MediaEncoding
                 && isVaapiSupported
                 && isCodecAvailable)
             {
-                return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty);
+                return " -hwaccel vaapi" + (outputHwSurface ? " -hwaccel_output_format vaapi" : string.Empty)
+                    + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty);
             }
 
+            // Apple videotoolbox
             if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)
                 && isVideotoolboxSupported
                 && isCodecAvailable)
@@ -5738,7 +5854,9 @@ namespace MediaBrowser.Controller.MediaEncoding
                 // video processing filters.
                 var videoProcessParam = GetVideoProcessingFilterParam(state, encodingOptions, videoCodec);
 
-                args += videoProcessParam;
+                var negativeMapArgs = GetNegativeMapArgsByFilters(state, videoProcessParam);
+
+                args = negativeMapArgs + args + videoProcessParam;
 
                 hasCopyTs = videoProcessParam.Contains("copyts", StringComparison.OrdinalIgnoreCase);
 

+ 30 - 0
MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs

@@ -153,6 +153,14 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// <returns>System.String.</returns>
         string GetInputArgument(string inputFile, MediaSourceInfo mediaSource);
 
+        /// <summary>
+        /// Gets the input argument.
+        /// </summary>
+        /// <param name="inputFiles">The input files.</param>
+        /// <param name="mediaSource">The mediaSource.</param>
+        /// <returns>System.String.</returns>
+        string GetInputArgument(IReadOnlyList<string> inputFiles, MediaSourceInfo mediaSource);
+
         /// <summary>
         /// Gets the input argument for an external subtitle file.
         /// </summary>
@@ -187,5 +195,27 @@ namespace MediaBrowser.Controller.MediaEncoding
         /// <param name="path">The path.</param>
         /// <param name="pathType">The type of path.</param>
         void UpdateEncoderPath(string path, string pathType);
+
+        /// <summary>
+        /// Gets the primary playlist of .vob files.
+        /// </summary>
+        /// <param name="path">The to the .vob files.</param>
+        /// <param name="titleNumber">The title number to start with.</param>
+        /// <returns>A playlist.</returns>
+        IReadOnlyList<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber);
+
+        /// <summary>
+        /// Gets the primary playlist of .m2ts files.
+        /// </summary>
+        /// <param name="path">The to the .m2ts files.</param>
+        /// <returns>A playlist.</returns>
+        IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path);
+
+        /// <summary>
+        /// Generates a FFmpeg concat config for the source.
+        /// </summary>
+        /// <param name="source">The <see cref="MediaSourceInfo"/>.</param>
+        /// <param name="concatFilePath">The path the config should be written to.</param>
+        void GenerateConcatConfig(MediaSourceInfo source, string concatFilePath);
     }
 }

+ 3 - 2
MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs

@@ -14,6 +14,7 @@ using MediaBrowser.Common.Extensions;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.MediaEncoding.Encoder;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.IO;
@@ -301,10 +302,10 @@ namespace MediaBrowser.MediaEncoding.Attachments
 
             var processArgs = string.Format(
                 CultureInfo.InvariantCulture,
-                "-dump_attachment:{1} {2} -i {0} -t 0 -f null null",
+                "-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null",
                 inputPath,
                 attachmentStreamIndex,
-                outputPath);
+                EncodingUtils.NormalizePath(outputPath));
 
             int exitCode;
 

+ 123 - 0
MediaBrowser.MediaEncoding/BdInfo/BdInfoDirectoryInfo.cs

@@ -0,0 +1,123 @@
+using System;
+using System.IO;
+using System.Linq;
+using BDInfo.IO;
+using MediaBrowser.Model.IO;
+
+namespace MediaBrowser.MediaEncoding.BdInfo;
+
+/// <summary>
+/// Class BdInfoDirectoryInfo.
+/// </summary>
+public class BdInfoDirectoryInfo : IDirectoryInfo
+{
+    private readonly IFileSystem _fileSystem;
+
+    private readonly FileSystemMetadata _impl;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BdInfoDirectoryInfo" /> class.
+    /// </summary>
+    /// <param name="fileSystem">The filesystem.</param>
+    /// <param name="path">The path.</param>
+    public BdInfoDirectoryInfo(IFileSystem fileSystem, string path)
+    {
+        _fileSystem = fileSystem;
+        _impl = _fileSystem.GetDirectoryInfo(path);
+    }
+
+    private BdInfoDirectoryInfo(IFileSystem fileSystem, FileSystemMetadata impl)
+    {
+        _fileSystem = fileSystem;
+        _impl = impl;
+    }
+
+    /// <summary>
+    /// Gets the name.
+    /// </summary>
+    public string Name => _impl.Name;
+
+    /// <summary>
+    /// Gets the full name.
+    /// </summary>
+    public string FullName => _impl.FullName;
+
+    /// <summary>
+    /// Gets the parent directory information.
+    /// </summary>
+    public IDirectoryInfo? Parent
+    {
+        get
+        {
+            var parentFolder = Path.GetDirectoryName(_impl.FullName);
+            if (parentFolder is not null)
+            {
+                return new BdInfoDirectoryInfo(_fileSystem, parentFolder);
+            }
+
+            return null;
+        }
+    }
+
+    /// <summary>
+    /// Gets the directories.
+    /// </summary>
+    /// <returns>An array with all directories.</returns>
+    public IDirectoryInfo[] GetDirectories()
+    {
+        return _fileSystem.GetDirectories(_impl.FullName)
+            .Select(x => new BdInfoDirectoryInfo(_fileSystem, x))
+            .ToArray();
+    }
+
+    /// <summary>
+    /// Gets the files.
+    /// </summary>
+    /// <returns>All files of the directory.</returns>
+    public IFileInfo[] GetFiles()
+    {
+        return _fileSystem.GetFiles(_impl.FullName)
+            .Select(x => new BdInfoFileInfo(x))
+            .ToArray();
+    }
+
+    /// <summary>
+    /// Gets the files matching a pattern.
+    /// </summary>
+    /// <param name="searchPattern">The search pattern.</param>
+    /// <returns>All files of the directory matchign the search pattern.</returns>
+    public IFileInfo[] GetFiles(string searchPattern)
+    {
+        return _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false)
+            .Select(x => new BdInfoFileInfo(x))
+            .ToArray();
+    }
+
+    /// <summary>
+    /// Gets the files matching a pattern and search options.
+    /// </summary>
+    /// <param name="searchPattern">The search pattern.</param>
+    /// <param name="searchOption">The search optin.</param>
+    /// <returns>All files of the directory matchign the search pattern and options.</returns>
+    public IFileInfo[] GetFiles(string searchPattern, SearchOption searchOption)
+    {
+        return _fileSystem.GetFiles(
+                _impl.FullName,
+                new[] { searchPattern },
+                false,
+                (searchOption & SearchOption.AllDirectories) == SearchOption.AllDirectories)
+            .Select(x => new BdInfoFileInfo(x))
+            .ToArray();
+    }
+
+    /// <summary>
+    /// Gets the bdinfo of a file system path.
+    /// </summary>
+    /// <param name="fs">The file system.</param>
+    /// <param name="path">The path.</param>
+    /// <returns>The BD directory information of the path on the file system.</returns>
+    public static IDirectoryInfo FromFileSystemPath(IFileSystem fs, string path)
+    {
+        return new BdInfoDirectoryInfo(fs, path);
+    }
+}

+ 187 - 0
MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs

@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using BDInfo;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+
+namespace MediaBrowser.MediaEncoding.BdInfo;
+
+/// <summary>
+/// Class BdInfoExaminer.
+/// </summary>
+public class BdInfoExaminer : IBlurayExaminer
+{
+    private readonly IFileSystem _fileSystem;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BdInfoExaminer" /> class.
+    /// </summary>
+    /// <param name="fileSystem">The filesystem.</param>
+    public BdInfoExaminer(IFileSystem fileSystem)
+    {
+        _fileSystem = fileSystem;
+    }
+
+    /// <summary>
+    /// Gets the disc info.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <returns>BlurayDiscInfo.</returns>
+    public BlurayDiscInfo GetDiscInfo(string path)
+    {
+        if (string.IsNullOrWhiteSpace(path))
+        {
+            throw new ArgumentNullException(nameof(path));
+        }
+
+        var bdrom = new BDROM(BdInfoDirectoryInfo.FromFileSystemPath(_fileSystem, path));
+
+        bdrom.Scan();
+
+        // Get the longest playlist
+        var playlist = bdrom.PlaylistFiles.Values.OrderByDescending(p => p.TotalLength).FirstOrDefault(p => p.IsValid);
+
+        var outputStream = new BlurayDiscInfo
+        {
+            MediaStreams = Array.Empty<MediaStream>()
+        };
+
+        if (playlist is null)
+        {
+            return outputStream;
+        }
+
+        outputStream.Chapters = playlist.Chapters.ToArray();
+
+        outputStream.RunTimeTicks = TimeSpan.FromSeconds(playlist.TotalLength).Ticks;
+
+        var sortedStreams = playlist.SortedStreams;
+        var mediaStreams = new List<MediaStream>(sortedStreams.Count);
+
+        foreach (var stream in sortedStreams)
+        {
+            switch (stream)
+            {
+                case TSVideoStream videoStream:
+                    AddVideoStream(mediaStreams, videoStream);
+                    break;
+                case TSAudioStream audioStream:
+                    AddAudioStream(mediaStreams, audioStream);
+                    break;
+                case TSTextStream textStream:
+                    AddSubtitleStream(mediaStreams, textStream);
+                    break;
+                case TSGraphicsStream graphicStream:
+                    AddSubtitleStream(mediaStreams, graphicStream);
+                    break;
+            }
+        }
+
+        outputStream.MediaStreams = mediaStreams.ToArray();
+
+        outputStream.PlaylistName = playlist.Name;
+
+        if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0)
+        {
+            // Get the files in the playlist
+            outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.Name).ToArray();
+        }
+
+        return outputStream;
+    }
+
+    /// <summary>
+    /// Adds the video stream.
+    /// </summary>
+    /// <param name="streams">The streams.</param>
+    /// <param name="videoStream">The video stream.</param>
+    private void AddVideoStream(List<MediaStream> streams, TSVideoStream videoStream)
+    {
+        var mediaStream = new MediaStream
+        {
+            BitRate = Convert.ToInt32(videoStream.BitRate),
+            Width = videoStream.Width,
+            Height = videoStream.Height,
+            Codec = videoStream.CodecShortName,
+            IsInterlaced = videoStream.IsInterlaced,
+            Type = MediaStreamType.Video,
+            Index = streams.Count
+        };
+
+        if (videoStream.FrameRateDenominator > 0)
+        {
+            float frameRateEnumerator = videoStream.FrameRateEnumerator;
+            float frameRateDenominator = videoStream.FrameRateDenominator;
+
+            mediaStream.AverageFrameRate = mediaStream.RealFrameRate = frameRateEnumerator / frameRateDenominator;
+        }
+
+        streams.Add(mediaStream);
+    }
+
+    /// <summary>
+    /// Adds the audio stream.
+    /// </summary>
+    /// <param name="streams">The streams.</param>
+    /// <param name="audioStream">The audio stream.</param>
+    private void AddAudioStream(List<MediaStream> streams, TSAudioStream audioStream)
+    {
+        var stream = new MediaStream
+        {
+            Codec = audioStream.CodecShortName,
+            Language = audioStream.LanguageCode,
+            Channels = audioStream.ChannelCount,
+            SampleRate = audioStream.SampleRate,
+            Type = MediaStreamType.Audio,
+            Index = streams.Count
+        };
+
+        var bitrate = Convert.ToInt32(audioStream.BitRate);
+
+        if (bitrate > 0)
+        {
+            stream.BitRate = bitrate;
+        }
+
+        if (audioStream.LFE > 0)
+        {
+            stream.Channels = audioStream.ChannelCount + 1;
+        }
+
+        streams.Add(stream);
+    }
+
+    /// <summary>
+    /// Adds the subtitle stream.
+    /// </summary>
+    /// <param name="streams">The streams.</param>
+    /// <param name="textStream">The text stream.</param>
+    private void AddSubtitleStream(List<MediaStream> streams, TSTextStream textStream)
+    {
+        streams.Add(new MediaStream
+        {
+            Language = textStream.LanguageCode,
+            Codec = textStream.CodecShortName,
+            Type = MediaStreamType.Subtitle,
+            Index = streams.Count
+        });
+    }
+
+    /// <summary>
+    /// Adds the subtitle stream.
+    /// </summary>
+    /// <param name="streams">The streams.</param>
+    /// <param name="textStream">The text stream.</param>
+    private void AddSubtitleStream(List<MediaStream> streams, TSGraphicsStream textStream)
+    {
+        streams.Add(new MediaStream
+        {
+            Language = textStream.LanguageCode,
+            Codec = textStream.CodecShortName,
+            Type = MediaStreamType.Subtitle,
+            Index = streams.Count
+        });
+    }
+}

+ 68 - 0
MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs

@@ -0,0 +1,68 @@
+using System.IO;
+using MediaBrowser.Model.IO;
+
+namespace MediaBrowser.MediaEncoding.BdInfo;
+
+/// <summary>
+/// Class BdInfoFileInfo.
+/// </summary>
+public class BdInfoFileInfo : BDInfo.IO.IFileInfo
+{
+    private FileSystemMetadata _impl;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="BdInfoFileInfo" /> class.
+    /// </summary>
+    /// <param name="impl">The <see cref="FileSystemMetadata" />.</param>
+    public BdInfoFileInfo(FileSystemMetadata impl)
+    {
+        _impl = impl;
+    }
+
+    /// <summary>
+    /// Gets the name.
+    /// </summary>
+    public string Name => _impl.Name;
+
+    /// <summary>
+    /// Gets the full name.
+    /// </summary>
+    public string FullName => _impl.FullName;
+
+    /// <summary>
+    /// Gets the extension.
+    /// </summary>
+    public string Extension => _impl.Extension;
+
+    /// <summary>
+    /// Gets the length.
+    /// </summary>
+    public long Length => _impl.Length;
+
+    /// <summary>
+    /// Gets a value indicating whether this is a directory.
+    /// </summary>
+    public bool IsDir => _impl.IsDirectory;
+
+    /// <summary>
+    /// Gets a file as file stream.
+    /// </summary>
+    /// <returns>A <see cref="FileStream" /> for the file.</returns>
+    public Stream OpenRead()
+    {
+        return new FileStream(
+            FullName,
+            FileMode.Open,
+            FileAccess.Read,
+            FileShare.Read);
+    }
+
+    /// <summary>
+    /// Gets a files's content with a stream reader.
+    /// </summary>
+    /// <returns>A <see cref="StreamReader" /> for the file's content.</returns>
+    public StreamReader OpenText()
+    {
+        return new StreamReader(OpenRead());
+    }
+}

+ 24 - 5
MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs

@@ -1,7 +1,9 @@
 #pragma warning disable CS1591
 
 using System;
+using System.Collections.Generic;
 using System.Globalization;
+using System.Linq;
 using MediaBrowser.Model.MediaInfo;
 
 namespace MediaBrowser.MediaEncoding.Encoder
@@ -15,21 +17,38 @@ namespace MediaBrowser.MediaEncoding.Encoder
                 return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", inputFile);
             }
 
-            return GetConcatInputArgument(inputFile, inputPrefix);
+            return GetFileInputArgument(inputFile, inputPrefix);
+        }
+
+        public static string GetInputArgument(string inputPrefix, IReadOnlyList<string> inputFiles, MediaProtocol protocol)
+        {
+            if (protocol != MediaProtocol.File)
+            {
+                return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", inputFiles[0]);
+            }
+
+            return GetConcatInputArgument(inputFiles, inputPrefix);
         }
 
         /// <summary>
         /// Gets the concat input argument.
         /// </summary>
-        /// <param name="inputFile">The input file.</param>
+        /// <param name="inputFiles">The input files.</param>
         /// <param name="inputPrefix">The input prefix.</param>
         /// <returns>System.String.</returns>
-        private static string GetConcatInputArgument(string inputFile, string inputPrefix)
+        private static string GetConcatInputArgument(IReadOnlyList<string> inputFiles, string inputPrefix)
         {
             // Get all streams
             // If there's more than one we'll need to use the concat command
+            if (inputFiles.Count > 1)
+            {
+                var files = string.Join("|", inputFiles.Select(NormalizePath));
+
+                return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files);
+            }
+
             // Determine the input path for video files
-            return GetFileInputArgument(inputFile, inputPrefix);
+            return GetFileInputArgument(inputFiles[0], inputPrefix);
         }
 
         /// <summary>
@@ -56,7 +75,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
         /// </summary>
         /// <param name="path">The path.</param>
         /// <returns>System.String.</returns>
-        private static string NormalizePath(string path)
+        public static string NormalizePath(string path)
         {
             // Quotes are valid path characters in linux and they need to be escaped here with a leading \
             return path.Replace("\"", "\\\"", StringComparison.Ordinal);

+ 141 - 28
MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs

@@ -11,6 +11,7 @@ using System.Text.Json;
 using System.Text.RegularExpressions;
 using System.Threading;
 using System.Threading.Tasks;
+using Jellyfin.Extensions;
 using Jellyfin.Extensions.Json;
 using Jellyfin.Extensions.Json.Converters;
 using MediaBrowser.Common;
@@ -51,6 +52,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
         private readonly IServerConfigurationManager _configurationManager;
         private readonly IFileSystem _fileSystem;
         private readonly ILocalizationManager _localization;
+        private readonly IBlurayExaminer _blurayExaminer;
         private readonly IConfiguration _config;
         private readonly IServerConfigurationManager _serverConfig;
         private readonly string _startupOptionFFmpegPath;
@@ -95,6 +97,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
             ILogger<MediaEncoder> logger,
             IServerConfigurationManager configurationManager,
             IFileSystem fileSystem,
+            IBlurayExaminer blurayExaminer,
             ILocalizationManager localization,
             IConfiguration config,
             IServerConfigurationManager serverConfig)
@@ -102,6 +105,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
             _logger = logger;
             _configurationManager = configurationManager;
             _fileSystem = fileSystem;
+            _blurayExaminer = blurayExaminer;
             _localization = localization;
             _config = config;
             _serverConfig = serverConfig;
@@ -117,16 +121,22 @@ namespace MediaBrowser.MediaEncoding.Encoder
         /// <inheritdoc />
         public string ProbePath => _ffprobePath;
 
+        /// <inheritdoc />
         public Version EncoderVersion => _ffmpegVersion;
 
+        /// <inheritdoc />
         public bool IsPkeyPauseSupported => _isPkeyPauseSupported;
 
+        /// <inheritdoc />
         public bool IsVaapiDeviceAmd => _isVaapiDeviceAmd;
 
+        /// <inheritdoc />
         public bool IsVaapiDeviceInteliHD => _isVaapiDeviceInteliHD;
 
+        /// <inheritdoc />
         public bool IsVaapiDeviceInteli965 => _isVaapiDeviceInteli965;
 
+        /// <inheritdoc />
         public bool IsVaapiDeviceSupportVulkanFmtModifier => _isVaapiDeviceSupportVulkanFmtModifier;
 
         /// <summary>
@@ -344,26 +354,31 @@ namespace MediaBrowser.MediaEncoding.Encoder
             _ffmpegVersion = validator.GetFFmpegVersion();
         }
 
+        /// <inheritdoc />
         public bool SupportsEncoder(string encoder)
         {
             return _encoders.Contains(encoder, StringComparer.OrdinalIgnoreCase);
         }
 
+        /// <inheritdoc />
         public bool SupportsDecoder(string decoder)
         {
             return _decoders.Contains(decoder, StringComparer.OrdinalIgnoreCase);
         }
 
+        /// <inheritdoc />
         public bool SupportsHwaccel(string hwaccel)
         {
             return _hwaccels.Contains(hwaccel, StringComparer.OrdinalIgnoreCase);
         }
 
+        /// <inheritdoc />
         public bool SupportsFilter(string filter)
         {
             return _filters.Contains(filter, StringComparer.OrdinalIgnoreCase);
         }
 
+        /// <inheritdoc />
         public bool SupportsFilterWithOption(FilterOptionType option)
         {
             if (_filtersWithOption.TryGetValue((int)option, out var val))
@@ -394,24 +409,16 @@ namespace MediaBrowser.MediaEncoding.Encoder
             return true;
         }
 
-        /// <summary>
-        /// Gets the media info.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <param name="cancellationToken">The cancellation token.</param>
-        /// <returns>Task.</returns>
+        /// <inheritdoc />
         public Task<MediaInfo> GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken)
         {
             var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters;
-            var inputFile = request.MediaSource.Path;
-
             string analyzeDuration = string.Empty;
             string ffmpegAnalyzeDuration = _config.GetFFmpegAnalyzeDuration() ?? string.Empty;
 
             if (request.MediaSource.AnalyzeDurationMs > 0)
             {
-                analyzeDuration = "-analyzeduration " +
-                                  (request.MediaSource.AnalyzeDurationMs * 1000).ToString();
+                analyzeDuration = "-analyzeduration " + (request.MediaSource.AnalyzeDurationMs * 1000).ToString();
             }
             else if (!string.IsNullOrEmpty(ffmpegAnalyzeDuration))
             {
@@ -419,7 +426,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
             }
 
             return GetMediaInfoInternal(
-                GetInputArgument(inputFile, request.MediaSource),
+                GetInputArgument(request.MediaSource.Path, request.MediaSource),
                 request.MediaSource.Path,
                 request.MediaSource.Protocol,
                 extractChapters,
@@ -429,36 +436,30 @@ namespace MediaBrowser.MediaEncoding.Encoder
                 cancellationToken);
         }
 
-        /// <summary>
-        /// Gets the input argument.
-        /// </summary>
-        /// <param name="inputFile">The input file.</param>
-        /// <param name="mediaSource">The mediaSource.</param>
-        /// <returns>System.String.</returns>
-        /// <exception cref="ArgumentException">Unrecognized InputType.</exception>
+        /// <inheritdoc />
+        public string GetInputArgument(IReadOnlyList<string> inputFiles, MediaSourceInfo mediaSource)
+        {
+            return EncodingUtils.GetInputArgument("file", inputFiles, mediaSource.Protocol);
+        }
+
+        /// <inheritdoc />
         public string GetInputArgument(string inputFile, MediaSourceInfo mediaSource)
         {
             var prefix = "file";
-            if (mediaSource.VideoType == VideoType.BluRay
-                || mediaSource.IsoType == IsoType.BluRay)
+            if (mediaSource.IsoType == IsoType.BluRay)
             {
                 prefix = "bluray";
             }
 
-            return EncodingUtils.GetInputArgument(prefix, inputFile, mediaSource.Protocol);
+            return EncodingUtils.GetInputArgument(prefix, new[] { inputFile }, mediaSource.Protocol);
         }
 
-        /// <summary>
-        /// Gets the input argument for an external subtitle file.
-        /// </summary>
-        /// <param name="inputFile">The input file.</param>
-        /// <returns>System.String.</returns>
-        /// <exception cref="ArgumentException">Unrecognized InputType.</exception>
+        /// <inheritdoc />
         public string GetExternalSubtitleInputArgument(string inputFile)
         {
             const string Prefix = "file";
 
-            return EncodingUtils.GetInputArgument(Prefix, inputFile, MediaProtocol.File);
+            return EncodingUtils.GetInputArgument(Prefix, new[] { inputFile }, MediaProtocol.File);
         }
 
         /// <summary>
@@ -549,6 +550,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
             }
         }
 
+        /// <inheritdoc />
         public Task<string> ExtractAudioImage(string path, int? imageStreamIndex, CancellationToken cancellationToken)
         {
             var mediaSource = new MediaSourceInfo
@@ -559,11 +561,13 @@ namespace MediaBrowser.MediaEncoding.Encoder
             return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, ImageFormat.Jpg, cancellationToken);
         }
 
+        /// <inheritdoc />
         public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken)
         {
             return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, ImageFormat.Jpg, cancellationToken);
         }
 
+        /// <inheritdoc />
         public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, ImageFormat? targetFormat, CancellationToken cancellationToken)
         {
             return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, targetFormat, cancellationToken);
@@ -767,6 +771,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
             }
         }
 
+        /// <inheritdoc />
         public string GetTimeParameter(long ticks)
         {
             var time = TimeSpan.FromTicks(ticks);
@@ -865,6 +870,114 @@ namespace MediaBrowser.MediaEncoding.Encoder
             throw new NotImplementedException();
         }
 
+        /// <inheritdoc />
+        public IReadOnlyList<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber)
+        {
+            // Eliminate menus and intros by omitting VIDEO_TS.VOB and all subsequent title .vob files ending with _0.VOB
+            var allVobs = _fileSystem.GetFiles(path, true)
+                .Where(file => string.Equals(file.Extension, ".VOB", StringComparison.OrdinalIgnoreCase))
+                .Where(file => !string.Equals(file.Name, "VIDEO_TS.VOB", StringComparison.OrdinalIgnoreCase))
+                .Where(file => !file.Name.EndsWith("_0.VOB", StringComparison.OrdinalIgnoreCase))
+                .OrderBy(i => i.FullName)
+                .ToList();
+
+            if (titleNumber.HasValue)
+            {
+                var prefix = string.Format(CultureInfo.InvariantCulture, "VTS_{0:D2}_", titleNumber.Value);
+                var vobs = allVobs.Where(i => i.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList();
+
+                if (vobs.Count > 0)
+                {
+                    return vobs.Select(i => i.FullName).ToList();
+                }
+
+                _logger.LogWarning("Could not determine .vob files for title {Title} of {Path}.", titleNumber, path);
+            }
+
+            // Check for multiple big titles (> 900 MB)
+            var titles = allVobs
+                .Where(vob => vob.Length >= 900 * 1024 * 1024)
+                .Select(vob => _fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString())
+                .Distinct()
+                .ToList();
+
+            // Fall back to first title if no big title is found
+            if (titles.Count == 0)
+            {
+                titles.Add(_fileSystem.GetFileNameWithoutExtension(allVobs[0]).AsSpan().RightPart('_').ToString());
+            }
+
+            // Aggregate all .vob files of the titles
+            return allVobs
+                .Where(vob => titles.Contains(_fileSystem.GetFileNameWithoutExtension(vob).AsSpan().RightPart('_').ToString()))
+                .Select(i => i.FullName)
+                .ToList();
+        }
+
+        /// <inheritdoc />
+        public IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path)
+        {
+            // Get all playable .m2ts files
+            var validPlaybackFiles = _blurayExaminer.GetDiscInfo(path).Files;
+
+            // Get all files from the BDMV/STREAMING directory
+            var directoryFiles = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM"));
+
+            // Only return playable local .m2ts files
+            return directoryFiles
+                .Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase))
+                .Select(f => f.FullName)
+                .ToList();
+        }
+
+        /// <inheritdoc />
+        public void GenerateConcatConfig(MediaSourceInfo source, string concatFilePath)
+        {
+            // Get all playable files
+            IReadOnlyList<string> files;
+            var videoType = source.VideoType;
+            if (videoType == VideoType.Dvd)
+            {
+                files = GetPrimaryPlaylistVobFiles(source.Path, null);
+            }
+            else if (videoType == VideoType.BluRay)
+            {
+                files = GetPrimaryPlaylistM2tsFiles(source.Path);
+            }
+            else
+            {
+                return;
+            }
+
+            // Generate concat configuration entries for each file and write to file
+            using (StreamWriter sw = new StreamWriter(concatFilePath))
+            {
+                foreach (var path in files)
+                {
+                    var mediaInfoResult = GetMediaInfo(
+                        new MediaInfoRequest
+                        {
+                            MediaType = DlnaProfileType.Video,
+                            MediaSource = new MediaSourceInfo
+                            {
+                                Path = path,
+                                Protocol = MediaProtocol.File,
+                                VideoType = videoType
+                            }
+                        },
+                        CancellationToken.None).GetAwaiter().GetResult();
+
+                    var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds;
+
+                    // Add file path stanza to concat configuration
+                    sw.WriteLine("file '{0}'", path);
+
+                    // Add duration stanza to concat configuration
+                    sw.WriteLine("duration {0}", duration);
+                }
+            }
+        }
+
         public bool CanExtractSubtitles(string codec)
         {
             // TODO is there ever a case when a subtitle can't be extracted??

+ 1 - 0
MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj

@@ -22,6 +22,7 @@
   </ItemGroup>
 
   <ItemGroup>
+    <PackageReference Include="BDInfo" />
     <PackageReference Include="libse" />
     <PackageReference Include="Microsoft.Extensions.Http" />
     <PackageReference Include="System.Text.Encoding.CodePages" />

+ 12 - 1
MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs

@@ -248,12 +248,23 @@ namespace MediaBrowser.MediaEncoding.Probing
                 return null;
             }
 
+            // Handle MPEG-1 container
             if (string.Equals(format, "mpegvideo", StringComparison.OrdinalIgnoreCase))
             {
                 return "mpeg";
             }
 
-            format = format.Replace("matroska", "mkv", StringComparison.OrdinalIgnoreCase);
+            // Handle MPEG-2 container
+            if (string.Equals(format, "mpeg", StringComparison.OrdinalIgnoreCase))
+            {
+                return "ts";
+            }
+
+            // Handle matroska container
+            if (string.Equals(format, "matroska", StringComparison.OrdinalIgnoreCase))
+            {
+                return "mkv";
+            }
 
             return format;
         }

+ 2 - 1
MediaBrowser.Model/Configuration/EncodingOptions.cs

@@ -39,7 +39,8 @@ public class EncodingOptions
         DeinterlaceMethod = "yadif";
         EnableDecodingColorDepth10Hevc = true;
         EnableDecodingColorDepth10Vp9 = true;
-        EnableEnhancedNvdecDecoder = false;
+        // Enhanced Nvdec or system native decoder is required for DoVi to SDR tone-mapping.
+        EnableEnhancedNvdecDecoder = true;
         PreferSystemNativeHwDecoder = true;
         EnableIntelLowPowerH264HwEncoder = false;
         EnableIntelLowPowerHevcHwEncoder = false;

+ 2 - 8
MediaBrowser.Model/Configuration/ServerConfiguration.cs

@@ -243,16 +243,10 @@ namespace MediaBrowser.Model.Configuration
         public bool AllowClientLogUpload { get; set; } = true;
 
         /// <summary>
-        /// Gets or sets the dummy chapters duration in seconds.
+        /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation alltogether.
         /// </summary>
         /// <value>The dummy chapters duration.</value>
-        public int DummyChapterDuration { get; set; } = 300;
-
-        /// <summary>
-        /// Gets or sets the dummy chapter count.
-        /// </summary>
-        /// <value>The dummy chapter count.</value>
-        public int DummyChapterCount { get; set; } = 100;
+        public int DummyChapterDuration { get; set; } = 0;
 
         /// <summary>
         /// Gets or sets the chapter image resolution.

+ 34 - 5
MediaBrowser.Model/Dlna/ConditionProcessor.cs

@@ -136,12 +136,26 @@ namespace MediaBrowser.Model.Dlna
                 return !condition.IsRequired;
             }
 
-            if (int.TryParse(condition.Value, CultureInfo.InvariantCulture, out var expected))
+            var conditionType = condition.Condition;
+            if (condition.Condition == ProfileConditionType.EqualsAny)
             {
-                switch (condition.Condition)
+                foreach (var singleConditionString in condition.Value.AsSpan().Split('|'))
+                {
+                    if (int.TryParse(singleConditionString, NumberStyles.Integer, CultureInfo.InvariantCulture, out int conditionValue)
+                        && conditionValue.Equals(currentValue))
+                    {
+                        return true;
+                    }
+                }
+
+                return false;
+            }
+
+            if (int.TryParse(condition.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var expected))
+            {
+                switch (conditionType)
                 {
                     case ProfileConditionType.Equals:
-                    case ProfileConditionType.EqualsAny:
                         return currentValue.Value.Equals(expected);
                     case ProfileConditionType.GreaterThanEqual:
                         return currentValue.Value >= expected;
@@ -212,9 +226,24 @@ namespace MediaBrowser.Model.Dlna
                 return !condition.IsRequired;
             }
 
-            if (double.TryParse(condition.Value, CultureInfo.InvariantCulture, out var expected))
+            var conditionType = condition.Condition;
+            if (condition.Condition == ProfileConditionType.EqualsAny)
             {
-                switch (condition.Condition)
+                foreach (var singleConditionString in condition.Value.AsSpan().Split('|'))
+                {
+                    if (double.TryParse(singleConditionString, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double conditionValue)
+                        && conditionValue.Equals(currentValue))
+                    {
+                        return true;
+                    }
+                }
+
+                return false;
+            }
+
+            if (double.TryParse(condition.Value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var expected))
+            {
+                switch (conditionType)
                 {
                     case ProfileConditionType.Equals:
                         return currentValue.Value.Equals(expected);

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

@@ -11,7 +11,7 @@ namespace MediaBrowser.Model.Dlna
         [XmlAttribute("type")]
         public DlnaProfileType Type { get; set; }
 
-        public ProfileCondition[]? Conditions { get; set; } = Array.Empty<ProfileCondition>();
+        public ProfileCondition[] Conditions { get; set; } = Array.Empty<ProfileCondition>();
 
         [XmlAttribute("container")]
         public string Container { get; set; } = string.Empty;

+ 3 - 3
MediaBrowser.Model/Dlna/DirectPlayProfile.cs

@@ -18,17 +18,17 @@ namespace MediaBrowser.Model.Dlna
         [XmlAttribute("type")]
         public DlnaProfileType Type { get; set; }
 
-        public bool SupportsContainer(string container)
+        public bool SupportsContainer(string? container)
         {
             return ContainerProfile.ContainsContainer(Container, container);
         }
 
-        public bool SupportsVideoCodec(string codec)
+        public bool SupportsVideoCodec(string? codec)
         {
             return Type == DlnaProfileType.Video && ContainerProfile.ContainsContainer(VideoCodec, codec);
         }
 
-        public bool SupportsAudioCodec(string codec)
+        public bool SupportsAudioCodec(string? codec)
         {
             return (Type == DlnaProfileType.Audio || Type == DlnaProfileType.Video) && ContainerProfile.ContainsContainer(AudioCodec, codec);
         }

+ 0 - 18
MediaBrowser.Model/Dlna/ITranscoderSupport.cs

@@ -10,22 +10,4 @@ namespace MediaBrowser.Model.Dlna
 
         bool CanExtractSubtitles(string codec);
     }
-
-    public class FullTranscoderSupport : ITranscoderSupport
-    {
-        public bool CanEncodeToAudioCodec(string codec)
-        {
-            return true;
-        }
-
-        public bool CanEncodeToSubtitleCodec(string codec)
-        {
-            return true;
-        }
-
-        public bool CanExtractSubtitles(string codec)
-        {
-            return true;
-        }
-    }
 }

+ 4 - 6
MediaBrowser.Model/Dlna/MediaOptions.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using MediaBrowser.Model.Dto;
 
@@ -59,22 +57,22 @@ namespace MediaBrowser.Model.Dlna
         /// <summary>
         /// Gets or sets the media sources.
         /// </summary>
-        public MediaSourceInfo[] MediaSources { get; set; }
+        public MediaSourceInfo[] MediaSources { get; set; } = Array.Empty<MediaSourceInfo>();
 
         /// <summary>
         /// Gets or sets the device profile.
         /// </summary>
-        public DeviceProfile Profile { get; set; }
+        required public DeviceProfile Profile { get; set; }
 
         /// <summary>
         /// Gets or sets a media source id. Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
         /// </summary>
-        public string MediaSourceId { get; set; }
+        public string? MediaSourceId { get; set; }
 
         /// <summary>
         /// Gets or sets the device id.
         /// </summary>
-        public string DeviceId { get; set; }
+        public string? DeviceId { get; set; }
 
         /// <summary>
         /// Gets or sets an override of supported number of audio channels

+ 74 - 72
MediaBrowser.Model/Dlna/StreamBuilder.cs

@@ -1,5 +1,3 @@
-#nullable disable
-
 using System;
 using System.Collections.Generic;
 using System.Globalization;
@@ -37,54 +35,37 @@ namespace MediaBrowser.Model.Dlna
             _logger = logger;
         }
 
-        /// <summary>
-        /// Initializes a new instance of the <see cref="StreamBuilder"/> class.
-        /// </summary>
-        /// <param name="logger">The <see cref="ILogger"/> object.</param>
-        public StreamBuilder(ILogger<StreamBuilder> logger)
-            : this(new FullTranscoderSupport(), logger)
-        {
-        }
-
         /// <summary>
         /// Gets the optimal audio stream.
         /// </summary>
         /// <param name="options">The <see cref="MediaOptions"/> object to get the audio stream from.</param>
         /// <returns>The <see cref="StreamInfo"/> of the optimal audio stream.</returns>
-        public StreamInfo GetOptimalAudioStream(MediaOptions options)
+        public StreamInfo? GetOptimalAudioStream(MediaOptions options)
         {
             ValidateMediaOptions(options, false);
 
-            var mediaSources = new List<MediaSourceInfo>();
+            var streams = new List<StreamInfo>();
             foreach (var mediaSource in options.MediaSources)
             {
-                if (string.IsNullOrEmpty(options.MediaSourceId) ||
-                    string.Equals(mediaSource.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase))
+                if (!(string.IsNullOrEmpty(options.MediaSourceId)
+                    || string.Equals(mediaSource.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase)))
                 {
-                    mediaSources.Add(mediaSource);
+                    continue;
                 }
-            }
 
-            var streams = new List<StreamInfo>();
-            foreach (var mediaSourceInfo in mediaSources)
-            {
-                StreamInfo streamInfo = GetOptimalAudioStream(mediaSourceInfo, options);
+                StreamInfo? streamInfo = GetOptimalAudioStream(mediaSource, options);
                 if (streamInfo is not null)
                 {
+                    streamInfo.DeviceId = options.DeviceId;
+                    streamInfo.DeviceProfileId = options.Profile.Id;
                     streams.Add(streamInfo);
                 }
             }
 
-            foreach (var stream in streams)
-            {
-                stream.DeviceId = options.DeviceId;
-                stream.DeviceProfileId = options.Profile.Id;
-            }
-
             return GetOptimalStream(streams, options.GetMaxBitrate(true) ?? 0);
         }
 
-        private StreamInfo GetOptimalAudioStream(MediaSourceInfo item, MediaOptions options)
+        private StreamInfo? GetOptimalAudioStream(MediaSourceInfo item, MediaOptions options)
         {
             var playlistItem = new StreamInfo
             {
@@ -138,7 +119,7 @@ namespace MediaBrowser.Model.Dlna
                 }
             }
 
-            TranscodingProfile transcodingProfile = null;
+            TranscodingProfile? transcodingProfile = null;
             foreach (var tcProfile in options.Profile.TranscodingProfiles)
             {
                 if (tcProfile.Type == playlistItem.MediaType
@@ -190,15 +171,15 @@ namespace MediaBrowser.Model.Dlna
         /// </summary>
         /// <param name="options">The <see cref="MediaOptions"/> object to get the video stream from.</param>
         /// <returns>The <see cref="StreamInfo"/> of the optimal video stream.</returns>
-        public StreamInfo GetOptimalVideoStream(MediaOptions options)
+        public StreamInfo? GetOptimalVideoStream(MediaOptions options)
         {
             ValidateMediaOptions(options, true);
 
             var mediaSources = new List<MediaSourceInfo>();
             foreach (var mediaSourceInfo in options.MediaSources)
             {
-                if (string.IsNullOrEmpty(options.MediaSourceId) ||
-                    string.Equals(mediaSourceInfo.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase))
+                if (string.IsNullOrEmpty(options.MediaSourceId)
+                    || string.Equals(mediaSourceInfo.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase))
                 {
                     mediaSources.Add(mediaSourceInfo);
                 }
@@ -223,7 +204,7 @@ namespace MediaBrowser.Model.Dlna
             return GetOptimalStream(streams, options.GetMaxBitrate(false) ?? 0);
         }
 
-        private static StreamInfo GetOptimalStream(List<StreamInfo> streams, long maxBitrate)
+        private static StreamInfo? GetOptimalStream(List<StreamInfo> streams, long maxBitrate)
             => SortMediaSources(streams, maxBitrate).FirstOrDefault();
 
         private static IOrderedEnumerable<StreamInfo> SortMediaSources(List<StreamInfo> streams, long maxBitrate)
@@ -366,7 +347,7 @@ namespace MediaBrowser.Model.Dlna
         /// <param name="type">The <see cref="DlnaProfileType"/>.</param>
         /// <param name="playProfile">The <see cref="DirectPlayProfile"/> object to get the video stream from.</param>
         /// <returns>The the normalized input container.</returns>
-        public static string NormalizeMediaSourceFormatIntoSingleContainer(string inputContainer, DeviceProfile profile, DlnaProfileType type, DirectPlayProfile playProfile = null)
+        public static string? NormalizeMediaSourceFormatIntoSingleContainer(string inputContainer, DeviceProfile? profile, DlnaProfileType type, DirectPlayProfile? playProfile = null)
         {
             if (string.IsNullOrEmpty(inputContainer))
             {
@@ -394,7 +375,7 @@ namespace MediaBrowser.Model.Dlna
             return formats[0];
         }
 
-        private (DirectPlayProfile Profile, PlayMethod? PlayMethod, TranscodeReason TranscodeReasons) GetAudioDirectPlayProfile(MediaSourceInfo item, MediaStream audioStream, MediaOptions options)
+        private (DirectPlayProfile? Profile, PlayMethod? PlayMethod, TranscodeReason TranscodeReasons) GetAudioDirectPlayProfile(MediaSourceInfo item, MediaStream audioStream, MediaOptions options)
         {
             var directPlayProfile = options.Profile.DirectPlayProfiles
                 .FirstOrDefault(x => x.Type == DlnaProfileType.Audio && IsAudioDirectPlaySupported(x, item, audioStream));
@@ -410,7 +391,6 @@ namespace MediaBrowser.Model.Dlna
                 return (null, null, GetTranscodeReasonsFromDirectPlayProfile(item, null, audioStream, options.Profile.DirectPlayProfiles));
             }
 
-            var playMethods = new List<PlayMethod>();
             TranscodeReason transcodeReasons = 0;
 
             // The profile describes what the device supports
@@ -449,7 +429,7 @@ namespace MediaBrowser.Model.Dlna
             return (directPlayProfile, null, transcodeReasons);
         }
 
-        private static TranscodeReason GetTranscodeReasonsFromDirectPlayProfile(MediaSourceInfo item, MediaStream videoStream, MediaStream audioStream, IEnumerable<DirectPlayProfile> directPlayProfiles)
+        private static TranscodeReason GetTranscodeReasonsFromDirectPlayProfile(MediaSourceInfo item, MediaStream? videoStream, MediaStream audioStream, IEnumerable<DirectPlayProfile> directPlayProfiles)
         {
             var mediaType = videoStream is null ? DlnaProfileType.Audio : DlnaProfileType.Video;
 
@@ -575,7 +555,7 @@ namespace MediaBrowser.Model.Dlna
             }
         }
 
-        private static void SetStreamInfoOptionsFromDirectPlayProfile(MediaOptions options, MediaSourceInfo item, StreamInfo playlistItem, DirectPlayProfile directPlayProfile)
+        private static void SetStreamInfoOptionsFromDirectPlayProfile(MediaOptions options, MediaSourceInfo item, StreamInfo playlistItem, DirectPlayProfile? directPlayProfile)
         {
             var container = NormalizeMediaSourceFormatIntoSingleContainer(item.Container, options.Profile, DlnaProfileType.Video, directPlayProfile);
             var protocol = "http";
@@ -587,7 +567,7 @@ namespace MediaBrowser.Model.Dlna
             playlistItem.SubProtocol = protocol;
 
             playlistItem.VideoCodecs = new[] { item.VideoStream.Codec };
-            playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile.AudioCodec);
+            playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile?.AudioCodec);
         }
 
         private StreamInfo BuildVideoItem(MediaSourceInfo item, MediaOptions options)
@@ -634,6 +614,12 @@ namespace MediaBrowser.Model.Dlna
             var isEligibleForDirectStream = options.EnableDirectStream && (options.ForceDirectStream || !bitrateLimitExceeded);
             TranscodeReason transcodeReasons = 0;
 
+            // Force transcode or remux for BD/DVD folders
+            if (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay)
+            {
+                isEligibleForDirectPlay = false;
+            }
+
             if (bitrateLimitExceeded)
             {
                 transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit;
@@ -646,7 +632,7 @@ namespace MediaBrowser.Model.Dlna
                 isEligibleForDirectPlay,
                 isEligibleForDirectStream);
 
-            DirectPlayProfile directPlayProfile = null;
+            DirectPlayProfile? directPlayProfile = null;
             if (isEligibleForDirectPlay || isEligibleForDirectStream)
             {
                 // See if it can be direct played
@@ -677,16 +663,16 @@ namespace MediaBrowser.Model.Dlna
                         playlistItem.AudioStreamIndex = audioStream?.Index;
                         if (audioStream is not null)
                         {
-                            playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile.AudioCodec);
+                            playlistItem.AudioCodecs = ContainerProfile.SplitValue(directPlayProfile?.AudioCodec);
                         }
 
                         SetStreamInfoOptionsFromDirectPlayProfile(options, item, playlistItem, directPlayProfile);
-                        BuildStreamVideoItem(playlistItem, options, item, videoStream, audioStream, candidateAudioStreams, directPlayProfile.Container, directPlayProfile.VideoCodec, directPlayProfile.AudioCodec);
+                        BuildStreamVideoItem(playlistItem, options, item, videoStream, audioStream, candidateAudioStreams, directPlayProfile?.Container, directPlayProfile?.VideoCodec, directPlayProfile?.AudioCodec);
                     }
 
                     if (subtitleStream is not null)
                     {
-                        var subtitleProfile = GetSubtitleProfile(item, subtitleStream, options.Profile.SubtitleProfiles, directPlay.Value, _transcoderSupport, directPlayProfile.Container, null);
+                        var subtitleProfile = GetSubtitleProfile(item, subtitleStream, options.Profile.SubtitleProfiles, directPlay.Value, _transcoderSupport, directPlayProfile?.Container, null);
 
                         playlistItem.SubtitleDeliveryMethod = subtitleProfile.Method;
                         playlistItem.SubtitleFormat = subtitleProfile.Format;
@@ -748,7 +734,14 @@ namespace MediaBrowser.Model.Dlna
             return playlistItem;
         }
 
-        private TranscodingProfile GetVideoTranscodeProfile(MediaSourceInfo item, MediaOptions options, MediaStream videoStream, MediaStream audioStream, IEnumerable<MediaStream> candidateAudioStreams, MediaStream subtitleStream, StreamInfo playlistItem)
+        private TranscodingProfile? GetVideoTranscodeProfile(
+            MediaSourceInfo item,
+            MediaOptions options,
+            MediaStream? videoStream,
+            MediaStream? audioStream,
+            IEnumerable<MediaStream> candidateAudioStreams,
+            MediaStream? subtitleStream,
+            StreamInfo playlistItem)
         {
             if (!(item.SupportsTranscoding || item.SupportsDirectStream))
             {
@@ -795,7 +788,16 @@ namespace MediaBrowser.Model.Dlna
             return transcodingProfiles.FirstOrDefault();
         }
 
-        private void BuildStreamVideoItem(StreamInfo playlistItem, MediaOptions options, MediaSourceInfo item, MediaStream videoStream, MediaStream audioStream, IEnumerable<MediaStream> candidateAudioStreams, string container, string videoCodec, string audioCodec)
+        private void BuildStreamVideoItem(
+            StreamInfo playlistItem,
+            MediaOptions options,
+            MediaSourceInfo item,
+            MediaStream? videoStream,
+            MediaStream? audioStream,
+            IEnumerable<MediaStream> candidateAudioStreams,
+            string? container,
+            string? videoCodec,
+            string? audioCodec)
         {
             // Prefer matching video codecs
             var videoCodecs = ContainerProfile.SplitValue(videoCodec);
@@ -862,12 +864,12 @@ namespace MediaBrowser.Model.Dlna
             int? bitDepth = videoStream?.BitDepth;
             int? videoBitrate = videoStream?.BitRate;
             double? videoLevel = videoStream?.Level;
-            string videoProfile = videoStream?.Profile;
-            string videoRangeType = videoStream?.VideoRangeType;
+            string? videoProfile = videoStream?.Profile;
+            string? videoRangeType = videoStream?.VideoRangeType;
             float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0;
             bool? isAnamorphic = videoStream?.IsAnamorphic;
             bool? isInterlaced = videoStream?.IsInterlaced;
-            string videoCodecTag = videoStream?.CodecTag;
+            string? videoCodecTag = videoStream?.CodecTag;
             bool? isAvc = videoStream?.IsAVC;
 
             TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp;
@@ -903,11 +905,11 @@ namespace MediaBrowser.Model.Dlna
             playlistItem.AudioBitrate = Math.Min(playlistItem.AudioBitrate ?? audioBitrate, audioBitrate);
 
             bool? isSecondaryAudio = audioStream is null ? null : item.IsSecondaryAudio(audioStream);
-            int? inputAudioBitrate = audioStream is null ? null : audioStream.BitRate;
-            int? audioChannels = audioStream is null ? null : audioStream.Channels;
-            string audioProfile = audioStream is null ? null : audioStream.Profile;
-            int? inputAudioSampleRate = audioStream is null ? null : audioStream.SampleRate;
-            int? inputAudioBitDepth = audioStream is null ? null : audioStream.BitDepth;
+            int? inputAudioBitrate = audioStream?.BitRate;
+            int? audioChannels = audioStream?.Channels;
+            string? audioProfile = audioStream?.Profile;
+            int? inputAudioSampleRate = audioStream?.SampleRate;
+            int? inputAudioBitDepth = audioStream?.BitDepth;
 
             var appliedAudioConditions = options.Profile.CodecProfiles
                 .Where(i => i.Type == CodecType.VideoAudio &&
@@ -955,7 +957,7 @@ namespace MediaBrowser.Model.Dlna
                 playlistItem?.TranscodeReasons);
         }
 
-        private static int GetDefaultAudioBitrate(string audioCodec, int? audioChannels)
+        private static int GetDefaultAudioBitrate(string? audioCodec, int? audioChannels)
         {
             if (!string.IsNullOrEmpty(audioCodec))
             {
@@ -988,9 +990,9 @@ namespace MediaBrowser.Model.Dlna
             return 192000;
         }
 
-        private static int GetAudioBitrate(long maxTotalBitrate, string[] targetAudioCodecs, MediaStream audioStream, StreamInfo item)
+        private static int GetAudioBitrate(long maxTotalBitrate, string[] targetAudioCodecs, MediaStream? audioStream, StreamInfo item)
         {
-            string targetAudioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0];
+            string? targetAudioCodec = targetAudioCodecs.Length == 0 ? null : targetAudioCodecs[0];
 
             int? targetAudioChannels = item.GetTargetAudioChannels(targetAudioCodec);
 
@@ -1081,13 +1083,13 @@ namespace MediaBrowser.Model.Dlna
             return 7168000;
         }
 
-        private (DirectPlayProfile Profile, PlayMethod? PlayMethod, int? AudioStreamIndex, TranscodeReason TranscodeReasons) GetVideoDirectPlayProfile(
+        private (DirectPlayProfile? Profile, PlayMethod? PlayMethod, int? AudioStreamIndex, TranscodeReason TranscodeReasons) GetVideoDirectPlayProfile(
             MediaOptions options,
             MediaSourceInfo mediaSource,
-            MediaStream videoStream,
-            MediaStream audioStream,
+            MediaStream? videoStream,
+            MediaStream? audioStream,
             ICollection<MediaStream> candidateAudioStreams,
-            MediaStream subtitleStream,
+            MediaStream? subtitleStream,
             bool isEligibleForDirectPlay,
             bool isEligibleForDirectStream)
         {
@@ -1110,12 +1112,12 @@ namespace MediaBrowser.Model.Dlna
             int? bitDepth = videoStream?.BitDepth;
             int? videoBitrate = videoStream?.BitRate;
             double? videoLevel = videoStream?.Level;
-            string videoProfile = videoStream?.Profile;
-            string videoRangeType = videoStream?.VideoRangeType;
+            string? videoProfile = videoStream?.Profile;
+            string? videoRangeType = videoStream?.VideoRangeType;
             float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0;
             bool? isAnamorphic = videoStream?.IsAnamorphic;
             bool? isInterlaced = videoStream?.IsInterlaced;
-            string videoCodecTag = videoStream?.CodecTag;
+            string? videoCodecTag = videoStream?.CodecTag;
             bool? isAvc = videoStream?.IsAVC;
 
             TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : mediaSource.Timestamp;
@@ -1203,14 +1205,14 @@ namespace MediaBrowser.Model.Dlna
                     }
 
                     // Check video codec
-                    string videoCodec = videoStream?.Codec;
+                    string? videoCodec = videoStream?.Codec;
                     if (!directPlayProfile.SupportsVideoCodec(videoCodec))
                     {
                         directPlayProfileReasons |= TranscodeReason.VideoCodecNotSupported;
                     }
 
                     // Check audio codec
-                    MediaStream selectedAudioStream = null;
+                    MediaStream? selectedAudioStream = null;
                     if (candidateAudioStreams.Any())
                     {
                         selectedAudioStream = candidateAudioStreams.FirstOrDefault(audioStream => directPlayProfile.SupportsAudioCodec(audioStream.Codec));
@@ -1331,8 +1333,8 @@ namespace MediaBrowser.Model.Dlna
             SubtitleProfile[] subtitleProfiles,
             PlayMethod playMethod,
             ITranscoderSupport transcoderSupport,
-            string outputContainer,
-            string transcodingSubProtocol)
+            string? outputContainer,
+            string? transcodingSubProtocol)
         {
             if (!subtitleStream.IsExternal && (playMethod != PlayMethod.Transcode || !string.Equals(transcodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)))
             {
@@ -1405,7 +1407,7 @@ namespace MediaBrowser.Model.Dlna
                 };
         }
 
-        private static bool IsSubtitleEmbedSupported(string transcodingContainer)
+        private static bool IsSubtitleEmbedSupported(string? transcodingContainer)
         {
             if (!string.IsNullOrEmpty(transcodingContainer))
             {
@@ -1427,7 +1429,7 @@ namespace MediaBrowser.Model.Dlna
             return false;
         }
 
-        private static SubtitleProfile GetExternalSubtitleProfile(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, ITranscoderSupport transcoderSupport, bool allowConversion)
+        private static SubtitleProfile? GetExternalSubtitleProfile(MediaSourceInfo mediaSource, MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, ITranscoderSupport transcoderSupport, bool allowConversion)
         {
             foreach (var profile in subtitleProfiles)
             {
@@ -1560,7 +1562,7 @@ namespace MediaBrowser.Model.Dlna
         private static IEnumerable<ProfileCondition> GetProfileConditionsForAudio(
             IEnumerable<CodecProfile> codecProfiles,
             string container,
-            string codec,
+            string? codec,
             int? audioChannels,
             int? audioBitrate,
             int? audioSampleRate,
@@ -1580,7 +1582,7 @@ namespace MediaBrowser.Model.Dlna
             return conditions.Where(condition => !ConditionProcessor.IsAudioConditionSatisfied(condition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth));
         }
 
-        private void ApplyTranscodingConditions(StreamInfo item, IEnumerable<ProfileCondition> conditions, string qualifier, bool enableQualifiedConditions, bool enableNonQualifiedConditions)
+        private void ApplyTranscodingConditions(StreamInfo item, IEnumerable<ProfileCondition> conditions, string? qualifier, bool enableQualifiedConditions, bool enableNonQualifiedConditions)
         {
             foreach (ProfileCondition condition in conditions)
             {
@@ -2056,7 +2058,7 @@ namespace MediaBrowser.Model.Dlna
             }
 
             // Check audio codec
-            string audioCodec = audioStream?.Codec;
+            string? audioCodec = audioStream?.Codec;
             if (!profile.SupportsAudioCodec(audioCodec))
             {
                 return false;

+ 2 - 3
MediaBrowser.Model/Dlna/StreamInfo.cs

@@ -107,9 +107,8 @@ namespace MediaBrowser.Model.Dlna
 
         public string MediaSourceId => MediaSource?.Id;
 
-        public bool IsDirectStream =>
-            PlayMethod == PlayMethod.DirectStream ||
-            PlayMethod == PlayMethod.DirectPlay;
+        public bool IsDirectStream => MediaSource?.VideoType is not (VideoType.Dvd or VideoType.BluRay)
+            && PlayMethod is PlayMethod.DirectStream or PlayMethod.DirectPlay;
 
         /// <summary>
         /// Gets the audio stream that will be used.

+ 2 - 1
MediaBrowser.Model/Globalization/ILocalizationManager.cs

@@ -30,8 +30,9 @@ namespace MediaBrowser.Model.Globalization
         /// Gets the rating level.
         /// </summary>
         /// <param name="rating">The rating.</param>
+        /// <param name="countryCode">The optional two letter ISO language string.</param>
         /// <returns><see cref="int" /> or <c>null</c>.</returns>
-        int? GetRatingLevel(string rating);
+        int? GetRatingLevel(string rating, string? countryCode = null);
 
         /// <summary>
         /// Gets the localized string.

+ 41 - 0
MediaBrowser.Model/MediaInfo/BlurayDiscInfo.cs

@@ -0,0 +1,41 @@
+#nullable disable
+
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Model.MediaInfo;
+
+/// <summary>
+/// Represents the result of BDInfo output.
+/// </summary>
+public class BlurayDiscInfo
+{
+    /// <summary>
+    /// Gets or sets the media streams.
+    /// </summary>
+    /// <value>The media streams.</value>
+    public MediaStream[] MediaStreams { get; set; }
+
+    /// <summary>
+    /// Gets or sets the run time ticks.
+    /// </summary>
+    /// <value>The run time ticks.</value>
+    public long? RunTimeTicks { get; set; }
+
+    /// <summary>
+    /// Gets or sets the files.
+    /// </summary>
+    /// <value>The files.</value>
+    public string[] Files { get; set; }
+
+    /// <summary>
+    /// Gets or sets the playlist name.
+    /// </summary>
+    /// <value>The playlist name.</value>
+    public string PlaylistName { get; set; }
+
+    /// <summary>
+    /// Gets or sets the chapters.
+    /// </summary>
+    /// <value>The chapters.</value>
+    public double[] Chapters { get; set; }
+}

+ 14 - 0
MediaBrowser.Model/MediaInfo/IBlurayExaminer.cs

@@ -0,0 +1,14 @@
+namespace MediaBrowser.Model.MediaInfo;
+
+/// <summary>
+/// Interface IBlurayExaminer.
+/// </summary>
+public interface IBlurayExaminer
+{
+    /// <summary>
+    /// Gets the disc info.
+    /// </summary>
+    /// <param name="path">The path.</param>
+    /// <returns>BlurayDiscInfo.</returns>
+    BlurayDiscInfo GetDiscInfo(string path);
+}

+ 172 - 34
MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs

@@ -36,6 +36,7 @@ namespace MediaBrowser.Providers.MediaInfo
         private readonly ILogger<FFProbeVideoInfo> _logger;
         private readonly IMediaEncoder _mediaEncoder;
         private readonly IItemRepository _itemRepo;
+        private readonly IBlurayExaminer _blurayExaminer;
         private readonly ILocalizationManager _localization;
         private readonly IEncodingManager _encodingManager;
         private readonly IServerConfigurationManager _config;
@@ -51,6 +52,7 @@ namespace MediaBrowser.Providers.MediaInfo
             IMediaSourceManager mediaSourceManager,
             IMediaEncoder mediaEncoder,
             IItemRepository itemRepo,
+            IBlurayExaminer blurayExaminer,
             ILocalizationManager localization,
             IEncodingManager encodingManager,
             IServerConfigurationManager config,
@@ -64,6 +66,7 @@ namespace MediaBrowser.Providers.MediaInfo
             _mediaSourceManager = mediaSourceManager;
             _mediaEncoder = mediaEncoder;
             _itemRepo = itemRepo;
+            _blurayExaminer = blurayExaminer;
             _localization = localization;
             _encodingManager = encodingManager;
             _config = config;
@@ -80,16 +83,77 @@ namespace MediaBrowser.Providers.MediaInfo
             CancellationToken cancellationToken)
             where T : Video
         {
+            BlurayDiscInfo blurayDiscInfo = null;
+
             Model.MediaInfo.MediaInfo mediaInfoResult = null;
 
             if (!item.IsShortcut || options.EnableRemoteContentProbe)
             {
-                mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false);
+                if (item.VideoType == VideoType.Dvd)
+                {
+                    // Get list of playable .vob files
+                    var vobs = _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, null);
+
+                    // Return if no playable .vob files are found
+                    if (vobs.Count == 0)
+                    {
+                        _logger.LogError("No playable .vob files found in DVD structure, skipping FFprobe.");
+                        return ItemUpdateType.MetadataImport;
+                    }
+
+                    // Fetch metadata of first .vob file
+                    mediaInfoResult = await GetMediaInfo(
+                        new Video
+                        {
+                            Path = vobs[0]
+                        },
+                        cancellationToken).ConfigureAwait(false);
+
+                    // Sum up the runtime of all .vob files skipping the first .vob
+                    for (var i = 1; i < vobs.Count; i++)
+                    {
+                        var tmpMediaInfo = await GetMediaInfo(
+                            new Video
+                            {
+                                Path = vobs[i]
+                            },
+                            cancellationToken).ConfigureAwait(false);
+
+                        mediaInfoResult.RunTimeTicks += tmpMediaInfo.RunTimeTicks;
+                    }
+                }
+                else if (item.VideoType == VideoType.BluRay)
+                {
+                    // Get BD disc information
+                    blurayDiscInfo = GetBDInfo(item.Path);
+
+                    // Get playable .m2ts files
+                    var m2ts = _mediaEncoder.GetPrimaryPlaylistM2tsFiles(item.Path);
+
+                    // Return if no playable .m2ts files are found
+                    if (blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0)
+                    {
+                        _logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe.");
+                        return ItemUpdateType.MetadataImport;
+                    }
+
+                    // Fetch metadata of first .m2ts file
+                    mediaInfoResult = await GetMediaInfo(
+                        new Video
+                        {
+                            Path = m2ts[0]
+                        },
+                        cancellationToken).ConfigureAwait(false);
+                }
+                else
+                {
+                    mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false);
+                }
 
                 cancellationToken.ThrowIfCancellationRequested();
             }
 
-            await Fetch(item, cancellationToken, mediaInfoResult, options).ConfigureAwait(false);
+            await Fetch(item, cancellationToken, mediaInfoResult, blurayDiscInfo, options).ConfigureAwait(false);
 
             return ItemUpdateType.MetadataImport;
         }
@@ -129,6 +193,7 @@ namespace MediaBrowser.Providers.MediaInfo
             Video video,
             CancellationToken cancellationToken,
             Model.MediaInfo.MediaInfo mediaInfo,
+            BlurayDiscInfo blurayInfo,
             MetadataRefreshOptions options)
         {
             List<MediaStream> mediaStreams;
@@ -153,19 +218,8 @@ namespace MediaBrowser.Providers.MediaInfo
                 }
 
                 mediaAttachments = mediaInfo.MediaAttachments;
-
                 video.TotalBitrate = mediaInfo.Bitrate;
-                // video.FormatName = (mediaInfo.Container ?? string.Empty)
-                //    .Replace("matroska", "mkv", StringComparison.OrdinalIgnoreCase);
-
-                // For DVDs this may not always be accurate, so don't set the runtime if the item already has one
-                var needToSetRuntime = video.VideoType != VideoType.Dvd || video.RunTimeTicks is null || video.RunTimeTicks.Value == 0;
-
-                if (needToSetRuntime)
-                {
-                    video.RunTimeTicks = mediaInfo.RunTimeTicks;
-                }
-
+                video.RunTimeTicks = mediaInfo.RunTimeTicks;
                 video.Size = mediaInfo.Size;
 
                 if (video.VideoType == VideoType.VideoFile)
@@ -182,6 +236,10 @@ namespace MediaBrowser.Providers.MediaInfo
                 video.Container = mediaInfo.Container;
 
                 chapters = mediaInfo.Chapters ?? Array.Empty<ChapterInfo>();
+                if (blurayInfo is not null)
+                {
+                    FetchBdInfo(video, ref chapters, mediaStreams, blurayInfo);
+                }
             }
             else
             {
@@ -240,7 +298,7 @@ namespace MediaBrowser.Providers.MediaInfo
             if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh ||
                 options.MetadataRefreshMode == MetadataRefreshMode.Default)
             {
-                if (chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video))
+                if (_config.Configuration.DummyChapterDuration > 0 && chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video))
                 {
                     chapters = CreateDummyChapters(video);
                 }
@@ -277,6 +335,86 @@ namespace MediaBrowser.Providers.MediaInfo
             }
         }
 
+        private void FetchBdInfo(Video video, ref ChapterInfo[] chapters, List<MediaStream> mediaStreams, BlurayDiscInfo blurayInfo)
+        {
+            if (blurayInfo.Files.Length <= 1)
+            {
+                return;
+            }
+
+            // Use BD Info if it has multiple m2ts. Otherwise, treat it like a video file and rely more on ffprobe output
+            int? currentHeight = null;
+            int? currentWidth = null;
+            int? currentBitRate = null;
+
+            var videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
+
+            // Grab the values that ffprobe recorded
+            if (videoStream is not null)
+            {
+                currentBitRate = videoStream.BitRate;
+                currentWidth = videoStream.Width;
+                currentHeight = videoStream.Height;
+            }
+
+            // Fill video properties from the BDInfo result
+            mediaStreams.Clear();
+            mediaStreams.AddRange(blurayInfo.MediaStreams);
+
+            if (blurayInfo.RunTimeTicks.HasValue && blurayInfo.RunTimeTicks.Value > 0)
+            {
+                video.RunTimeTicks = blurayInfo.RunTimeTicks;
+            }
+
+            if (blurayInfo.Chapters is not null)
+            {
+                double[] brChapter = blurayInfo.Chapters;
+                chapters = new ChapterInfo[brChapter.Length];
+                for (int i = 0; i < brChapter.Length; i++)
+                {
+                    chapters[i] = new ChapterInfo
+                    {
+                        StartPositionTicks = TimeSpan.FromSeconds(brChapter[i]).Ticks
+                    };
+                }
+            }
+
+            videoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
+
+            // Use the ffprobe values if these are empty
+            if (videoStream is not null)
+            {
+                videoStream.BitRate = IsEmpty(videoStream.BitRate) ? currentBitRate : videoStream.BitRate;
+                videoStream.Width = IsEmpty(videoStream.Width) ? currentWidth : videoStream.Width;
+                videoStream.Height = IsEmpty(videoStream.Height) ? currentHeight : videoStream.Height;
+            }
+        }
+
+        private bool IsEmpty(int? num)
+        {
+            return !num.HasValue || num.Value == 0;
+        }
+
+        /// <summary>
+        /// Gets information about the longest playlist on a bdrom.
+        /// </summary>
+        /// <param name="path">The path.</param>
+        /// <returns>VideoStream.</returns>
+        private BlurayDiscInfo GetBDInfo(string path)
+        {
+            ArgumentException.ThrowIfNullOrEmpty(path);
+
+            try
+            {
+                return _blurayExaminer.GetDiscInfo(path);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error getting BDInfo");
+                return null;
+            }
+        }
+
         private void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions)
         {
             var replaceData = refreshOptions.ReplaceAllMetadata;
@@ -524,39 +662,39 @@ namespace MediaBrowser.Providers.MediaInfo
         private ChapterInfo[] CreateDummyChapters(Video video)
         {
             var runtime = video.RunTimeTicks ?? 0;
-            long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks;
 
-            if (runtime < 0)
+            // Only process files with a runtime higher than 0 and lower than 12h. The latter are likely corrupted.
+            if (runtime < 0 || runtime > TimeSpan.FromHours(12).Ticks)
             {
                 throw new ArgumentException(
                     string.Format(
                         CultureInfo.InvariantCulture,
-                        "{0} has invalid runtime of {1}",
+                        "{0} has an invalid runtime of {1} minutes",
                         video.Name,
-                        runtime));
+                        TimeSpan.FromTicks(runtime).Minutes));
             }
 
-            if (runtime < dummyChapterDuration)
+            long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks;
+            if (runtime > dummyChapterDuration)
             {
-                return Array.Empty<ChapterInfo>();
-            }
-
-            // Limit the chapters just in case there's some incorrect metadata here
-            int chapterCount = (int)Math.Min(runtime / dummyChapterDuration, _config.Configuration.DummyChapterCount);
-            var chapters = new ChapterInfo[chapterCount];
+                int chapterCount = (int)(runtime / dummyChapterDuration);
+                var chapters = new ChapterInfo[chapterCount];
 
-            long currentChapterTicks = 0;
-            for (int i = 0; i < chapterCount; i++)
-            {
-                chapters[i] = new ChapterInfo
+                long currentChapterTicks = 0;
+                for (int i = 0; i < chapterCount; i++)
                 {
-                    StartPositionTicks = currentChapterTicks
-                };
+                    chapters[i] = new ChapterInfo
+                    {
+                        StartPositionTicks = currentChapterTicks
+                    };
+
+                    currentChapterTicks += dummyChapterDuration;
+                }
 
-                currentChapterTicks += dummyChapterDuration;
+                return chapters;
             }
 
-            return chapters;
+            return Array.Empty<ChapterInfo>();
         }
     }
 }

+ 3 - 0
MediaBrowser.Providers/MediaInfo/ProbeProvider.cs

@@ -53,6 +53,7 @@ namespace MediaBrowser.Providers.MediaInfo
         /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
         /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
         /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
+        /// <param name="blurayExaminer">Instance of the <see cref="IBlurayExaminer"/> interface.</param>
         /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
         /// <param name="encodingManager">Instance of the <see cref="IEncodingManager"/> interface.</param>
         /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
@@ -66,6 +67,7 @@ namespace MediaBrowser.Providers.MediaInfo
             IMediaSourceManager mediaSourceManager,
             IMediaEncoder mediaEncoder,
             IItemRepository itemRepo,
+            IBlurayExaminer blurayExaminer,
             ILocalizationManager localization,
             IEncodingManager encodingManager,
             IServerConfigurationManager config,
@@ -85,6 +87,7 @@ namespace MediaBrowser.Providers.MediaInfo
                 mediaSourceManager,
                 mediaEncoder,
                 itemRepo,
+                blurayExaminer,
                 localization,
                 encodingManager,
                 config,

+ 1 - 1
MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs

@@ -53,7 +53,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages
         /// <inheritdoc />
         public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
         {
-            return new List<ImageType>
+            return new ImageType[]
             {
                 ImageType.Thumb
             };

+ 40 - 67
MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs

@@ -274,16 +274,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     {
                         var val = reader.ReadElementContentAsString();
 
-                        if (!string.IsNullOrWhiteSpace(val))
+                        if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added))
                         {
-                            if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added))
-                            {
-                                item.DateCreated = added;
-                            }
-                            else
-                            {
-                                Logger.LogWarning("Invalid Added value found: {Value}", val);
-                            }
+                            item.DateCreated = added;
+                        }
+                        else
+                        {
+                            Logger.LogWarning("Invalid Added value found: {Value}", val);
                         }
 
                         break;
@@ -376,15 +373,13 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                 case "playcount":
                     {
                         var val = reader.ReadElementContentAsString();
-                        if (!string.IsNullOrWhiteSpace(val) && !string.IsNullOrWhiteSpace(nfoConfiguration.UserId))
+                        if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)
+                            && Guid.TryParse(nfoConfiguration.UserId, out var guid))
                         {
-                            if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
-                            {
-                                var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId));
-                                userData = _userDataManager.GetUserData(user, item);
-                                userData.PlayCount = count;
-                                _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
-                            }
+                            var user = _userManager.GetUserById(guid);
+                            userData = _userDataManager.GetUserData(user, item);
+                            userData.PlayCount = count;
+                            _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
                         }
 
                         break;
@@ -393,11 +388,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                 case "lastplayed":
                     {
                         var val = reader.ReadElementContentAsString();
-                        if (!string.IsNullOrWhiteSpace(val) && !string.IsNullOrWhiteSpace(nfoConfiguration.UserId))
+                        if (Guid.TryParse(nfoConfiguration.UserId, out var guid))
                         {
                             if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added))
                             {
-                                var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId));
+                                var user = _userManager.GetUserById(guid);
                                 userData = _userDataManager.GetUserData(user, item);
                                 userData.LastPlayedDate = added;
                                 _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
@@ -487,12 +482,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     {
                         var text = reader.ReadElementContentAsString();
 
-                        if (!string.IsNullOrWhiteSpace(text))
+                        if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime))
                         {
-                            if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime))
-                            {
-                                item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks;
-                            }
+                            item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks;
                         }
 
                         break;
@@ -630,13 +622,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     {
                         var val = reader.ReadElementContentAsString();
 
-                        var hasDisplayOrder = item as IHasDisplayOrder;
-                        if (hasDisplayOrder is not null)
+                        if (item is IHasDisplayOrder hasDisplayOrder && !string.IsNullOrWhiteSpace(val))
                         {
-                            if (!string.IsNullOrWhiteSpace(val))
-                            {
-                                hasDisplayOrder.DisplayOrder = val;
-                            }
+                            hasDisplayOrder.DisplayOrder = val;
                         }
 
                         break;
@@ -646,12 +634,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     {
                         var val = reader.ReadElementContentAsString();
 
-                        if (!string.IsNullOrWhiteSpace(val))
+                        if (int.TryParse(val, out var productionYear) && productionYear > 1850)
                         {
-                            if (int.TryParse(val, out var productionYear) && productionYear > 1850)
-                            {
-                                item.ProductionYear = productionYear;
-                            }
+                            item.ProductionYear = productionYear;
                         }
 
                         break;
@@ -661,13 +646,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                     {
                         var rating = reader.ReadElementContentAsString();
 
-                        if (!string.IsNullOrWhiteSpace(rating))
+                        // All external meta is saving this as '.' for decimal I believe...but just to be sure
+                        if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val))
                         {
-                            // All external meta is saving this as '.' for decimal I believe...but just to be sure
-                            if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val))
-                            {
-                                item.CommunityRating = val;
-                            }
+                            item.CommunityRating = val;
                         }
 
                         break;
@@ -697,13 +679,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers
 
                         var val = reader.ReadElementContentAsString();
 
-                        if (!string.IsNullOrWhiteSpace(val))
+                        if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850)
                         {
-                            if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850)
-                            {
-                                item.PremiereDate = date;
-                                item.ProductionYear = date.Year;
-                            }
+                            item.PremiereDate = date;
+                            item.ProductionYear = date.Year;
                         }
 
                         break;
@@ -715,12 +694,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
 
                         var val = reader.ReadElementContentAsString();
 
-                        if (!string.IsNullOrWhiteSpace(val))
+                        if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850)
                         {
-                            if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850)
-                            {
-                                item.EndDate = date;
-                            }
+                            item.EndDate = date;
                         }
 
                         break;
@@ -1191,21 +1167,21 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                         case "value":
                             var val = reader.ReadElementContentAsString();
 
-                            if (!string.IsNullOrWhiteSpace(val))
+                            if (float.TryParse(val, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var ratingValue))
                             {
-                                if (float.TryParse(val, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var ratingValue))
+                                // if ratingName contains tomato --> assume critic rating
+                                if (ratingName is not null
+                                    && ratingName.Contains("tomato", StringComparison.OrdinalIgnoreCase)
+                                    && !ratingName.Contains("audience", StringComparison.OrdinalIgnoreCase))
                                 {
-                                    // if ratingName contains tomato --> assume critic rating
-                                    if (ratingName is not null &&
-                                        ratingName.Contains("tomato", StringComparison.OrdinalIgnoreCase) &&
-                                        !ratingName.Contains("audience", StringComparison.OrdinalIgnoreCase))
+                                    if (!ratingName.Contains("avg", StringComparison.OrdinalIgnoreCase))
                                     {
                                         item.CriticRating = ratingValue;
                                     }
-                                    else
-                                    {
-                                        item.CommunityRating = ratingValue;
-                                    }
+                                }
+                                else
+                                {
+                                    item.CommunityRating = ratingValue;
                                 }
                             }
 
@@ -1289,12 +1265,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                             {
                                 var val = reader.ReadElementContentAsString();
 
-                                if (!string.IsNullOrWhiteSpace(val))
+                                if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal))
                                 {
-                                    if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal))
-                                    {
-                                        sortOrder = intVal;
-                                    }
+                                    sortOrder = intVal;
                                 }
 
                                 break;

+ 1 - 1
deployment/Dockerfile.centos.amd64

@@ -13,7 +13,7 @@ RUN yum update -yq \
   && yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git wget
 
 # Install DotNET SDK
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/794cd64a-31ac-4070-ac39-34858e8c00da/9568dfe47bd2d22de99268ceac5b2bef/dotnet-sdk-7.0.103-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
   && mkdir -p dotnet-sdk \
   && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
   && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.fedora.amd64

@@ -12,7 +12,7 @@ RUN dnf update -yq \
   && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget make
 
 # Install DotNET SDK
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/794cd64a-31ac-4070-ac39-34858e8c00da/9568dfe47bd2d22de99268ceac5b2bef/dotnet-sdk-7.0.103-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
   && mkdir -p dotnet-sdk \
   && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
   && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.ubuntu.amd64

@@ -17,7 +17,7 @@ RUN apt-get update -yqq \
     libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0
 
 # Install dotnet repository
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/794cd64a-31ac-4070-ac39-34858e8c00da/9568dfe47bd2d22de99268ceac5b2bef/dotnet-sdk-7.0.103-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
   && mkdir -p dotnet-sdk \
   && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
   && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.ubuntu.arm64

@@ -16,7 +16,7 @@ RUN apt-get update -yqq \
     mmv build-essential lsb-release
 
 # Install dotnet repository
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/794cd64a-31ac-4070-ac39-34858e8c00da/9568dfe47bd2d22de99268ceac5b2bef/dotnet-sdk-7.0.103-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
   && mkdir -p dotnet-sdk \
   && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
   && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 1 - 1
deployment/Dockerfile.ubuntu.armhf

@@ -16,7 +16,7 @@ RUN apt-get update -yqq \
     mmv build-essential lsb-release
 
 # Install dotnet repository
-RUN wget -q https://download.visualstudio.microsoft.com/download/pr/794cd64a-31ac-4070-ac39-34858e8c00da/9568dfe47bd2d22de99268ceac5b2bef/dotnet-sdk-7.0.103-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
+RUN wget -q https://download.visualstudio.microsoft.com/download/pr/bda88810-e1a6-4cf0-8139-7fd7fe7b2c7a/7a9ffa3e12e5f1c3d8b640e326c1eb14/dotnet-sdk-7.0.202-linux-x64.tar.gz -O dotnet-sdk.tar.gz \
   && mkdir -p dotnet-sdk \
   && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \
   && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet

+ 4 - 0
tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs

@@ -17,6 +17,8 @@ namespace Jellyfin.MediaEncoding.Tests
         }
 
         [Theory]
+        [InlineData(EncoderValidatorTestsData.FFmpegV60Output, true)]
+        [InlineData(EncoderValidatorTestsData.FFmpegV512Output, true)]
         [InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)]
         [InlineData(EncoderValidatorTestsData.FFmpegV432Output, true)]
         [InlineData(EncoderValidatorTestsData.FFmpegV431Output, true)]
@@ -36,6 +38,8 @@ namespace Jellyfin.MediaEncoding.Tests
         {
             public GetFFmpegVersionTestData()
             {
+                Add(EncoderValidatorTestsData.FFmpegV60Output, new Version(6, 0));
+                Add(EncoderValidatorTestsData.FFmpegV512Output, new Version(5, 1, 2));
                 Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4));
                 Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2));
                 Add(EncoderValidatorTestsData.FFmpegV431Output, new Version(4, 3, 1));

+ 24 - 0
tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs

@@ -2,6 +2,30 @@ namespace Jellyfin.MediaEncoding.Tests
 {
     internal static class EncoderValidatorTestsData
     {
+        public const string FFmpegV60Output = @"ffmpeg version 6.0-Jellyfin Copyright (c) 2000-2023 the FFmpeg developers
+built with gcc 12.2.0 (crosstool-NG 1.25.0.90_cf9beb1)
+configuration: --prefix=/ffbuild/prefix --pkg-config=pkg-config --pkg-config-flags=--static --cross-prefix=x86_64-w64-mingw32- --arch=x86_64 --target-os=mingw32 --extra-version=Jellyfin --extra-cflags= --extra-cxxflags= --extra-ldflags= --extra-ldexeflags= --extra-libs= --enable-gpl --enable-version3 --enable-lto --disable-ffplay --disable-debug --disable-doc --disable-ptx-compression --disable-sdl2 --disable-w32threads --enable-pthreads --enable-iconv --enable-libxml2 --enable-zlib --enable-libfreetype --enable-libfribidi --enable-gmp --enable-lzma --enable-fontconfig --enable-libvorbis --enable-opencl --enable-amf --enable-chromaprint --enable-libdav1d --enable-dxva2 --enable-d3d11va --enable-libfdk-aac --enable-ffnvcodec --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvdec --enable-nvenc --enable-libass --enable-libbluray --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvpx --enable-libwebp --enable-libvpl --enable-schannel --enable-libsrt --enable-libsvtav1 --enable-vulkan --enable-libshaderc --enable-libplacebo --enable-libx264 --enable-libx265 --enable-libzimg --enable-libzvbi
+libavutil      58.  2.100 / 58.  2.100
+libavcodec     60.  3.100 / 60.  3.100
+libavformat    60.  3.100 / 60.  3.100
+libavdevice    60.  1.100 / 60.  1.100
+libavfilter     9.  3.100 /  9.  3.100
+libswscale      7.  1.100 /  7.  1.100
+libswresample   4. 10.100 /  4. 10.100
+libpostproc    57.  1.100 / 57.  1.100";
+
+        public const string FFmpegV512Output = @"ffmpeg version 5.1.2-Jellyfin Copyright (c) 2000-2022 the FFmpeg developers
+built with gcc 10-win32 (GCC) 20220324
+configuration: --prefix=/opt/ffmpeg --arch=x86_64 --target-os=mingw32 --cross-prefix=x86_64-w64-mingw32- --pkg-config=pkg-config --pkg-config-flags=--static --extra-libs='-lfftw3f -lstdc++' --extra-cflags=-DCHROMAPRINT_NODLL --extra-version=Jellyfin --disable-ffplay --disable-debug --disable-doc --disable-sdl2 --disable-ptx-compression --disable-w32threads --enable-pthreads --enable-shared --enable-lto --enable-gpl --enable-version3 --enable-schannel --enable-iconv --enable-libxml2 --enable-zlib --enable-lzma --enable-gmp --enable-chromaprint --enable-libfreetype --enable-libfribidi --enable-libfontconfig --enable-libass --enable-libbluray --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libzimg --enable-libx264 --enable-libx265 --enable-libsvtav1 --enable-libdav1d --enable-libfdk-aac --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-ffnvcodec --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvdec --enable-nvenc
+libavutil      57. 28.100 / 57. 28.100
+libavcodec     59. 37.100 / 59. 37.100
+libavformat    59. 27.100 / 59. 27.100
+libavdevice    59.  7.100 / 59.  7.100
+libavfilter     8. 44.100 /  8. 44.100
+libswscale      6.  7.100 /  6.  7.100
+libswresample   4.  7.100 /  4.  7.100
+libpostproc    56.  6.100 / 56.  6.100";
+
         public const string FFmpegV44Output = @"ffmpeg version 4.4-Jellyfin Copyright (c) 2000-2021 the FFmpeg developers
 built with gcc 10.3.0 (Rev5, Built by MSYS2 project)
 configuration:  --disable-static --enable-shared --extra-version=Jellyfin --disable-ffplay --disable-debug --enable-gpl --enable-version3 --enable-bzlib --enable-iconv --enable-lzma --enable-zlib --enable-sdl2 --enable-fontconfig --enable-gmp --enable-libass --enable-libzimg --enable-libbluray --enable-libfreetype --enable-libmp3lame --enable-libopus --enable-libtheora --enable-libvorbis --enable-libwebp --enable-libvpx --enable-libx264 --enable-libx265 --enable-libdav1d --enable-opencl --enable-dxva2 --enable-d3d11va --enable-amf --enable-libmfx --enable-cuda --enable-cuda-llvm --enable-cuvid --enable-nvenc --enable-nvdec --enable-ffnvcodec --enable-gnutls

+ 76 - 0
tests/Jellyfin.Server.Implementations.Tests/Library/AudioResolverTests.cs

@@ -0,0 +1,76 @@
+using System.Linq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library.Resolvers.Audio;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class AudioResolverTests
+{
+    private static readonly NamingOptions _namingOptions = new();
+
+    [Theory]
+    [InlineData("words.mp3")] // single non-tagged file
+    [InlineData("chapter 01.mp3")]
+    [InlineData("part 1.mp3")]
+    [InlineData("chapter 01.mp3", "non-media.txt")]
+    [InlineData("title.mp3", "title.epub")]
+    [InlineData("01.mp3", "subdirectory/")] // single media file with sub-directory - note that this will hide any contents in the subdirectory
+    public void Resolve_AudiobookDirectory_SingleResult(params string[] children)
+    {
+        var resolved = TestResolveChildren("/parent/title", children);
+        Assert.NotNull(resolved);
+    }
+
+    [Theory]
+    /* Results that can't be displayed as an audio book. */
+    [InlineData] // no contents
+    [InlineData("subdirectory/")]
+    [InlineData("non-media.txt")]
+    /* Names don't indicate parts of a single book. */
+    [InlineData("Name.mp3", "Another Name.mp3")]
+    /* Results that are an audio book but not currently navigable as such (multiple chapters and/or parts). */
+    [InlineData("01.mp3", "02.mp3")]
+    [InlineData("chapter 01.mp3", "chapter 02.mp3")]
+    [InlineData("part 1.mp3", "part 2.mp3")]
+    [InlineData("chapter 01 part 01.mp3", "chapter 01 part 02.mp3")]
+    /* Mismatched chapters, parts, and named files. */
+    [InlineData("chapter 01.mp3", "part 2.mp3")]
+    [InlineData("book title.mp3", "chapter name.mp3")] // "book title" resolves as alternate version of book based on directory name
+    [InlineData("01 Content.mp3", "01 Credits.mp3")] // resolves as alternate versions of chapter 1
+    [InlineData("Chapter Name.mp3", "Part 1.mp3")]
+    public void Resolve_AudiobookDirectory_NoResult(params string[] children)
+    {
+        var resolved = TestResolveChildren("/parent/book title", children);
+        Assert.Null(resolved);
+    }
+
+    private Audio? TestResolveChildren(string parent, string[] children)
+    {
+        var childrenMetadata = children.Select(name => new FileSystemMetadata
+        {
+            FullName = parent + "/" + name,
+            IsDirectory = name.EndsWith('/')
+        }).ToArray();
+
+        var resolver = new AudioResolver(_namingOptions);
+        var itemResolveArgs = new ItemResolveArgs(
+            null,
+            Mock.Of<ILibraryManager>())
+        {
+            CollectionType = "books",
+            FileInfo = new FileSystemMetadata
+            {
+                FullName = parent,
+                IsDirectory = true
+            },
+            FileSystemChildren = childrenMetadata
+        };
+
+        return resolver.Resolve(itemResolveArgs);
+    }
+}

+ 5 - 5
tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs

@@ -22,10 +22,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library
         {
             var parent = new Folder { Name = "extras" };
 
-            var episodeResolver = new EpisodeResolver(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions);
+            var episodeResolver = new EpisodeResolver(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
             var itemResolveArgs = new ItemResolveArgs(
                 Mock.Of<IServerApplicationPaths>(),
-                Mock.Of<IDirectoryService>())
+                null)
             {
                 Parent = parent,
                 CollectionType = CollectionType.TvShows,
@@ -45,10 +45,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library
 
             // Have to create a mock because of moq proxies not being castable to a concrete implementation
             // https://github.com/jellyfin/jellyfin/blob/ab0cff8556403e123642dc9717ba778329554634/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs#L48
-            var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions);
+            var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
             var itemResolveArgs = new ItemResolveArgs(
                 Mock.Of<IServerApplicationPaths>(),
-                Mock.Of<IDirectoryService>())
+                null)
             {
                 Parent = series,
                 CollectionType = CollectionType.TvShows,
@@ -62,7 +62,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
 
         private sealed class EpisodeResolverMock : EpisodeResolver
         {
-            public EpisodeResolverMock(ILogger<EpisodeResolver> logger, NamingOptions namingOptions) : base(logger, namingOptions)
+            public EpisodeResolverMock(ILogger<EpisodeResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) : base(logger, namingOptions, directoryService)
             {
             }
 

+ 2 - 2
tests/Jellyfin.Server.Implementations.Tests/Library/MovieResolverTests.cs

@@ -18,10 +18,10 @@ public class MovieResolverTests
     [Fact]
     public void Resolve_GivenLocalAlternateVersion_ResolvesToVideo()
     {
-        var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions);
+        var movieResolver = new MovieResolver(Mock.Of<IImageProcessor>(), Mock.Of<ILogger<MovieResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
         var itemResolveArgs = new ItemResolveArgs(
             Mock.Of<IServerApplicationPaths>(),
-            Mock.Of<IDirectoryService>())
+            null)
         {
             Parent = null,
             FileInfo = new FileSystemMetadata

+ 2 - 2
tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs

@@ -83,7 +83,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
             await localizationManager.LoadAll();
             var ratings = localizationManager.GetParentalRatings().ToList();
 
-            Assert.Equal(53, ratings.Count);
+            Assert.Equal(54, ratings.Count);
 
             var tvma = ratings.FirstOrDefault(x => x.Name.Equals("TV-MA", StringComparison.Ordinal));
             Assert.NotNull(tvma);
@@ -100,7 +100,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
             await localizationManager.LoadAll();
             var ratings = localizationManager.GetParentalRatings().ToList();
 
-            Assert.Equal(18, ratings.Count);
+            Assert.Equal(19, ratings.Count);
 
             var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal));
             Assert.NotNull(fsk);