فهرست منبع

Merge branch 'master' into network-rewrite

Shadowghost 2 سال پیش
والد
کامیت
32499f0e98
74فایلهای تغییر یافته به همراه2137 افزوده شده و 322 حذف شده
  1. 1 1
      .github/workflows/codeql-analysis.yml
  2. 7 7
      .github/workflows/commands.yml
  3. 4 4
      .github/workflows/openapi.yml
  4. 1 0
      CONTRIBUTORS.md
  5. 1 1
      Directory.Packages.props
  6. 9 5
      Emby.Dlna/PlayTo/DlnaHttpClient.cs
  7. 80 18
      Emby.Server.Implementations/Library/PathExtensions.cs
  8. 18 8
      Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
  9. 142 5
      Emby.Server.Implementations/Plugins/PluginManager.cs
  10. 5 2
      Emby.Server.Implementations/Updates/InstallationManager.cs
  11. 138 10
      Jellyfin.Server/Filters/AdditionalModelFilter.cs
  12. 1 1
      MediaBrowser.Common/Plugins/IPluginManager.cs
  13. 9 0
      MediaBrowser.Common/Plugins/PluginManifest.cs
  14. 4 0
      MediaBrowser.Controller/Entities/TV/Series.cs
  15. 221 136
      MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
  16. 28 0
      MediaBrowser.Controller/Net/WebSocketMessage.cs
  17. 33 0
      MediaBrowser.Controller/Net/WebSocketMessageOfT.cs
  18. 10 0
      MediaBrowser.Controller/Net/WebSocketMessages/IInboundWebSocketMessage.cs
  19. 10 0
      MediaBrowser.Controller/Net/WebSocketMessages/IOutboundWebSocketMessage.cs
  20. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs
  21. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs
  22. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs
  23. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs
  24. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs
  25. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs
  26. 9 0
      MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs
  27. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs
  28. 23 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs
  29. 23 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs
  30. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs
  31. 23 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs
  32. 23 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs
  33. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs
  34. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs
  35. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs
  36. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs
  37. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs
  38. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs
  39. 14 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs
  40. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs
  41. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs
  42. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs
  43. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs
  44. 14 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs
  45. 14 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs
  46. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs
  47. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs
  48. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs
  49. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs
  50. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs
  51. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs
  52. 25 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs
  53. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs
  54. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs
  55. 23 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs
  56. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs
  57. 24 0
      MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs
  58. 9 0
      MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs
  59. 23 0
      MediaBrowser.Controller/Net/WebSocketMessages/Shared/KeepAliveMessage.cs
  60. 12 12
      MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
  61. 8 8
      MediaBrowser.Model/Configuration/EncodingOptions.cs
  62. 11 8
      MediaBrowser.Model/Dlna/StreamBuilder.cs
  63. 0 31
      MediaBrowser.Model/Net/WebSocketMessage.cs
  64. 21 33
      MediaBrowser.Model/SyncPlay/GroupUpdate.cs
  65. 31 0
      MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs
  66. 2 2
      MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs
  67. 3 3
      MediaBrowser.Model/SyncPlay/SyncPlayQueueItem.cs
  68. 1 0
      MediaBrowser.Providers/Manager/MetadataService.cs
  69. 53 21
      MediaBrowser.Providers/TV/SeriesMetadataService.cs
  70. 12 0
      MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs
  71. 15 0
      MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
  72. 43 0
      tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
  73. 298 5
      tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
  74. 1 1
      tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json

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

@@ -20,7 +20,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
+      uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
     - name: Setup .NET
       uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
       with:

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

@@ -17,14 +17,14 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Notify as seen
-        uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
+        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           comment-id: ${{ github.event.comment.id }}
           reactions: '+1'
 
       - name: Checkout the latest code
-        uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
+        uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0
@@ -43,7 +43,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Notify as seen
-        uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
+        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
         if: ${{ github.event.comment != null }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -51,14 +51,14 @@ jobs:
           reactions: eyes
 
       - name: Checkout the latest code
-        uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
+        uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
           fetch-depth: 0
 
       - name: Notify as running
         id: comment_running
-        uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
+        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
         if: ${{ github.event.comment != null }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -93,7 +93,7 @@ jobs:
           exit ${retcode}
 
       - name: Notify with result success
-        uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
+        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
         if: ${{ github.event.comment != null && success() }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}
@@ -108,7 +108,7 @@ jobs:
           reactions: hooray
 
       - name: Notify with result failure
-        uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
+        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
         if: ${{ github.event.comment != null && failure() }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}

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

@@ -14,7 +14,7 @@ jobs:
     permissions: read-all
     steps:
       - name: Checkout repository
-        uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
+        uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
         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@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
+        uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
         with:
           ref: ${{ github.event.pull_request.head.sha }}
           repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -110,7 +110,7 @@ jobs:
           direction: last
           body-includes: openapi-diff-workflow-comment
       - name: Reply or edit difference comment (changed)
-        uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
+        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
         if: ${{ steps.read-diff.outputs.body != '' }}
         with:
           issue-number: ${{ github.event.pull_request.number }}
@@ -125,7 +125,7 @@ jobs:
 
             </details>
       - name: Edit difference comment (unchanged)
-        uses: peter-evans/create-or-update-comment@ca08ebd5dc95aa0cd97021e9708fcd6b87138c9b # v3.0.1
+        uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3.0.2
         if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
         with:
           issue-number: ${{ github.event.pull_request.number }}

+ 1 - 0
CONTRIBUTORS.md

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

+ 1 - 1
Directory.Packages.props

@@ -63,7 +63,7 @@
     <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
     <PackageVersion Include="Serilog.Sinks.Console" Version="4.1.0" />
     <PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
-    <PackageVersion Include="Serilog.Sinks.Graylog" Version="2.3.0" />
+    <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.0.0" />
     <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
     <PackageVersion Include="SharpFuzz" Version="2.0.2" />
     <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" />

+ 9 - 5
Emby.Dlna/PlayTo/DlnaHttpClient.cs

@@ -49,20 +49,24 @@ namespace Emby.Dlna.PlayTo
 
         private async Task<XDocument?> SendRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
         {
-            using var response = await _httpClientFactory.CreateClient(NamedClient.Dlna).SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+            var client = _httpClientFactory.CreateClient(NamedClient.Dlna);
+            using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
             response.EnsureSuccessStatusCode();
-            await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+            await using MemoryStream ms = new MemoryStream();
+            await response.Content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false);
             try
             {
                 return await XDocument.LoadAsync(
-                    stream,
+                    ms,
                     LoadOptions.None,
                     cancellationToken).ConfigureAwait(false);
             }
             catch (XmlException)
             {
                 // try correcting the Xml response with common errors
-                var xmlString = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+                ms.Position = 0;
+                using StreamReader sr = new StreamReader(ms);
+                var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
 
                 // find and replace unescaped ampersands (&)
                 xmlString = EscapeAmpersandRegex().Replace(xmlString, "&amp;");
@@ -70,7 +74,7 @@ namespace Emby.Dlna.PlayTo
                 try
                 {
                     // retry reading Xml
-                    var xmlReader = new StringReader(xmlString);
+                    using var xmlReader = new StringReader(xmlString);
                     return await XDocument.LoadAsync(
                         xmlReader,
                         LoadOptions.None,

+ 80 - 18
Emby.Server.Implementations/Library/PathExtensions.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Diagnostics.CodeAnalysis;
+using System.IO;
 using MediaBrowser.Common.Providers;
 
 namespace Emby.Server.Implementations.Library
@@ -86,24 +87,8 @@ namespace Emby.Server.Implementations.Library
                 return false;
             }
 
-            char oldDirectorySeparatorChar;
-            char newDirectorySeparatorChar;
-            // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162
-            // The reasoning behind this is that a forward slash likely means it's a Linux path and
-            // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much).
-            if (newSubPath.Contains('/', StringComparison.Ordinal))
-            {
-                oldDirectorySeparatorChar = '\\';
-                newDirectorySeparatorChar = '/';
-            }
-            else
-            {
-                oldDirectorySeparatorChar = '/';
-                newDirectorySeparatorChar = '\\';
-            }
-
-            path = path.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar);
-            subPath = subPath.Replace(oldDirectorySeparatorChar, newDirectorySeparatorChar);
+            subPath = subPath.NormalizePath(out var newDirectorySeparatorChar);
+            path = path.NormalizePath(newDirectorySeparatorChar);
 
             // We have to ensure that the sub path ends with a directory separator otherwise we'll get weird results
             // when the sub path matches a similar but in-complete subpath
@@ -127,5 +112,82 @@ namespace Emby.Server.Implementations.Library
 
             return true;
         }
+
+        /// <summary>
+        /// Retrieves the full resolved path and normalizes path separators to the <see cref="Path.DirectorySeparatorChar"/>.
+        /// </summary>
+        /// <param name="path">The path to canonicalize.</param>
+        /// <returns>The fully expanded, normalized path.</returns>
+        public static string Canonicalize(this string path)
+        {
+            return Path.GetFullPath(path).NormalizePath();
+        }
+
+        /// <summary>
+        /// Normalizes the path's directory separator character to the currently defined <see cref="Path.DirectorySeparatorChar"/>.
+        /// </summary>
+        /// <param name="path">The path to normalize.</param>
+        /// <returns>The normalized path string or <see langword="null"/> if the input path is null or empty.</returns>
+        [return: NotNullIfNotNull(nameof(path))]
+        public static string? NormalizePath(this string? path)
+        {
+            return path.NormalizePath(Path.DirectorySeparatorChar);
+        }
+
+        /// <summary>
+        /// Normalizes the path's directory separator character.
+        /// </summary>
+        /// <param name="path">The path to normalize.</param>
+        /// <param name="separator">The separator character the path now uses or <see langword="null"/>.</param>
+        /// <returns>The normalized path string or <see langword="null"/> if the input path is null or empty.</returns>
+        [return: NotNullIfNotNull(nameof(path))]
+        public static string? NormalizePath(this string? path, out char separator)
+        {
+            if (string.IsNullOrEmpty(path))
+            {
+                separator = default;
+                return path;
+            }
+
+            var newSeparator = '\\';
+
+            // True normalization is still not possible https://github.com/dotnet/runtime/issues/2162
+            // The reasoning behind this is that a forward slash likely means it's a Linux path and
+            // so the whole path should be normalized to use / and vice versa for Windows (although Windows doesn't care much).
+            if (path.Contains('/', StringComparison.Ordinal))
+            {
+                newSeparator = '/';
+            }
+
+            separator = newSeparator;
+
+            return path.NormalizePath(newSeparator);
+        }
+
+        /// <summary>
+        /// Normalizes the path's directory separator character to the specified character.
+        /// </summary>
+        /// <param name="path">The path to normalize.</param>
+        /// <param name="newSeparator">The replacement directory separator character. Must be a valid directory separator.</param>
+        /// <returns>The normalized path.</returns>
+        /// <exception cref="ArgumentException">Thrown if the new separator character is not a directory separator.</exception>
+        [return: NotNullIfNotNull(nameof(path))]
+        public static string? NormalizePath(this string? path, char newSeparator)
+        {
+            const char Bs = '\\';
+            const char Fs = '/';
+
+            if (!(newSeparator == Bs || newSeparator == Fs))
+            {
+                throw new ArgumentException("The character must be a directory separator.");
+            }
+
+            if (string.IsNullOrEmpty(path))
+            {
+                return path;
+            }
+
+            return newSeparator == Bs ? path.Replace(Fs, newSeparator) : path.Replace(Bs, newSeparator);
+        }
     }
 }

+ 18 - 8
Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs

@@ -81,14 +81,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
                 if (season.IndexNumber.HasValue)
                 {
                     var seasonNumber = season.IndexNumber.Value;
-
-                    season.Name = seasonNumber == 0 ?
-                        args.LibraryOptions.SeasonZeroDisplayName :
-                        string.Format(
-                            CultureInfo.InvariantCulture,
-                            _localization.GetLocalizedString("NameSeasonNumber"),
-                            seasonNumber,
-                            args.LibraryOptions.PreferredMetadataLanguage);
+                    if (string.IsNullOrEmpty(season.Name))
+                    {
+                        var seasonNames = series.SeasonNames;
+                        if (seasonNames.TryGetValue(seasonNumber, out var seasonName))
+                        {
+                            season.Name = seasonName;
+                        }
+                        else
+                        {
+                            season.Name = seasonNumber == 0 ?
+                                args.LibraryOptions.SeasonZeroDisplayName :
+                                string.Format(
+                                    CultureInfo.InvariantCulture,
+                                    _localization.GetLocalizedString("NameSeasonNumber"),
+                                    seasonNumber,
+                                    args.LibraryOptions.PreferredMetadataLanguage);
+                        }
+                    }
                 }
 
                 return season;

+ 142 - 5
Emby.Server.Implementations/Plugins/PluginManager.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Data;
 using System.Globalization;
 using System.IO;
 using System.Linq;
@@ -9,6 +10,8 @@ using System.Runtime.Loader;
 using System.Text;
 using System.Text.Json;
 using System.Threading.Tasks;
+using Emby.Server.Implementations.Library;
+using Jellyfin.Extensions;
 using Jellyfin.Extensions.Json;
 using Jellyfin.Extensions.Json.Converters;
 using MediaBrowser.Common;
