diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2023-06-15 17:53:52 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2023-06-15 17:53:52 +0200 |
| commit | 32499f0e98a870872c184b23cd6d514f7a9fa09b (patch) | |
| tree | a46776045d8e29366803dded6ecd717f757cbccd | |
| parent | 006b04dc0b2fcbdcad50cbaf213cb1e7e47ea52a (diff) | |
| parent | d874262bf9826b348e146efb4958af447d75f7c8 (diff) | |
Merge branch 'master' into network-rewrite
74 files changed, 2137 insertions, 322 deletions
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d03d44a1c..9f1be0232 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.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: diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index ae06c4141..178959afc 100644 --- a/.github/workflows/commands.yml +++ b/.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 }} diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 539da7aef..ad1cedd52 100644 --- a/.github/workflows/openapi.yml +++ b/.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 }} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 0b322685d..dfb61df0a 100644 --- a/CONTRIBUTORS.md +++ b/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 diff --git a/Directory.Packages.props b/Directory.Packages.props index f0389038f..9fe38634c 100644 --- a/Directory.Packages.props +++ b/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" /> diff --git a/Emby.Dlna/PlayTo/DlnaHttpClient.cs b/Emby.Dlna/PlayTo/DlnaHttpClient.cs index 4e9903f26..8b983e9e3 100644 --- a/Emby.Dlna/PlayTo/DlnaHttpClient.cs +++ b/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, "&"); @@ -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, diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs index 64e7d5446..c4b6b3756 100644 --- a/Emby.Server.Implementations/Library/PathExtensions.cs +++ b/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); + } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 62a524d2e..e9538a5c9 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/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; diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index f14f87ccd..48584ae0c 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/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,10 +432,72 @@ 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> /// <param name="plugin">The <see cref="LocalPlugin"/> instance.</param> @@ -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; @@ -735,6 +810,68 @@ namespace Emby.Server.Implementations.Plugins } /// <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> /// <param name="plugin">The <see cref="LocalPlugin"/> that's master.</param> diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 5e897833e..6c198b6f9 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/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); } diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs index 645696e31..bf38f741c 100644 --- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs +++ b/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()) diff --git a/MediaBrowser.Common/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs index fa92d383a..1d73de3c9 100644 --- a/MediaBrowser.Common/Plugins/IPluginManager.cs +++ b/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. diff --git a/MediaBrowser.Common/Plugins/PluginManifest.cs b/MediaBrowser.Common/Plugins/PluginManifest.cs index 2910dbe14..e0847ccea 100644 --- a/MediaBrowser.Common/Plugins/PluginManifest.cs +++ b/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; } } } diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index e7a8a773e..a49c1609d 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -28,6 +28,7 @@ namespace MediaBrowser.Controller.Entities.TV public Series() { AirDays = Array.Empty<DayOfWeek>(); + SeasonNames = new Dictionary<int, string>(); } public DayOfWeek[] AirDays { get; set; } @@ -35,6 +36,9 @@ namespace MediaBrowser.Controller.Entities.TV public string AirTime { get; set; } [JsonIgnore] + public Dictionary<int, string> SeasonNames { get; set; } + + [JsonIgnore] public override bool SupportsAddingToPlaylist => true; [JsonIgnore] diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 920925fc6..39d53768e 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/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); } } } diff --git a/MediaBrowser.Controller/Net/WebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessage.cs new file mode 100644 index 000000000..c02bcd70b --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs b/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs new file mode 100644 index 000000000..7c35c8010 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/IInboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/IInboundWebSocketMessage.cs new file mode 100644 index 000000000..c3cf9955a --- /dev/null +++ b/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 +{ +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/IOutboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/IOutboundWebSocketMessage.cs new file mode 100644 index 000000000..c74a254a6 --- /dev/null +++ b/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 +{ +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs new file mode 100644 index 000000000..b9f71b922 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs new file mode 100644 index 000000000..eac129b20 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs new file mode 100644 index 000000000..dd2a7145e --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs new file mode 100644 index 000000000..84e1f0166 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs new file mode 100644 index 000000000..e35a5dc3a --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs new file mode 100644 index 000000000..7e3582d64 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs new file mode 100644 index 000000000..20ca888e1 --- /dev/null +++ b/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 +{ +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs new file mode 100644 index 000000000..5650ee4bb --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs new file mode 100644 index 000000000..94ade5e81 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs new file mode 100644 index 000000000..6c71e73f9 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs new file mode 100644 index 000000000..6432ae8ef --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs new file mode 100644 index 000000000..7f943bda1 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs new file mode 100644 index 000000000..804ccb37d --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs new file mode 100644 index 000000000..3d7dc5c93 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs new file mode 100644 index 000000000..81268007f --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs new file mode 100644 index 000000000..9177f1293 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs new file mode 100644 index 000000000..e371440a0 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs new file mode 100644 index 000000000..b2994fc95 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs new file mode 100644 index 000000000..42dbc3029 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs new file mode 100644 index 000000000..3f3d9e4c8 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs new file mode 100644 index 000000000..d69662b00 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs new file mode 100644 index 000000000..41a05b0de --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs new file mode 100644 index 000000000..d4950b8b6 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs new file mode 100644 index 000000000..091c10be6 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs new file mode 100644 index 000000000..a465d8b00 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs new file mode 100644 index 000000000..0b998a523 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs new file mode 100644 index 000000000..4c91e0bca --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs new file mode 100644 index 000000000..17a0fc66e --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs new file mode 100644 index 000000000..d145d0e01 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs new file mode 100644 index 000000000..668392c66 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs new file mode 100644 index 000000000..ec8c3344f --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs new file mode 100644 index 000000000..465363f14 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs new file mode 100644 index 000000000..b87e9bf71 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs new file mode 100644 index 000000000..0e70549ef --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs new file mode 100644 index 000000000..295b3081c --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs new file mode 100644 index 000000000..b60769540 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs new file mode 100644 index 000000000..6d527be7f --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs new file mode 100644 index 000000000..99e9a1f91 --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs new file mode 100644 index 000000000..dba3c8392 --- /dev/null +++ b/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 +{ +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Shared/KeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Shared/KeepAliveMessage.cs new file mode 100644 index 000000000..7f636212c --- /dev/null +++ b/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; +} diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs index bdebbbfd4..c0a168192 100644 --- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs +++ b/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) { diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index f9f63f751..ac2f1e71a 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/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; @@ -138,6 +138,11 @@ public class EncodingOptions 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> public string TonemappingRange { get; set; } @@ -148,11 +153,6 @@ public class EncodingOptions 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> public double TonemappingPeak { get; set; } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index df185e40c..0a955e917 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/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); diff --git a/MediaBrowser.Model/Net/WebSocketMessage.cs b/MediaBrowser.Model/Net/WebSocketMessage.cs deleted file mode 100644 index b00158cb3..000000000 --- a/MediaBrowser.Model/Net/WebSocketMessage.cs +++ /dev/null @@ -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; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs index 6f159d653..ec67d7ea8 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs +++ b/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; } } diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateOfT.cs new file mode 100644 index 000000000..25cd44461 --- /dev/null +++ b/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; } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs index cce99c77d..376d926c9 100644 --- a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs +++ b/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. diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/SyncPlayQueueItem.cs index a6dcc109e..da81fecbd 100644 --- a/MediaBrowser.Model/SyncPlay/QueueItem.cs +++ b/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; } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 80f77f7c3..834ef29f5 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/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; diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 97f938397..9016e5de0 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/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; + } } } diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs index 2f5fd40e2..51d5f932b 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs +++ b/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; diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs index 3011d65a6..f22b861eb 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs +++ b/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; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index be2dfe0a8..c33a957e6 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/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')); + } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index bc6a44741..d4b90dac0 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/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); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json index fa8fbd8d2..57367ce88 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/Updates/manifest-stable.json @@ -681,4 +681,4 @@ } ] } -]
\ No newline at end of file +] |