@@ -29,6 +32,8 @@ namespace Emby.Server.Implementations.Plugins
     /// </summary>
     public class PluginManager : IPluginManager
     {
+        private const string MetafileName = "meta.json";
+
         private readonly string _pluginsPath;
         private readonly Version _appVersion;
         private readonly List<AssemblyLoadContext> _assemblyLoadContexts;
@@ -44,7 +49,7 @@ namespace Emby.Server.Implementations.Plugins
         /// <summary>
         /// Initializes a new instance of the <see cref="PluginManager"/> class.
         /// </summary>
-        /// <param name="logger">The <see cref="ILogger"/>.</param>
+        /// <param name="logger">The <see cref="ILogger{PluginManager}"/>.</param>
         /// <param name="appHost">The <see cref="IApplicationHost"/>.</param>
         /// <param name="config">The <see cref="ServerConfiguration"/>.</param>
         /// <param name="pluginsPath">The plugin path.</param>
@@ -371,7 +376,7 @@ namespace Emby.Server.Implementations.Plugins
             try
             {
                 var data = JsonSerializer.Serialize(manifest, _jsonOptions);
-                File.WriteAllText(Path.Combine(path, "meta.json"), data);
+                File.WriteAllText(Path.Combine(path, MetafileName), data);
                 return true;
             }
             catch (ArgumentException e)
@@ -382,7 +387,7 @@ namespace Emby.Server.Implementations.Plugins
         }
 
         /// <inheritdoc/>
-        public async Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status)
+        public async Task<bool> PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status)
         {
             var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString());
             var imagePath = string.Empty;
@@ -427,9 +432,71 @@ namespace Emby.Server.Implementations.Plugins
                 ImagePath = imagePath
             };
 
+            if (!await ReconcileManifest(manifest, path))
+            {
+                // An error occurred during reconciliation and saving could be undesirable.
+                return false;
+            }
+
             return SaveManifest(manifest, path);
         }
 
+        /// <summary>
+        /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path.
+        /// If no file is found, no reconciliation occurs.
+        /// </summary>
+        /// <param name="manifest">The <see cref="PluginManifest"/> to reconcile against.</param>
+        /// <param name="path">The plugin path.</param>
+        /// <returns>The reconciled <see cref="PluginManifest"/>.</returns>
+        private async Task<bool> ReconcileManifest(PluginManifest manifest, string path)
+        {
+            try
+            {
+                var metafile = Path.Combine(path, MetafileName);
+                if (!File.Exists(metafile))
+                {
+                    _logger.LogInformation("No local manifest exists for plugin {Plugin}. Skipping manifest reconciliation.", manifest.Name);
+                    return true;
+                }
+
+                using var metaStream = File.OpenRead(metafile);
+                var localManifest = await JsonSerializer.DeserializeAsync<PluginManifest>(metaStream, _jsonOptions);
+                localManifest ??= new PluginManifest();
+
+                if (!Equals(localManifest.Id, manifest.Id))
+                {
+                    _logger.LogError("The manifest ID {LocalUUID} did not match the package info ID {PackageUUID}.", localManifest.Id, manifest.Id);
+                    manifest.Status = PluginStatus.Malfunctioned;
+                }
+
+                if (localManifest.Version != manifest.Version)
+                {
+                    // Package information provides the version and is the source of truth. Pre-packages meta.json is assumed to be a mistake in this regard.
+                    _logger.LogWarning("The version of the local manifest was {LocalVersion}, but {PackageVersion} was expected. The value will be replaced.", localManifest.Version, manifest.Version);
+                }
+
+                // Explicitly mapping properties instead of using reflection is preferred here.
+                manifest.Category = string.IsNullOrEmpty(localManifest.Category) ? manifest.Category : localManifest.Category;
+                manifest.AutoUpdate = localManifest.AutoUpdate; // Preserve whatever is local. Package info does not have this property.
+                manifest.Changelog = string.IsNullOrEmpty(localManifest.Changelog) ? manifest.Changelog : localManifest.Changelog;
+                manifest.Description = string.IsNullOrEmpty(localManifest.Description) ? manifest.Description : localManifest.Description;
+                manifest.Name = string.IsNullOrEmpty(localManifest.Name) ? manifest.Name : localManifest.Name;
+                manifest.Overview = string.IsNullOrEmpty(localManifest.Overview) ? manifest.Overview : localManifest.Overview;
+                manifest.Owner = string.IsNullOrEmpty(localManifest.Owner) ? manifest.Owner : localManifest.Owner;
+                manifest.TargetAbi = string.IsNullOrEmpty(localManifest.TargetAbi) ? manifest.TargetAbi : localManifest.TargetAbi;
+                manifest.Timestamp = localManifest.Timestamp.Equals(default) ? manifest.Timestamp : localManifest.Timestamp;
+                manifest.ImagePath = string.IsNullOrEmpty(localManifest.ImagePath) ? manifest.ImagePath : localManifest.ImagePath;
+                manifest.Assemblies = localManifest.Assemblies;
+
+                return true;
+            }
+            catch (Exception e)
+            {
+                _logger.LogWarning(e, "Unable to reconcile plugin manifest due to an error. {Path}", path);
+                return false;
+            }
+        }
+
         /// <summary>
         /// Changes a plugin's load status.
         /// </summary>
@@ -594,7 +661,7 @@ namespace Emby.Server.Implementations.Plugins
         {
             Version? version;
             PluginManifest? manifest = null;
-            var metafile = Path.Combine(dir, "meta.json");
+            var metafile = Path.Combine(dir, MetafileName);
             if (File.Exists(metafile))
             {
                 // Only path where this stays null is when File.ReadAllBytes throws an IOException
@@ -688,7 +755,15 @@ namespace Emby.Server.Implementations.Plugins
                 var entry = versions[x];
                 if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase))
                 {
-                    entry.DllFiles = Directory.GetFiles(entry.Path, "*.dll", SearchOption.AllDirectories);
+                    if (!TryGetPluginDlls(entry, out var allowedDlls))
+                    {
+                        _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name);
+                        ChangePluginState(entry, PluginStatus.Malfunctioned);
+                        continue;
+                    }
+
+                    entry.DllFiles = allowedDlls;
+
                     if (entry.IsEnabledAndSupported)
                     {
                         lastName = entry.Name;
@@ -734,6 +809,68 @@ namespace Emby.Server.Implementations.Plugins
             return versions.Where(p => p.DllFiles.Count != 0);
         }
 
+        /// <summary>
+        /// Attempts to retrieve valid DLLs from the plugin path. This method will consider the assembly whitelist
+        /// from the manifest.
+        /// </summary>
+        /// <remarks>
+        /// Loading DLLs from externally supplied paths introduces a path traversal risk. This method
+        /// uses a safelisting tactic of considering DLLs from the plugin directory and only using
+        /// the plugin's canonicalized assembly whitelist for comparison. See
+        /// <see href="https://owasp.org/www-community/attacks/Path_Traversal"/> for more details.
+        /// </remarks>
+        /// <param name="plugin">The plugin.</param>
+        /// <param name="whitelistedDlls">The whitelisted DLLs. If the method returns <see langword="false"/>, this will be empty.</param>
+        /// <returns>
+        /// <see langword="true"/> if all assemblies listed in the manifest were available in the plugin directory.
+        /// <see langword="false"/> if any assemblies were invalid or missing from the plugin directory.
+        /// </returns>
+        /// <exception cref="ArgumentNullException">If the <see cref="LocalPlugin"/> is null.</exception>
+        private bool TryGetPluginDlls(LocalPlugin plugin, out IReadOnlyList<string> whitelistedDlls)
+        {
+            ArgumentNullException.ThrowIfNull(nameof(plugin));
+
+            IReadOnlyList<string> pluginDlls = Directory.GetFiles(plugin.Path, "*.dll", SearchOption.AllDirectories);
+
+            whitelistedDlls = Array.Empty<string>();
+            if (pluginDlls.Count > 0 && plugin.Manifest.Assemblies.Count > 0)
+            {
+                _logger.LogInformation("Registering whitelisted assemblies for plugin \"{Plugin}\"...", plugin.Name);
+
+                var canonicalizedPaths = new List<string>();
+                foreach (var path in plugin.Manifest.Assemblies)
+                {
+                    var canonicalized = Path.Combine(plugin.Path, path).Canonicalize();
+
+                    // Ensure we stay in the plugin directory.
+                    if (!canonicalized.StartsWith(plugin.Path.NormalizePath(), StringComparison.Ordinal))
+                    {
+                        _logger.LogError("Assembly path {Path} is not inside the plugin directory.", path);
+                        return false;
+                    }
+
+                    canonicalizedPaths.Add(canonicalized);
+                }
+
+                var intersected = pluginDlls.Intersect(canonicalizedPaths).ToList();
+
+                if (intersected.Count != canonicalizedPaths.Count)
+                {
+                    _logger.LogError("Plugin {Plugin} contained assembly paths that were not found in the directory.", plugin.Name);
+                    return false;
+                }
+
+                whitelistedDlls = intersected;
+            }
+            else
+            {
+                // No whitelist, default to loading all DLLs in plugin directory.
+                whitelistedDlls = pluginDlls;
+            }
+
+            return true;
+        }
+
         /// <summary>
         /// Changes the status of the other versions of the plugin to "Superceded".
         /// </summary>

+ 5 - 2
Emby.Server.Implementations/Updates/InstallationManager.cs

@@ -183,7 +183,7 @@ namespace Emby.Server.Implementations.Updates
                             var plugin = _pluginManager.GetPlugin(package.Id, version.VersionNumber);
                             if (plugin is not null)
                             {
-                                await _pluginManager.GenerateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false);
+                                await _pluginManager.PopulateManifest(package, version.VersionNumber, plugin.Path, plugin.Manifest.Status).ConfigureAwait(false);
                             }
 
                             // Remove versions with a target ABI greater then the current application version.
@@ -555,7 +555,10 @@ namespace Emby.Server.Implementations.Updates
             stream.Position = 0;
             using var reader = new ZipArchive(stream);
             reader.ExtractToDirectory(targetDir, true);
-            await _pluginManager.GenerateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false);
+
+            // Ensure we create one or populate existing ones with missing data.
+            await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status);
+
             _pluginManager.ImportPluginFrom(targetDir);
         }
 

+ 138 - 10
Jellyfin.Server/Filters/AdditionalModelFilter.cs

@@ -1,12 +1,16 @@
 using System;
+using System.Collections.Generic;
+using System.ComponentModel;
 using System.Linq;
+using System.Reflection;
 using Jellyfin.Extensions;
 using Jellyfin.Server.Migrations;
 using MediaBrowser.Common.Plugins;
 using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Net.WebSocketMessages;
+using MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
 using MediaBrowser.Model.ApiClient;
-using MediaBrowser.Model.Entities;
 using MediaBrowser.Model.Session;
 using MediaBrowser.Model.SyncPlay;
 using Microsoft.OpenApi.Any;
@@ -36,17 +40,141 @@ namespace Jellyfin.Server.Filters
         /// <inheritdoc />
         public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
         {
-            context.SchemaGenerator.GenerateSchema(typeof(LibraryUpdateInfo), context.SchemaRepository);
             context.SchemaGenerator.GenerateSchema(typeof(IPlugin), context.SchemaRepository);
-            context.SchemaGenerator.GenerateSchema(typeof(PlayRequest), context.SchemaRepository);
-            context.SchemaGenerator.GenerateSchema(typeof(PlaystateRequest), context.SchemaRepository);
-            context.SchemaGenerator.GenerateSchema(typeof(TimerEventInfo), context.SchemaRepository);
-            context.SchemaGenerator.GenerateSchema(typeof(SendCommand), context.SchemaRepository);
-            context.SchemaGenerator.GenerateSchema(typeof(GeneralCommandType), context.SchemaRepository);
 
-            context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<object>), context.SchemaRepository);
+            var webSocketTypes = typeof(WebSocketMessage).Assembly.GetTypes()
+                .Where(t => t.IsSubclassOf(typeof(WebSocketMessage))
+                            && !t.IsGenericType
+                            && t != typeof(WebSocketMessageInfo))
+                .ToList();
+
+            var inboundWebSocketSchemas = new List<OpenApiSchema>();
+            var inboundWebSocketDiscriminators = new Dictionary<string, string>();
+            foreach (var type in webSocketTypes.Where(t => typeof(IInboundWebSocketMessage).IsAssignableFrom(t)))
+            {
+                var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
+                if (messageType is null)
+                {
+                    continue;
+                }
+
+                var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
+                inboundWebSocketSchemas.Add(schema);
+                inboundWebSocketDiscriminators[messageType.ToString()!] = schema.Reference.ReferenceV3;
+            }
+
+            var inboundWebSocketMessageSchema = new OpenApiSchema
+            {
+                Type = "object",
+                Description = "Represents the list of possible inbound websocket types",
+                Reference = new OpenApiReference
+                {
+                    Id = nameof(InboundWebSocketMessage),
+                    Type = ReferenceType.Schema
+                },
+                OneOf = inboundWebSocketSchemas,
+                Discriminator = new OpenApiDiscriminator
+                {
+                    PropertyName = nameof(WebSocketMessage.MessageType),
+                    Mapping = inboundWebSocketDiscriminators
+                }
+            };
+
+            context.SchemaRepository.AddDefinition(nameof(InboundWebSocketMessage), inboundWebSocketMessageSchema);
+
+            var outboundWebSocketSchemas = new List<OpenApiSchema>();
+            var outboundWebSocketDiscriminators = new Dictionary<string, string>();
+            foreach (var type in webSocketTypes.Where(t => typeof(IOutboundWebSocketMessage).IsAssignableFrom(t)))
+            {
+                var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
+                if (messageType is null)
+                {
+                    continue;
+                }
+
+                // Additional discriminator needed for GroupUpdate models...
+                if (messageType == SessionMessageType.SyncPlayGroupUpdate && type != typeof(SyncPlayGroupUpdateCommandMessage))
+                {
+                    continue;
+                }
+
+                var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
+                outboundWebSocketSchemas.Add(schema);
+                outboundWebSocketDiscriminators.Add(messageType.ToString()!, schema.Reference.ReferenceV3);
+            }
+
+            var outboundWebSocketMessageSchema = new OpenApiSchema
+            {
+                Type = "object",
+                Description = "Represents the list of possible outbound websocket types",
+                Reference = new OpenApiReference
+                {
+                    Id = nameof(OutboundWebSocketMessage),
+                    Type = ReferenceType.Schema
+                },
+                OneOf = outboundWebSocketSchemas,
+                Discriminator = new OpenApiDiscriminator
+                {
+                    PropertyName = nameof(WebSocketMessage.MessageType),
+                    Mapping = outboundWebSocketDiscriminators
+                }
+            };
+
+            context.SchemaRepository.AddDefinition(nameof(OutboundWebSocketMessage), outboundWebSocketMessageSchema);
+            context.SchemaRepository.AddDefinition(
+                nameof(WebSocketMessage),
+                new OpenApiSchema
+                {
+                    Type = "object",
+                    Description = "Represents the possible websocket types",
+                    Reference = new OpenApiReference
+                    {
+                        Id = nameof(WebSocketMessage),
+                        Type = ReferenceType.Schema
+                    },
+                    OneOf = new[]
+                    {
+                        inboundWebSocketMessageSchema,
+                        outboundWebSocketMessageSchema
+                    }
+                });
+
+            // Manually generate sync play GroupUpdate messages.
+            if (!context.SchemaRepository.Schemas.TryGetValue(nameof(GroupUpdate), out var groupUpdateSchema))
+            {
+                groupUpdateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate), context.SchemaRepository);
+            }
+
+            var groupUpdateOfGroupInfoSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<GroupInfoDto>), context.SchemaRepository);
+            var groupUpdateOfGroupStateSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<GroupStateUpdate>), context.SchemaRepository);
+            var groupUpdateOfStringSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<string>), context.SchemaRepository);
+            var groupUpdateOfPlayQueueSchema = context.SchemaGenerator.GenerateSchema(typeof(GroupUpdate<PlayQueueUpdate>), context.SchemaRepository);
+
+            groupUpdateSchema.OneOf = new List<OpenApiSchema>
+            {
+                groupUpdateOfGroupInfoSchema,
+                groupUpdateOfGroupStateSchema,
+                groupUpdateOfStringSchema,
+                groupUpdateOfPlayQueueSchema
+            };
+
+            groupUpdateSchema.Discriminator = new OpenApiDiscriminator
+            {
+                PropertyName = nameof(GroupUpdate.Type),
+                Mapping = new Dictionary<string, string>
+                {
+                    { GroupUpdateType.UserJoined.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.UserLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.GroupJoined.ToString(), groupUpdateOfGroupInfoSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.GroupLeft.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.StateUpdate.ToString(), groupUpdateOfGroupStateSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.PlayQueue.ToString(), groupUpdateOfPlayQueueSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.NotInGroup.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.GroupDoesNotExist.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 },
+                    { GroupUpdateType.LibraryAccessDenied.ToString(), groupUpdateOfStringSchema.Reference.ReferenceV3 }
+                }
+            };
 
-            context.SchemaGenerator.GenerateSchema(typeof(SessionMessageType), context.SchemaRepository);
             context.SchemaGenerator.GenerateSchema(typeof(ServerDiscoveryInfo), context.SchemaRepository);
 
             foreach (var configuration in _serverConfigurationManager.GetConfigurationStores())

+ 1 - 1
MediaBrowser.Common/Plugins/IPluginManager.cs

@@ -57,7 +57,7 @@ namespace MediaBrowser.Common.Plugins
         /// <param name="path">The path where to save the manifest.</param>
         /// <param name="status">Initial status of the plugin.</param>
         /// <returns>True if successful.</returns>
-        Task<bool> GenerateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status);
+        Task<bool> PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus status);
 
         /// <summary>
         /// Imports plugin details from a folder.

+ 9 - 0
MediaBrowser.Common/Plugins/PluginManifest.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Collections.Generic;
 using System.Text.Json.Serialization;
 using MediaBrowser.Model.Plugins;
 
@@ -23,6 +24,7 @@ namespace MediaBrowser.Common.Plugins
             Overview = string.Empty;
             TargetAbi = string.Empty;
             Version = string.Empty;
+            Assemblies = Array.Empty<string>();
         }
 
         /// <summary>
@@ -104,5 +106,12 @@ namespace MediaBrowser.Common.Plugins
         /// </summary>
         [JsonPropertyName("imagePath")]
         public string? ImagePath { get; set; }
+
+        /// <summary>
+        /// Gets or sets the collection of assemblies that should be loaded.
+        /// Paths are considered relative to the plugin folder.
+        /// </summary>
+        [JsonPropertyName("assemblies")]
+        public IReadOnlyList<string> Assemblies { get; set; }
     }
 }

+ 4 - 0
MediaBrowser.Controller/Entities/TV/Series.cs

@@ -28,12 +28,16 @@ namespace MediaBrowser.Controller.Entities.TV
         public Series()
         {
             AirDays = Array.Empty<DayOfWeek>();
+            SeasonNames = new Dictionary<int, string>();
         }
 
         public DayOfWeek[] AirDays { get; set; }
 
         public string AirTime { get; set; }
 
+        [JsonIgnore]
+        public Dictionary<int, string> SeasonNames { get; set; }
+
         [JsonIgnore]
         public override bool SupportsAddingToPlaylist => true;
 

+ 221 - 136
MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs

@@ -45,6 +45,7 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         private readonly Version _minFFmpegImplictHwaccel = new Version(6, 0);
         private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0);
+        private readonly Version _minFFmpegOclCuTonemapMode = new Version(5, 1, 3);
 
         private static readonly string[] _videoProfilesH264 = new[]
         {
@@ -162,7 +163,8 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         private bool IsVaapiFullSupported()
         {
-            return _mediaEncoder.SupportsHwaccel("vaapi")
+            return _mediaEncoder.SupportsHwaccel("drm")
+                   && _mediaEncoder.SupportsHwaccel("vaapi")
                    && _mediaEncoder.SupportsFilter("scale_vaapi")
                    && _mediaEncoder.SupportsFilter("deinterlace_vaapi")
                    && _mediaEncoder.SupportsFilter("tonemap_vaapi")
@@ -712,28 +714,43 @@ namespace MediaBrowser.Controller.MediaEncoding
                 options);
         }
 
-        private string GetVaapiDeviceArgs(string renderNodePath, string driver, string kernelDriver, string alias)
+        private string GetVaapiDeviceArgs(string renderNodePath, string driver, string kernelDriver, string srcDeviceAlias, string alias)
         {
             alias ??= VaapiAlias;
             renderNodePath = renderNodePath ?? "/dev/dri/renderD128";
-            var options = string.IsNullOrEmpty(driver)
-                ? renderNodePath
-                : ",driver=" + driver + (string.IsNullOrEmpty(kernelDriver) ? string.Empty : ",kernel_driver=" + kernelDriver);
+            var driverOpts = string.IsNullOrEmpty(driver)
+                ? ":" + renderNodePath
+                : ":,driver=" + driver + (string.IsNullOrEmpty(kernelDriver) ? string.Empty : ",kernel_driver=" + kernelDriver);
+            var options = string.IsNullOrEmpty(srcDeviceAlias)
+                ? driverOpts
+                : "@" + srcDeviceAlias;
 
             return string.Format(
                 CultureInfo.InvariantCulture,
-                " -init_hw_device vaapi={0}:{1}",
+                " -init_hw_device vaapi={0}{1}",
                 alias,
                 options);
         }
 
+        private string GetDrmDeviceArgs(string renderNodePath, string alias)
+        {
+            alias ??= DrmAlias;
+            renderNodePath = renderNodePath ?? "/dev/dri/renderD128";
+
+            return string.Format(
+                CultureInfo.InvariantCulture,
+                " -init_hw_device drm={0}:{1}",
+                alias,
+                renderNodePath);
+        }
+
         private string GetQsvDeviceArgs(string alias)
         {
             var arg = " -init_hw_device qsv=" + (alias ?? QsvAlias);
             if (OperatingSystem.IsLinux())
             {
                 // derive qsv from vaapi device
-                return GetVaapiDeviceArgs(null, "iHD", "i915", VaapiAlias) + arg + "@" + VaapiAlias;
+                return GetVaapiDeviceArgs(null, "iHD", "i915", null, VaapiAlias) + arg + "@" + VaapiAlias;
             }
 
             if (OperatingSystem.IsWindows())
@@ -754,9 +771,12 @@ namespace MediaBrowser.Controller.MediaEncoding
 
         public string GetGraphicalSubCanvasSize(EncodingJobInfo state)
         {
+            // DVBSUB and DVDSUB use the fixed canvas size 720x576
             if (state.SubtitleStream is not null
                 && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode
-                && !state.SubtitleStream.IsTextSubtitleStream)
+                && !state.SubtitleStream.IsTextSubtitleStream
+                && !string.Equals(state.SubtitleStream.Codec, "DVBSUB", StringComparison.OrdinalIgnoreCase)
+                && !string.Equals(state.SubtitleStream.Codec, "DVDSUB", StringComparison.OrdinalIgnoreCase))
             {
                 var inW = state.VideoStream?.Width;
                 var inH = state.VideoStream?.Height;
@@ -824,21 +844,17 @@ namespace MediaBrowser.Controller.MediaEncoding
 
                 if (_mediaEncoder.IsVaapiDeviceInteliHD)
                 {
-                    args.Append(GetVaapiDeviceArgs(null, "iHD", null, VaapiAlias));
+                    args.Append(GetVaapiDeviceArgs(null, "iHD", null, null, VaapiAlias));
                 }
                 else if (_mediaEncoder.IsVaapiDeviceInteli965)
                 {
                     // Only override i965 since it has lower priority than iHD in libva lookup.
                     Environment.SetEnvironmentVariable("LIBVA_DRIVER_NAME", "i965");
                     Environment.SetEnvironmentVariable("LIBVA_DRIVER_NAME_JELLYFIN", "i965");
-                    args.Append(GetVaapiDeviceArgs(null, "i965", null, VaapiAlias));
-                }
-                else
-                {
-                    args.Append(GetVaapiDeviceArgs(options.VaapiDevice, null, null, VaapiAlias));
+                    args.Append(GetVaapiDeviceArgs(null, "i965", null, null, VaapiAlias));
                 }
 
-                var filterDevArgs = GetFilterHwDeviceArgs(VaapiAlias);
+                var filterDevArgs = string.Empty;
                 var doOclTonemap = isHwTonemapAvailable && IsOpenclFullSupported();
 
                 if (_mediaEncoder.IsVaapiDeviceInteliHD || _mediaEncoder.IsVaapiDeviceInteli965)
@@ -855,15 +871,24 @@ namespace MediaBrowser.Controller.MediaEncoding
                         && _mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier
                         && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier)
                     {
+                        args.Append(GetDrmDeviceArgs(options.VaapiDevice, DrmAlias));
+                        args.Append(GetVaapiDeviceArgs(null, null, null, DrmAlias, VaapiAlias));
+                        args.Append(GetVulkanDeviceArgs(0, null, DrmAlias, VulkanAlias));
+
                         // libplacebo wants an explicitly set vulkan filter device.
-                        args.Append(GetVulkanDeviceArgs(0, null, VaapiAlias, VulkanAlias));
                         filterDevArgs = GetFilterHwDeviceArgs(VulkanAlias);
                     }
-                    else if (doOclTonemap)
+                    else
                     {
-                        // ROCm/ROCr OpenCL runtime
-                        args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias));
-                        filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias);
+                        args.Append(GetVaapiDeviceArgs(options.VaapiDevice, null, null, null, VaapiAlias));
+                        filterDevArgs = GetFilterHwDeviceArgs(VaapiAlias);
+
+                        if (doOclTonemap)
+                        {
+                            // ROCm/ROCr OpenCL runtime
+                            args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias));
+                            filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias);
+                        }
                     }
                 }
                 else if (doOclTonemap)
@@ -1549,11 +1574,11 @@ namespace MediaBrowser.Controller.MediaEncoding
                         param += " -preset p7";
                         break;
 
-                    case "slow":
+                    case "slower":
                         param += " -preset p6";
                         break;
 
-                    case "slower":
+                    case "slow":
                         param += " -preset p5";
                         break;
 
@@ -1586,8 +1611,8 @@ namespace MediaBrowser.Controller.MediaEncoding
                 switch (encodingOptions.EncoderPreset)
                 {
                     case "veryslow":
-                    case "slow":
                     case "slower":
+                    case "slow":
                         param += " -quality quality";
                         break;
 
@@ -2929,7 +2954,7 @@ namespace MediaBrowser.Controller.MediaEncoding
             return string.Empty;
         }
 
-        public static string GetHwTonemapFilter(EncodingOptions options, string hwTonemapSuffix, string videoFormat)
+        public string GetHwTonemapFilter(EncodingOptions options, string hwTonemapSuffix, string videoFormat)
         {
             if (string.IsNullOrEmpty(hwTonemapSuffix))
             {
@@ -2941,7 +2966,8 @@ namespace MediaBrowser.Controller.MediaEncoding
 
             if (string.Equals(hwTonemapSuffix, "vaapi", StringComparison.OrdinalIgnoreCase))
             {
-                args = "tonemap_vaapi=format={0}:p=bt709:t=bt709:m=bt709,procamp_vaapi=b={1}:c={2}:extra_hw_frames=16";
+                args = "procamp_vaapi=b={2}:c={3}," + args + ":extra_hw_frames=32";
+
                 return string.Format(
                         CultureInfo.InvariantCulture,
                         args,
@@ -2972,14 +2998,24 @@ namespace MediaBrowser.Controller.MediaEncoding
             {
                 args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709:tonemap={2}:peak={3}:desat={4}";
 
+                if (string.Equals(options.TonemappingMode, "max", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(options.TonemappingMode, "rgb", StringComparison.OrdinalIgnoreCase))
+                {
+                    if (_mediaEncoder.EncoderVersion >= _minFFmpegOclCuTonemapMode)
+                    {
+                        args += ":tonemap_mode={5}";
+                    }
+                }
+
                 if (options.TonemappingParam != 0)
                 {
-                    args += ":param={5}";
+                    args += ":param={6}";
                 }
 
-                if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase))
+                if (string.Equals(options.TonemappingRange, "tv", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(options.TonemappingRange, "pc", StringComparison.OrdinalIgnoreCase))
                 {
-                    args += ":range={6}";
+                    args += ":range={7}";
                 }
             }
 
@@ -2991,10 +3027,80 @@ namespace MediaBrowser.Controller.MediaEncoding
                     algorithm,
                     options.TonemappingPeak,
                     options.TonemappingDesat,
+                    options.TonemappingMode,
                     options.TonemappingParam,
                     options.TonemappingRange);
         }
 
+        public string GetLibplaceboFilter(
+            EncodingOptions options,
+            string videoFormat,
+            bool doTonemap,
+            int? videoWidth,
+            int? videoHeight,
+            int? requestedWidth,
+            int? requestedHeight,
+            int? requestedMaxWidth,
+            int? requestedMaxHeight)
+        {
+            var (outWidth, outHeight) = GetFixedOutputSize(
+                videoWidth,
+                videoHeight,
+                requestedWidth,
+                requestedHeight,
+                requestedMaxWidth,
+                requestedMaxHeight);
+
+            var isFormatFixed = !string.IsNullOrEmpty(videoFormat);
+            var isSizeFixed = !videoWidth.HasValue
+                || outWidth.Value != videoWidth.Value
+                || !videoHeight.HasValue
+                || outHeight.Value != videoHeight.Value;
+
+            var sizeArg = isSizeFixed ? (":w=" + outWidth.Value + ":h=" + outHeight.Value) : string.Empty;
+            var formatArg = isFormatFixed ? (":format=" + videoFormat) : string.Empty;
+            var tonemapArg = string.Empty;
+
+            if (doTonemap)
+            {
+                var algorithm = options.TonemappingAlgorithm;
+                var mode = options.TonemappingMode;
+                var range = options.TonemappingRange;
+
+                if (string.Equals(algorithm, "bt2390", StringComparison.OrdinalIgnoreCase))
+                {
+                    algorithm = "bt.2390";
+                }
+                else if (string.Equals(algorithm, "none", StringComparison.OrdinalIgnoreCase))
+                {
+                    algorithm = "clip";
+                }
+
+                tonemapArg = ":tonemapping=" + algorithm;
+
+                if (string.Equals(mode, "max", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(mode, "rgb", StringComparison.OrdinalIgnoreCase))
+                {
+                    tonemapArg += ":tonemapping_mode=" + mode;
+                }
+
+                tonemapArg += ":peak_detect=0:color_primaries=bt709:color_trc=bt709:colorspace=bt709";
+
+                if (string.Equals(range, "tv", StringComparison.OrdinalIgnoreCase)
+                    || string.Equals(range, "pc", StringComparison.OrdinalIgnoreCase))
+                {
+                    tonemapArg += ":range=" + range;
+                }
+            }
+
+            return string.Format(
+                CultureInfo.InvariantCulture,
+                "libplacebo=upscaler=none:downscaler=none{0}{1}{2}",
+                sizeArg,
+                formatArg,
+                tonemapArg);
+        }
+
         /// <summary>
         /// Gets the parameter of software filter chain.
         /// </summary>
@@ -4224,7 +4330,6 @@ namespace MediaBrowser.Controller.MediaEncoding
             var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
             var isSwDecoder = string.IsNullOrEmpty(vidDecoder);
             var isSwEncoder = !isVaapiEncoder;
-            var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
 
             var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
             var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
@@ -4253,99 +4358,81 @@ namespace MediaBrowser.Controller.MediaEncoding
                     mainFilters.Add(swDeintFilter);
                 }
 
-                var outFormat = doVkTonemap ? "yuv420p10le" : "nv12";
-                var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
-                // sw scale
-                mainFilters.Add(swScaleFilter);
-                mainFilters.Add("format=" + outFormat);
-
-                // keep video at memory except vk tonemap,
-                // since the overhead caused by hwupload >>> using sw filter.
-                // sw => hw
-                if (doVkTonemap)
+                if (doVkTonemap || hasSubs)
                 {
-                    mainFilters.Add("hwupload=derive_device=vaapi");
-                    mainFilters.Add("format=vaapi");
-                    mainFilters.Add("hwmap=derive_device=vulkan");
+                    // sw => hw
+                    mainFilters.Add("hwupload=derive_device=vulkan");
                     mainFilters.Add("format=vulkan");
                 }
+                else
+                {
+                    // sw scale
+                    var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
+                    mainFilters.Add(swScaleFilter);
+                    mainFilters.Add("format=nv12");
+                }
             }
             else if (isVaapiDecoder)
             {
                 // INPUT vaapi surface(vram)
-                // hw deint
-                if (doDeintH2645)
+                if (doVkTonemap || hasSubs)
                 {
-                    var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi");
-                    mainFilters.Add(deintFilter);
+                    // map from vaapi to vulkan/drm via interop (Vega/gfx9+).
+                    mainFilters.Add("hwmap=derive_device=vulkan");
+                    mainFilters.Add("format=vulkan");
                 }
-
-                var outFormat = doVkTonemap ? string.Empty : (hasSubs && isVaInVaOut ? "bgra" : "nv12");
-                var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH);
-
-                // allocate extra pool sizes for overlay_vulkan
-                if (!string.IsNullOrEmpty(hwScaleFilter) && isVaInVaOut && hasSubs)
+                else
                 {
-                    hwScaleFilter += ":extra_hw_frames=32";
-                }
-
-                // hw scale
-                mainFilters.Add(hwScaleFilter);
-            }
+                    // hw deint
+                    if (doDeintH2645)
+                    {
+                        var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi");
+                        mainFilters.Add(deintFilter);
+                    }
 
-            if ((isVaapiDecoder && doVkTonemap) || (isVaInVaOut && (doVkTonemap || hasSubs)))
-            {
-                // map from vaapi to vulkan via vaapi-vulkan interop (Vega/gfx9+).
-                mainFilters.Add("hwmap=derive_device=vulkan");
-                mainFilters.Add("format=vulkan");
+                    // hw scale
+                    var hwScaleFilter = GetHwScaleFilter("vaapi", "nv12", inW, inH, reqW, reqH, reqMaxW, reqMaxH);
+                    mainFilters.Add(hwScaleFilter);
+                }
             }
 
-            // vk tonemap
-            if (doVkTonemap)
+            // vk libplacebo
+            if (doVkTonemap || hasSubs)
             {
-                var outFormat = isVaInVaOut && hasSubs ? "bgra" : "nv12";
-                var tonemapFilter = GetHwTonemapFilter(options, "vulkan", outFormat);
-                mainFilters.Add(tonemapFilter);
+                var libplaceboFilter = GetLibplaceboFilter(options, "bgra", doVkTonemap, inW, inH, reqW, reqH, reqMaxW, reqMaxH);
+                mainFilters.Add(libplaceboFilter);
             }
 
-            if (doVkTonemap && isVaInVaOut && !hasSubs)
+            if (doVkTonemap && !hasSubs)
             {
-                // OUTPUT vaapi(nv12/bgra) surface(vram)
-                // reverse-mapping via vaapi-vulkan interop.
-                mainFilters.Add("hwmap=derive_device=vaapi:reverse=1");
+                // OUTPUT vaapi(nv12) surface(vram)
+                // map from vulkan/drm to vaapi via interop (Vega/gfx9+).
+                mainFilters.Add("hwmap=derive_device=drm");
+                mainFilters.Add("format=drm_prime");
+                mainFilters.Add("hwmap=derive_device=vaapi");
                 mainFilters.Add("format=vaapi");
-            }
-
-            var memoryOutput = false;
-            var isUploadForVkTonemap = isSwDecoder && doVkTonemap;
-            if ((isVaapiDecoder && isSwEncoder) || isUploadForVkTonemap)
-            {
-                memoryOutput = true;
 
-                // OUTPUT nv12 surface(memory)
-                mainFilters.Add("hwdownload");
-                mainFilters.Add("format=nv12");
-            }
+                // clear the surf->meta_offset and output nv12
+                mainFilters.Add("scale_vaapi=format=nv12");
 
-            // OUTPUT nv12 surface(memory)
-            if (isSwDecoder && isVaapiEncoder)
-            {
-                memoryOutput = true;
+                // hw deint
+                if (doDeintH2645)
+                {
+                    var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi");
+                    mainFilters.Add(deintFilter);
+                }
             }
 
-            if (memoryOutput)
+            if (!hasSubs)
             {
-                // text subtitles
-                if (hasTextSubs)
+                // OUTPUT nv12 surface(memory)
+                if (isSwEncoder && (doVkTonemap || isVaapiDecoder))
                 {
-                    var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false);
-                    mainFilters.Add(textSubtitlesFilter);
+                    mainFilters.Add("hwdownload");
+                    mainFilters.Add("format=nv12");
                 }
-            }
 
-            if (memoryOutput && isVaapiEncoder)
-            {
-                if (!hasGraphicalSubs)
+                if (isSwDecoder && isVaapiEncoder && !doVkTonemap)
                 {
                     mainFilters.Add("hwupload_vaapi");
                 }
@@ -4354,55 +4441,53 @@ namespace MediaBrowser.Controller.MediaEncoding
             /* Make sub and overlay filters for subtitle stream */
             var subFilters = new List<string>();
             var overlayFilters = new List<string>();
-            if (isVaInVaOut)
+            if (hasSubs)
             {
-                if (hasSubs)
+                if (hasGraphicalSubs)
                 {
-                    if (hasGraphicalSubs)
-                    {
-                        // scale=s=1280x720,format=bgra,hwupload
-                        var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
-                        subFilters.Add(subSwScaleFilter);
-                        subFilters.Add("format=bgra");
-                    }
-                    else if (hasTextSubs)
-                    {
-                        var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5);
-                        var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
-                        subFilters.Add(alphaSrcFilter);
-                        subFilters.Add("format=bgra");
-                        subFilters.Add(subTextSubtitlesFilter);
-                    }
-
-                    // prefer vaapi hwupload to vulkan hwupload,
-                    // Mesa RADV does not support a dedicated transfer queue.
-                    subFilters.Add("hwupload=derive_device=vaapi");
-                    subFilters.Add("format=vaapi");
-                    subFilters.Add("hwmap=derive_device=vulkan");
-                    subFilters.Add("format=vulkan");
+                    // scale=s=1280x720,format=bgra,hwupload
+                    var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
+                    subFilters.Add(subSwScaleFilter);
+                    subFilters.Add("format=bgra");
+                }
+                else if (hasTextSubs)
+                {
+                    var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5);
+                    var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
+                    subFilters.Add(alphaSrcFilter);
+                    subFilters.Add("format=bgra");
+                    subFilters.Add(subTextSubtitlesFilter);
+                }
 
-                    overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0");
+                subFilters.Add("hwupload=derive_device=vulkan");
+                subFilters.Add("format=vulkan");
 
-                    // TODO: figure out why libplacebo can sync without vaSyncSurface VPP support in radeonsi.
-                    overlayFilters.Add("libplacebo=format=nv12:apply_filmgrain=0:apply_dolbyvision=0:upscaler=none:downscaler=none:dithering=none");
+                overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0");
 
-                    // OUTPUT vaapi(nv12/bgra) surface(vram)
-                    // reverse-mapping via vaapi-vulkan interop.
-                    overlayFilters.Add("hwmap=derive_device=vaapi:reverse=1");
-                    overlayFilters.Add("format=vaapi");
+                if (isSwEncoder)
+                {
+                    // OUTPUT nv12 surface(memory)
+                    overlayFilters.Add("scale_vulkan=format=nv12");
+                    overlayFilters.Add("hwdownload");
+                    overlayFilters.Add("format=nv12");
                 }
-            }
-            else if (memoryOutput)
-            {
-                if (hasGraphicalSubs)
+                else if (isVaapiEncoder)
                 {
-                    var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH);
-                    subFilters.Add(subSwScaleFilter);
-                    overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0");
+                    // OUTPUT vaapi(nv12) surface(vram)
+                    // map from vulkan/drm to vaapi via interop (Vega/gfx9+).
+                    overlayFilters.Add("hwmap=derive_device=drm");
+                    overlayFilters.Add("format=drm_prime");
+                    overlayFilters.Add("hwmap=derive_device=vaapi");
+                    overlayFilters.Add("format=vaapi");
 
-                    if (isVaapiEncoder)
+                    // clear the surf->meta_offset and output nv12
+                    overlayFilters.Add("scale_vaapi=format=nv12");
+
+                    // hw deint
+                    if (doDeintH2645)
                     {
-                        overlayFilters.Add("hwupload_vaapi");
+                        var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi");
+                        overlayFilters.Add(deintFilter);
                     }
                 }
             }

+ 28 - 0
MediaBrowser.Controller/Net/WebSocketMessage.cs

@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json.Serialization;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net;
+
+/// <summary>
+/// Websocket message without data.
+/// </summary>
+public abstract class WebSocketMessage
+{
+    /// <summary>
+    /// Gets or sets the type of the message.
+    /// TODO make this abstract and get only.
+    /// </summary>
+    public virtual SessionMessageType MessageType { get; set; }
+
+    /// <summary>
+    /// Gets or sets the message id.
+    /// </summary>
+    public Guid MessageId { get; set; }
+
+    /// <summary>
+    /// Gets or sets the server id.
+    /// </summary>
+    [JsonIgnore]
+    public string? ServerId { get; set; }
+}

+ 33 - 0
MediaBrowser.Controller/Net/WebSocketMessageOfT.cs

@@ -0,0 +1,33 @@
+#pragma warning disable SA1649 // File name must equal class name.
+
+namespace MediaBrowser.Controller.Net;
+
+/// <summary>
+/// Class WebSocketMessage.
+/// </summary>
+/// <typeparam name="T">The type of the data.</typeparam>
+// TODO make this abstract, remove empty ctor.
+public class WebSocketMessage<T> : WebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="WebSocketMessage{T}"/> class.
+    /// </summary>
+    public WebSocketMessage()
+    {
+    }
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="WebSocketMessage{T}"/> class.
+    /// </summary>
+    /// <param name="data">The data to send.</param>
+    protected WebSocketMessage(T data)
+    {
+        Data = data;
+    }
+
+    /// <summary>
+    /// Gets or sets the data.
+    /// </summary>
+    // TODO make this set only.
+    public T? Data { get; set; }
+}

+ 10 - 0
MediaBrowser.Controller/Net/WebSocketMessages/IInboundWebSocketMessage.cs

@@ -0,0 +1,10 @@
+#pragma warning disable CA1040
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages;
+
+/// <summary>
+/// Interface representing that the websocket message is inbound.
+/// </summary>
+public interface IInboundWebSocketMessage
+{
+}

+ 10 - 0
MediaBrowser.Controller/Net/WebSocketMessages/IOutboundWebSocketMessage.cs

@@ -0,0 +1,10 @@
+#pragma warning disable CA1040
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages;
+
+/// <summary>
+/// Interface representing that the websocket message is outbound.
+/// </summary>
+public interface IOutboundWebSocketMessage
+{
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs

@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound;
+
+/// <summary>
+/// Activity log entry start message.
+/// </summary>
+public class ActivityLogEntryStartMessage : WebSocketMessage<IReadOnlyCollection<ActivityLogEntry>>, IInboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ActivityLogEntryStartMessage"/> class.
+    /// </summary>
+    /// <param name="data">Collection of activity log entries.</param>
+    public ActivityLogEntryStartMessage(IReadOnlyCollection<ActivityLogEntry> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ActivityLogEntryStart)]
+    public override SessionMessageType MessageType => SessionMessageType.ActivityLogEntryStart;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs

@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound;
+
+/// <summary>
+/// Activity log entry stop message.
+/// </summary>
+public class ActivityLogEntryStopMessage : WebSocketMessage<IReadOnlyCollection<ActivityLogEntry>>, IInboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ActivityLogEntryStopMessage"/> class.
+    /// </summary>
+    /// <param name="data">Collection of activity log entries.</param>
+    public ActivityLogEntryStopMessage(IReadOnlyCollection<ActivityLogEntry> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ActivityLogEntryStop)]
+    public override SessionMessageType MessageType => SessionMessageType.ActivityLogEntryStop;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs

@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Tasks;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound;
+
+/// <summary>
+/// Scheduled tasks info start message.
+/// </summary>
+public class ScheduledTasksInfoStartMessage : WebSocketMessage<IReadOnlyCollection<TaskInfo>>, IInboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ScheduledTasksInfoStartMessage"/> class.
+    /// </summary>
+    /// <param name="data">Collection of task info.</param>
+    public ScheduledTasksInfoStartMessage(IReadOnlyCollection<TaskInfo> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ScheduledTasksInfoStart)]
+    public override SessionMessageType MessageType => SessionMessageType.ScheduledTasksInfoStart;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs

@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Tasks;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound;
+
+/// <summary>
+/// Scheduled tasks info stop message.
+/// </summary>
+public class ScheduledTasksInfoStopMessage : WebSocketMessage<IReadOnlyCollection<TaskInfo>>, IInboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ScheduledTasksInfoStopMessage"/> class.
+    /// </summary>
+    /// <param name="data">Collection of task info.</param>
+    public ScheduledTasksInfoStopMessage(IReadOnlyCollection<TaskInfo> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ScheduledTasksInfoStop)]
+    public override SessionMessageType MessageType => SessionMessageType.ScheduledTasksInfoStop;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound;
+
+/// <summary>
+/// Sessions start message.
+/// </summary>
+public class SessionsStartMessage : WebSocketMessage<SessionInfo>, IInboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SessionsStartMessage"/> class.
+    /// </summary>
+    /// <param name="data">Session info.</param>
+    public SessionsStartMessage(SessionInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SessionsStart)]
+    public override SessionMessageType MessageType => SessionMessageType.SessionsStart;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound;
+
+/// <summary>
+/// Sessions stop message.
+/// </summary>
+public class SessionsStopMessage : WebSocketMessage<SessionInfo>, IInboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SessionsStopMessage"/> class.
+    /// </summary>
+    /// <param name="data">Session info.</param>
+    public SessionsStopMessage(SessionInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SessionsStop)]
+    public override SessionMessageType MessageType => SessionMessageType.SessionsStop;
+}

+ 9 - 0
MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs

@@ -0,0 +1,9 @@
+namespace MediaBrowser.Controller.Net.WebSocketMessages;
+
+/// <summary>
+/// Class representing the list of outbound websocket message types.
+/// Only used in openapi generation.
+/// </summary>
+public class InboundWebSocketMessage : WebSocketMessage
+{
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs

@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Activity log created message.
+/// </summary>
+public class ActivityLogEntryMessage : WebSocketMessage<IReadOnlyList<ActivityLogEntry>>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ActivityLogEntryMessage"/> class.
+    /// </summary>
+    /// <param name="data">List of activity log entries.</param>
+    public ActivityLogEntryMessage(IReadOnlyList<ActivityLogEntry> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ActivityLogEntry)]
+    public override SessionMessageType MessageType => SessionMessageType.ActivityLogEntry;
+}

+ 23 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs

@@ -0,0 +1,23 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Force keep alive websocket messages.
+/// </summary>
+public class ForceKeepAliveMessage : WebSocketMessage<int>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ForceKeepAliveMessage"/> class.
+    /// </summary>
+    /// <param name="data">The timeout in seconds.</param>
+    public ForceKeepAliveMessage(int data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ForceKeepAlive)]
+    public override SessionMessageType MessageType => SessionMessageType.ForceKeepAlive;
+}

+ 23 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs

@@ -0,0 +1,23 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// General command websocket message.
+/// </summary>
+public class GeneralCommandMessage : WebSocketMessage<GeneralCommand>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GeneralCommandMessage"/> class.
+    /// </summary>
+    /// <param name="data">The general command.</param>
+    public GeneralCommandMessage(GeneralCommand data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.GeneralCommand)]
+    public override SessionMessageType MessageType => SessionMessageType.GeneralCommand;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Library changed message.
+/// </summary>
+public class LibraryChangedMessage : WebSocketMessage<LibraryUpdateInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="LibraryChangedMessage"/> class.
+    /// </summary>
+    /// <param name="data">The library update info.</param>
+    public LibraryChangedMessage(LibraryUpdateInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.LibraryChanged)]
+    public override SessionMessageType MessageType => SessionMessageType.LibraryChanged;
+}

+ 23 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs

@@ -0,0 +1,23 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Play command websocket message.
+/// </summary>
+public class PlayMessage : WebSocketMessage<PlayRequest>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PlayMessage"/> class.
+    /// </summary>
+    /// <param name="data">The play request.</param>
+    public PlayMessage(PlayRequest data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.Play)]
+    public override SessionMessageType MessageType => SessionMessageType.Play;
+}

+ 23 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs

@@ -0,0 +1,23 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Playstate message.
+/// </summary>
+public class PlaystateMessage : WebSocketMessage<PlaystateRequest>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PlaystateMessage"/> class.
+    /// </summary>
+    /// <param name="data">Playstate request data.</param>
+    public PlaystateMessage(PlaystateRequest data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.Playstate)]
+    public override SessionMessageType MessageType => SessionMessageType.Playstate;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Updates;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Plugin installation cancelled message.
+/// </summary>
+public class PluginInstallationCancelledMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PluginInstallationCancelledMessage"/> class.
+    /// </summary>
+    /// <param name="data">Installation info.</param>
+    public PluginInstallationCancelledMessage(InstallationInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.PackageInstallationCancelled)]
+    public override SessionMessageType MessageType => SessionMessageType.PackageInstallationCancelled;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Updates;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Plugin installation completed message.
+/// </summary>
+public class PluginInstallationCompletedMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PluginInstallationCompletedMessage"/> class.
+    /// </summary>
+    /// <param name="data">Installation info.</param>
+    public PluginInstallationCompletedMessage(InstallationInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.PackageInstallationCompleted)]
+    public override SessionMessageType MessageType => SessionMessageType.PackageInstallationCompleted;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Updates;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Plugin installation failed message.
+/// </summary>
+public class PluginInstallationFailedMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PluginInstallationFailedMessage"/> class.
+    /// </summary>
+    /// <param name="data">Installation info.</param>
+    public PluginInstallationFailedMessage(InstallationInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.PackageInstallationFailed)]
+    public override SessionMessageType MessageType => SessionMessageType.PackageInstallationFailed;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Updates;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Package installing message.
+/// </summary>
+public class PluginInstallingMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PluginInstallingMessage"/> class.
+    /// </summary>
+    /// <param name="data">Installation info.</param>
+    public PluginInstallingMessage(InstallationInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.PackageInstalling)]
+    public override SessionMessageType MessageType => SessionMessageType.PackageInstalling;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Plugins;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Plugin uninstalled message.
+/// </summary>
+public class PluginUninstalledMessage : WebSocketMessage<PluginInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="PluginUninstalledMessage"/> class.
+    /// </summary>
+    /// <param name="data">Plugin info.</param>
+    public PluginUninstalledMessage(PluginInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.PackageUninstalled)]
+    public override SessionMessageType MessageType => SessionMessageType.PackageUninstalled;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs

@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Refresh progress message.
+/// </summary>
+public class RefreshProgressMessage : WebSocketMessage<Dictionary<string, string>>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="RefreshProgressMessage"/> class.
+    /// </summary>
+    /// <param name="data">Refresh progress data.</param>
+    public RefreshProgressMessage(Dictionary<string, string> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.RefreshProgress)]
+    public override SessionMessageType MessageType => SessionMessageType.RefreshProgress;
+}

+ 14 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs

@@ -0,0 +1,14 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Restart required.
+/// </summary>
+public class RestartRequiredMessage : WebSocketMessage, IOutboundWebSocketMessage
+{
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.RestartRequired)]
+    public override SessionMessageType MessageType => SessionMessageType.RestartRequired;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Tasks;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Scheduled task ended message.
+/// </summary>
+public class ScheduledTaskEndedMessage : WebSocketMessage<TaskResult>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ScheduledTaskEndedMessage"/> class.
+    /// </summary>
+    /// <param name="data">Task result.</param>
+    public ScheduledTaskEndedMessage(TaskResult data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ScheduledTaskEnded)]
+    public override SessionMessageType MessageType => SessionMessageType.ScheduledTaskEnded;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs

@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Tasks;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Scheduled tasks info message.
+/// </summary>
+public class ScheduledTasksInfoMessage : WebSocketMessage<IReadOnlyList<TaskInfo>>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ScheduledTasksInfoMessage"/> class.
+    /// </summary>
+    /// <param name="data">List of task infos.</param>
+    public ScheduledTasksInfoMessage(IReadOnlyList<TaskInfo> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ScheduledTasksInfo)]
+    public override SessionMessageType MessageType => SessionMessageType.ScheduledTasksInfo;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Series timer cancelled message.
+/// </summary>
+public class SeriesTimerCancelledMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SeriesTimerCancelledMessage"/> class.
+    /// </summary>
+    /// <param name="data">The timer event info.</param>
+    public SeriesTimerCancelledMessage(TimerEventInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SeriesTimerCancelled)]
+    public override SessionMessageType MessageType => SessionMessageType.SeriesTimerCancelled;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Series timer created message.
+/// </summary>
+public class SeriesTimerCreatedMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SeriesTimerCreatedMessage"/> class.
+    /// </summary>
+    /// <param name="data">timer event info.</param>
+    public SeriesTimerCreatedMessage(TimerEventInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SeriesTimerCreated)]
+    public override SessionMessageType MessageType => SessionMessageType.SeriesTimerCreated;
+}

+ 14 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs

@@ -0,0 +1,14 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Server restarting down message.
+/// </summary>
+public class ServerRestartingMessage : WebSocketMessage, IOutboundWebSocketMessage
+{
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ServerRestarting)]
+    public override SessionMessageType MessageType => SessionMessageType.ServerRestarting;
+}

+ 14 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs

@@ -0,0 +1,14 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Server shutting down message.
+/// </summary>
+public class ServerShuttingDownMessage : WebSocketMessage, IOutboundWebSocketMessage
+{
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.ServerShuttingDown)]
+    public override SessionMessageType MessageType => SessionMessageType.ServerShuttingDown;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Sessions message.
+/// </summary>
+public class SessionsMessage : WebSocketMessage<SessionInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SessionsMessage"/> class.
+    /// </summary>
+    /// <param name="data">Session info.</param>
+    public SessionsMessage(SessionInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.Sessions)]
+    public override SessionMessageType MessageType => SessionMessageType.Sessions;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Sync play command.
+/// </summary>
+public class SyncPlayCommandMessage : WebSocketMessage<SendCommand>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SyncPlayCommandMessage"/> class.
+    /// </summary>
+    /// <param name="data">The send command.</param>
+    public SyncPlayCommandMessage(SendCommand data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SyncPlayCommand)]
+    public override SessionMessageType MessageType => SessionMessageType.SyncPlayCommand;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Untyped sync play command.
+/// </summary>
+public class SyncPlayGroupUpdateCommandMessage : WebSocketMessage<GroupUpdate>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandMessage"/> class.
+    /// </summary>
+    /// <param name="data">The send command.</param>
+    public SyncPlayGroupUpdateCommandMessage(GroupUpdate data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)]
+    public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs

@@ -0,0 +1,25 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Sync play group update command with group info.
+/// GroupUpdateTypes: GroupJoined.
+/// </summary>
+public class SyncPlayGroupUpdateCommandOfGroupInfoMessage : WebSocketMessage<GroupUpdate<GroupInfoDto>>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfGroupInfoMessage"/> class.
+    /// </summary>
+    /// <param name="data">The group info.</param>
+    public SyncPlayGroupUpdateCommandOfGroupInfoMessage(GroupUpdate<GroupInfoDto> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)]
+    public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs

@@ -0,0 +1,25 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Sync play group update command with group state update.
+/// GroupUpdateTypes: StateUpdate.
+/// </summary>
+public class SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage : WebSocketMessage<GroupUpdate<GroupStateUpdate>>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage"/> class.
+    /// </summary>
+    /// <param name="data">The group info.</param>
+    public SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage(GroupUpdate<GroupStateUpdate> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)]
+    public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs

@@ -0,0 +1,25 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Sync play group update command with play queue update.
+/// GroupUpdateTypes: PlayQueue.
+/// </summary>
+public class SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage : WebSocketMessage<GroupUpdate<PlayQueueUpdate>>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage"/> class.
+    /// </summary>
+    /// <param name="data">The play queue update.</param>
+    public SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage(GroupUpdate<PlayQueueUpdate> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)]
+    public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate;
+}

+ 25 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs

@@ -0,0 +1,25 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Sync play group update command with string.
+/// GroupUpdateTypes: GroupDoesNotExist (error), LibraryAccessDenied (error), NotInGroup (error), GroupLeft (groupId), UserJoined (username), UserLeft (username).
+/// </summary>
+public class SyncPlayGroupUpdateCommandOfStringMessage : WebSocketMessage<GroupUpdate<string>>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfStringMessage"/> class.
+    /// </summary>
+    /// <param name="data">The send command.</param>
+    public SyncPlayGroupUpdateCommandOfStringMessage(GroupUpdate<string> data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.SyncPlayGroupUpdate)]
+    public override SessionMessageType MessageType => SessionMessageType.SyncPlayGroupUpdate;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Timer cancelled message.
+/// </summary>
+public class TimerCancelledMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TimerCancelledMessage"/> class.
+    /// </summary>
+    /// <param name="data">Timer event info.</param>
+    public TimerCancelledMessage(TimerEventInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.TimerCancelled)]
+    public override SessionMessageType MessageType => SessionMessageType.TimerCancelled;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// Timer created message.
+/// </summary>
+public class TimerCreatedMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TimerCreatedMessage"/> class.
+    /// </summary>
+    /// <param name="data">Timer event info.</param>
+    public TimerCreatedMessage(TimerEventInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.TimerCreated)]
+    public override SessionMessageType MessageType => SessionMessageType.TimerCreated;
+}

+ 23 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs

@@ -0,0 +1,23 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// User data changed message.
+/// </summary>
+public class UserDataChangedMessage : WebSocketMessage<UserDataChangeInfo>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UserDataChangedMessage"/> class.
+    /// </summary>
+    /// <param name="data">The data change info.</param>
+    public UserDataChangedMessage(UserDataChangeInfo data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.UserDataChanged)]
+    public override SessionMessageType MessageType => SessionMessageType.UserDataChanged;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs

@@ -0,0 +1,24 @@
+using System;
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// User deleted message.
+/// </summary>
+public class UserDeletedMessage : WebSocketMessage<Guid>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UserDeletedMessage"/> class.
+    /// </summary>
+    /// <param name="data">The user id.</param>
+    public UserDeletedMessage(Guid data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.UserDeleted)]
+    public override SessionMessageType MessageType => SessionMessageType.UserDeleted;
+}

+ 24 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs

@@ -0,0 +1,24 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
+
+/// <summary>
+/// User updated message.
+/// </summary>
+public class UserUpdatedMessage : WebSocketMessage<UserDto>, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="UserUpdatedMessage"/> class.
+    /// </summary>
+    /// <param name="data">The user dto.</param>
+    public UserUpdatedMessage(UserDto data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.UserUpdated)]
+    public override SessionMessageType MessageType => SessionMessageType.UserUpdated;
+}

+ 9 - 0
MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs

@@ -0,0 +1,9 @@
+namespace MediaBrowser.Controller.Net.WebSocketMessages;
+
+/// <summary>
+/// Class representing the list of outbound websocket message types.
+/// Only used in openapi generation.
+/// </summary>
+public class OutboundWebSocketMessage : WebSocketMessage
+{
+}

+ 23 - 0
MediaBrowser.Controller/Net/WebSocketMessages/Shared/KeepAliveMessage.cs

@@ -0,0 +1,23 @@
+using System.ComponentModel;
+using MediaBrowser.Model.Session;
+
+namespace MediaBrowser.Controller.Net.WebSocketMessages.Shared;
+
+/// <summary>
+/// Keep alive websocket messages.
+/// </summary>
+public class KeepAliveMessage : WebSocketMessage<int>, IInboundWebSocketMessage, IOutboundWebSocketMessage
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="KeepAliveMessage"/> class.
+    /// </summary>
+    /// <param name="data">The seconds to keep alive for.</param>
+    public KeepAliveMessage(int data)
+        : base(data)
+    {
+    }
+
+    /// <inheritdoc />
+    [DefaultValue(SessionMessageType.KeepAlive)]
+    public override SessionMessageType MessageType => SessionMessageType.KeepAlive;
+}

+ 12 - 12
MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs

@@ -23,13 +23,13 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
         /// The sorted playlist.
         /// </summary>
         /// <value>The sorted playlist, or play queue of the group.</value>
-        private List<QueueItem> _sortedPlaylist = new List<QueueItem>();
+        private List<SyncPlayQueueItem> _sortedPlaylist = new List<SyncPlayQueueItem>();
 
         /// <summary>
         /// The shuffled playlist.
         /// </summary>
         /// <value>The shuffled playlist, or play queue of the group.</value>
-        private List<QueueItem> _shuffledPlaylist = new List<QueueItem>();
+        private List<SyncPlayQueueItem> _shuffledPlaylist = new List<SyncPlayQueueItem>();
 
         /// <summary>
         /// Initializes a new instance of the <see cref="PlayQueueManager" /> class.
@@ -76,7 +76,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
         /// Gets the current playlist considering the shuffle mode.
         /// </summary>
         /// <returns>The playlist.</returns>
-        public IReadOnlyList<QueueItem> GetPlaylist()
+        public IReadOnlyList<SyncPlayQueueItem> GetPlaylist()
         {
             return GetPlaylistInternal();
         }
@@ -93,7 +93,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
             _sortedPlaylist = CreateQueueItemsFromArray(items);
             if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
             {
-                _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist);
+                _shuffledPlaylist = new List<SyncPlayQueueItem>(_sortedPlaylist);
                 _shuffledPlaylist.Shuffle();
             }
 
@@ -125,14 +125,14 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
         {
             if (PlayingItemIndex == NoPlayingItemIndex)
             {
-                _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist);
+                _shuffledPlaylist = new List<SyncPlayQueueItem>(_sortedPlaylist);
                 _shuffledPlaylist.Shuffle();
             }
             else if (ShuffleMode.Equals(GroupShuffleMode.Sorted))
             {
                 // First time shuffle.
                 var playingItem = _sortedPlaylist[PlayingItemIndex];
-                _shuffledPlaylist = new List<QueueItem>(_sortedPlaylist);
+                _shuffledPlaylist = new List<SyncPlayQueueItem>(_sortedPlaylist);
                 _shuffledPlaylist.RemoveAt(PlayingItemIndex);
                 _shuffledPlaylist.Shuffle();
                 _shuffledPlaylist.Insert(0, playingItem);
@@ -407,7 +407,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
         /// Gets the next item in the playlist considering repeat mode and shuffle mode.
         /// </summary>
         /// <returns>The next item in the playlist.</returns>
-        public QueueItem GetNextItemPlaylistId()
+        public SyncPlayQueueItem GetNextItemPlaylistId()
         {
             int newIndex;
             var playlist = GetPlaylistInternal();
@@ -502,12 +502,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
         /// Creates a list from the array of items. Each item is given an unique playlist identifier.
         /// </summary>
         /// <returns>The list of queue items.</returns>
-        private List<QueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items)
+        private List<SyncPlayQueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items)
         {
-            var list = new List<QueueItem>();
+            var list = new List<SyncPlayQueueItem>();
             foreach (var item in items)
             {
-                var queueItem = new QueueItem(item);
+                var queueItem = new SyncPlayQueueItem(item);
                 list.Add(queueItem);
             }
 
@@ -518,7 +518,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
         /// Gets the current playlist considering the shuffle mode.
         /// </summary>
         /// <returns>The playlist.</returns>
-        private List<QueueItem> GetPlaylistInternal()
+        private List<SyncPlayQueueItem> GetPlaylistInternal()
         {
             if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
             {
@@ -532,7 +532,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
         /// Gets the current playing item, depending on the shuffle mode.
         /// </summary>
         /// <returns>The playing item.</returns>
-        private QueueItem GetPlayingItem()
+        private SyncPlayQueueItem GetPlayingItem()
         {
             if (PlayingItemIndex == NoPlayingItemIndex)
             {

+ 8 - 8
MediaBrowser.Model/Configuration/EncodingOptions.cs

@@ -27,13 +27,13 @@ public class EncodingOptions
         EnableTonemapping = false;
         EnableVppTonemapping = false;
         TonemappingAlgorithm = "bt2390";
+        TonemappingMode = "auto";
         TonemappingRange = "auto";
         TonemappingDesat = 0;
-        TonemappingThreshold = 0.8;
         TonemappingPeak = 100;
         TonemappingParam = 0;
-        VppTonemappingBrightness = 0;
-        VppTonemappingContrast = 1.2;
+        VppTonemappingBrightness = 16;
+        VppTonemappingContrast = 1;
         H264Crf = 23;
         H265Crf = 28;
         DeinterlaceDoubleRate = false;
@@ -137,6 +137,11 @@ public class EncodingOptions
     /// </summary>
     public string TonemappingAlgorithm { get; set; }
 
+    /// <summary>
+    /// Gets or sets the tone-mapping mode.
+    /// </summary>
+    public string TonemappingMode { get; set; }
+
     /// <summary>
     /// Gets or sets the tone-mapping range.
     /// </summary>
@@ -147,11 +152,6 @@ public class EncodingOptions
     /// </summary>
     public double TonemappingDesat { get; set; }
 
-    /// <summary>
-    /// Gets or sets the tone-mapping threshold.
-    /// </summary>
-    public double TonemappingThreshold { get; set; }
-
     /// <summary>
     /// Gets or sets the tone-mapping peak.
     /// </summary>

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

@@ -757,8 +757,8 @@ namespace MediaBrowser.Model.Dlna
             if (options.AllowVideoStreamCopy)
             {
                 // prefer direct copy profile
-                float videoFramerate = videoStream is null ? 0 : videoStream.AverageFrameRate ?? videoStream.AverageFrameRate ?? 0;
-                TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp;
+                float videoFramerate = videoStream?.AverageFrameRate ?? videoStream?.RealFrameRate ?? 0;
+                TransportStreamTimestamp? timestamp = videoStream == null ? TransportStreamTimestamp.None : item.Timestamp;
                 int? numAudioStreams = item.GetStreamCount(MediaStreamType.Audio);
                 int? numVideoStreams = item.GetStreamCount(MediaStreamType.Video);
 
@@ -768,7 +768,7 @@ namespace MediaBrowser.Model.Dlna
 
                     if (ContainerProfile.ContainsContainer(videoCodecs, item.VideoStream?.Codec))
                     {
-                        var videoCodec = transcodingProfile.VideoCodec;
+                        var videoCodec = videoStream?.Codec;
                         var container = transcodingProfile.Container;
                         var appliedVideoConditions = options.Profile.CodecProfiles
                             .Where(i => i.Type == CodecType.Video &&
@@ -905,7 +905,7 @@ namespace MediaBrowser.Model.Dlna
 
             var appliedVideoConditions = options.Profile.CodecProfiles
                 .Where(i => i.Type == CodecType.Video &&
-                    i.ContainsAnyCodec(videoCodec, container) &&
+                    i.ContainsAnyCodec(videoStream?.Codec, container) &&
                     i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)));
             var isFirstAppliedCodecProfile = true;
             foreach (var i in appliedVideoConditions)
@@ -937,7 +937,7 @@ namespace MediaBrowser.Model.Dlna
 
             var appliedAudioConditions = options.Profile.CodecProfiles
                 .Where(i => i.Type == CodecType.VideoAudio &&
-                    i.ContainsAnyCodec(audioCodec, container) &&
+                    i.ContainsAnyCodec(audioStream?.Codec, container) &&
                     i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, audioProfile, isSecondaryAudio)));
             isFirstAppliedCodecProfile = true;
             foreach (var codecProfile in appliedAudioConditions)
@@ -1176,7 +1176,8 @@ namespace MediaBrowser.Model.Dlna
                 profile,
                 "VideoCodecProfile",
                 profile.CodecProfiles
-                    .Where(codecProfile => codecProfile.Type == CodecType.Video && codecProfile.ContainsAnyCodec(videoStream?.Codec, container) &&
+                    .Where(codecProfile => codecProfile.Type == CodecType.Video &&
+                        codecProfile.ContainsAnyCodec(videoStream?.Codec, container) &&
                         !checkVideoConditions(codecProfile.ApplyConditions).Any())
                     .SelectMany(codecProfile => checkVideoConditions(codecProfile.Conditions)));
 
@@ -1585,7 +1586,8 @@ namespace MediaBrowser.Model.Dlna
             bool? isSecondaryAudio)
         {
             return codecProfiles
-                .Where(profile => profile.Type == CodecType.VideoAudio && profile.ContainsAnyCodec(codec, container) &&
+                .Where(profile => profile.Type == CodecType.VideoAudio &&
+                    profile.ContainsAnyCodec(codec, container) &&
                     profile.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, isSecondaryAudio)))
                 .SelectMany(profile => profile.Conditions)
                 .Where(condition => !ConditionProcessor.IsVideoAudioConditionSatisfied(condition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth, audioProfile, isSecondaryAudio));
@@ -1602,7 +1604,8 @@ namespace MediaBrowser.Model.Dlna
             bool checkConditions)
         {
             var conditions = codecProfiles
-                .Where(profile => profile.Type == CodecType.Audio && profile.ContainsAnyCodec(codec, container) &&
+                .Where(profile => profile.Type == CodecType.Audio &&
+                    profile.ContainsAnyCodec(codec, container) &&
                     profile.ApplyConditions.All(applyCondition => ConditionProcessor.IsAudioConditionSatisfied(applyCondition, audioChannels, audioBitrate, audioSampleRate, audioBitDepth)))
                 .SelectMany(profile => profile.Conditions);
 

+ 0 - 31
MediaBrowser.Model/Net/WebSocketMessage.cs

@@ -1,31 +0,0 @@
-#nullable disable
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Model.Session;
-
-namespace MediaBrowser.Model.Net
-{
-    /// <summary>
-    /// Class WebSocketMessage.
-    /// </summary>
-    /// <typeparam name="T">The type of the data.</typeparam>
-    public class WebSocketMessage<T>
-    {
-        /// <summary>
-        /// Gets or sets the type of the message.
-        /// </summary>
-        /// <value>The type of the message.</value>
-        public SessionMessageType MessageType { get; set; }
-
-        public Guid MessageId { get; set; }
-
-        public string ServerId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the data.
-        /// </summary>
-        /// <value>The data.</value>
-        public T Data { get; set; }
-    }
-}

+ 21 - 33
MediaBrowser.Model/SyncPlay/GroupUpdate.cs

@@ -1,42 +1,30 @@
 using System;
 
-namespace MediaBrowser.Model.SyncPlay
+namespace MediaBrowser.Model.SyncPlay;
+
+/// <summary>
+/// Group update without data.
+/// </summary>
+public abstract class GroupUpdate
 {
     /// <summary>
-    /// Class GroupUpdate.
+    /// Initializes a new instance of the <see cref="GroupUpdate"/> class.
     /// </summary>
-    /// <typeparam name="T">The type of the data of the message.</typeparam>
-    public class GroupUpdate<T>
+    /// <param name="groupId">The group identifier.</param>
+    protected GroupUpdate(Guid groupId)
     {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class.
-        /// </summary>
-        /// <param name="groupId">The group identifier.</param>
-        /// <param name="type">The update type.</param>
-        /// <param name="data">The update data.</param>
-        public GroupUpdate(Guid groupId, GroupUpdateType type, T data)
-        {
-            GroupId = groupId;
-            Type = type;
-            Data = data;
-        }
-
-        /// <summary>
-        /// Gets the group identifier.
-        /// </summary>
-        /// <value>The group identifier.</value>
-        public Guid GroupId { get; }
+        GroupId = groupId;
+    }
 
-        /// <summary>
-        /// Gets the update type.
-        /// </summary>
-        /// <value>The update type.</value>
-        public GroupUpdateType Type { get; }
+    /// <summary>
+    /// Gets the group identifier.
+    /// </summary>
+    /// <value>The group identifier.</value>
+    public Guid GroupId { get; }
 
-        /// <summary>
-        /// Gets the update data.
-        /// </summary>
-        /// <value>The update data.</value>
-        public T Data { get; }
-    }
+    /// <summary>
+    /// Gets the update type.
+    /// </summary>
+    /// <value>The update type.</value>
+    public GroupUpdateType Type { get; init; }
 }

+ 31 - 0
MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs

@@ -0,0 +1,31 @@
+#pragma warning disable SA1649
+
+using System;
+
+namespace MediaBrowser.Model.SyncPlay;
+
+/// <summary>
+/// Class GroupUpdate.
+/// </summary>
+/// <typeparam name="T">The type of the data of the message.</typeparam>
+public class GroupUpdate<T> : GroupUpdate
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class.
+    /// </summary>
+    /// <param name="groupId">The group identifier.</param>
+    /// <param name="type">The update type.</param>
+    /// <param name="data">The update data.</param>
+    public GroupUpdate(Guid groupId, GroupUpdateType type, T data)
+        : base(groupId)
+    {
+        Data = data;
+        Type = type;
+    }
+
+    /// <summary>
+    /// Gets the update data.
+    /// </summary>
+    /// <value>The update data.</value>
+    public T Data { get; }
+}

+ 2 - 2
MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs

@@ -19,7 +19,7 @@ namespace MediaBrowser.Model.SyncPlay
         /// <param name="isPlaying">The playing item status.</param>
         /// <param name="shuffleMode">The shuffle mode.</param>
         /// <param name="repeatMode">The repeat mode.</param>
-        public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, bool isPlaying, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode)
+        public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<SyncPlayQueueItem> playlist, int playingItemIndex, long startPositionTicks, bool isPlaying, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode)
         {
             Reason = reason;
             LastUpdate = lastUpdate;
@@ -47,7 +47,7 @@ namespace MediaBrowser.Model.SyncPlay
         /// Gets the playlist.
         /// </summary>
         /// <value>The playlist.</value>
-        public IReadOnlyList<QueueItem> Playlist { get; }
+        public IReadOnlyList<SyncPlayQueueItem> Playlist { get; }
 
         /// <summary>
         /// Gets the playing item index in the playlist.

+ 3 - 3
MediaBrowser.Model/SyncPlay/QueueItem.cs → MediaBrowser.Model/SyncPlay/SyncPlayQueueItem.cs

@@ -5,13 +5,13 @@ namespace MediaBrowser.Model.SyncPlay
     /// <summary>
     /// Class QueueItem.
     /// </summary>
-    public class QueueItem
+    public class SyncPlayQueueItem
     {
         /// <summary>
-        /// Initializes a new instance of the <see cref="QueueItem"/> class.
+        /// Initializes a new instance of the <see cref="SyncPlayQueueItem"/> class.
         /// </summary>
         /// <param name="itemId">The item identifier.</param>
-        public QueueItem(Guid itemId)
+        public SyncPlayQueueItem(Guid itemId)
         {
             ItemId = itemId;
         }

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

@@ -12,6 +12,7 @@ using Jellyfin.Extensions;
 using MediaBrowser.Controller.Configuration;
 using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
 using MediaBrowser.Controller.Library;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Model.Configuration;

+ 53 - 21
MediaBrowser.Providers/TV/SeriesMetadataService.cs

@@ -41,7 +41,7 @@ namespace MediaBrowser.Providers.TV
 
             RemoveObsoleteEpisodes(item);
             RemoveObsoleteSeasons(item);
-            await FillInMissingSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
+            await UpdateAndCreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
         }
 
         /// <inheritdoc />
@@ -67,6 +67,20 @@ namespace MediaBrowser.Providers.TV
 
             var sourceItem = source.Item;
             var targetItem = target.Item;
+            var sourceSeasonNames = sourceItem.SeasonNames;
+            var targetSeasonNames = targetItem.SeasonNames;
+
+            if (replaceData || targetSeasonNames.Count == 0)
+            {
+                targetItem.SeasonNames = sourceSeasonNames;
+            }
+            else if (targetSeasonNames.Count != sourceSeasonNames.Count || !sourceSeasonNames.Keys.All(targetSeasonNames.ContainsKey))
+            {
+                foreach (var (number, name) in sourceSeasonNames)
+                {
+                    targetSeasonNames.TryAdd(number, name);
+                }
+            }
 
             if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
             {
@@ -86,7 +100,7 @@ namespace MediaBrowser.Providers.TV
 
         private void RemoveObsoleteSeasons(Series series)
         {
-            // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in FillInMissingSeasonsAsync.
+            // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in UpdateAndCreateSeasonsAsync.
             var physicalSeasonNumbers = new HashSet<int>();
             var virtualSeasons = new List<Season>();
             foreach (var existingSeason in series.Children.OfType<Season>())
@@ -177,36 +191,43 @@ namespace MediaBrowser.Providers.TV
         }
 
         /// <summary>
-        /// Creates seasons for all episodes that aren't in a season folder.
+        /// Creates seasons for all episodes if they don't exist.
         /// If no season number can be determined, a dummy season will be created.
+        /// Updates seasons names.
         /// </summary>
         /// <param name="series">The series.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The async task.</returns>
-        private async Task FillInMissingSeasonsAsync(Series series, CancellationToken cancellationToken)
+        private async Task UpdateAndCreateSeasonsAsync(Series series, CancellationToken cancellationToken)
         {
+            var seasonNames = series.SeasonNames;
             var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
-            var episodesInSeriesFolder = seriesChildren
+            var seasons = seriesChildren.OfType<Season>().ToList();
+            var uniqueSeasonNumbers = seriesChildren
                 .OfType<Episode>()
-                .Where(i => !i.IsInSeasonFolder);
-
-            List<Season> seasons = seriesChildren.OfType<Season>().ToList();
+                .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
+                .Distinct();
 
             // Loop through the unique season numbers
-            foreach (var episode in episodesInSeriesFolder)
+            foreach (var seasonNumber in uniqueSeasonNumbers)
             {
                 // Null season numbers will have a 'dummy' season created because seasons are always required.
-                var seasonNumber = episode.ParentIndexNumber >= 0 ? episode.ParentIndexNumber : null;
                 var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
+                string? seasonName = null;
+
+                if (seasonNumber.HasValue && seasonNames.TryGetValue(seasonNumber.Value, out var tmp))
+                {
+                    seasonName = tmp;
+                }
 
                 if (existingSeason is null)
                 {
-                    var season = await CreateSeasonAsync(series, seasonNumber, cancellationToken).ConfigureAwait(false);
-                    seasons.Add(season);
+                    var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
+                    series.AddChild(season);
                 }
-                else if (existingSeason.IsVirtualItem)
+                else
                 {
-                    existingSeason.IsVirtualItem = false;
+                    existingSeason.Name = GetValidSeasonNameForSeries(series, seasonName, seasonNumber);
                     await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
                 }
             }
@@ -216,21 +237,17 @@ namespace MediaBrowser.Providers.TV
         /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
         /// </summary>
         /// <param name="series">The series.</param>
+        /// <param name="seasonName">The season name.</param>
         /// <param name="seasonNumber">The season number.</param>
         /// <param name="cancellationToken">The cancellation token.</param>
         /// <returns>The newly created season.</returns>
         private async Task<Season> CreateSeasonAsync(
             Series series,
+            string? seasonName,
             int? seasonNumber,
             CancellationToken cancellationToken)
         {
-            string seasonName = seasonNumber switch
-            {
-                null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
-                0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
-                _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value)
-            };
-
+            seasonName = GetValidSeasonNameForSeries(series, seasonName, seasonNumber);
             Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
 
             var season = new Season
@@ -251,5 +268,20 @@ namespace MediaBrowser.Providers.TV
 
             return season;
         }
+
+        private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
+        {
+            if (string.IsNullOrEmpty(seasonName))
+            {
+                seasonName = seasonNumber switch
+                {
+                    null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
+                    0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
+                    _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value)
+                };
+            }
+
+            return seasonName;
+        }
     }
 }

+ 12 - 0
MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs

@@ -55,6 +55,18 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                         break;
                     }
 
+                case "seasonname":
+                    {
+                        var name = reader.ReadElementContentAsString();
+
+                        if (!string.IsNullOrWhiteSpace(name))
+                        {
+                            item.Name = name;
+                        }
+
+                        break;
+                    }
+
                 default:
                     base.FetchDataFromXmlNode(reader, itemResult);
                     break;

+ 15 - 0
MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs

@@ -1,4 +1,6 @@
 using System;
+using System.Collections.Generic;
+using System.Globalization;
 using System.Xml;
 using MediaBrowser.Common.Configuration;
 using MediaBrowser.Controller.Entities.TV;
@@ -110,6 +112,19 @@ namespace MediaBrowser.XbmcMetadata.Parsers
                         break;
                     }
 
+                case "namedseason":
+                    {
+                        var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber);
+                        var name = reader.ReadElementContentAsString();
+
+                        if (!string.IsNullOrWhiteSpace(name) && parsed)
+                        {
+                            item.SeasonNames[seasonNumber] = name;
+                        }
+
+                        break;
+                    }
+
                 default:
                     base.FetchDataFromXmlNode(reader, itemResult);
                     break;

+ 43 - 0
tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs

@@ -1,4 +1,5 @@
 using System;
+using System.IO;
 using Emby.Server.Implementations.Library;
 using Xunit;
 
@@ -73,5 +74,47 @@ namespace Jellyfin.Server.Implementations.Tests.Library
             Assert.False(PathExtensions.TryReplaceSubPath(path, subPath, newSubPath, out var result));
             Assert.Null(result);
         }
+
+        [Theory]
+        [InlineData(null, '/', null)]
+        [InlineData(null, '\\', null)]
+        [InlineData("/home/jeff/myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")]
+        [InlineData("C:\\Users\\Jeff\\myfile.mkv", '/', "C:/Users/Jeff/myfile.mkv")]
+        [InlineData("\\home/jeff\\myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")]
+        [InlineData("\\home/jeff\\myfile.mkv", '/', "/home/jeff/myfile.mkv")]
+        [InlineData("", '/', "")]
+        public void NormalizePath_SpecifyingSeparator_Normalizes(string path, char separator, string expectedPath)
+        {
+            Assert.Equal(expectedPath, path.NormalizePath(separator));
+        }
+
+        [Theory]
+        [InlineData("/home/jeff/myfile.mkv")]
+        [InlineData("C:\\Users\\Jeff\\myfile.mkv")]
+        [InlineData("\\home/jeff\\myfile.mkv")]
+        public void NormalizePath_NoArgs_UsesDirectorySeparatorChar(string path)
+        {
+            var separator = Path.DirectorySeparatorChar;
+
+            Assert.Equal(path.Replace('\\', separator).Replace('/', separator), path.NormalizePath());
+        }
+
+        [Theory]
+        [InlineData("/home/jeff/myfile.mkv", '/')]
+        [InlineData("C:\\Users\\Jeff\\myfile.mkv", '\\')]
+        [InlineData("\\home/jeff\\myfile.mkv", '/')]
+        public void NormalizePath_OutVar_Correct(string path, char expectedSeparator)
+        {
+            var result = path.NormalizePath(out var separator);
+
+            Assert.Equal(expectedSeparator, separator);
+            Assert.Equal(path.Replace('\\', separator).Replace('/', separator), result);
+        }
+
+        [Fact]
+        public void NormalizePath_SpecifyInvalidSeparator_ThrowsException()
+        {
+            Assert.Throws<ArgumentException>(() => string.Empty.NormalizePath('a'));
+        }
     }
 }

+ 298 - 5
tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs

@@ -1,7 +1,16 @@
 using System;
+using System.Globalization;
 using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AutoFixture;
+using Emby.Server.Implementations.Library;
 using Emby.Server.Implementations.Plugins;
+using Jellyfin.Extensions.Json;
+using Jellyfin.Extensions.Json.Converters;
 using MediaBrowser.Common.Plugins;
+using MediaBrowser.Model.Plugins;
+using MediaBrowser.Model.Updates;
 using Microsoft.Extensions.Logging.Abstractions;
 using Xunit;
 
@@ -11,6 +20,21 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
     {
         private static readonly string _testPathRoot = Path.Combine(Path.GetTempPath(), "jellyfin-test-data");
 
+        private string _tempPath = string.Empty;
+
+        private string _pluginPath = string.Empty;
+
+        private JsonSerializerOptions _options;
+
+        public PluginManagerTests()
+        {
+            (_tempPath, _pluginPath) = GetTestPaths("plugin-" + Path.GetRandomFileName());
+
+            Directory.CreateDirectory(_pluginPath);
+
+            _options = GetTestSerializerOptions();
+        }
+
         [Fact]
         public void SaveManifest_RoundTrip_Success()
         {
@@ -20,12 +44,9 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
                 Version = "1.0"
             };
 
-            var tempPath = Path.Combine(_testPathRoot, "manifest-" + Path.GetRandomFileName());
-            Directory.CreateDirectory(tempPath);
-
-            Assert.True(pluginManager.SaveManifest(manifest, tempPath));
+            Assert.True(pluginManager.SaveManifest(manifest, _pluginPath));
 
-            var res = pluginManager.LoadManifest(tempPath);
+            var res = pluginManager.LoadManifest(_pluginPath);
 
             Assert.Equal(manifest.Category, res.Manifest.Category);
             Assert.Equal(manifest.Changelog, res.Manifest.Changelog);
@@ -40,6 +61,278 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
             Assert.Equal(manifest.Status, res.Manifest.Status);
             Assert.Equal(manifest.AutoUpdate, res.Manifest.AutoUpdate);
             Assert.Equal(manifest.ImagePath, res.Manifest.ImagePath);
+            Assert.Equal(manifest.Assemblies, res.Manifest.Assemblies);
+        }
+
+        /// <summary>
+        ///  Tests safe traversal within the plugin directory.
+        /// </summary>
+        /// <param name="dllFile">The safe path to evaluate.</param>
+        [Theory]
+        [InlineData("./some.dll")]
+        [InlineData("some.dll")]
+        [InlineData("sub/path/some.dll")]
+        public void Constructor_DiscoversSafePluginAssembly_Status_Active(string dllFile)
+        {
+            var manifest = new PluginManifest
+            {
+                Id = Guid.NewGuid(),
+                Name = "Safe Assembly",
+                Assemblies = new string[] { dllFile }
+            };
+
+            var filename = Path.GetFileName(dllFile)!;
+            var dllPath = Path.GetDirectoryName(Path.Combine(_pluginPath, dllFile))!;
+
+            Directory.CreateDirectory(dllPath);
+            File.Create(Path.Combine(dllPath, filename));
+            var metafilePath = Path.Combine(_pluginPath, "meta.json");
+
+            File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options));
+
+            var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0));
+
+            var res = JsonSerializer.Deserialize<PluginManifest>(File.ReadAllText(metafilePath), _options);
+
+            var expectedFullPath = Path.Combine(_pluginPath, dllFile).Canonicalize();
+
+            Assert.NotNull(res);
+            Assert.NotEmpty(pluginManager.Plugins);
+            Assert.Equal(PluginStatus.Active, res!.Status);
+            Assert.Equal(expectedFullPath, pluginManager.Plugins[0].DllFiles[0]);
+            Assert.StartsWith(_pluginPath, expectedFullPath, StringComparison.InvariantCulture);
+        }
+
+        /// <summary>
+        ///  Tests unsafe attempts to traverse to higher directories.
+        /// </summary>
+        /// <remarks>
+        ///  Attempts to load directories outside of the plugin should be
+        ///  constrained. Path traversal, shell expansion, and double encoding
+        ///  can be used to load unintended files.
+        ///  See <see href="https://owasp.org/www-community/attacks/Path_Traversal"/> for more.
+        /// </remarks>
+        /// <param name="unsafePath">The unsafe path to evaluate.</param>
+        [Theory]
+        [InlineData("/some.dll")] // Root path.
+        [InlineData("../some.dll")] // Simple traversal.
+        [InlineData("C:\\some.dll")] // Windows root path.
+        [InlineData("test.txt")] // Not a DLL
+        [InlineData(".././.././../some.dll")] // Traversal with current and parent
+        [InlineData("..\\.\\..\\.\\..\\some.dll")] // Windows traversal with current and parent
+        [InlineData("\\\\network\\resource.dll")] // UNC Path
+        [InlineData("https://jellyfin.org/some.dll")] // URL
+        [InlineData("~/some.dll")] // Tilde poses a shell expansion risk, but is a valid path character.
+        public void Constructor_DiscoversUnsafePluginAssembly_Status_Malfunctioned(string unsafePath)
+        {
+            var manifest = new PluginManifest
+            {
+                Id = Guid.NewGuid(),
+                Name = "Unsafe Assembly",
+                Assemblies = new string[] { unsafePath }
+            };
+
+            // Only create very specific files. Otherwise the test will be exploiting path traversal.
+            var files = new string[]
+            {
+                "../other.dll",
+                "some.dll"
+            };
+
+            foreach (var file in files)
+            {
+                File.Create(Path.Combine(_pluginPath, file));
+            }
+
+            var metafilePath = Path.Combine(_pluginPath, "meta.json");
+
+            File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options));
+
+            var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0));
+
+            var res = JsonSerializer.Deserialize<PluginManifest>(File.ReadAllText(metafilePath), _options);
+
+            Assert.NotNull(res);
+            Assert.Empty(pluginManager.Plugins);
+            Assert.Equal(PluginStatus.Malfunctioned, res!.Status);
+        }
+
+        [Fact]
+        public async Task PopulateManifest_ExistingMetafilePlugin_PopulatesMissingFields()
+        {
+            var packageInfo = GenerateTestPackage();
+
+            // Partial plugin without a name, but matching version and package ID
+            var partial = new PluginManifest
+            {
+                Id = packageInfo.Id,
+                AutoUpdate = false, // Turn off AutoUpdate
+                Status = PluginStatus.Restart,
+                Version = new Version(1, 0, 0).ToString(),
+                Assemblies = new[] { "Jellyfin.Test.dll" }
+            };
+
+            var expectedManifest = new PluginManifest
+            {
+                Id = partial.Id,
+                Name = packageInfo.Name,
+                AutoUpdate = partial.AutoUpdate,
+                Status = PluginStatus.Active,
+                Owner = packageInfo.Owner,
+                Assemblies = partial.Assemblies,
+                Category = packageInfo.Category,
+                Description = packageInfo.Description,
+                Overview = packageInfo.Overview,
+                TargetAbi = packageInfo.Versions[0].TargetAbi!,
+                Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture),
+                Changelog = packageInfo.Versions[0].Changelog!,
+                Version = new Version(1, 0).ToString(),
+                ImagePath = string.Empty
+            };
+
+            var metafilePath = Path.Combine(_pluginPath, "meta.json");
+            File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options));
+
+            var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0));
+
+            await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active);
+
+            var resultBytes = File.ReadAllBytes(metafilePath);
+            var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options);
+
+            Assert.NotNull(result);
+            Assert.Equivalent(expectedManifest, result);
+        }
+
+        [Fact]
+        public async Task PopulateManifest_NoMetafile_PreservesManifest()
+        {
+            var packageInfo = GenerateTestPackage();
+            var expectedManifest = new PluginManifest
+            {
+                Id = packageInfo.Id,
+                Name = packageInfo.Name,
+                AutoUpdate = true,
+                Status = PluginStatus.Active,
+                Owner = packageInfo.Owner,
+                Assemblies = Array.Empty<string>(),
+                Category = packageInfo.Category,
+                Description = packageInfo.Description,
+                Overview = packageInfo.Overview,
+                TargetAbi = packageInfo.Versions[0].TargetAbi!,
+                Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture),
+                Changelog = packageInfo.Versions[0].Changelog!,
+                Version = packageInfo.Versions[0].Version,
+                ImagePath = string.Empty
+            };
+
+            var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, null!, new Version(1, 0));
+
+            await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active);
+
+            var metafilePath = Path.Combine(_pluginPath, "meta.json");
+            var resultBytes = File.ReadAllBytes(metafilePath);
+            var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options);
+
+            Assert.NotNull(result);
+            Assert.Equivalent(expectedManifest, result);
+        }
+
+        [Fact]
+        public async Task PopulateManifest_ExistingMetafileMismatchedIds_Status_Malfunctioned()
+        {
+            var packageInfo = GenerateTestPackage();
+
+            // Partial plugin without a name, but matching version and package ID
+            var partial = new PluginManifest
+            {
+                Id = Guid.NewGuid(),
+                Version = new Version(1, 0, 0).ToString()
+            };
+
+            var metafilePath = Path.Combine(_pluginPath, "meta.json");
+            File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options));
+
+            var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0));
+
+            await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active);
+
+            var resultBytes = File.ReadAllBytes(metafilePath);
+            var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options);
+
+            Assert.NotNull(result);
+            Assert.Equal(packageInfo.Name, result.Name);
+            Assert.Equal(PluginStatus.Malfunctioned, result.Status);
+        }
+
+        [Fact]
+        public async Task PopulateManifest_ExistingMetafileMismatchedVersions_Updates_Version()
+        {
+            var packageInfo = GenerateTestPackage();
+
+            var partial = new PluginManifest
+            {
+                Id = packageInfo.Id,
+                Version = new Version(2, 0, 0).ToString()
+            };
+
+            var metafilePath = Path.Combine(_pluginPath, "meta.json");
+            File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options));
+
+            var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0));
+
+            await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active);
+
+            var resultBytes = File.ReadAllBytes(metafilePath);
+            var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options);
+
+            Assert.NotNull(result);
+            Assert.Equal(packageInfo.Name, result.Name);
+            Assert.Equal(PluginStatus.Active, result.Status);
+            Assert.Equal(packageInfo.Versions[0].Version, result.Version);
+        }
+
+        private PackageInfo GenerateTestPackage()
+        {
+            var fixture = new Fixture();
+            fixture.Customize<PackageInfo>(c => c.Without(x => x.Versions).Without(x => x.ImageUrl));
+            fixture.Customize<VersionInfo>(c => c.Without(x => x.Version).Without(x => x.Timestamp));
+
+            var versionInfo = fixture.Create<VersionInfo>();
+            versionInfo.Version = new Version(1, 0).ToString();
+            versionInfo.Timestamp = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture);
+
+            var packageInfo = fixture.Create<PackageInfo>();
+            packageInfo.Versions = new[] { versionInfo };
+
+            return packageInfo;
+        }
+
+        private JsonSerializerOptions GetTestSerializerOptions()
+        {
+            var options = new JsonSerializerOptions(JsonDefaults.Options)
+            {
+                WriteIndented = true
+            };
+
+            for (var i = 0; i < options.Converters.Count; i++)
+            {
+                // Remove the Guid converter for parity with plugin manager.
+                if (options.Converters[i] is JsonGuidConverter converter)
+                {
+                    options.Converters.Remove(converter);
+                }
+            }
+
+            return options;
+        }
+
+        private (string TempPath, string PluginPath) GetTestPaths(string pluginFolderName)
+        {
+            var tempPath = Path.Combine(_testPathRoot, "plugin-manager" + Path.GetRandomFileName());
+            var pluginPath = Path.Combine(tempPath, pluginFolderName);
+
+            return (tempPath, pluginPath);
         }
     }
 }

+ 1 - 1
tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json

@@ -681,4 +681,4 @@
             }
         ]
     }
-]
+]