diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml index b558d2a6f0..14df7e7c8d 100644 --- a/.ci/azure-pipelines-abi.yml +++ b/.ci/azure-pipelines-abi.yml @@ -7,7 +7,7 @@ parameters: default: "ubuntu-latest" - name: DotNetSdkVersion type: string - default: 3.1.100 + default: 5.0.100 jobs: - job: CompatibilityCheck diff --git a/.ci/azure-pipelines-api-client.yml b/.ci/azure-pipelines-api-client.yml index de6bbf04ce..1c447fd977 100644 --- a/.ci/azure-pipelines-api-client.yml +++ b/.ci/azure-pipelines-api-client.yml @@ -9,6 +9,7 @@ jobs: - job: GenerateApiClients displayName: 'Generate Api Clients' + condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') dependsOn: Test pool: @@ -35,17 +36,8 @@ jobs: customEndpoint: 'jellyfin-bot for NPM' ## Generate npm api client -# Unstable - - task: CmdLine@2 - displayName: 'Build unstable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') - inputs: - script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory) $(Build.BuildNumber)" - -# Stable - task: CmdLine@2 displayName: 'Build stable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') inputs: script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory)" @@ -57,20 +49,8 @@ jobs: workingDir: ./apiclient/generated/typescript/axios ## Publish npm packages -# Unstable - - task: Npm@1 - displayName: 'Publish unstable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master') - inputs: - command: publish - publishRegistry: useFeed - publishFeed: 'jellyfin/unstable' - workingDir: ./apiclient/generated/typescript/axios - -# Stable - task: Npm@1 displayName: 'Publish stable typescript axios client' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') inputs: command: publish publishRegistry: useExternalRegistry diff --git a/.ci/azure-pipelines-main.yml b/.ci/azure-pipelines-main.yml index 7617f0f5a2..95dd3ccac2 100644 --- a/.ci/azure-pipelines-main.yml +++ b/.ci/azure-pipelines-main.yml @@ -1,7 +1,7 @@ parameters: LinuxImage: 'ubuntu-latest' RestoreBuildProjects: 'Jellyfin.Server/Jellyfin.Server.csproj' - DotNetSdkVersion: 3.1.100 + DotNetSdkVersion: 5.0.100 jobs: - job: Build diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index 0dc604a794..d478516b83 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -188,6 +188,12 @@ jobs: vmImage: 'ubuntu-latest' steps: + - task: UseDotNet@2 + displayName: 'Use .NET 5.0 sdk' + inputs: + packageType: 'sdk' + version: '5.0.x' + - task: DotNetCoreCLI@2 displayName: 'Build Stable Nuget packages' condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v') diff --git a/.ci/azure-pipelines-test.yml b/.ci/azure-pipelines-test.yml index 6a36698b56..36152c82a4 100644 --- a/.ci/azure-pipelines-test.yml +++ b/.ci/azure-pipelines-test.yml @@ -10,7 +10,7 @@ parameters: default: "tests/**/*Tests.csproj" - name: DotNetSdkVersion type: string - default: 3.1.100 + default: 5.0.100 jobs: - job: Test @@ -30,11 +30,11 @@ jobs: # This is required for the SonarCloud analyzer - task: UseDotNet@2 - displayName: "Install .NET Core SDK 2.1" + displayName: "Install .NET SDK 5.x" condition: eq(variables['ImageName'], 'ubuntu-latest') inputs: packageType: sdk - version: '2.1.805' + version: '5.x' - task: UseDotNet@2 displayName: "Update DotNet" @@ -94,5 +94,5 @@ jobs: displayName: 'Publish OpenAPI Artifact' condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) inputs: - targetPath: "tests/Jellyfin.Api.Tests/bin/Release/netcoreapp3.1/openapi.json" + targetPath: "tests/Jellyfin.Api.Tests/bin/Release/net5.0/openapi.json" artifactName: 'OpenAPI Spec' diff --git a/.ci/azure-pipelines.yml b/.ci/azure-pipelines.yml index 5b5a17dea2..ec4c254358 100644 --- a/.ci/azure-pipelines.yml +++ b/.ci/azure-pipelines.yml @@ -6,7 +6,7 @@ variables: - name: RestoreBuildProjects value: 'Jellyfin.Server/Jellyfin.Server.csproj' - name: DotNetSdkVersion - value: 3.1.100 + value: 5.0.100 pr: autoCancel: true diff --git a/.vscode/launch.json b/.vscode/launch.json index 05f60cfa69..e55ea22485 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll", "args": [], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", @@ -22,7 +22,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", - "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/netcoreapp3.1/jellyfin.dll", + "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net5.0/jellyfin.dll", "args": ["--nowebclient"], "cwd": "${workspaceFolder}/Jellyfin.Server", "console": "internalConsole", diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7b4772730a..a97a4c7416 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -104,6 +104,7 @@ - [sorinyo2004](https://github.com/sorinyo2004) - [sparky8251](https://github.com/sparky8251) - [spookbits](https://github.com/spookbits) + - [ssenart] (https://github.com/ssenart) - [stanionascu](https://github.com/stanionascu) - [stevehayles](https://github.com/stevehayles) - [SuperSandro2000](https://github.com/SuperSandro2000) diff --git a/Dockerfile b/Dockerfile index 69af9b77b5..963027b49c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 FROM node:alpine as web-builder ARG JELLYFIN_WEB_VERSION=master @@ -8,7 +8,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && yarn install \ && mv dist /dist -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster as builder +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim as builder WORKDIR /repo COPY . . ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 diff --git a/Dockerfile.arm b/Dockerfile.arm index efeed25dff..e0eaca0edd 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -2,7 +2,7 @@ ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 FROM node:alpine as web-builder @@ -14,7 +14,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && mv dist /dist -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder WORKDIR /repo COPY . . ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 1f2c2ec36d..db7de935cf 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -2,7 +2,7 @@ ##################################### # Requires binfm_misc registration # https://github.com/multiarch/qemu-user-static#binfmt_misc-register -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 FROM node:alpine as web-builder @@ -14,7 +14,7 @@ RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine- && mv dist /dist -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION} as builder +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} as builder WORKDIR /repo COPY . . ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 diff --git a/DvdLib/DvdLib.csproj b/DvdLib/DvdLib.csproj index 64d041cb05..7bbd9acf82 100644 --- a/DvdLib/DvdLib.csproj +++ b/DvdLib/DvdLib.csproj @@ -10,7 +10,7 @@ - netstandard2.1 + net5.0 false true true diff --git a/DvdLib/Ifo/Dvd.cs b/DvdLib/Ifo/Dvd.cs index 361319625c..b4a11ed5d6 100644 --- a/DvdLib/Ifo/Dvd.cs +++ b/DvdLib/Ifo/Dvd.cs @@ -31,7 +31,7 @@ namespace DvdLib.Ifo continue; } - var nums = ifo.Name.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries); + var nums = ifo.Name.Split('_', StringSplitOptions.RemoveEmptyEntries); if (nums.Length >= 2 && ushort.TryParse(nums[1], out var ifoNumber)) { ReadVTS(ifoNumber, ifo.FullName); diff --git a/Emby.Dlna/Common/Argument.cs b/Emby.Dlna/Common/Argument.cs index f375e6049c..e4e9c55e0d 100644 --- a/Emby.Dlna/Common/Argument.cs +++ b/Emby.Dlna/Common/Argument.cs @@ -1,13 +1,23 @@ -#pragma warning disable CS1591 - namespace Emby.Dlna.Common { + /// + /// DLNA Query parameter type, used when querying DLNA devices via SOAP. + /// public class Argument { - public string Name { get; set; } + /// + /// Gets or sets name of the DLNA argument. + /// + public string Name { get; set; } = string.Empty; - public string Direction { get; set; } + /// + /// Gets or sets the direction of the parameter. + /// + public string Direction { get; set; } = string.Empty; - public string RelatedStateVariable { get; set; } + /// + /// Gets or sets the related DLNA state variable for this argument. + /// + public string RelatedStateVariable { get; set; } = string.Empty; } } diff --git a/Emby.Dlna/Common/DeviceIcon.cs b/Emby.Dlna/Common/DeviceIcon.cs index c3f7fa8aaa..f9fd1dcec6 100644 --- a/Emby.Dlna/Common/DeviceIcon.cs +++ b/Emby.Dlna/Common/DeviceIcon.cs @@ -1,29 +1,41 @@ -#pragma warning disable CS1591 - using System.Globalization; namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class DeviceIcon { - public string Url { get; set; } + /// + /// Gets or sets the Url. + /// + public string Url { get; set; } = string.Empty; - public string MimeType { get; set; } + /// + /// Gets or sets the MimeType. + /// + public string MimeType { get; set; } = string.Empty; + /// + /// Gets or sets the Width. + /// public int Width { get; set; } + /// + /// Gets or sets the Height. + /// public int Height { get; set; } - public string Depth { get; set; } + /// + /// Gets or sets the Depth. + /// + public string Depth { get; set; } = string.Empty; /// public override string ToString() { - return string.Format( - CultureInfo.InvariantCulture, - "{0}x{1}", - Height, - Width); + return string.Format(CultureInfo.InvariantCulture, "{0}x{1}", Height, Width); } } } diff --git a/Emby.Dlna/Common/DeviceService.cs b/Emby.Dlna/Common/DeviceService.cs index 44c0a0412a..c1369558ec 100644 --- a/Emby.Dlna/Common/DeviceService.cs +++ b/Emby.Dlna/Common/DeviceService.cs @@ -1,21 +1,36 @@ -#pragma warning disable CS1591 - namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class DeviceService { - public string ServiceType { get; set; } + /// + /// Gets or sets the Service Type. + /// + public string ServiceType { get; set; } = string.Empty; - public string ServiceId { get; set; } + /// + /// Gets or sets the Service Id. + /// + public string ServiceId { get; set; } = string.Empty; - public string ScpdUrl { get; set; } + /// + /// Gets or sets the Scpd Url. + /// + public string ScpdUrl { get; set; } = string.Empty; - public string ControlUrl { get; set; } + /// + /// Gets or sets the Control Url. + /// + public string ControlUrl { get; set; } = string.Empty; - public string EventSubUrl { get; set; } + /// + /// Gets or sets the EventSubUrl. + /// + public string EventSubUrl { get; set; } = string.Empty; /// - public override string ToString() - => ServiceId; + public override string ToString() => ServiceId; } } diff --git a/Emby.Dlna/Common/ServiceAction.cs b/Emby.Dlna/Common/ServiceAction.cs index d458d7f3f6..02b81a0aa7 100644 --- a/Emby.Dlna/Common/ServiceAction.cs +++ b/Emby.Dlna/Common/ServiceAction.cs @@ -1,24 +1,31 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class ServiceAction { + /// + /// Initializes a new instance of the class. + /// public ServiceAction() { ArgumentList = new List(); } - public string Name { get; set; } + /// + /// Gets or sets the name of the action. + /// + public string Name { get; set; } = string.Empty; + /// + /// Gets the ArgumentList. + /// public List ArgumentList { get; } /// - public override string ToString() - { - return Name; - } + public override string ToString() => Name; } } diff --git a/Emby.Dlna/Common/StateVariable.cs b/Emby.Dlna/Common/StateVariable.cs index 6daf7ab6b2..fd733e0853 100644 --- a/Emby.Dlna/Common/StateVariable.cs +++ b/Emby.Dlna/Common/StateVariable.cs @@ -1,27 +1,34 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; namespace Emby.Dlna.Common { + /// + /// Defines the . + /// public class StateVariable { - public StateVariable() - { - AllowedValues = Array.Empty(); - } + /// + /// Gets or sets the name of the state variable. + /// + public string Name { get; set; } = string.Empty; - public string Name { get; set; } - - public string DataType { get; set; } + /// + /// Gets or sets the data type of the state variable. + /// + public string DataType { get; set; } = string.Empty; + /// + /// Gets or sets a value indicating whether it sends events. + /// public bool SendsEvents { get; set; } - public IReadOnlyList AllowedValues { get; set; } + /// + /// Gets or sets the allowed values range. + /// + public IReadOnlyList AllowedValues { get; set; } = Array.Empty(); /// - public override string ToString() - => Name; + public override string ToString() => Name; } } diff --git a/Emby.Dlna/Configuration/DlnaOptions.cs b/Emby.Dlna/Configuration/DlnaOptions.cs index 6dd9a445a8..e63a858605 100644 --- a/Emby.Dlna/Configuration/DlnaOptions.cs +++ b/Emby.Dlna/Configuration/DlnaOptions.cs @@ -2,8 +2,14 @@ namespace Emby.Dlna.Configuration { + /// + /// The DlnaOptions class contains the user definable parameters for the dlna subsystems. + /// public class DlnaOptions { + /// + /// Initializes a new instance of the class. + /// public DlnaOptions() { EnablePlayTo = true; @@ -11,23 +17,76 @@ namespace Emby.Dlna.Configuration BlastAliveMessages = true; SendOnlyMatchedHost = true; ClientDiscoveryIntervalSeconds = 60; - BlastAliveMessageIntervalSeconds = 1800; + AliveMessageIntervalSeconds = 1800; } + /// + /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna playTo subsystem. + /// public bool EnablePlayTo { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna server subsystem. + /// public bool EnableServer { get; set; } + /// + /// Gets or sets a value indicating whether detailed dlna server logs are sent to the console/log. + /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work. + /// public bool EnableDebugLog { get; set; } - public bool BlastAliveMessages { get; set; } - - public bool SendOnlyMatchedHost { get; set; } + /// + /// Gets or sets a value indicating whether whether detailed playTo debug logs are sent to the console/log. + /// If the setting "Emby.Dlna.PlayTo": "Debug" msut be set in logging.default.json for this property to work. + /// + public bool EnablePlayToTracing { get; set; } + /// + /// Gets or sets the ssdp client discovery interval time (in seconds). + /// This is the time after which the server will send a ssdp search request. + /// public int ClientDiscoveryIntervalSeconds { get; set; } - public int BlastAliveMessageIntervalSeconds { get; set; } + /// + /// Gets or sets the frequency at which ssdp alive notifications are transmitted. + /// + public int AliveMessageIntervalSeconds { get; set; } + /// + /// Gets or sets the frequency at which ssdp alive notifications are transmitted. MIGRATING - TO BE REMOVED ONCE WEB HAS BEEN ALTERED. + /// + public int BlastAliveMessageIntervalSeconds + { + get + { + return AliveMessageIntervalSeconds; + } + + set + { + AliveMessageIntervalSeconds = value; + } + } + + /// + /// Gets or sets the default user account that the dlna server uses. + /// public string DefaultUserId { get; set; } + + /// + /// Gets or sets a value indicating whether playTo device profiles should be created. + /// + public bool AutoCreatePlayToProfiles { get; set; } + + /// + /// Gets or sets a value indicating whether to blast alive messages. + /// + public bool BlastAliveMessages { get; set; } = true; + + /// + /// gets or sets a value indicating whether to send only matched host. + /// + public bool SendOnlyMatchedHost { get; set; } = true; } } diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs index f5a7eca720..916044a0cc 100644 --- a/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs +++ b/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs @@ -9,11 +9,21 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.ConnectionManager { + /// + /// Defines the . + /// public class ConnectionManagerService : BaseService, IConnectionManager { private readonly IDlnaManager _dlna; private readonly IServerConfigurationManager _config; + /// + /// Initializes a new instance of the class. + /// + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance.. + /// The for use with the instance.. public ConnectionManagerService( IDlnaManager dlna, IServerConfigurationManager config, @@ -28,7 +38,7 @@ namespace Emby.Dlna.ConnectionManager /// public string GetServiceXml() { - return new ConnectionManagerXmlBuilder().GetXml(); + return ConnectionManagerXmlBuilder.GetXml(); } /// diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs index c8db5a3674..c484dac542 100644 --- a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs +++ b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs @@ -6,45 +6,57 @@ using Emby.Dlna.Service; namespace Emby.Dlna.ConnectionManager { - public class ConnectionManagerXmlBuilder + /// + /// Defines the . + /// + public static class ConnectionManagerXmlBuilder { - public string GetXml() + /// + /// Gets the ConnectionManager:1 service template. + /// See http://upnp.org/specs/av/UPnP-av-ConnectionManager-v1-Service.pdf. + /// + /// An XML description of this service. + public static string GetXml() { - return new ServiceXmlBuilder().GetXml(new ServiceActionListBuilder().GetActions(), GetStateVariables()); + return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables()); } + /// + /// Get the list of state variables for this invocation. + /// + /// The . private static IEnumerable GetStateVariables() { - var list = new List(); - - list.Add(new StateVariable + var list = new List { - Name = "SourceProtocolInfo", - DataType = "string", - SendsEvents = true - }); + new StateVariable + { + Name = "SourceProtocolInfo", + DataType = "string", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "SinkProtocolInfo", - DataType = "string", - SendsEvents = true - }); + new StateVariable + { + Name = "SinkProtocolInfo", + DataType = "string", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "CurrentConnectionIDs", - DataType = "string", - SendsEvents = true - }); + new StateVariable + { + Name = "CurrentConnectionIDs", + DataType = "string", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ConnectionStatus", - DataType = "string", - SendsEvents = false, + new StateVariable + { + Name = "A_ARG_TYPE_ConnectionStatus", + DataType = "string", + SendsEvents = false, - AllowedValues = new[] + AllowedValues = new[] { "OK", "ContentFormatMismatch", @@ -52,55 +64,56 @@ namespace Emby.Dlna.ConnectionManager "UnreliableChannel", "Unknown" } - }); + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ConnectionManager", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ConnectionManager", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Direction", - DataType = "string", - SendsEvents = false, + new StateVariable + { + Name = "A_ARG_TYPE_Direction", + DataType = "string", + SendsEvents = false, - AllowedValues = new[] + AllowedValues = new[] { "Output", "Input" } - }); + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ProtocolInfo", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ProtocolInfo", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ConnectionID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ConnectionID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_AVTransportID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_AVTransportID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_RcsID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_RcsID", + DataType = "ui4", + SendsEvents = false + } + }; return list; } diff --git a/Emby.Dlna/ConnectionManager/ControlHandler.cs b/Emby.Dlna/ConnectionManager/ControlHandler.cs index d4cc653942..2f8d197a7a 100644 --- a/Emby.Dlna/ConnectionManager/ControlHandler.cs +++ b/Emby.Dlna/ConnectionManager/ControlHandler.cs @@ -11,10 +11,19 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.ConnectionManager { + /// + /// Defines the . + /// public class ControlHandler : BaseControlHandler { private readonly DeviceProfile _profile; + /// + /// Initializes a new instance of the class. + /// + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. public ControlHandler(IServerConfigurationManager config, ILogger logger, DeviceProfile profile) : base(config, logger) { @@ -33,6 +42,10 @@ namespace Emby.Dlna.ConnectionManager throw new ResourceNotFoundException("Unexpected control request name: " + methodName); } + /// + /// Builds the response to the GetProtocolInfo request. + /// + /// The . private void HandleGetProtocolInfo(XmlWriter xmlWriter) { xmlWriter.WriteElementString("Source", _profile.ProtocolInfo); diff --git a/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs b/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs index b853e7eab6..542c7bfb4b 100644 --- a/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs +++ b/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs @@ -5,9 +5,16 @@ using Emby.Dlna.Common; namespace Emby.Dlna.ConnectionManager { - public class ServiceActionListBuilder + /// + /// Defines the . + /// + public static class ServiceActionListBuilder { - public IEnumerable GetActions() + /// + /// Returns an enumerable of the ConnectionManagar:1 DLNA actions. + /// + /// An . + public static IEnumerable GetActions() { var list = new List { @@ -21,6 +28,10 @@ namespace Emby.Dlna.ConnectionManager return list; } + /// + /// Returns the action details for "PrepareForConnection". + /// + /// The . private static ServiceAction PrepareForConnection() { var action = new ServiceAction @@ -80,6 +91,10 @@ namespace Emby.Dlna.ConnectionManager return action; } + /// + /// Returns the action details for "GetCurrentConnectionInfo". + /// + /// The . private static ServiceAction GetCurrentConnectionInfo() { var action = new ServiceAction @@ -146,7 +161,11 @@ namespace Emby.Dlna.ConnectionManager return action; } - private ServiceAction GetProtocolInfo() + /// + /// Returns the action details for "GetProtocolInfo". + /// + /// The . + private static ServiceAction GetProtocolInfo() { var action = new ServiceAction { @@ -170,7 +189,11 @@ namespace Emby.Dlna.ConnectionManager return action; } - private ServiceAction GetCurrentConnectionIDs() + /// + /// Returns the action details for "GetCurrentConnectionIDs". + /// + /// The . + private static ServiceAction GetCurrentConnectionIDs() { var action = new ServiceAction { @@ -187,7 +210,11 @@ namespace Emby.Dlna.ConnectionManager return action; } - private ServiceAction ConnectionComplete() + /// + /// Returns the action details for "ConnectionComplete". + /// + /// The . + private static ServiceAction ConnectionComplete() { var action = new ServiceAction { diff --git a/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs index 5760f260cf..2f3107450c 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryService.cs @@ -19,6 +19,9 @@ using Microsoft.Extensions.Logging; namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the . + /// public class ContentDirectoryService : BaseService, IContentDirectory { private readonly ILibraryManager _libraryManager; @@ -33,6 +36,22 @@ namespace Emby.Dlna.ContentDirectory private readonly IMediaEncoder _mediaEncoder; private readonly ITVSeriesManager _tvSeriesManager; + /// + /// Initializes a new instance of the class. + /// + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. + /// The to use in the instance. public ContentDirectoryService( IDlnaManager dlna, IUserDataManager userDataManager, @@ -62,7 +81,10 @@ namespace Emby.Dlna.ContentDirectory _tvSeriesManager = tvSeriesManager; } - private int SystemUpdateId + /// + /// Gets the system id. (A unique id which changes on when our definition changes.) + /// + private static int SystemUpdateId { get { @@ -75,14 +97,18 @@ namespace Emby.Dlna.ContentDirectory /// public string GetServiceXml() { - return new ContentDirectoryXmlBuilder().GetXml(); + return ContentDirectoryXmlBuilder.GetXml(); } /// public Task ProcessControlRequestAsync(ControlRequest request) { - var profile = _dlna.GetProfile(request.Headers) ?? - _dlna.GetDefaultProfile(); + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + var profile = _dlna.GetProfile(request.Headers) ?? _dlna.GetDefaultProfile(); var serverAddress = request.RequestedUrl.Substring(0, request.RequestedUrl.IndexOf("/dlna", StringComparison.OrdinalIgnoreCase)); @@ -107,6 +133,11 @@ namespace Emby.Dlna.ContentDirectory .ProcessControlRequestAsync(request); } + /// + /// Get the user stored in the device profile. + /// + /// The . + /// The . private User GetUser(DeviceProfile profile) { if (!string.IsNullOrEmpty(profile.UserId)) diff --git a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs index 743dcc5161..3edaabb70e 100644 --- a/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs +++ b/Emby.Dlna/ContentDirectory/ContentDirectoryXmlBuilder.cs @@ -6,143 +6,154 @@ using Emby.Dlna.Service; namespace Emby.Dlna.ContentDirectory { - public class ContentDirectoryXmlBuilder + /// + /// Defines the . + /// + public static class ContentDirectoryXmlBuilder { - public string GetXml() + /// + /// Gets the ContentDirectory:1 service template. + /// See http://upnp.org/specs/av/UPnP-av-ContentDirectory-v1-Service.pdf. + /// + /// An XML description of this service. + public static string GetXml() { - return new ServiceXmlBuilder().GetXml( - new ServiceActionListBuilder().GetActions(), - GetStateVariables()); + return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables()); } + /// + /// Get the list of state variables for this invocation. + /// + /// The . private static IEnumerable GetStateVariables() { - var list = new List(); - - list.Add(new StateVariable + var list = new List { - Name = "A_ARG_TYPE_Filter", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Filter", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_SortCriteria", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_SortCriteria", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Index", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Index", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Count", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Count", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_UpdateID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_UpdateID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "SearchCapabilities", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "SearchCapabilities", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "SortCapabilities", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "SortCapabilities", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "SystemUpdateID", - DataType = "ui4", - SendsEvents = true - }); + new StateVariable + { + Name = "SystemUpdateID", + DataType = "ui4", + SendsEvents = true + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_SearchCriteria", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_SearchCriteria", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Result", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Result", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_ObjectID", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_ObjectID", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_BrowseFlag", - DataType = "string", - SendsEvents = false, + new StateVariable + { + Name = "A_ARG_TYPE_BrowseFlag", + DataType = "string", + SendsEvents = false, - AllowedValues = new[] + AllowedValues = new[] { "BrowseMetadata", "BrowseDirectChildren" } - }); + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_BrowseLetter", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_BrowseLetter", + DataType = "string", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_CategoryType", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_CategoryType", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_RID", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_RID", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_PosSec", - DataType = "ui4", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_PosSec", + DataType = "ui4", + SendsEvents = false + }, - list.Add(new StateVariable - { - Name = "A_ARG_TYPE_Featurelist", - DataType = "string", - SendsEvents = false - }); + new StateVariable + { + Name = "A_ARG_TYPE_Featurelist", + DataType = "string", + SendsEvents = false + } + }; return list; } diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs index 5f25b8cdc0..b93651746b 100644 --- a/Emby.Dlna/ContentDirectory/ControlHandler.cs +++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs @@ -1,6 +1,5 @@ -#pragma warning disable CS1591 - using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -8,6 +7,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Xml; +using Emby.Dlna.Configuration; using Emby.Dlna.Didl; using Emby.Dlna.Service; using Jellyfin.Data.Entities; @@ -38,6 +38,9 @@ using Series = MediaBrowser.Controller.Entities.TV.Series; namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the . + /// public class ControlHandler : BaseControlHandler { private const string NsDc = "http://purl.org/dc/elements/1.1/"; @@ -58,6 +61,24 @@ namespace Emby.Dlna.ContentDirectory private readonly DeviceProfile _profile; + /// + /// Initializes a new instance of the class. + /// + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The server address to use in this instance> for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The system id for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. + /// The for use with the instance. public ControlHandler( ILogger logger, ILibraryManager libraryManager, @@ -102,6 +123,16 @@ namespace Emby.Dlna.ContentDirectory /// protected override void WriteResult(string methodName, IDictionary methodParams, XmlWriter xmlWriter) { + if (xmlWriter == null) + { + throw new ArgumentNullException(nameof(xmlWriter)); + } + + if (methodParams == null) + { + throw new ArgumentNullException(nameof(methodParams)); + } + const string DeviceId = "test"; if (string.Equals(methodName, "GetSearchCapabilities", StringComparison.OrdinalIgnoreCase)) @@ -167,6 +198,10 @@ namespace Emby.Dlna.ContentDirectory throw new ResourceNotFoundException("Unexpected control request name: " + methodName); } + /// + /// Adds a "XSetBookmark" element to the xml document. + /// + /// The . private void HandleXSetBookmark(IDictionary sparams) { var id = sparams["ObjectID"]; @@ -189,41 +224,69 @@ namespace Emby.Dlna.ContentDirectory CancellationToken.None); } - private void HandleGetSearchCapabilities(XmlWriter xmlWriter) + /// + /// Adds the "SearchCaps" element to the xml document. + /// + /// The . + private static void HandleGetSearchCapabilities(XmlWriter xmlWriter) { xmlWriter.WriteElementString( "SearchCaps", "res@resolution,res@size,res@duration,dc:title,dc:creator,upnp:actor,upnp:artist,upnp:genre,upnp:album,dc:date,upnp:class,@id,@refID,@protocolInfo,upnp:author,dc:description,pv:avKeywords"); } - private void HandleGetSortCapabilities(XmlWriter xmlWriter) + /// + /// Adds the "SortCaps" element to the xml document. + /// + /// The . + private static void HandleGetSortCapabilities(XmlWriter xmlWriter) { xmlWriter.WriteElementString( "SortCaps", "res@duration,res@size,res@bitrate,dc:date,dc:title,dc:size,upnp:album,upnp:artist,upnp:albumArtist,upnp:episodeNumber,upnp:genre,upnp:originalTrackNumber,upnp:rating"); } - private void HandleGetSortExtensionCapabilities(XmlWriter xmlWriter) + /// + /// Adds the "SortExtensionCaps" element to the xml document. + /// + /// The . + private static void HandleGetSortExtensionCapabilities(XmlWriter xmlWriter) { xmlWriter.WriteElementString( "SortExtensionCaps", "res@duration,res@size,res@bitrate,dc:date,dc:title,dc:size,upnp:album,upnp:artist,upnp:albumArtist,upnp:episodeNumber,upnp:genre,upnp:originalTrackNumber,upnp:rating"); } + /// + /// Adds the "Id" element to the xml document. + /// + /// The . private void HandleGetSystemUpdateID(XmlWriter xmlWriter) { xmlWriter.WriteElementString("Id", _systemUpdateId.ToString(CultureInfo.InvariantCulture)); } - private void HandleGetFeatureList(XmlWriter xmlWriter) + /// + /// Adds the "FeatureList" element to the xml document. + /// + /// The . + private static void HandleGetFeatureList(XmlWriter xmlWriter) { xmlWriter.WriteElementString("FeatureList", WriteFeatureListXml()); } - private void HandleXGetFeatureList(XmlWriter xmlWriter) + /// + /// Adds the "FeatureList" element to the xml document. + /// + /// The . + private static void HandleXGetFeatureList(XmlWriter xmlWriter) => HandleGetFeatureList(xmlWriter); - private string WriteFeatureListXml() + /// + /// Builds a static feature list. + /// + /// The xml feature list. + private static string WriteFeatureListXml() { // TODO: clean this up var builder = new StringBuilder(); @@ -242,9 +305,16 @@ namespace Emby.Dlna.ContentDirectory return builder.ToString(); } - public string GetValueOrDefault(IDictionary sparams, string key, string defaultValue) + /// + /// Returns the value in the key of the dictionary, or defaultValue if it doesn't exist. + /// + /// The . + /// The key. + /// The defaultValue. + /// The . + public static string GetValueOrDefault(IDictionary sparams, string key, string defaultValue) { - if (sparams.TryGetValue(key, out string val)) + if (sparams != null && sparams.TryGetValue(key, out string val)) { return val; } @@ -252,6 +322,12 @@ namespace Emby.Dlna.ContentDirectory return defaultValue; } + /// + /// Builds the "Browse" xml response. + /// + /// The . + /// The . + /// The device Id to use. private void HandleBrowse(XmlWriter xmlWriter, IDictionary sparams, string deviceId) { var id = sparams["ObjectID"]; @@ -313,7 +389,6 @@ namespace Emby.Dlna.ContentDirectory } else { - var dlnaOptions = _config.GetDlnaConfiguration(); _didlBuilder.WriteItemElement(writer, item, _user, null, null, deviceId, filter); } @@ -326,7 +401,6 @@ namespace Emby.Dlna.ContentDirectory provided = childrenResult.Items.Count; - var dlnaOptions = _config.GetDlnaConfiguration(); foreach (var i in childrenResult.Items) { var childItem = i.Item; @@ -357,12 +431,24 @@ namespace Emby.Dlna.ContentDirectory xmlWriter.WriteElementString("UpdateID", _systemUpdateId.ToString(CultureInfo.InvariantCulture)); } + /// + /// Builds the response to the "X_BrowseByLetter request. + /// + /// The . + /// The . + /// The device id. private void HandleXBrowseByLetter(XmlWriter xmlWriter, IDictionary sparams, string deviceId) { // TODO: Implement this method HandleSearch(xmlWriter, sparams, deviceId); } + /// + /// Builds a response to the "Search" request. + /// + /// The xmlWriter. + /// The sparams. + /// The deviceId. private void HandleSearch(XmlWriter xmlWriter, IDictionary sparams, string deviceId) { var searchCriteria = new SearchCriteria(GetValueOrDefault(sparams, "SearchCriteria", string.Empty)); @@ -442,7 +528,17 @@ namespace Emby.Dlna.ContentDirectory xmlWriter.WriteElementString("UpdateID", _systemUpdateId.ToString(CultureInfo.InvariantCulture)); } - private QueryResult GetChildrenSorted(BaseItem item, User user, SearchCriteria search, SortCriteria sort, int? startIndex, int? limit) + /// + /// Returns the child items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . + private static QueryResult GetChildrenSorted(BaseItem item, User user, SearchCriteria search, SortCriteria sort, int? startIndex, int? limit) { var folder = (Folder)item; @@ -494,11 +590,25 @@ namespace Emby.Dlna.ContentDirectory }); } - private DtoOptions GetDtoOptions() + /// + /// Returns a new DtoOptions object. + /// + /// The . + private static DtoOptions GetDtoOptions() { return new DtoOptions(true); } + /// + /// Returns the User items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetUserItems(BaseItem item, StubType? stubType, User user, SortCriteria sort, int? startIndex, int? limit) { if (item is MusicGenre) @@ -568,6 +678,14 @@ namespace Emby.Dlna.ContentDirectory return ToResult(queryResult); } + /// + /// Returns the Live Tv Channels meeting the criteria. + /// + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetLiveTvChannels(User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -584,6 +702,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMusicFolders(BaseItem item, User user, StubType? stubType, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -643,57 +771,58 @@ namespace Emby.Dlna.ContentDirectory return GetMusicGenres(item, user, query); } - var list = new List(); - - list.Add(new ServerItem(item) + var list = new List { - StubType = StubType.Latest - }); + new ServerItem(item) + { + StubType = StubType.Latest + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Playlists - }); + new ServerItem(item) + { + StubType = StubType.Playlists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Albums - }); + new ServerItem(item) + { + StubType = StubType.Albums + }, - list.Add(new ServerItem(item) - { - StubType = StubType.AlbumArtists - }); + new ServerItem(item) + { + StubType = StubType.AlbumArtists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Artists - }); + new ServerItem(item) + { + StubType = StubType.Artists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Songs - }); + new ServerItem(item) + { + StubType = StubType.Songs + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Genres - }); + new ServerItem(item) + { + StubType = StubType.Genres + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteArtists - }); + new ServerItem(item) + { + StubType = StubType.FavoriteArtists + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteAlbums - }); + new ServerItem(item) + { + StubType = StubType.FavoriteAlbums + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteSongs - }); + new ServerItem(item) + { + StubType = StubType.FavoriteSongs + } + }; return new QueryResult { @@ -702,6 +831,16 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the movie folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMovieFolders(BaseItem item, User user, StubType? stubType, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -776,6 +915,13 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the folders meeting the criteria. + /// + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetFolders(User user, int? startIndex, int? limit) { var folders = _libraryManager.GetUserRootFolder().GetChildren(user, true) @@ -796,6 +942,16 @@ namespace Emby.Dlna.ContentDirectory limit); } + /// + /// Returns the TV folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetTvFolders(BaseItem item, User user, StubType? stubType, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -840,42 +996,43 @@ namespace Emby.Dlna.ContentDirectory return GetGenres(item, user, query); } - var list = new List(); - - list.Add(new ServerItem(item) + var list = new List { - StubType = StubType.ContinueWatching - }); + new ServerItem(item) + { + StubType = StubType.ContinueWatching + }, - list.Add(new ServerItem(item) - { - StubType = StubType.NextUp - }); + new ServerItem(item) + { + StubType = StubType.NextUp + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Latest - }); + new ServerItem(item) + { + StubType = StubType.Latest + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Series - }); + new ServerItem(item) + { + StubType = StubType.Series + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteSeries - }); + new ServerItem(item) + { + StubType = StubType.FavoriteSeries + }, - list.Add(new ServerItem(item) - { - StubType = StubType.FavoriteEpisodes - }); + new ServerItem(item) + { + StubType = StubType.FavoriteEpisodes + }, - list.Add(new ServerItem(item) - { - StubType = StubType.Genres - }); + new ServerItem(item) + { + StubType = StubType.Genres + } + }; return new QueryResult { @@ -884,6 +1041,13 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the Movies that are part watched that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieContinueWatching(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -904,6 +1068,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the series meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetSeries(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -917,6 +1088,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the Movie folders meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieMovies(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -930,6 +1108,12 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the Movie collections meeting the criteria. + /// + /// The see cref="User"/>. + /// The see cref="InternalItemsQuery"/>. + /// The . private QueryResult GetMovieCollections(User user, InternalItemsQuery query) { query.Recursive = true; @@ -943,6 +1127,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the Music albums meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicAlbums(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -956,6 +1147,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the Music songs meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicSongs(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -969,6 +1167,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the songs tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteSongs(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -982,6 +1187,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the series tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteSeries(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -995,6 +1207,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the episodes tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteEpisodes(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -1008,6 +1227,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the movies tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieFavorites(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -1021,6 +1247,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// /// Returns the albums tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteAlbums(BaseItem parent, User user, InternalItemsQuery query) { query.Recursive = true; @@ -1034,6 +1267,14 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the genres meeting the criteria. + /// The GetGenres. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetGenres(BaseItem parent, User user, InternalItemsQuery query) { var genresResult = _libraryManager.GetGenres(new InternalItemsQuery(user) @@ -1052,6 +1293,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music genres meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicGenres(BaseItem parent, User user, InternalItemsQuery query) { var genresResult = _libraryManager.GetMusicGenres(new InternalItemsQuery(user) @@ -1070,6 +1318,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music albums by artist that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicAlbumArtists(BaseItem parent, User user, InternalItemsQuery query) { var artists = _libraryManager.GetAlbumArtists(new InternalItemsQuery(user) @@ -1088,6 +1343,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music artists meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicArtists(BaseItem parent, User user, InternalItemsQuery query) { var artists = _libraryManager.GetArtists(new InternalItemsQuery(user) @@ -1106,6 +1368,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the artists tagged as favourite that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetFavoriteArtists(BaseItem parent, User user, InternalItemsQuery query) { var artists = _libraryManager.GetArtists(new InternalItemsQuery(user) @@ -1125,6 +1394,12 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music playlists meeting the criteria. + /// + /// The user. + /// The query. + /// The . private QueryResult GetMusicPlaylists(User user, InternalItemsQuery query) { query.Parent = null; @@ -1137,6 +1412,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the latest music meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMusicLatest(BaseItem parent, User user, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1155,6 +1437,12 @@ namespace Emby.Dlna.ContentDirectory return ToResult(items); } + /// + /// Returns the next up item meeting the criteria. + /// + /// The . + /// The . + /// The . private QueryResult GetNextUp(BaseItem parent, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1172,6 +1460,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the latest tv meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetTvLatest(BaseItem parent, User user, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1190,6 +1485,13 @@ namespace Emby.Dlna.ContentDirectory return ToResult(items); } + /// + /// Returns the latest movies meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . private QueryResult GetMovieLatest(BaseItem parent, User user, InternalItemsQuery query) { query.OrderBy = Array.Empty<(string, SortOrder)>(); @@ -1208,6 +1510,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(items); } + /// + /// Returns music artist items that meet the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMusicArtistItems(BaseItem item, Guid parentId, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -1228,6 +1540,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the genre items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetGenreItems(BaseItem item, Guid parentId, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -1252,6 +1574,16 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } + /// + /// Returns the music genre items meeting the criteria. + /// + /// The . + /// The . + /// The . + /// The . + /// The start index. + /// The maximum number to return. + /// The . private QueryResult GetMusicGenreItems(BaseItem item, Guid parentId, User user, SortCriteria sort, int? startIndex, int? limit) { var query = new InternalItemsQuery(user) @@ -1272,7 +1604,12 @@ namespace Emby.Dlna.ContentDirectory return ToResult(result); } - private QueryResult ToResult(BaseItem[] result) + /// + /// Converts a array into a . + /// + /// An array of . + /// A . + private static QueryResult ToResult(BaseItem[] result) { var serverItems = result .Select(i => new ServerItem(i)) @@ -1285,7 +1622,12 @@ namespace Emby.Dlna.ContentDirectory }; } - private QueryResult ToResult(QueryResult result) + /// + /// Converts a to a . + /// + /// A . + /// The . + private static QueryResult ToResult(QueryResult result) { var serverItems = result .Items @@ -1299,7 +1641,13 @@ namespace Emby.Dlna.ContentDirectory }; } - private void SetSorting(InternalItemsQuery query, SortCriteria sort, bool isPreSorted) + /// + /// Sets the sorting method on a query. + /// + /// The . + /// The . + /// True if pre-sorted. + private static void SetSorting(InternalItemsQuery query, SortCriteria sort, bool isPreSorted) { if (isPreSorted) { @@ -1311,13 +1659,25 @@ namespace Emby.Dlna.ContentDirectory } } - private QueryResult ApplyPaging(QueryResult result, int? startIndex, int? limit) + /// + /// Apply paging to a query. + /// + /// The . + /// The start index. + /// The maximum number to return. + /// A . + private static QueryResult ApplyPaging(QueryResult result, int? startIndex, int? limit) { result.Items = result.Items.Skip(startIndex ?? 0).Take(limit ?? int.MaxValue).ToArray(); return result; } + /// + /// Retrieves the ServerItem id. + /// + /// The id. + /// The . private ServerItem GetItemFromObjectId(string id) { return DidlBuilder.IsIdRoot(id) @@ -1326,6 +1686,11 @@ namespace Emby.Dlna.ContentDirectory : ParseItemId(id); } + /// + /// Parses the item id into a . + /// + /// The . + /// The corresponding . private ServerItem ParseItemId(string id) { StubType? stubType = null; diff --git a/Emby.Dlna/ContentDirectory/ServerItem.cs b/Emby.Dlna/ContentDirectory/ServerItem.cs index e406054149..34244000c1 100644 --- a/Emby.Dlna/ContentDirectory/ServerItem.cs +++ b/Emby.Dlna/ContentDirectory/ServerItem.cs @@ -4,8 +4,15 @@ using MediaBrowser.Controller.Entities; namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the . + /// internal class ServerItem { + /// + /// Initializes a new instance of the class. + /// + /// The . public ServerItem(BaseItem item) { Item = item; @@ -16,8 +23,14 @@ namespace Emby.Dlna.ContentDirectory } } + /// + /// Gets or sets the underlying base item. + /// public BaseItem Item { get; set; } + /// + /// Gets or sets the DLNA item type. + /// public StubType? StubType { get; set; } } } diff --git a/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs b/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs index 921b14e394..7e3db46519 100644 --- a/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs +++ b/Emby.Dlna/ContentDirectory/ServiceActionListBuilder.cs @@ -1,13 +1,18 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using Emby.Dlna.Common; namespace Emby.Dlna.ContentDirectory { - public class ServiceActionListBuilder + /// + /// Defines the . + /// + public static class ServiceActionListBuilder { - public IEnumerable GetActions() + /// + /// Returns a list of services that this instance provides. + /// + /// An . + public static IEnumerable GetActions() { return new[] { @@ -22,6 +27,10 @@ namespace Emby.Dlna.ContentDirectory }; } + /// + /// Returns the action details for "GetSystemUpdateID". + /// + /// The . private static ServiceAction GetGetSystemUpdateIDAction() { var action = new ServiceAction @@ -39,6 +48,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "GetSearchCapabilities". + /// + /// The . private static ServiceAction GetSearchCapabilitiesAction() { var action = new ServiceAction @@ -56,6 +69,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "GetSortCapabilities". + /// + /// The . private static ServiceAction GetSortCapabilitiesAction() { var action = new ServiceAction @@ -73,6 +90,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "X_GetFeatureList". + /// + /// The . private static ServiceAction GetX_GetFeatureListAction() { var action = new ServiceAction @@ -90,6 +111,10 @@ namespace Emby.Dlna.ContentDirectory return action; } + /// + /// Returns the action details for "Search". + /// + /// The . private static ServiceAction GetSearchAction() { var action = new ServiceAction @@ -170,7 +195,11 @@ namespace Emby.Dlna.ContentDirectory return action; } - private ServiceAction GetBrowseAction() + /// + /// Returns the action details for "Browse". + /// + /// The . + private static ServiceAction GetBrowseAction() { var action = new ServiceAction { @@ -250,7 +279,11 @@ namespace Emby.Dlna.ContentDirectory return action; } - private ServiceAction GetBrowseByLetterAction() + /// + /// Returns the action details for "X_BrowseByLetter". + /// + /// The . + private static ServiceAction GetBrowseByLetterAction() { var action = new ServiceAction { @@ -337,7 +370,11 @@ namespace Emby.Dlna.ContentDirectory return action; } - private ServiceAction GetXSetBookmarkAction() + /// + /// Returns the action details for "X_SetBookmark". + /// + /// The . + private static ServiceAction GetXSetBookmarkAction() { var action = new ServiceAction { diff --git a/Emby.Dlna/ContentDirectory/StubType.cs b/Emby.Dlna/ContentDirectory/StubType.cs index eee405d3e7..982ae5d68e 100644 --- a/Emby.Dlna/ContentDirectory/StubType.cs +++ b/Emby.Dlna/ContentDirectory/StubType.cs @@ -3,6 +3,9 @@ namespace Emby.Dlna.ContentDirectory { + /// + /// Defines the DLNA item types. + /// public enum StubType { Folder = 0, diff --git a/Emby.Dlna/Didl/Filter.cs b/Emby.Dlna/Didl/Filter.cs index b58fdff2c9..d703f043eb 100644 --- a/Emby.Dlna/Didl/Filter.cs +++ b/Emby.Dlna/Didl/Filter.cs @@ -18,7 +18,7 @@ namespace Emby.Dlna.Didl { _all = string.Equals(filter, "*", StringComparison.OrdinalIgnoreCase); - _fields = (filter ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + _fields = (filter ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries); } public bool Contains(string field) diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index 069400833e..fedd20b68a 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -484,10 +484,10 @@ namespace Emby.Dlna /// /// Recreates the object using serialization, to ensure it's not a subclass. - /// If it's a subclass it may not serlialize properly to xml (different root element tag name). + /// If it's a subclass it may not serialize properly to xml (different root element tag name). /// /// The device profile. - /// The reserialized device profile. + /// The re-serialized device profile. private DeviceProfile ReserializeProfile(DeviceProfile profile) { if (profile.GetType() == typeof(DeviceProfile)) diff --git a/Emby.Dlna/Emby.Dlna.csproj b/Emby.Dlna/Emby.Dlna.csproj index 6ed49944c0..bd30cc1e11 100644 --- a/Emby.Dlna/Emby.Dlna.csproj +++ b/Emby.Dlna/Emby.Dlna.csproj @@ -17,7 +17,7 @@ - netstandard2.1 + net5.0 false true true diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index 770d56c30f..b6e45c50ea 100644 --- a/Emby.Dlna/Eventing/DlnaEventManager.cs +++ b/Emby.Dlna/Eventing/DlnaEventManager.cs @@ -83,7 +83,7 @@ namespace Emby.Dlna.Eventing if (!string.IsNullOrEmpty(header)) { // Starts with SECOND- - header = header.Split('-').Last(); + header = header.Split('-')[^1]; if (int.TryParse(header, NumberStyles.Integer, _usCulture, out var val)) { diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs index c97acdb026..f8ff03076a 100644 --- a/Emby.Dlna/PlayTo/Device.cs +++ b/Emby.Dlna/PlayTo/Device.cs @@ -480,7 +480,7 @@ namespace Emby.Dlna.PlayTo return; } - // If we're not playing anything make sure we don't get data more often than neccessry to keep the Session alive + // If we're not playing anything make sure we don't get data more often than necessary to keep the Session alive if (transportState.Value == TransportState.Stopped) { RestartTimerInactive(); @@ -775,7 +775,7 @@ namespace Emby.Dlna.PlayTo if (track == null) { - // If track is null, some vendors do this, use GetMediaInfo instead + // If track is null, some vendors do this, use GetMediaInfo instead. return (true, null); } @@ -812,7 +812,7 @@ namespace Emby.Dlna.PlayTo private XElement ParseResponse(string xml) { - // Handle different variations sent back by devices + // Handle different variations sent back by devices. try { return XElement.Parse(xml); @@ -821,7 +821,7 @@ namespace Emby.Dlna.PlayTo { } - // first try to add a root node with a dlna namesapce + // first try to add a root node with a dlna namespace. try { return XElement.Parse("" + xml + "") diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index c07c8aefa6..3907b2a396 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -945,7 +945,10 @@ namespace Emby.Dlna.PlayTo request.DeviceId = values.GetValueOrDefault("DeviceId"); request.MediaSourceId = values.GetValueOrDefault("MediaSourceId"); request.LiveStreamId = values.GetValueOrDefault("LiveStreamId"); - request.IsDirectStream = string.Equals("true", values.GetValueOrDefault("Static"), StringComparison.OrdinalIgnoreCase); + + // Be careful, IsDirectStream==true by default (Static != false or not in query). + // See initialization of StreamingRequestDto in AudioController.GetAudioStream() method : Static = @static ?? true. + request.IsDirectStream = !string.Equals("false", values.GetValueOrDefault("Static"), StringComparison.OrdinalIgnoreCase); request.AudioStreamIndex = GetIntValue(values, "AudioStreamIndex"); request.SubtitleStreamIndex = GetIntValue(values, "SubtitleStreamIndex"); diff --git a/Emby.Dlna/PlayTo/SsdpHttpClient.cs b/Emby.Dlna/PlayTo/SsdpHttpClient.cs index c8c36fc972..f4d7937907 100644 --- a/Emby.Dlna/PlayTo/SsdpHttpClient.cs +++ b/Emby.Dlna/PlayTo/SsdpHttpClient.cs @@ -45,7 +45,7 @@ namespace Emby.Dlna.PlayTo header, cancellationToken) .ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var reader = new StreamReader(stream, Encoding.UTF8); return XDocument.Parse( await reader.ReadToEndAsync().ConfigureAwait(false), @@ -94,7 +94,7 @@ namespace Emby.Dlna.PlayTo options.Headers.UserAgent.ParseAdd(USERAGENT); options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var reader = new StreamReader(stream, Encoding.UTF8); return XDocument.Parse( await reader.ReadToEndAsync().ConfigureAwait(false), diff --git a/Emby.Dlna/Service/BaseControlHandler.cs b/Emby.Dlna/Service/BaseControlHandler.cs index 4b0bbba969..198852ec17 100644 --- a/Emby.Dlna/Service/BaseControlHandler.cs +++ b/Emby.Dlna/Service/BaseControlHandler.cs @@ -169,6 +169,7 @@ namespace Emby.Dlna.Service var result = new ControlRequestInfo(localName, namespaceURI); using var subReader = reader.ReadSubtree(); await ParseFirstBodyChildAsync(subReader, result.Headers).ConfigureAwait(false); + return result; } else { diff --git a/Emby.Drawing/Emby.Drawing.csproj b/Emby.Drawing/Emby.Drawing.csproj index 092f8580a6..7d479a5c65 100644 --- a/Emby.Drawing/Emby.Drawing.csproj +++ b/Emby.Drawing/Emby.Drawing.csproj @@ -6,7 +6,7 @@ - netstandard2.1 + net5.0 false true true diff --git a/Emby.Naming/Audio/AlbumParser.cs b/Emby.Naming/Audio/AlbumParser.cs index b63be3a647..bbfdccc902 100644 --- a/Emby.Naming/Audio/AlbumParser.cs +++ b/Emby.Naming/Audio/AlbumParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; @@ -9,15 +6,27 @@ using Emby.Naming.Common; namespace Emby.Naming.Audio { + /// + /// Helper class to determine if Album is multipart. + /// public class AlbumParser { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// Naming options containing AlbumStackingPrefixes. public AlbumParser(NamingOptions options) { _options = options; } + /// + /// Function that determines if album is multipart. + /// + /// Path to file. + /// True if album is multipart. public bool IsMultiPart(string path) { var filename = Path.GetFileName(path); diff --git a/Emby.Naming/Audio/AudioFileParser.cs b/Emby.Naming/Audio/AudioFileParser.cs index 6b2f4be93e..8b47dd12e4 100644 --- a/Emby.Naming/Audio/AudioFileParser.cs +++ b/Emby.Naming/Audio/AudioFileParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -8,8 +5,17 @@ using Emby.Naming.Common; namespace Emby.Naming.Audio { + /// + /// Static helper class to determine if file at path is audio file. + /// public static class AudioFileParser { + /// + /// Static helper method to determine if file at path is audio file. + /// + /// Path to file. + /// containing AudioFileExtensions. + /// True if file at path is audio file. public static bool IsAudioFile(string path, NamingOptions options) { var extension = Path.GetExtension(path); diff --git a/Emby.Naming/AudioBook/AudioBookFileInfo.cs b/Emby.Naming/AudioBook/AudioBookFileInfo.cs index c4863b50ab..862e396677 100644 --- a/Emby.Naming/AudioBook/AudioBookFileInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookFileInfo.cs @@ -7,6 +7,21 @@ namespace Emby.Naming.AudioBook /// public class AudioBookFileInfo : IComparable { + /// + /// Initializes a new instance of the class. + /// + /// Path to audiobook file. + /// File type. + /// Number of part this file represents. + /// Number of chapter this file represents. + public AudioBookFileInfo(string path, string container, int? partNumber = default, int? chapterNumber = default) + { + Path = path; + Container = container; + PartNumber = partNumber; + ChapterNumber = chapterNumber; + } + /// /// Gets or sets the path. /// @@ -31,14 +46,8 @@ namespace Emby.Naming.AudioBook /// The chapter number. public int? ChapterNumber { get; set; } - /// - /// Gets or sets a value indicating whether this instance is a directory. - /// - /// The type. - public bool IsDirectory { get; set; } - /// - public int CompareTo(AudioBookFileInfo other) + public int CompareTo(AudioBookFileInfo? other) { if (ReferenceEquals(this, other)) { diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs index 14edd64926..7b4429ab15 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParser.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System.Globalization; using System.IO; using System.Text.RegularExpressions; @@ -8,15 +5,27 @@ using Emby.Naming.Common; namespace Emby.Naming.AudioBook { + /// + /// Parser class to extract part and/or chapter number from audiobook filename. + /// public class AudioBookFilePathParser { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// Naming options containing AudioBookPartsExpressions. public AudioBookFilePathParser(NamingOptions options) { _options = options; } + /// + /// Based on regex determines if filename includes part/chapter number. + /// + /// Path to audiobook file. + /// Returns object. public AudioBookFilePathParserResult Parse(string path) { AudioBookFilePathParserResult result = default; @@ -52,8 +61,6 @@ namespace Emby.Naming.AudioBook } } - result.Success = result.ChapterNumber.HasValue || result.PartNumber.HasValue; - return result; } } diff --git a/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs b/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs index 7bfc4479d2..48ab8b57dc 100644 --- a/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs +++ b/Emby.Naming/AudioBook/AudioBookFilePathParserResult.cs @@ -1,14 +1,18 @@ -#nullable enable -#pragma warning disable CS1591 - namespace Emby.Naming.AudioBook { + /// + /// Data object for passing result of audiobook part/chapter extraction. + /// public struct AudioBookFilePathParserResult { + /// + /// Gets or sets optional number of path extracted from audiobook filename. + /// public int? PartNumber { get; set; } + /// + /// Gets or sets optional number of chapter extracted from audiobook filename. + /// public int? ChapterNumber { get; set; } - - public bool Success { get; set; } } } diff --git a/Emby.Naming/AudioBook/AudioBookInfo.cs b/Emby.Naming/AudioBook/AudioBookInfo.cs index b0b5bd881f..adf403ab6d 100644 --- a/Emby.Naming/AudioBook/AudioBookInfo.cs +++ b/Emby.Naming/AudioBook/AudioBookInfo.cs @@ -10,11 +10,18 @@ namespace Emby.Naming.AudioBook /// /// Initializes a new instance of the class. /// - public AudioBookInfo() + /// Name of audiobook. + /// Year of audiobook release. + /// List of files composing the actual audiobook. + /// List of extra files. + /// Alternative version of files. + public AudioBookInfo(string name, int? year, List? files, List? extras, List? alternateVersions) { - Files = new List(); - Extras = new List(); - AlternateVersions = new List(); + Name = name; + Year = year; + Files = files ?? new List(); + Extras = extras ?? new List(); + AlternateVersions = alternateVersions ?? new List(); } /// diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index f4ba11a0d1..e9ea9b7a5d 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -1,6 +1,6 @@ -#pragma warning disable CS1591 - +using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; @@ -8,40 +8,145 @@ using MediaBrowser.Model.IO; namespace Emby.Naming.AudioBook { + /// + /// Class used to resolve Name, Year, alternative files and extras from stack of files. + /// public class AudioBookListResolver { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// Naming options passed along to and . public AudioBookListResolver(NamingOptions options) { _options = options; } + /// + /// Resolves Name, Year and differentiate alternative files and extras from regular audiobook files. + /// + /// List of files related to audiobook. + /// Returns IEnumerable of . public IEnumerable Resolve(IEnumerable files) { var audioBookResolver = new AudioBookResolver(_options); + // File with empty fullname will be sorted out here. var audiobookFileInfos = files - .Select(i => audioBookResolver.Resolve(i.FullName, i.IsDirectory)) - .Where(i => i != null) + .Select(i => audioBookResolver.Resolve(i.FullName)) + .OfType() .ToList(); - // Filter out all extras, otherwise they could cause stacks to not be resolved - // See the unit test TestStackedWithTrailer - var metadata = audiobookFileInfos - .Select(i => new FileSystemMetadata { FullName = i.Path, IsDirectory = i.IsDirectory }); - var stackResult = new StackResolver(_options) - .ResolveAudioBooks(metadata); + .ResolveAudioBooks(audiobookFileInfos); foreach (var stack in stackResult) { - var stackFiles = stack.Files.Select(i => audioBookResolver.Resolve(i, stack.IsDirectoryStack)).ToList(); + var stackFiles = stack.Files + .Select(i => audioBookResolver.Resolve(i)) + .OfType() + .ToList(); + stackFiles.Sort(); - var info = new AudioBookInfo { Files = stackFiles, Name = stack.Name }; + + var nameParserResult = new AudioBookNameParser(_options).Parse(stack.Name); + + FindExtraAndAlternativeFiles(ref stackFiles, out var extras, out var alternativeVersions, nameParserResult); + + var info = new AudioBookInfo( + nameParserResult.Name, + nameParserResult.Year, + stackFiles, + extras, + alternativeVersions); yield return info; } } + + private void FindExtraAndAlternativeFiles(ref List stackFiles, out List extras, out List alternativeVersions, AudioBookNameParserResult nameParserResult) + { + extras = new List(); + alternativeVersions = new List(); + + var haveChaptersOrPages = stackFiles.Any(x => x.ChapterNumber != null || x.PartNumber != null); + var groupedBy = stackFiles.GroupBy(file => new { file.ChapterNumber, file.PartNumber }); + var nameWithReplacedDots = nameParserResult.Name.Replace(" ", "."); + + foreach (var group in groupedBy) + { + if (group.Key.ChapterNumber == null && group.Key.PartNumber == null) + { + if (group.Count() > 1 || haveChaptersOrPages) + { + var ex = new List(); + var alt = new List(); + + foreach (var audioFile in group) + { + var name = Path.GetFileNameWithoutExtension(audioFile.Path); + if (name.Equals("audiobook") || + name.Contains(nameParserResult.Name, StringComparison.OrdinalIgnoreCase) || + name.Contains(nameWithReplacedDots, StringComparison.OrdinalIgnoreCase)) + { + alt.Add(audioFile); + } + else + { + ex.Add(audioFile); + } + } + + if (ex.Count > 0) + { + var extra = ex + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .ToList(); + + stackFiles = stackFiles.Except(extra).ToList(); + extras.AddRange(extra); + } + + if (alt.Count > 0) + { + var alternatives = alt + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .ToList(); + + var main = FindMainAudioBookFile(alternatives, nameParserResult.Name); + alternatives.Remove(main); + stackFiles = stackFiles.Except(alternatives).ToList(); + alternativeVersions.AddRange(alternatives); + } + } + } + else if (group.Count() > 1) + { + var alternatives = group + .OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .Skip(1) + .ToList(); + + stackFiles = stackFiles.Except(alternatives).ToList(); + alternativeVersions.AddRange(alternatives); + } + } + } + + private AudioBookFileInfo FindMainAudioBookFile(List files, string name) + { + var main = files.Find(x => Path.GetFileNameWithoutExtension(x.Path).Equals(name, StringComparison.OrdinalIgnoreCase)); + main ??= files.FirstOrDefault(x => Path.GetFileNameWithoutExtension(x.Path).Equals("audiobook", StringComparison.OrdinalIgnoreCase)); + main ??= files.OrderBy(x => x.Container) + .ThenBy(x => x.Path) + .First(); + + return main; + } } } diff --git a/Emby.Naming/AudioBook/AudioBookNameParser.cs b/Emby.Naming/AudioBook/AudioBookNameParser.cs new file mode 100644 index 0000000000..120482bc2c --- /dev/null +++ b/Emby.Naming/AudioBook/AudioBookNameParser.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Emby.Naming.Common; + +namespace Emby.Naming.AudioBook +{ + /// + /// Helper class to retrieve name and year from audiobook previously retrieved name. + /// + public class AudioBookNameParser + { + private readonly NamingOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// Naming options containing AudioBookNamesExpressions. + public AudioBookNameParser(NamingOptions options) + { + _options = options; + } + + /// + /// Parse name and year from previously determined name of audiobook. + /// + /// Name of the audiobook. + /// Returns object. + public AudioBookNameParserResult Parse(string name) + { + AudioBookNameParserResult result = default; + foreach (var expression in _options.AudioBookNamesExpressions) + { + var match = new Regex(expression, RegexOptions.IgnoreCase).Match(name); + if (match.Success) + { + if (result.Name == null) + { + var value = match.Groups["name"]; + if (value.Success) + { + result.Name = value.Value; + } + } + + if (!result.Year.HasValue) + { + var value = match.Groups["year"]; + if (value.Success) + { + if (int.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intValue)) + { + result.Year = intValue; + } + } + } + } + } + + if (string.IsNullOrEmpty(result.Name)) + { + result.Name = name; + } + + return result; + } + } +} diff --git a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs new file mode 100644 index 0000000000..3f2d7b2b0b --- /dev/null +++ b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs @@ -0,0 +1,18 @@ +namespace Emby.Naming.AudioBook +{ + /// + /// Data object used to pass result of name and year parsing. + /// + public struct AudioBookNameParserResult + { + /// + /// Gets or sets name of audiobook. + /// + public string Name { get; set; } + + /// + /// Gets or sets optional year of release. + /// + public int? Year { get; set; } + } +} diff --git a/Emby.Naming/AudioBook/AudioBookResolver.cs b/Emby.Naming/AudioBook/AudioBookResolver.cs index 5807d4688c..f6ad3601d7 100644 --- a/Emby.Naming/AudioBook/AudioBookResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookResolver.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -8,25 +5,32 @@ using Emby.Naming.Common; namespace Emby.Naming.AudioBook { + /// + /// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file. + /// public class AudioBookResolver { private readonly NamingOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// containing AudioFileExtensions and also used to pass to AudioBookFilePathParser. public AudioBookResolver(NamingOptions options) { _options = options; } - public AudioBookFileInfo? Resolve(string path, bool isDirectory = false) + /// + /// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file. + /// + /// Path to audiobook file. + /// Returns object. + public AudioBookFileInfo? Resolve(string path) { - if (path.Length == 0) - { - throw new ArgumentException("String can't be empty.", nameof(path)); - } - - // TODO - if (isDirectory) + if (path.Length == 0 || Path.GetFileNameWithoutExtension(path).Length == 0) { + // Return null to indicate this path will not be used, instead of stopping whole process with exception return null; } @@ -42,14 +46,11 @@ namespace Emby.Naming.AudioBook var parsingResult = new AudioBookFilePathParser(_options).Parse(path); - return new AudioBookFileInfo - { - Path = path, - Container = container, - ChapterNumber = parsingResult.ChapterNumber, - PartNumber = parsingResult.PartNumber, - IsDirectory = isDirectory - }; + return new AudioBookFileInfo( + path, + container, + chapterNumber: parsingResult.ChapterNumber, + partNumber: parsingResult.PartNumber); } } } diff --git a/Emby.Naming/Common/EpisodeExpression.cs b/Emby.Naming/Common/EpisodeExpression.cs index ed6ba8881c..19d3c7aab0 100644 --- a/Emby.Naming/Common/EpisodeExpression.cs +++ b/Emby.Naming/Common/EpisodeExpression.cs @@ -1,28 +1,32 @@ -#pragma warning disable CS1591 - using System; using System.Text.RegularExpressions; namespace Emby.Naming.Common { + /// + /// Regular expressions for parsing TV Episodes. + /// public class EpisodeExpression { private string _expression; - private Regex _regex; + private Regex? _regex; - public EpisodeExpression(string expression, bool byDate) + /// + /// Initializes a new instance of the class. + /// + /// Regular expressions. + /// True if date is expected. + public EpisodeExpression(string expression, bool byDate = false) { - Expression = expression; + _expression = expression; IsByDate = byDate; DateTimeFormats = Array.Empty(); SupportsAbsoluteEpisodeNumbers = true; } - public EpisodeExpression(string expression) - : this(expression, false) - { - } - + /// + /// Gets or sets raw expressions string. + /// public string Expression { get => _expression; @@ -33,16 +37,34 @@ namespace Emby.Naming.Common } } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if date can be find in expression. + /// public bool IsByDate { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if expression is optimistic. + /// public bool IsOptimistic { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if expression is named. + /// public bool IsNamed { get; set; } + /// + /// Gets or sets a value indicating whether gets or sets property indicating if expression supports episodes with absolute numbers. + /// public bool SupportsAbsoluteEpisodeNumbers { get; set; } + /// + /// Gets or sets optional list of date formats used for date parsing. + /// public string[] DateTimeFormats { get; set; } + /// + /// Gets a expressions objects (creates it if null). + /// public Regex Regex => _regex ??= new Regex(Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled); } } diff --git a/Emby.Naming/Common/MediaType.cs b/Emby.Naming/Common/MediaType.cs index 148833765f..dc9784c6da 100644 --- a/Emby.Naming/Common/MediaType.cs +++ b/Emby.Naming/Common/MediaType.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Common { + /// + /// Type of audiovisual media. + /// public enum MediaType { /// diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 20667abd27..316ed9f891 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -1,15 +1,21 @@ -#pragma warning disable CS1591 - using System; using System.Linq; using System.Text.RegularExpressions; using Emby.Naming.Video; using MediaBrowser.Model.Entities; +// ReSharper disable StringLiteralTypo + namespace Emby.Naming.Common { + /// + /// Big ugly class containing lot of different naming options that should be split and injected instead of passes everywhere. + /// public class NamingOptions { + /// + /// Initializes a new instance of the class. + /// public NamingOptions() { VideoFileExtensions = new[] @@ -75,63 +81,52 @@ namespace Emby.Naming.Common StubTypes = new[] { - new StubTypeRule - { - StubType = "dvd", - Token = "dvd" - }, - new StubTypeRule - { - StubType = "hddvd", - Token = "hddvd" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bluray" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "brrip" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bd25" - }, - new StubTypeRule - { - StubType = "bluray", - Token = "bd50" - }, - new StubTypeRule - { - StubType = "vhs", - Token = "vhs" - }, - new StubTypeRule - { - StubType = "tv", - Token = "HDTV" - }, - new StubTypeRule - { - StubType = "tv", - Token = "PDTV" - }, - new StubTypeRule - { - StubType = "tv", - Token = "DSR" - } + new StubTypeRule( + stubType: "dvd", + token: "dvd"), + + new StubTypeRule( + stubType: "hddvd", + token: "hddvd"), + + new StubTypeRule( + stubType: "bluray", + token: "bluray"), + + new StubTypeRule( + stubType: "bluray", + token: "brrip"), + + new StubTypeRule( + stubType: "bluray", + token: "bd25"), + + new StubTypeRule( + stubType: "bluray", + token: "bd50"), + + new StubTypeRule( + stubType: "vhs", + token: "vhs"), + + new StubTypeRule( + stubType: "tv", + token: "HDTV"), + + new StubTypeRule( + stubType: "tv", + token: "PDTV"), + + new StubTypeRule( + stubType: "tv", + token: "DSR") }; VideoFileStackingExpressions = new[] { - "(.*?)([ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(.*?)(\\.[^.]+)$", - "(.*?)([ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(.*?)(\\.[^.]+)$", - "(.*?)([ ._-]*[a-d])(.*?)(\\.[^.]+)$" + "(?.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(?<ignore>.*?)(?<extension>\\.[^.]+)$", + "(?<title>.*?)(?<volume>[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$", + "(?<title>.*?)(?<volume>[ ._-]*[a-d])(?<ignore>.*?)(?<extension>\\.[^.]+)$" }; CleanDateTimes = new[] @@ -144,7 +139,7 @@ namespace Emby.Naming.Common CleanStrings = new[] { - @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|x264|h264|xvid|xvidvd|xxx|www.www|\[.*\])([ _\,\.\(\)\[\]\-]|$)", + @"[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multisubs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r3|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)", @"(\[.*\])" }; @@ -257,7 +252,7 @@ namespace Emby.Naming.Common }, // <!-- foo.ep01, foo.EP_01 --> new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), - new EpisodeExpression("([0-9]{4})[\\.-]([0-9]{2})[\\.-]([0-9]{2})", true) + new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true) { DateTimeFormats = new[] { @@ -266,7 +261,7 @@ namespace Emby.Naming.Common "yyyy_MM_dd" } }, - new EpisodeExpression("([0-9]{2})[\\.-]([0-9]{2})[\\.-]([0-9]{4})", true) + new EpisodeExpression(@"(?<day>[0-9]{2})[.-](?<month>[0-9]{2})[.-](?<year>[0-9]{4})", true) { DateTimeFormats = new[] { @@ -288,7 +283,12 @@ namespace Emby.Naming.Common { SupportsAbsoluteEpisodeNumbers = true }, - new EpisodeExpression(@"[\\\\/\\._ -](?<seriesname>(?![0-9]+[0-9][0-9])([^\\\/])*)[\\\\/\\._ -](?<seasonnumber>[0-9]+)(?<epnumber>[0-9][0-9](?:(?:[a-i]|\\.[1-9])(?![0-9]))?)([\\._ -][^\\\\/]*)$") + + // Case Closed (1996-2007)/Case Closed - 317.mkv + // /server/anything_102.mp4 + // /server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv + // /server/anything_1996.11.14.mp4 + new EpisodeExpression(@"[\\/._ -](?<seriesname>(?![0-9]+[0-9][0-9])([^\\\/_])*)[\\\/._ -](?<seasonnumber>[0-9]+)(?<epnumber>[0-9][0-9](?:(?:[a-i]|\.[1-9])(?![0-9]))?)([._ -][^\\\/]*)$") { IsOptimistic = true, IsNamed = true, @@ -383,247 +383,193 @@ namespace Emby.Naming.Common VideoExtraRules = new[] { - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Filename, - Token = "trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = "-trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = ".trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = "_trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Trailer, - RuleType = ExtraRuleType.Suffix, - Token = " trailer", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Filename, - Token = "sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = "-sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = ".sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = "_sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.Suffix, - Token = " sample", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.ThemeSong, - RuleType = ExtraRuleType.Filename, - Token = "theme", - MediaType = MediaType.Audio - }, - new ExtraRule - { - ExtraType = ExtraType.Scene, - RuleType = ExtraRuleType.Suffix, - Token = "-scene", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-clip", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Interview, - RuleType = ExtraRuleType.Suffix, - Token = "-interview", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.BehindTheScenes, - RuleType = ExtraRuleType.Suffix, - Token = "-behindthescenes", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.DeletedScene, - RuleType = ExtraRuleType.Suffix, - Token = "-deleted", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-featurette", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.Suffix, - Token = "-short", - MediaType = MediaType.Video - }, - new ExtraRule - { - ExtraType = ExtraType.BehindTheScenes, - RuleType = ExtraRuleType.DirectoryName, - Token = "behind the scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.DeletedScene, - RuleType = ExtraRuleType.DirectoryName, - Token = "deleted scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Interview, - RuleType = ExtraRuleType.DirectoryName, - Token = "interviews", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Scene, - RuleType = ExtraRuleType.DirectoryName, - Token = "scenes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Sample, - RuleType = ExtraRuleType.DirectoryName, - Token = "samples", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.DirectoryName, - Token = "shorts", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Clip, - RuleType = ExtraRuleType.DirectoryName, - Token = "featurettes", - MediaType = MediaType.Video, - }, - new ExtraRule - { - ExtraType = ExtraType.Unknown, - RuleType = ExtraRuleType.DirectoryName, - Token = "extras", - MediaType = MediaType.Video, - }, + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Filename, + "trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + "-trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + ".trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + "_trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Trailer, + ExtraRuleType.Suffix, + " trailer", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Filename, + "sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + "-sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + ".sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + "_sample", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.Suffix, + " sample", + MediaType.Video), + + new ExtraRule( + ExtraType.ThemeSong, + ExtraRuleType.Filename, + "theme", + MediaType.Audio), + + new ExtraRule( + ExtraType.Scene, + ExtraRuleType.Suffix, + "-scene", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-clip", + MediaType.Video), + + new ExtraRule( + ExtraType.Interview, + ExtraRuleType.Suffix, + "-interview", + MediaType.Video), + + new ExtraRule( + ExtraType.BehindTheScenes, + ExtraRuleType.Suffix, + "-behindthescenes", + MediaType.Video), + + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.Suffix, + "-deleted", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-featurette", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.Suffix, + "-short", + MediaType.Video), + + new ExtraRule( + ExtraType.BehindTheScenes, + ExtraRuleType.DirectoryName, + "behind the scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.DeletedScene, + ExtraRuleType.DirectoryName, + "deleted scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Interview, + ExtraRuleType.DirectoryName, + "interviews", + MediaType.Video), + + new ExtraRule( + ExtraType.Scene, + ExtraRuleType.DirectoryName, + "scenes", + MediaType.Video), + + new ExtraRule( + ExtraType.Sample, + ExtraRuleType.DirectoryName, + "samples", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "shorts", + MediaType.Video), + + new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "featurettes", + MediaType.Video), + + new ExtraRule( + ExtraType.Unknown, + ExtraRuleType.DirectoryName, + "extras", + MediaType.Video), }; Format3DRules = new[] { // Kodi rules: - new Format3DRule - { - PreceedingToken = "3d", - Token = "hsbs" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "sbs" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "htab" - }, - new Format3DRule - { - PreceedingToken = "3d", - Token = "tab" - }, - // Media Browser rules: - new Format3DRule - { - Token = "fsbs" - }, - new Format3DRule - { - Token = "hsbs" - }, - new Format3DRule - { - Token = "sbs" - }, - new Format3DRule - { - Token = "ftab" - }, - new Format3DRule - { - Token = "htab" - }, - new Format3DRule - { - Token = "tab" - }, - new Format3DRule - { - Token = "sbs3d" - }, - new Format3DRule - { - Token = "mvc" - } + new Format3DRule( + precedingToken: "3d", + token: "hsbs"), + + new Format3DRule( + precedingToken: "3d", + token: "sbs"), + + new Format3DRule( + precedingToken: "3d", + token: "htab"), + + new Format3DRule( + precedingToken: "3d", + token: "tab"), + + // Media Browser rules: + new Format3DRule("fsbs"), + new Format3DRule("hsbs"), + new Format3DRule("sbs"), + new Format3DRule("ftab"), + new Format3DRule("htab"), + new Format3DRule("tab"), + new Format3DRule("sbs3d"), + new Format3DRule("mvc") }; + AudioBookPartsExpressions = new[] { // Detect specified chapters, like CH 01 @@ -633,13 +579,20 @@ namespace Emby.Naming.Common // Chapter is often beginning of filename "^(?<chapter>[0-9]+)", // Part if often ending of filename - "(?<part>[0-9]+)$", + @"(?<!ch(?:apter) )(?<part>[0-9]+)$", // Sometimes named as 0001_005 (chapter_part) "(?<chapter>[0-9]+)_(?<part>[0-9]+)", // Some audiobooks are ripped from cd's, and will be named by disk number. @"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)" }; + AudioBookNamesExpressions = new[] + { + // Detect year usually in brackets after name Batman (2020) + @"^(?<name>.+?)\s*\(\s*(?<year>\d{4})\s*\)\s*$", + @"^\s*(?<name>[^ ].*?)\s*$" + }; + var extensions = VideoFileExtensions.ToList(); extensions.AddRange(new[] @@ -675,7 +628,7 @@ namespace Emby.Naming.Common ".mxf" }); - MultipleEpisodeExpressions = new string[] + MultipleEpisodeExpressions = new[] { @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$", @".*(\\|\/)[sS]?(?<seasonnumber>[0-9]{1,4})[xX](?<epnumber>[0-9]{1,3})((-| - )[0-9]{1,4}[xX][eE](?<endingepnumber>[0-9]{1,3}))+[^\\\/]*$", @@ -699,56 +652,139 @@ namespace Emby.Naming.Common Compile(); } + /// <summary> + /// Gets or sets list of audio file extensions. + /// </summary> public string[] AudioFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of album stacking prefixes. + /// </summary> public string[] AlbumStackingPrefixes { get; set; } + /// <summary> + /// Gets or sets list of subtitle file extensions. + /// </summary> public string[] SubtitleFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of subtitles flag delimiters. + /// </summary> public char[] SubtitleFlagDelimiters { get; set; } + /// <summary> + /// Gets or sets list of subtitle forced flags. + /// </summary> public string[] SubtitleForcedFlags { get; set; } + /// <summary> + /// Gets or sets list of subtitle default flags. + /// </summary> public string[] SubtitleDefaultFlags { get; set; } + /// <summary> + /// Gets or sets list of episode regular expressions. + /// </summary> public EpisodeExpression[] EpisodeExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw episode without season regular expressions strings. + /// </summary> public string[] EpisodeWithoutSeasonExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw multi-part episodes regular expressions strings. + /// </summary> public string[] EpisodeMultiPartExpressions { get; set; } + /// <summary> + /// Gets or sets list of video file extensions. + /// </summary> public string[] VideoFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of video stub file extensions. + /// </summary> public string[] StubFileExtensions { get; set; } + /// <summary> + /// Gets or sets list of raw audiobook parts regular expressions strings. + /// </summary> public string[] AudioBookPartsExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw audiobook names regular expressions strings. + /// </summary> + public string[] AudioBookNamesExpressions { get; set; } + + /// <summary> + /// Gets or sets list of stub type rules. + /// </summary> public StubTypeRule[] StubTypes { get; set; } + /// <summary> + /// Gets or sets list of video flag delimiters. + /// </summary> public char[] VideoFlagDelimiters { get; set; } + /// <summary> + /// Gets or sets list of 3D Format rules. + /// </summary> public Format3DRule[] Format3DRules { get; set; } + /// <summary> + /// Gets or sets list of raw video file-stacking expressions strings. + /// </summary> public string[] VideoFileStackingExpressions { get; set; } + /// <summary> + /// Gets or sets list of raw clean DateTimes regular expressions strings. + /// </summary> public string[] CleanDateTimes { get; set; } + /// <summary> + /// Gets or sets list of raw clean strings regular expressions strings. + /// </summary> public string[] CleanStrings { get; set; } + /// <summary> + /// Gets or sets list of multi-episode regular expressions. + /// </summary> public EpisodeExpression[] MultipleEpisodeExpressions { get; set; } + /// <summary> + /// Gets or sets list of extra rules for videos. + /// </summary> public ExtraRule[] VideoExtraRules { get; set; } - public Regex[] VideoFileStackingRegexes { get; private set; } + /// <summary> + /// Gets list of video file-stack regular expressions. + /// </summary> + public Regex[] VideoFileStackingRegexes { get; private set; } = Array.Empty<Regex>(); - public Regex[] CleanDateTimeRegexes { get; private set; } + /// <summary> + /// Gets list of clean datetime regular expressions. + /// </summary> + public Regex[] CleanDateTimeRegexes { get; private set; } = Array.Empty<Regex>(); - public Regex[] CleanStringRegexes { get; private set; } + /// <summary> + /// Gets list of clean string regular expressions. + /// </summary> + public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>(); - public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } + /// <summary> + /// Gets list of episode without season regular expressions. + /// </summary> + public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } = Array.Empty<Regex>(); - public Regex[] EpisodeMultiPartRegexes { get; private set; } + /// <summary> + /// Gets list of multi-part episode regular expressions. + /// </summary> + public Regex[] EpisodeMultiPartRegexes { get; private set; } = Array.Empty<Regex>(); + /// <summary> + /// Compiles raw regex strings into regexes. + /// </summary> public void Compile() { VideoFileStackingRegexes = VideoFileStackingExpressions.Select(Compile).ToArray(); diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj index 6857f9952c..24c15759d4 100644 --- a/Emby.Naming/Emby.Naming.csproj +++ b/Emby.Naming/Emby.Naming.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> @@ -14,6 +14,7 @@ <EmbedUntrackedSources>true</EmbedUntrackedSources> <IncludeSymbols>true</IncludeSymbols> <SymbolPackageFormat>snupkg</SymbolPackageFormat> + <Nullable>enable</Nullable> </PropertyGroup> <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> @@ -38,7 +39,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> </ItemGroup> <!-- Code Analyzers--> diff --git a/Emby.Naming/Subtitles/SubtitleInfo.cs b/Emby.Naming/Subtitles/SubtitleInfo.cs index f39c496b7a..1fb2e0dc89 100644 --- a/Emby.Naming/Subtitles/SubtitleInfo.cs +++ b/Emby.Naming/Subtitles/SubtitleInfo.cs @@ -1,9 +1,23 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Subtitles { + /// <summary> + /// Class holding information about subtitle. + /// </summary> public class SubtitleInfo { + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleInfo"/> class. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="isDefault">Is subtitle default.</param> + /// <param name="isForced">Is subtitle forced.</param> + public SubtitleInfo(string path, bool isDefault, bool isForced) + { + Path = path; + IsDefault = isDefault; + IsForced = isForced; + } + /// <summary> /// Gets or sets the path. /// </summary> @@ -14,7 +28,7 @@ namespace Emby.Naming.Subtitles /// Gets or sets the language. /// </summary> /// <value>The language.</value> - public string Language { get; set; } + public string? Language { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance is default. diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleParser.cs index 24e59f90a3..a19340ef69 100644 --- a/Emby.Naming/Subtitles/SubtitleParser.cs +++ b/Emby.Naming/Subtitles/SubtitleParser.cs @@ -1,6 +1,3 @@ -#nullable enable -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -8,20 +5,32 @@ using Emby.Naming.Common; namespace Emby.Naming.Subtitles { + /// <summary> + /// Subtitle Parser class. + /// </summary> public class SubtitleParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleParser"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing SubtitleFileExtensions, SubtitleDefaultFlags, SubtitleForcedFlags and SubtitleFlagDelimiters.</param> public SubtitleParser(NamingOptions options) { _options = options; } + /// <summary> + /// Parse file to determine if is subtitle and <see cref="SubtitleInfo"/>. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>Returns null or <see cref="SubtitleInfo"/> object if parsing is successful.</returns> public SubtitleInfo? ParseFile(string path) { if (path.Length == 0) { - throw new ArgumentException("File path can't be empty.", nameof(path)); + return null; } var extension = Path.GetExtension(path); @@ -31,12 +40,10 @@ namespace Emby.Naming.Subtitles } var flags = GetFlags(path); - var info = new SubtitleInfo - { - Path = path, - IsDefault = _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)), - IsForced = _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)) - }; + var info = new SubtitleInfo( + path, + _options.SubtitleDefaultFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase)), + _options.SubtitleForcedFlags.Any(i => flags.Contains(i, StringComparer.OrdinalIgnoreCase))); var parts = flags.Where(i => !_options.SubtitleDefaultFlags.Contains(i, StringComparer.OrdinalIgnoreCase) && !_options.SubtitleForcedFlags.Contains(i, StringComparer.OrdinalIgnoreCase)) @@ -53,7 +60,7 @@ namespace Emby.Naming.Subtitles private string[] GetFlags(string path) { - // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _. + // Note: the tags need be surrounded be either a space ( ), hyphen -, dot . or underscore _. var file = Path.GetFileName(path); diff --git a/Emby.Naming/TV/EpisodeInfo.cs b/Emby.Naming/TV/EpisodeInfo.cs index 250df4e2d3..a8920b36ae 100644 --- a/Emby.Naming/TV/EpisodeInfo.cs +++ b/Emby.Naming/TV/EpisodeInfo.cs @@ -1,9 +1,19 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Holder object for Episode information. + /// </summary> public class EpisodeInfo { + /// <summary> + /// Initializes a new instance of the <see cref="EpisodeInfo"/> class. + /// </summary> + /// <param name="path">Path to the file.</param> + public EpisodeInfo(string path) + { + Path = path; + } + /// <summary> /// Gets or sets the path. /// </summary> @@ -14,19 +24,19 @@ namespace Emby.Naming.TV /// Gets or sets the container. /// </summary> /// <value>The container.</value> - public string Container { get; set; } + public string? Container { get; set; } /// <summary> /// Gets or sets the name of the series. /// </summary> /// <value>The name of the series.</value> - public string SeriesName { get; set; } + public string? SeriesName { get; set; } /// <summary> /// Gets or sets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } + public string? Format3D { get; set; } /// <summary> /// Gets or sets a value indicating whether [is3 d]. @@ -44,20 +54,41 @@ namespace Emby.Naming.TV /// Gets or sets the type of the stub. /// </summary> /// <value>The type of the stub.</value> - public string StubType { get; set; } + public string? StubType { get; set; } + /// <summary> + /// Gets or sets optional season number. + /// </summary> public int? SeasonNumber { get; set; } + /// <summary> + /// Gets or sets optional episode number. + /// </summary> public int? EpisodeNumber { get; set; } - public int? EndingEpsiodeNumber { get; set; } + /// <summary> + /// Gets or sets optional ending episode number. For multi-episode files 1-13. + /// </summary> + public int? EndingEpisodeNumber { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Year { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Month { get; set; } + /// <summary> + /// Gets or sets optional day of release. + /// </summary> public int? Day { get; set; } + /// <summary> + /// Gets or sets a value indicating whether by date expression was used. + /// </summary> public bool IsByDate { get; set; } } } diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs index a6af689c72..6d0597356b 100644 --- a/Emby.Naming/TV/EpisodePathParser.cs +++ b/Emby.Naming/TV/EpisodePathParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.Collections.Generic; using System.Globalization; @@ -9,15 +6,32 @@ using Emby.Naming.Common; namespace Emby.Naming.TV { + /// <summary> + /// Used to parse information about episode from path. + /// </summary> public class EpisodePathParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="EpisodePathParser"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing EpisodeExpressions and MultipleEpisodeExpressions.</param> public EpisodePathParser(NamingOptions options) { _options = options; } + /// <summary> + /// Parses information about episode from path. + /// </summary> + /// <param name="path">Path.</param> + /// <param name="isDirectory">Is path for a directory or file.</param> + /// <param name="isNamed">Do we want to use IsNamed expressions.</param> + /// <param name="isOptimistic">Do we want to use Optimistic expressions.</param> + /// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param> + /// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param> + /// <returns>Returns <see cref="EpisodePathParserResult"/> object.</returns> public EpisodePathParserResult Parse( string path, bool isDirectory, @@ -146,7 +160,7 @@ namespace Emby.Naming.TV { if (int.TryParse(endingNumberGroup.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)) { - result.EndingEpsiodeNumber = num; + result.EndingEpisodeNumber = num; } } } @@ -186,7 +200,7 @@ namespace Emby.Naming.TV private void FillAdditional(string path, EpisodePathParserResult info) { - var expressions = _options.MultipleEpisodeExpressions.ToList(); + var expressions = _options.MultipleEpisodeExpressions.Where(i => i.IsNamed).ToList(); if (string.IsNullOrEmpty(info.SeriesName)) { @@ -200,11 +214,6 @@ namespace Emby.Naming.TV { foreach (var i in expressions) { - if (!i.IsNamed) - { - continue; - } - var result = Parse(path, i); if (!result.Success) @@ -217,13 +226,13 @@ namespace Emby.Naming.TV info.SeriesName = result.SeriesName; } - if (!info.EndingEpsiodeNumber.HasValue && info.EpisodeNumber.HasValue) + if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue) { - info.EndingEpsiodeNumber = result.EndingEpsiodeNumber; + info.EndingEpisodeNumber = result.EndingEpisodeNumber; } if (!string.IsNullOrEmpty(info.SeriesName) - && (!info.EpisodeNumber.HasValue || info.EndingEpsiodeNumber.HasValue)) + && (!info.EpisodeNumber.HasValue || info.EndingEpisodeNumber.HasValue)) { break; } diff --git a/Emby.Naming/TV/EpisodePathParserResult.cs b/Emby.Naming/TV/EpisodePathParserResult.cs index 05f921edc9..233d5a4f6c 100644 --- a/Emby.Naming/TV/EpisodePathParserResult.cs +++ b/Emby.Naming/TV/EpisodePathParserResult.cs @@ -1,25 +1,54 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Holder object for <see cref="EpisodePathParser"/> result. + /// </summary> public class EpisodePathParserResult { + /// <summary> + /// Gets or sets optional season number. + /// </summary> public int? SeasonNumber { get; set; } + /// <summary> + /// Gets or sets optional episode number. + /// </summary> public int? EpisodeNumber { get; set; } - public int? EndingEpsiodeNumber { get; set; } + /// <summary> + /// Gets or sets optional ending episode number. For multi-episode files 1-13. + /// </summary> + public int? EndingEpisodeNumber { get; set; } - public string SeriesName { get; set; } + /// <summary> + /// Gets or sets the name of the series. + /// </summary> + /// <value>The name of the series.</value> + public string? SeriesName { get; set; } + /// <summary> + /// Gets or sets a value indicating whether parsing was successful. + /// </summary> public bool Success { get; set; } + /// <summary> + /// Gets or sets a value indicating whether by date expression was used. + /// </summary> public bool IsByDate { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Year { get; set; } + /// <summary> + /// Gets or sets optional year of release. + /// </summary> public int? Month { get; set; } + /// <summary> + /// Gets or sets optional day of release. + /// </summary> public int? Day { get; set; } } } diff --git a/Emby.Naming/TV/EpisodeResolver.cs b/Emby.Naming/TV/EpisodeResolver.cs index 6994f69fc4..f7df587864 100644 --- a/Emby.Naming/TV/EpisodeResolver.cs +++ b/Emby.Naming/TV/EpisodeResolver.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.IO; using System.Linq; @@ -9,15 +6,32 @@ using Emby.Naming.Video; namespace Emby.Naming.TV { + /// <summary> + /// Used to resolve information about episode from path. + /// </summary> public class EpisodeResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="EpisodeResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions and passed to <see cref="StubResolver"/>, <see cref="FlagParser"/>, <see cref="Format3DParser"/> and <see cref="EpisodePathParser"/>.</param> public EpisodeResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolve information about episode from path. + /// </summary> + /// <param name="path">Path.</param> + /// <param name="isDirectory">Is path for a directory or file.</param> + /// <param name="isNamed">Do we want to use IsNamed expressions.</param> + /// <param name="isOptimistic">Do we want to use Optimistic expressions.</param> + /// <param name="supportsAbsoluteNumbers">Do we want to use expressions supporting absolute episode numbers.</param> + /// <param name="fillExtendedInfo">Should we attempt to retrieve extended information.</param> + /// <returns>Returns null or <see cref="EpisodeInfo"/> object if successful.</returns> public EpisodeInfo? Resolve( string path, bool isDirectory, @@ -54,12 +68,11 @@ namespace Emby.Naming.TV var parsingResult = new EpisodePathParser(_options) .Parse(path, isDirectory, isNamed, isOptimistic, supportsAbsoluteNumbers, fillExtendedInfo); - return new EpisodeInfo + return new EpisodeInfo(path) { - Path = path, Container = container, IsStub = isStub, - EndingEpsiodeNumber = parsingResult.EndingEpsiodeNumber, + EndingEpisodeNumber = parsingResult.EndingEpisodeNumber, EpisodeNumber = parsingResult.EpisodeNumber, SeasonNumber = parsingResult.SeasonNumber, SeriesName = parsingResult.SeriesName, diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs index d2e324dda5..d11c7c99e8 100644 --- a/Emby.Naming/TV/SeasonPathParser.cs +++ b/Emby.Naming/TV/SeasonPathParser.cs @@ -1,11 +1,12 @@ -#pragma warning disable CS1591 - using System; using System.Globalization; using System.IO; namespace Emby.Naming.TV { + /// <summary> + /// Class to parse season paths. + /// </summary> public static class SeasonPathParser { /// <summary> @@ -23,6 +24,13 @@ namespace Emby.Naming.TV "stagione" }; + /// <summary> + /// Attempts to parse season number from path. + /// </summary> + /// <param name="path">Path to season.</param> + /// <param name="supportSpecialAliases">Support special aliases when parsing.</param> + /// <param name="supportNumericSeasonFolders">Support numeric season folders when parsing.</param> + /// <returns>Returns <see cref="SeasonPathParserResult"/> object.</returns> public static SeasonPathParserResult Parse(string path, bool supportSpecialAliases, bool supportNumericSeasonFolders) { var result = new SeasonPathParserResult(); @@ -101,9 +109,9 @@ namespace Emby.Naming.TV } var parts = filename.Split(new[] { '.', '_', ' ', '-' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < parts.Length; i++) + foreach (var part in parts) { - if (TryGetSeasonNumberFromPart(parts[i], out int seasonNumber)) + if (TryGetSeasonNumberFromPart(part, out int seasonNumber)) { return (seasonNumber, true); } @@ -139,7 +147,7 @@ namespace Emby.Naming.TV var numericStart = -1; var length = 0; - var hasOpenParenth = false; + var hasOpenParenthesis = false; var isSeasonFolder = true; // Find out where the numbers start, and then keep going until they end @@ -147,7 +155,7 @@ namespace Emby.Naming.TV { if (char.IsNumber(path[i])) { - if (!hasOpenParenth) + if (!hasOpenParenthesis) { if (numericStart == -1) { @@ -167,11 +175,11 @@ namespace Emby.Naming.TV var currentChar = path[i]; if (currentChar == '(') { - hasOpenParenth = true; + hasOpenParenthesis = true; } else if (currentChar == ')') { - hasOpenParenth = false; + hasOpenParenthesis = false; } } diff --git a/Emby.Naming/TV/SeasonPathParserResult.cs b/Emby.Naming/TV/SeasonPathParserResult.cs index a142fafea0..b4b6f236a7 100644 --- a/Emby.Naming/TV/SeasonPathParserResult.cs +++ b/Emby.Naming/TV/SeasonPathParserResult.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.TV { + /// <summary> + /// Data object to pass result of <see cref="SeasonPathParser"/>. + /// </summary> public class SeasonPathParserResult { /// <summary> @@ -16,6 +17,10 @@ namespace Emby.Naming.TV /// <value><c>true</c> if success; otherwise, <c>false</c>.</value> public bool Success { get; set; } + /// <summary> + /// Gets or sets a value indicating whether "Is season folder". + /// Seems redundant and barely used. + /// </summary> public bool IsSeasonFolder { get; set; } } } diff --git a/Emby.Naming/Video/CleanDateTimeParser.cs b/Emby.Naming/Video/CleanDateTimeParser.cs index 7c18ab039c..f2b538e6c7 100644 --- a/Emby.Naming/Video/CleanDateTimeParser.cs +++ b/Emby.Naming/Video/CleanDateTimeParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; @@ -12,9 +9,20 @@ namespace Emby.Naming.Video /// </summary> public static class CleanDateTimeParser { + /// <summary> + /// Attempts to clean the name. + /// </summary> + /// <param name="name">Name of video.</param> + /// <param name="cleanDateTimeRegexes">Optional list of regexes to clean the name.</param> + /// <returns>Returns <see cref="CleanDateTimeResult"/> object.</returns> public static CleanDateTimeResult Clean(string name, IReadOnlyList<Regex> cleanDateTimeRegexes) { CleanDateTimeResult result = new CleanDateTimeResult(name); + if (string.IsNullOrEmpty(name)) + { + return result; + } + var len = cleanDateTimeRegexes.Count; for (int i = 0; i < len; i++) { diff --git a/Emby.Naming/Video/CleanDateTimeResult.cs b/Emby.Naming/Video/CleanDateTimeResult.cs index 57eeaa7e32..c675a19d0f 100644 --- a/Emby.Naming/Video/CleanDateTimeResult.cs +++ b/Emby.Naming/Video/CleanDateTimeResult.cs @@ -1,22 +1,21 @@ -#pragma warning disable CS1591 -#nullable enable - namespace Emby.Naming.Video { + /// <summary> + /// Holder structure for name and year. + /// </summary> public readonly struct CleanDateTimeResult { - public CleanDateTimeResult(string name, int? year) + /// <summary> + /// Initializes a new instance of the <see cref="CleanDateTimeResult"/> struct. + /// </summary> + /// <param name="name">Name of video.</param> + /// <param name="year">Year of release.</param> + public CleanDateTimeResult(string name, int? year = null) { Name = name; Year = year; } - public CleanDateTimeResult(string name) - { - Name = name; - Year = null; - } - /// <summary> /// Gets the name. /// </summary> diff --git a/Emby.Naming/Video/CleanStringParser.cs b/Emby.Naming/Video/CleanStringParser.cs index 3f584d5847..09a0cd1893 100644 --- a/Emby.Naming/Video/CleanStringParser.cs +++ b/Emby.Naming/Video/CleanStringParser.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -12,6 +9,13 @@ namespace Emby.Naming.Video /// </summary> public static class CleanStringParser { + /// <summary> + /// Attempts to extract clean name with regular expressions. + /// </summary> + /// <param name="name">Name of file.</param> + /// <param name="expressions">List of regex to parse name and year from.</param> + /// <param name="newName">Parsing result string.</param> + /// <returns>True if parsing was successful.</returns> public static bool TryClean(string name, IReadOnlyList<Regex> expressions, out ReadOnlySpan<char> newName) { var len = expressions.Count; diff --git a/Emby.Naming/Video/ExtraResolver.cs b/Emby.Naming/Video/ExtraResolver.cs index fc0424faab..1d3b36a1ad 100644 --- a/Emby.Naming/Video/ExtraResolver.cs +++ b/Emby.Naming/Video/ExtraResolver.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.IO; using System.Linq; @@ -9,15 +7,27 @@ using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Resolve if file is extra for video. + /// </summary> public class ExtraResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="ExtraResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoExtraRules and passed to <see cref="AudioFileParser"/> and <see cref="VideoResolver"/>.</param> public ExtraResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Attempts to resolve if file is extra. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>Returns <see cref="ExtraResult"/> object.</returns> public ExtraResult GetExtraInfo(string path) { return _options.VideoExtraRules @@ -43,10 +53,6 @@ namespace Emby.Naming.Video return result; } } - else - { - return result; - } if (rule.RuleType == ExtraRuleType.Filename) { diff --git a/Emby.Naming/Video/ExtraResult.cs b/Emby.Naming/Video/ExtraResult.cs index 15db32e876..243fc2b415 100644 --- a/Emby.Naming/Video/ExtraResult.cs +++ b/Emby.Naming/Video/ExtraResult.cs @@ -1,9 +1,10 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Entities; namespace Emby.Naming.Video { + /// <summary> + /// Holder object for passing results from ExtraResolver. + /// </summary> public class ExtraResult { /// <summary> @@ -16,6 +17,6 @@ namespace Emby.Naming.Video /// Gets or sets the rule. /// </summary> /// <value>The rule.</value> - public ExtraRule Rule { get; set; } + public ExtraRule? Rule { get; set; } } } diff --git a/Emby.Naming/Video/ExtraRule.cs b/Emby.Naming/Video/ExtraRule.cs index 7c9702e244..e267ac55fc 100644 --- a/Emby.Naming/Video/ExtraRule.cs +++ b/Emby.Naming/Video/ExtraRule.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Entities; using MediaType = Emby.Naming.Common.MediaType; @@ -10,6 +8,21 @@ namespace Emby.Naming.Video /// </summary> public class ExtraRule { + /// <summary> + /// Initializes a new instance of the <see cref="ExtraRule"/> class. + /// </summary> + /// <param name="extraType">Type of extra.</param> + /// <param name="ruleType">Type of rule.</param> + /// <param name="token">Token.</param> + /// <param name="mediaType">Media type.</param> + public ExtraRule(ExtraType extraType, ExtraRuleType ruleType, string token, MediaType mediaType) + { + Token = token; + ExtraType = extraType; + RuleType = ruleType; + MediaType = mediaType; + } + /// <summary> /// Gets or sets the token to use for matching against the file path. /// </summary> diff --git a/Emby.Naming/Video/ExtraRuleType.cs b/Emby.Naming/Video/ExtraRuleType.cs index e89876f4ae..3243195057 100644 --- a/Emby.Naming/Video/ExtraRuleType.cs +++ b/Emby.Naming/Video/ExtraRuleType.cs @@ -1,7 +1,8 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Extra rules type to determine against what <see cref="ExtraRule.Token"/> should be matched. + /// </summary> public enum ExtraRuleType { /// <summary> @@ -22,6 +23,6 @@ namespace Emby.Naming.Video /// <summary> /// Match <see cref="ExtraRule.Token"/> against the name of the directory containing the file. /// </summary> - DirectoryName = 3, + DirectoryName = 3 } } diff --git a/Emby.Naming/Video/FileStack.cs b/Emby.Naming/Video/FileStack.cs index 3ef190b865..6519db57c3 100644 --- a/Emby.Naming/Video/FileStack.cs +++ b/Emby.Naming/Video/FileStack.cs @@ -1,24 +1,43 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; namespace Emby.Naming.Video { + /// <summary> + /// Object holding list of files paths with additional information. + /// </summary> public class FileStack { + /// <summary> + /// Initializes a new instance of the <see cref="FileStack"/> class. + /// </summary> public FileStack() { Files = new List<string>(); } - public string Name { get; set; } + /// <summary> + /// Gets or sets name of file stack. + /// </summary> + public string Name { get; set; } = string.Empty; + /// <summary> + /// Gets or sets list of paths in stack. + /// </summary> public List<string> Files { get; set; } + /// <summary> + /// Gets or sets a value indicating whether stack is directory stack. + /// </summary> public bool IsDirectoryStack { get; set; } + /// <summary> + /// Helper function to determine if path is in the stack. + /// </summary> + /// <param name="file">Path of desired file.</param> + /// <param name="isDirectory">Requested type of stack.</param> + /// <returns>True if file is in the stack.</returns> public bool ContainsFile(string file, bool isDirectory) { if (IsDirectoryStack == isDirectory) diff --git a/Emby.Naming/Video/FlagParser.cs b/Emby.Naming/Video/FlagParser.cs index a8bd9d5c5d..439de18138 100644 --- a/Emby.Naming/Video/FlagParser.cs +++ b/Emby.Naming/Video/FlagParser.cs @@ -1,37 +1,53 @@ -#pragma warning disable CS1591 - using System; using System.IO; using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Parses list of flags from filename based on delimiters. + /// </summary> public class FlagParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="FlagParser"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFlagDelimiters.</param> public FlagParser(NamingOptions options) { _options = options; } + /// <summary> + /// Calls GetFlags function with _options.VideoFlagDelimiters parameter. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>List of found flags.</returns> public string[] GetFlags(string path) { return GetFlags(path, _options.VideoFlagDelimiters); } - public string[] GetFlags(string path, char[] delimeters) + /// <summary> + /// Parses flags from filename based on delimiters. + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="delimiters">Delimiters used to extract flags.</param> + /// <returns>List of found flags.</returns> + public string[] GetFlags(string path, char[] delimiters) { if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException(nameof(path)); + return Array.Empty<string>(); } // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _. var file = Path.GetFileName(path); - return file.Split(delimeters, StringSplitOptions.RemoveEmptyEntries); + return file.Split(delimiters, StringSplitOptions.RemoveEmptyEntries); } } } diff --git a/Emby.Naming/Video/Format3DParser.cs b/Emby.Naming/Video/Format3DParser.cs index 51c26af863..4fd5d78ba7 100644 --- a/Emby.Naming/Video/Format3DParser.cs +++ b/Emby.Naming/Video/Format3DParser.cs @@ -1,28 +1,38 @@ -#pragma warning disable CS1591 - using System; using System.Linq; using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Parste 3D format related flags. + /// </summary> public class Format3DParser { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="Format3DParser"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFlagDelimiters and passes options to <see cref="FlagParser"/>.</param> public Format3DParser(NamingOptions options) { _options = options; } + /// <summary> + /// Parse 3D format related flags. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>Returns <see cref="Format3DResult"/> object.</returns> public Format3DResult Parse(string path) { int oldLen = _options.VideoFlagDelimiters.Length; - var delimeters = new char[oldLen + 1]; - _options.VideoFlagDelimiters.CopyTo(delimeters, 0); - delimeters[oldLen] = ' '; + var delimiters = new char[oldLen + 1]; + _options.VideoFlagDelimiters.CopyTo(delimiters, 0); + delimiters[oldLen] = ' '; - return Parse(new FlagParser(_options).GetFlags(path, delimeters)); + return Parse(new FlagParser(_options).GetFlags(path, delimiters)); } internal Format3DResult Parse(string[] videoFlags) @@ -44,7 +54,7 @@ namespace Emby.Naming.Video { var result = new Format3DResult(); - if (string.IsNullOrEmpty(rule.PreceedingToken)) + if (string.IsNullOrEmpty(rule.PrecedingToken)) { result.Format3D = new[] { rule.Token }.FirstOrDefault(i => videoFlags.Contains(i, StringComparer.OrdinalIgnoreCase)); result.Is3D = !string.IsNullOrEmpty(result.Format3D); @@ -57,13 +67,13 @@ namespace Emby.Naming.Video else { var foundPrefix = false; - string format = null; + string? format = null; foreach (var flag in videoFlags) { if (foundPrefix) { - result.Tokens.Add(rule.PreceedingToken); + result.Tokens.Add(rule.PrecedingToken); if (string.Equals(rule.Token, flag, StringComparison.OrdinalIgnoreCase)) { @@ -74,7 +84,7 @@ namespace Emby.Naming.Video break; } - foundPrefix = string.Equals(flag, rule.PreceedingToken, StringComparison.OrdinalIgnoreCase); + foundPrefix = string.Equals(flag, rule.PrecedingToken, StringComparison.OrdinalIgnoreCase); } result.Is3D = foundPrefix && !string.IsNullOrEmpty(format); diff --git a/Emby.Naming/Video/Format3DResult.cs b/Emby.Naming/Video/Format3DResult.cs index fa0e9d3b80..ac935f2030 100644 --- a/Emby.Naming/Video/Format3DResult.cs +++ b/Emby.Naming/Video/Format3DResult.cs @@ -1,11 +1,15 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; namespace Emby.Naming.Video { + /// <summary> + /// Helper object to return data from <see cref="Format3DParser"/>. + /// </summary> public class Format3DResult { + /// <summary> + /// Initializes a new instance of the <see cref="Format3DResult"/> class. + /// </summary> public Format3DResult() { Tokens = new List<string>(); @@ -21,7 +25,7 @@ namespace Emby.Naming.Video /// Gets or sets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } + public string? Format3D { get; set; } /// <summary> /// Gets or sets the tokens. diff --git a/Emby.Naming/Video/Format3DRule.cs b/Emby.Naming/Video/Format3DRule.cs index 310ec84e8f..e562691df9 100644 --- a/Emby.Naming/Video/Format3DRule.cs +++ b/Emby.Naming/Video/Format3DRule.cs @@ -1,9 +1,21 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Data holder class for 3D format rule. + /// </summary> public class Format3DRule { + /// <summary> + /// Initializes a new instance of the <see cref="Format3DRule"/> class. + /// </summary> + /// <param name="token">Token.</param> + /// <param name="precedingToken">Token present before current token.</param> + public Format3DRule(string token, string? precedingToken = null) + { + Token = token; + PrecedingToken = precedingToken; + } + /// <summary> /// Gets or sets the token. /// </summary> @@ -11,9 +23,9 @@ namespace Emby.Naming.Video public string Token { get; set; } /// <summary> - /// Gets or sets the preceeding token. + /// Gets or sets the preceding token. /// </summary> - /// <value>The preceeding token.</value> - public string PreceedingToken { get; set; } + /// <value>The preceding token.</value> + public string? PrecedingToken { get; set; } } } diff --git a/Emby.Naming/Video/StackResolver.cs b/Emby.Naming/Video/StackResolver.cs index f733cd2620..550c429614 100644 --- a/Emby.Naming/Video/StackResolver.cs +++ b/Emby.Naming/Video/StackResolver.cs @@ -1,58 +1,88 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; +using Emby.Naming.AudioBook; using Emby.Naming.Common; using MediaBrowser.Model.IO; namespace Emby.Naming.Video { + /// <summary> + /// Resolve <see cref="FileStack"/> from list of paths. + /// </summary> public class StackResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="StackResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileStackingRegexes and passes options to <see cref="VideoResolver"/>.</param> public StackResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolves only directories from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> public IEnumerable<FileStack> ResolveDirectories(IEnumerable<string> files) { return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true })); } + /// <summary> + /// Resolves only files from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of files.</returns> public IEnumerable<FileStack> ResolveFiles(IEnumerable<string> files) { return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false })); } - public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<FileSystemMetadata> files) + /// <summary> + /// Resolves audiobooks from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of directories.</returns> + public IEnumerable<FileStack> ResolveAudioBooks(IEnumerable<AudioBookFileInfo> files) { - var groupedDirectoryFiles = files.GroupBy(file => - file.IsDirectory - ? file.FullName - : Path.GetDirectoryName(file.FullName)); + var groupedDirectoryFiles = files.GroupBy(file => Path.GetDirectoryName(file.Path)); foreach (var directory in groupedDirectoryFiles) { - var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false }; - foreach (var file in directory) + if (string.IsNullOrEmpty(directory.Key)) { - if (file.IsDirectory) + foreach (var file in directory) { - continue; + var stack = new FileStack { Name = Path.GetFileNameWithoutExtension(file.Path), IsDirectoryStack = false }; + stack.Files.Add(file.Path); + yield return stack; + } + } + else + { + var stack = new FileStack { Name = Path.GetFileName(directory.Key), IsDirectoryStack = false }; + foreach (var file in directory) + { + stack.Files.Add(file.Path); } - stack.Files.Add(file.FullName); + yield return stack; } - - yield return stack; } } + /// <summary> + /// Resolves videos from paths. + /// </summary> + /// <param name="files">List of paths.</param> + /// <returns>Enumerable <see cref="FileStack"/> of videos.</returns> public IEnumerable<FileStack> Resolve(IEnumerable<FileSystemMetadata> files) { var resolver = new VideoResolver(_options); @@ -81,10 +111,10 @@ namespace Emby.Naming.Video if (match1.Success) { - var title1 = match1.Groups[1].Value; - var volume1 = match1.Groups[2].Value; - var ignore1 = match1.Groups[3].Value; - var extension1 = match1.Groups[4].Value; + var title1 = match1.Groups["title"].Value; + var volume1 = match1.Groups["volume"].Value; + var ignore1 = match1.Groups["ignore"].Value; + var extension1 = match1.Groups["extension"].Value; var j = i + 1; while (j < list.Count) diff --git a/Emby.Naming/Video/StubResolver.cs b/Emby.Naming/Video/StubResolver.cs index f1b5d7bcca..079987fe8a 100644 --- a/Emby.Naming/Video/StubResolver.cs +++ b/Emby.Naming/Video/StubResolver.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.IO; using System.Linq; @@ -8,13 +5,23 @@ using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Resolve if file is stub (.disc). + /// </summary> public static class StubResolver { + /// <summary> + /// Tries to resolve if file is stub (.disc). + /// </summary> + /// <param name="path">Path to file.</param> + /// <param name="options">NamingOptions containing StubFileExtensions and StubTypes.</param> + /// <param name="stubType">Stub type.</param> + /// <returns>True if file is a stub.</returns> public static bool TryResolveFile(string path, NamingOptions options, out string? stubType) { stubType = default; - if (path == null) + if (string.IsNullOrEmpty(path)) { return false; } diff --git a/Emby.Naming/Video/StubResult.cs b/Emby.Naming/Video/StubResult.cs deleted file mode 100644 index 1b8e99b0dc..0000000000 --- a/Emby.Naming/Video/StubResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -#pragma warning disable CS1591 - -namespace Emby.Naming.Video -{ - public struct StubResult - { - /// <summary> - /// Gets or sets a value indicating whether this instance is stub. - /// </summary> - /// <value><c>true</c> if this instance is stub; otherwise, <c>false</c>.</value> - public bool IsStub { get; set; } - - /// <summary> - /// Gets or sets the type of the stub. - /// </summary> - /// <value>The type of the stub.</value> - public string StubType { get; set; } - } -} diff --git a/Emby.Naming/Video/StubTypeRule.cs b/Emby.Naming/Video/StubTypeRule.cs index 8285cb51a3..dfb3ac013d 100644 --- a/Emby.Naming/Video/StubTypeRule.cs +++ b/Emby.Naming/Video/StubTypeRule.cs @@ -1,9 +1,21 @@ -#pragma warning disable CS1591 - namespace Emby.Naming.Video { + /// <summary> + /// Data class holding information about Stub type rule. + /// </summary> public class StubTypeRule { + /// <summary> + /// Initializes a new instance of the <see cref="StubTypeRule"/> class. + /// </summary> + /// <param name="token">Token.</param> + /// <param name="stubType">Stub type.</param> + public StubTypeRule(string token, string stubType) + { + Token = token; + StubType = stubType; + } + /// <summary> /// Gets or sets the token. /// </summary> diff --git a/Emby.Naming/Video/VideoFileInfo.cs b/Emby.Naming/Video/VideoFileInfo.cs index 11e789b663..1457db7378 100644 --- a/Emby.Naming/Video/VideoFileInfo.cs +++ b/Emby.Naming/Video/VideoFileInfo.cs @@ -7,6 +7,35 @@ namespace Emby.Naming.Video /// </summary> public class VideoFileInfo { + /// <summary> + /// Initializes a new instance of the <see cref="VideoFileInfo"/> class. + /// </summary> + /// <param name="name">Name of file.</param> + /// <param name="path">Path to the file.</param> + /// <param name="container">Container type.</param> + /// <param name="year">Year of release.</param> + /// <param name="extraType">Extra type.</param> + /// <param name="extraRule">Extra rule.</param> + /// <param name="format3D">Format 3D.</param> + /// <param name="is3D">Is 3D.</param> + /// <param name="isStub">Is Stub.</param> + /// <param name="stubType">Stub type.</param> + /// <param name="isDirectory">Is directory.</param> + public VideoFileInfo(string name, string path, string? container, int? year = default, ExtraType? extraType = default, ExtraRule? extraRule = default, string? format3D = default, bool is3D = default, bool isStub = default, string? stubType = default, bool isDirectory = default) + { + Path = path; + Container = container; + Name = name; + Year = year; + ExtraType = extraType; + ExtraRule = extraRule; + Format3D = format3D; + Is3D = is3D; + IsStub = isStub; + StubType = stubType; + IsDirectory = isDirectory; + } + /// <summary> /// Gets or sets the path. /// </summary> @@ -17,7 +46,7 @@ namespace Emby.Naming.Video /// Gets or sets the container. /// </summary> /// <value>The container.</value> - public string Container { get; set; } + public string? Container { get; set; } /// <summary> /// Gets or sets the name. @@ -41,13 +70,13 @@ namespace Emby.Naming.Video /// Gets or sets the extra rule. /// </summary> /// <value>The extra rule.</value> - public ExtraRule ExtraRule { get; set; } + public ExtraRule? ExtraRule { get; set; } /// <summary> /// Gets or sets the format3 d. /// </summary> /// <value>The format3 d.</value> - public string Format3D { get; set; } + public string? Format3D { get; set; } /// <summary> /// Gets or sets a value indicating whether [is3 d]. @@ -65,7 +94,7 @@ namespace Emby.Naming.Video /// Gets or sets the type of the stub. /// </summary> /// <value>The type of the stub.</value> - public string StubType { get; set; } + public string? StubType { get; set; } /// <summary> /// Gets or sets a value indicating whether this instance is a directory. @@ -84,8 +113,7 @@ namespace Emby.Naming.Video /// <inheritdoc /> public override string ToString() { - // Makes debugging easier - return Name ?? base.ToString(); + return "VideoFileInfo(Name: '" + Name + "')"; } } } diff --git a/Emby.Naming/Video/VideoInfo.cs b/Emby.Naming/Video/VideoInfo.cs index ea74c40e2a..930fdb33f8 100644 --- a/Emby.Naming/Video/VideoInfo.cs +++ b/Emby.Naming/Video/VideoInfo.cs @@ -12,7 +12,7 @@ namespace Emby.Naming.Video /// Initializes a new instance of the <see cref="VideoInfo" /> class. /// </summary> /// <param name="name">The name.</param> - public VideoInfo(string name) + public VideoInfo(string? name) { Name = name; @@ -25,7 +25,7 @@ namespace Emby.Naming.Video /// Gets or sets the name. /// </summary> /// <value>The name.</value> - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the year. diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 948fe037b5..5f83355c8f 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; @@ -11,22 +9,35 @@ using MediaBrowser.Model.IO; namespace Emby.Naming.Video { + /// <summary> + /// Resolves alternative versions and extras from list of video files. + /// </summary> public class VideoListResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="VideoListResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing CleanStringRegexes and VideoFlagDelimiters and passes options to <see cref="StackResolver"/> and <see cref="VideoResolver"/>.</param> public VideoListResolver(NamingOptions options) { _options = options; } + /// <summary> + /// Resolves alternative versions and extras from list of video files. + /// </summary> + /// <param name="files">List of related video files.</param> + /// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param> + /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns> public IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, bool supportMultiVersion = true) { var videoResolver = new VideoResolver(_options); var videoInfos = files .Select(i => videoResolver.Resolve(i.FullName, i.IsDirectory)) - .Where(i => i != null) + .OfType<VideoFileInfo>() .ToList(); // Filter out all extras, otherwise they could cause stacks to not be resolved @@ -39,7 +50,7 @@ namespace Emby.Naming.Video .Resolve(nonExtras).ToList(); var remainingFiles = videoInfos - .Where(i => !stackResult.Any(s => s.ContainsFile(i.Path, i.IsDirectory))) + .Where(i => !stackResult.Any(s => i.Path != null && s.ContainsFile(i.Path, i.IsDirectory))) .ToList(); var list = new List<VideoInfo>(); @@ -48,7 +59,9 @@ namespace Emby.Naming.Video { var info = new VideoInfo(stack.Name) { - Files = stack.Files.Select(i => videoResolver.Resolve(i, stack.IsDirectoryStack)).ToList() + Files = stack.Files.Select(i => videoResolver.Resolve(i, stack.IsDirectoryStack)) + .OfType<VideoFileInfo>() + .ToList() }; info.Year = info.Files[0].Year; @@ -133,7 +146,7 @@ namespace Emby.Naming.Video } // If there's only one video, accept all trailers - // Be lenient because people use all kinds of mish mash conventions with trailers + // Be lenient because people use all kinds of mishmash conventions with trailers. if (list.Count == 1) { var trailers = remainingFiles @@ -203,15 +216,21 @@ namespace Emby.Naming.Video return videos.Select(i => i.Year ?? -1).Distinct().Count() < 2; } - private bool IsEligibleForMultiVersion(string folderName, string testFilename) + private bool IsEligibleForMultiVersion(string folderName, string? testFilename) { testFilename = Path.GetFileNameWithoutExtension(testFilename) ?? string.Empty; if (testFilename.StartsWith(folderName, StringComparison.OrdinalIgnoreCase)) { + if (CleanStringParser.TryClean(testFilename, _options.CleanStringRegexes, out var cleanName)) + { + testFilename = cleanName.ToString(); + } + testFilename = testFilename.Substring(folderName.Length).Trim(); return string.IsNullOrEmpty(testFilename) - || testFilename[0] == '-' + || testFilename[0].Equals('-') + || testFilename[0].Equals('_') || string.IsNullOrWhiteSpace(Regex.Replace(testFilename, @"\[([^]]*)\]", string.Empty)); } diff --git a/Emby.Naming/Video/VideoResolver.cs b/Emby.Naming/Video/VideoResolver.cs index b4aee614b0..d7165d8d7f 100644 --- a/Emby.Naming/Video/VideoResolver.cs +++ b/Emby.Naming/Video/VideoResolver.cs @@ -1,6 +1,3 @@ -#pragma warning disable CS1591 -#nullable enable - using System; using System.IO; using System.Linq; @@ -8,10 +5,18 @@ using Emby.Naming.Common; namespace Emby.Naming.Video { + /// <summary> + /// Resolves <see cref="VideoFileInfo"/> from file path. + /// </summary> public class VideoResolver { private readonly NamingOptions _options; + /// <summary> + /// Initializes a new instance of the <see cref="VideoResolver"/> class. + /// </summary> + /// <param name="options"><see cref="NamingOptions"/> object containing VideoFileExtensions, StubFileExtensions, CleanStringRegexes and CleanDateTimeRegexes + /// and passes options in <see cref="StubResolver"/>, <see cref="FlagParser"/>, <see cref="Format3DParser"/> and <see cref="ExtraResolver"/>.</param> public VideoResolver(NamingOptions options) { _options = options; @@ -22,7 +27,7 @@ namespace Emby.Naming.Video /// </summary> /// <param name="path">The path.</param> /// <returns>VideoFileInfo.</returns> - public VideoFileInfo? ResolveDirectory(string path) + public VideoFileInfo? ResolveDirectory(string? path) { return Resolve(path, true); } @@ -32,7 +37,7 @@ namespace Emby.Naming.Video /// </summary> /// <param name="path">The path.</param> /// <returns>VideoFileInfo.</returns> - public VideoFileInfo? ResolveFile(string path) + public VideoFileInfo? ResolveFile(string? path) { return Resolve(path, false); } @@ -45,11 +50,11 @@ namespace Emby.Naming.Video /// <param name="parseName">Whether or not the name should be parsed for info.</param> /// <returns>VideoFileInfo.</returns> /// <exception cref="ArgumentNullException"><c>path</c> is <c>null</c>.</exception> - public VideoFileInfo? Resolve(string path, bool isDirectory, bool parseName = true) + public VideoFileInfo? Resolve(string? path, bool isDirectory, bool parseName = true) { if (string.IsNullOrEmpty(path)) { - throw new ArgumentNullException(nameof(path)); + return null; } bool isStub = false; @@ -99,39 +104,58 @@ namespace Emby.Naming.Video } } - return new VideoFileInfo - { - Path = path, - Container = container, - IsStub = isStub, - Name = name, - Year = year, - StubType = stubType, - Is3D = format3DResult.Is3D, - Format3D = format3DResult.Format3D, - ExtraType = extraResult.ExtraType, - IsDirectory = isDirectory, - ExtraRule = extraResult.Rule - }; + return new VideoFileInfo( + path: path, + container: container, + isStub: isStub, + name: name, + year: year, + stubType: stubType, + is3D: format3DResult.Is3D, + format3D: format3DResult.Format3D, + extraType: extraResult.ExtraType, + isDirectory: isDirectory, + extraRule: extraResult.Rule); } + /// <summary> + /// Determines if path is video file based on extension. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>True if is video file.</returns> public bool IsVideoFile(string path) { var extension = Path.GetExtension(path) ?? string.Empty; return _options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); } + /// <summary> + /// Determines if path is video file stub based on extension. + /// </summary> + /// <param name="path">Path to file.</param> + /// <returns>True if is video file stub.</returns> public bool IsStubFile(string path) { var extension = Path.GetExtension(path) ?? string.Empty; return _options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase); } + /// <summary> + /// Tries to clean name of clutter. + /// </summary> + /// <param name="name">Raw name.</param> + /// <param name="newName">Clean name.</param> + /// <returns>True if cleaning of name was successful.</returns> public bool TryCleanString(string name, out ReadOnlySpan<char> newName) { return CleanStringParser.TryClean(name, _options.CleanStringRegexes, out newName); } + /// <summary> + /// Tries to get name and year from raw name. + /// </summary> + /// <param name="name">Raw name.</param> + /// <returns>Returns <see cref="CleanDateTimeResult"/> with name and optional year.</returns> public CleanDateTimeResult CleanDateTime(string name) { return CleanDateTimeParser.Clean(name, _options.CleanDateTimeRegexes); diff --git a/Emby.Notifications/Emby.Notifications.csproj b/Emby.Notifications/Emby.Notifications.csproj index 1d430a5e58..16ee918c46 100644 --- a/Emby.Notifications/Emby.Notifications.csproj +++ b/Emby.Notifications/Emby.Notifications.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs index 7116d52b11..7433d3c8ae 100644 --- a/Emby.Notifications/NotificationEntryPoint.cs +++ b/Emby.Notifications/NotificationEntryPoint.cs @@ -83,7 +83,7 @@ namespace Emby.Notifications return Task.CompletedTask; } - private async void OnAppHostHasPendingRestartChanged(object sender, EventArgs e) + private async void OnAppHostHasPendingRestartChanged(object? sender, EventArgs e) { var type = NotificationType.ServerRestartRequired.ToString(); @@ -99,7 +99,7 @@ namespace Emby.Notifications await SendNotification(notification, null).ConfigureAwait(false); } - private async void OnActivityManagerEntryCreated(object sender, GenericEventArgs<ActivityLogEntry> e) + private async void OnActivityManagerEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) { var entry = e.Argument; @@ -132,7 +132,7 @@ namespace Emby.Notifications return _config.GetConfiguration<NotificationOptions>("notifications"); } - private async void OnAppHostHasUpdateAvailableChanged(object sender, EventArgs e) + private async void OnAppHostHasUpdateAvailableChanged(object? sender, EventArgs e) { if (!_appHost.HasUpdateAvailable) { @@ -151,7 +151,7 @@ namespace Emby.Notifications await SendNotification(notification, null).ConfigureAwait(false); } - private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) + private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) { if (!FilterItem(e.Item)) { @@ -197,7 +197,7 @@ namespace Emby.Notifications return item.SourceType == SourceType.Library; } - private async void LibraryUpdateTimerCallback(object state) + private async void LibraryUpdateTimerCallback(object? state) { List<BaseItem> items; diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj index dbe01257f4..62e33e6c44 100644 --- a/Emby.Photos/Emby.Photos.csproj +++ b/Emby.Photos/Emby.Photos.csproj @@ -19,7 +19,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 4ab0a2a3f2..4f72c8ce15 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -133,6 +133,33 @@ namespace Emby.Server.Implementations.AppBase } } + /// <summary> + /// Manually pre-loads a factory so that it is available pre system initialisation. + /// </summary> + /// <typeparam name="T">Class to register.</typeparam> + public virtual void RegisterConfiguration<T>() + where T : IConfigurationFactory + { + IConfigurationFactory factory = Activator.CreateInstance<T>(); + + if (_configurationFactories == null) + { + _configurationFactories = new[] { factory }; + } + else + { + var oldLen = _configurationFactories.Length; + var arr = new IConfigurationFactory[oldLen + 1]; + _configurationFactories.CopyTo(arr, 0); + arr[oldLen] = factory; + _configurationFactories = arr; + } + + _configurationStores = _configurationFactories + .SelectMany(i => i.GetConfigurations()) + .ToArray(); + } + /// <summary> /// Adds parts. /// </summary> diff --git a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs index 4c9ab33a7c..77819c7649 100644 --- a/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs +++ b/Emby.Server.Implementations/AppBase/ConfigurationHelper.cs @@ -3,6 +3,7 @@ using System; using System.IO; using System.Linq; +using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Serialization; namespace Emby.Server.Implementations.AppBase @@ -35,7 +36,7 @@ namespace Emby.Server.Implementations.AppBase } catch (Exception) { - configuration = Activator.CreateInstance(type); + configuration = Activator.CreateInstance(type) ?? throw new ArgumentException($"Provided path ({type}) is not valid.", nameof(type)); } using var stream = new MemoryStream(buffer?.Length ?? 0); @@ -48,8 +49,9 @@ namespace Emby.Server.Implementations.AppBase // If the file didn't exist before, or if something has changed, re-save if (buffer == null || !newBytes.AsSpan(0, newBytesLen).SequenceEqual(buffer)) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); + var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); + Directory.CreateDirectory(directory); // Save it after load in case we got new items using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read)) { diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 58edbe9878..5d47d1e401 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -30,7 +29,6 @@ using Emby.Server.Implementations.Cryptography; using Emby.Server.Implementations.Data; using Emby.Server.Implementations.Devices; using Emby.Server.Implementations.Dto; -using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; @@ -96,7 +94,6 @@ using MediaBrowser.Model.System; using MediaBrowser.Model.Tasks; using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Manager; -using MediaBrowser.Providers.Plugins.TheTvdb; using MediaBrowser.Providers.Plugins.Tmdb; using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; @@ -128,7 +125,6 @@ namespace Emby.Server.Implementations private IMediaEncoder _mediaEncoder; private ISessionManager _sessionManager; private IHttpClientFactory _httpClientFactory; - private string[] _urlPrefixes; /// <summary> @@ -499,24 +495,11 @@ namespace Emby.Server.Implementations HttpsPort = ServerConfiguration.DefaultHttpsPort; } - if (Plugins != null) - { - var pluginBuilder = new StringBuilder(); - - foreach (var plugin in Plugins) - { - pluginBuilder.Append(plugin.Name) - .Append(' ') - .Append(plugin.Version) - .AppendLine(); - } - - Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString()); - } - DiscoverTypes(); RegisterServices(); + + RegisterPluginServices(); } /// <summary> @@ -536,7 +519,6 @@ namespace Emby.Server.Implementations ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>(); ServiceCollection.AddSingleton(_fileSystemManager); - ServiceCollection.AddSingleton<TvdbClientManager>(); ServiceCollection.AddSingleton<TmdbClientManager>(); ServiceCollection.AddSingleton(_networkManager); @@ -781,10 +763,24 @@ namespace Emby.Server.Implementations ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); _plugins = GetExports<IPlugin>() - .Select(LoadPlugin) .Where(i => i != null) .ToArray(); + if (Plugins != null) + { + var pluginBuilder = new StringBuilder(); + + foreach (var plugin in Plugins) + { + pluginBuilder.Append(plugin.Name) + .Append(' ') + .Append(plugin.Version) + .AppendLine(); + } + + Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString()); + } + _urlPrefixes = GetUrlPrefixes().ToArray(); Resolve<ILibraryManager>().AddParts( @@ -814,21 +810,6 @@ namespace Emby.Server.Implementations Resolve<IIsoManager>().AddParts(GetExports<IIsoMounter>()); } - private IPlugin LoadPlugin(IPlugin plugin) - { - try - { - plugin.RegisterServices(ServiceCollection); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error loading plugin {PluginName}", plugin.GetType().FullName); - return null; - } - - return plugin; - } - /// <summary> /// Discovers the types. /// </summary> @@ -839,6 +820,22 @@ namespace Emby.Server.Implementations _allConcreteTypes = GetTypes(GetComposablePartAssemblies()).ToArray(); } + private void RegisterPluginServices() + { + foreach (var pluginServiceRegistrator in GetExportTypes<IPluginServiceRegistrator>()) + { + try + { + var instance = (IPluginServiceRegistrator)Activator.CreateInstance(pluginServiceRegistrator); + instance.RegisterServices(ServiceCollection); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator.Assembly); + } + } + } + private IEnumerable<Type> GetTypes(IEnumerable<Assembly> assemblies) { foreach (var ass in assemblies) @@ -993,79 +990,59 @@ namespace Emby.Server.Implementations protected abstract void RestartInternal(); - /// <summary> - /// Comparison function used in <see cref="GetPlugins" />. - /// </summary> - /// <param name="a">Item to compare.</param> - /// <param name="b">Item to compare with.</param> - /// <returns>Boolean result of the operation.</returns> - private static int VersionCompare( - (Version PluginVersion, string Name, string Path) a, - (Version PluginVersion, string Name, string Path) b) + /// <inheritdoc/> + public IEnumerable<LocalPlugin> GetLocalPlugins(string path, bool cleanup = true) { - int compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture); - - if (compare == 0) + var minimumVersion = new Version(0, 0, 0, 1); + var versions = new List<LocalPlugin>(); + if (!Directory.Exists(path)) { - return a.PluginVersion.CompareTo(b.PluginVersion); + // Plugin path doesn't exist, don't try to enumerate subfolders. + return Enumerable.Empty<LocalPlugin>(); } - return compare; - } - - /// <summary> - /// Returns a list of plugins to install. - /// </summary> - /// <param name="path">Path to check.</param> - /// <param name="cleanup">True if an attempt should be made to delete old plugs.</param> - /// <returns>Enumerable list of dlls to load.</returns> - private IEnumerable<string> GetPlugins(string path, bool cleanup = true) - { - var dllList = new List<string>(); - var versions = new List<(Version PluginVersion, string Name, string Path)>(); var directories = Directory.EnumerateDirectories(path, "*.*", SearchOption.TopDirectoryOnly); - string metafile; foreach (var dir in directories) { try { - metafile = Path.Combine(dir, "meta.json"); + var metafile = Path.Combine(dir, "meta.json"); if (File.Exists(metafile)) { var manifest = _jsonSerializer.DeserializeFromFile<PluginManifest>(metafile); if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) { - targetAbi = new Version(0, 0, 0, 1); + targetAbi = minimumVersion; } if (!Version.TryParse(manifest.Version, out var version)) { - version = new Version(0, 0, 0, 1); + version = minimumVersion; } if (ApplicationVersion >= targetAbi) { // Only load Plugins if the plugin is built for this version or below. - versions.Add((version, manifest.Name, dir)); + versions.Add(new LocalPlugin(manifest.Guid, manifest.Name, version, dir)); } } else { // No metafile, so lets see if the folder is versioned. - metafile = dir.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries)[^1]; + metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1]; int versionIndex = dir.LastIndexOf('_'); - if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version ver)) + if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version parsedVersion)) { // Versioned folder. - versions.Add((ver, metafile, dir)); + versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir)); } else { // Un-versioned folder - Add it under the path name and version 0.0.0.1. - versions.Add((new Version(0, 0, 0, 1), metafile, dir)); + versions.Add(new LocalPlugin(Guid.Empty, metafile, minimumVersion, dir)); } } } @@ -1076,14 +1053,14 @@ namespace Emby.Server.Implementations } string lastName = string.Empty; - versions.Sort(VersionCompare); + versions.Sort(LocalPlugin.Compare); // Traverse backwards through the list. // The first item will be the latest version. for (int x = versions.Count - 1; x >= 0; x--) { if (!string.Equals(lastName, versions[x].Name, StringComparison.OrdinalIgnoreCase)) { - dllList.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories)); + versions[x].DllFiles.AddRange(Directory.EnumerateFiles(versions[x].Path, "*.dll", SearchOption.AllDirectories)); lastName = versions[x].Name; continue; } @@ -1100,10 +1077,12 @@ namespace Emby.Server.Implementations { Logger.LogWarning(e, "Unable to delete {Path}", versions[x].Path); } + + versions.RemoveAt(x); } } - return dllList; + return versions; } /// <summary> @@ -1114,21 +1093,24 @@ namespace Emby.Server.Implementations { if (Directory.Exists(ApplicationPaths.PluginsPath)) { - foreach (var file in GetPlugins(ApplicationPaths.PluginsPath)) + foreach (var plugin in GetLocalPlugins(ApplicationPaths.PluginsPath)) { - Assembly plugAss; - try + foreach (var file in plugin.DllFiles) { - plugAss = Assembly.LoadFrom(file); - } - catch (FileLoadException ex) - { - Logger.LogError(ex, "Failed to load assembly {Path}", file); - continue; - } + Assembly plugAss; + try + { + plugAss = Assembly.LoadFrom(file); + } + catch (FileLoadException ex) + { + Logger.LogError(ex, "Failed to load assembly {Path}", file); + continue; + } - Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file); - yield return plugAss; + Logger.LogInformation("Loaded assembly {Assembly} from {Path}", plugAss.FullName, file); + yield return plugAss; + } } } @@ -1395,7 +1377,7 @@ namespace Emby.Server.Implementations using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await System.Text.Json.JsonSerializer.DeserializeAsync<string>(stream, JsonDefaults.GetOptions(), cancellationToken).ConfigureAwait(false); var valid = string.Equals(Name, result, StringComparison.OrdinalIgnoreCase); diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 19045b72b4..3d97a6ca8d 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -634,7 +634,7 @@ namespace Emby.Server.Implementations.Channels { var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray(); - if (query.ChannelIds.Length > 0) + if (query.ChannelIds.Count > 0) { // Avoid implicitly captured closure var ids = query.ChannelIds; diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs index fd302d1365..12a9e44e70 100644 --- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs +++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Security.Cryptography; +using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Cryptography; using static MediaBrowser.Common.Cryptography.Constants; @@ -80,7 +81,7 @@ namespace Emby.Server.Implementations.Cryptography throw new CryptographicException($"Requested hash method is not supported: {hashMethod}"); } - using var h = HashAlgorithm.Create(hashMethod); + using var h = HashAlgorithm.Create(hashMethod) ?? throw new ResourceNotFoundException($"Unknown hash method: {hashMethod}."); if (salt.Length == 0) { return h.ComputeHash(bytes); diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index 70a6df977f..1af301ceb0 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -107,20 +107,6 @@ namespace Emby.Server.Implementations.Data return null; } - public static void Attach(SQLiteDatabaseConnection db, string path, string alias) - { - var commandText = string.Format( - CultureInfo.InvariantCulture, - "attach @path as {0};", - alias); - - using (var statement = db.PrepareStatement(commandText)) - { - statement.TryBind("@path", path); - statement.MoveNext(); - } - } - public static bool IsDBNull(this IReadOnlyList<IResultSetValue> result, int index) { return result[index].SQLiteType == SQLiteType.Null; diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 81e8e38b33..7e01bd4b64 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -1007,7 +1007,7 @@ namespace Emby.Server.Implementations.Data return; } - var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + var parts = value.Split('|', StringSplitOptions.RemoveEmptyEntries); foreach (var part in parts) { @@ -1057,7 +1057,7 @@ namespace Emby.Server.Implementations.Data return; } - var parts = value.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + var parts = value.Split('|' , StringSplitOptions.RemoveEmptyEntries); var list = new List<ItemImageInfo>(); foreach (var part in parts) { @@ -1096,7 +1096,7 @@ namespace Emby.Server.Implementations.Data public ItemImageInfo ItemImageInfoFromValueString(string value) { - var parts = value.Split(new[] { '*' }, StringSplitOptions.None); + var parts = value.Split('*', StringSplitOptions.None); if (parts.Length < 3) { @@ -1532,7 +1532,7 @@ namespace Emby.Server.Implementations.Data { if (!reader.IsDBNull(index)) { - item.Genres = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Genres = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; @@ -1593,7 +1593,7 @@ namespace Emby.Server.Implementations.Data { IEnumerable<MetadataField> GetLockedFields(string s) { - foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (Enum.TryParse(i, true, out MetadataField parsedValue)) { @@ -1612,7 +1612,7 @@ namespace Emby.Server.Implementations.Data { if (!reader.IsDBNull(index)) { - item.Studios = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Studios = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; @@ -1622,7 +1622,7 @@ namespace Emby.Server.Implementations.Data { if (!reader.IsDBNull(index)) { - item.Tags = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + item.Tags = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; @@ -1636,7 +1636,7 @@ namespace Emby.Server.Implementations.Data { IEnumerable<TrailerType> GetTrailerTypes(string s) { - foreach (var i in s.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries)) + foreach (var i in s.Split('|', StringSplitOptions.RemoveEmptyEntries)) { if (Enum.TryParse(i, true, out TrailerType parsedValue)) { @@ -1811,7 +1811,7 @@ namespace Emby.Server.Implementations.Data { if (!reader.IsDBNull(index)) { - item.ProductionLocations = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries).ToArray(); + item.ProductionLocations = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries).ToArray(); } index++; @@ -1848,14 +1848,14 @@ namespace Emby.Server.Implementations.Data { if (item is IHasArtist hasArtists && !reader.IsDBNull(index)) { - hasArtists.Artists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + hasArtists.Artists = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; if (item is IHasAlbumArtist hasAlbumArtists && !reader.IsDBNull(index)) { - hasAlbumArtists.AlbumArtists = reader.GetString(index).Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + hasAlbumArtists.AlbumArtists = reader.GetString(index).Split('|', StringSplitOptions.RemoveEmptyEntries); } index++; @@ -2403,11 +2403,11 @@ namespace Emby.Server.Implementations.Data if (string.IsNullOrEmpty(item.OfficialRating)) { - builder.Append("((OfficialRating is null) * 10)"); + builder.Append("(OfficialRating is null * 10)"); } else { - builder.Append("((OfficialRating=@ItemOfficialRating) * 10)"); + builder.Append("(OfficialRating=@ItemOfficialRating * 10)"); } if (item.ProductionYear.HasValue) @@ -2416,8 +2416,26 @@ namespace Emby.Server.Implementations.Data builder.Append("+(Select Case When Abs(COALESCE(ProductionYear, 0) - @ItemProductionYear) < 5 Then 5 Else 0 End )"); } - //// genres, tags - builder.Append("+ ((Select count(CleanValue) from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId)) * 10)"); + // genres, tags, studios, person, year? + builder.Append("+ (Select count(1) * 10 from ItemValues where ItemId=Guid and CleanValue in (select CleanValue from itemvalues where ItemId=@SimilarItemId))"); + + if (item is MusicArtist) + { + // Match albums where the artist is AlbumArtist against other albums. + // It is assumed that similar albums => similar artists. + builder.Append( + @"+ (WITH artistValues AS ( + SELECT DISTINCT albumValues.CleanValue + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = @SimilarItemId + ), similarArtist AS ( + SELECT albumValues.ItemId + FROM ItemValues albumValues + INNER JOIN ItemValues artistAlbums ON albumValues.ItemId = artistAlbums.ItemId + INNER JOIN TypedBaseItems artistItem ON artistAlbums.CleanValue = artistItem.CleanName AND artistAlbums.TYPE = 1 AND artistItem.Guid = A.Guid + ) SELECT COUNT(DISTINCT(CleanValue)) * 10 FROM ItemValues WHERE ItemId IN (SELECT ItemId FROM similarArtist) AND CleanValue IN (SELECT CleanValue FROM artistValues))"); + } builder.Append(") as SimilarityScore"); @@ -3593,12 +3611,12 @@ namespace Emby.Server.Implementations.Data whereClauses.Add($"type in ({inClause})"); } - if (query.ChannelIds.Length == 1) + if (query.ChannelIds.Count == 1) { whereClauses.Add("ChannelId=@ChannelId"); statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture)); } - else if (query.ChannelIds.Length > 1) + else if (query.ChannelIds.Count > 1) { var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'")); whereClauses.Add($"ChannelId in ({inClause})"); @@ -4058,7 +4076,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(clause); } - if (query.GenreIds.Length > 0) + if (query.GenreIds.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -4079,7 +4097,7 @@ namespace Emby.Server.Implementations.Data whereClauses.Add(clause); } - if (query.Genres.Length > 0) + if (query.Genres.Count > 0) { var clauses = new List<string>(); var index = 0; @@ -5002,26 +5020,33 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select Distinct Name from People"; + var commandText = new StringBuilder("select Distinct p.Name from People p"); + + if (query.User != null && query.IsFavorite.HasValue) + { + commandText.Append(" LEFT JOIN TypedBaseItems tbi ON tbi.Name=p.Name AND tbi.Type='"); + commandText.Append(typeof(Person).FullName); + commandText.Append("' LEFT JOIN UserDatas ON tbi.UserDataKey=key AND userId=@UserId"); + } var whereClauses = GetPeopleWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandText.Append(" where ").Append(string.Join(" AND ", whereClauses)); } - commandText += " order by ListOrder"; + commandText.Append(" order by ListOrder"); if (query.Limit > 0) { - commandText += " LIMIT " + query.Limit; + commandText.Append(" LIMIT ").Append(query.Limit); } using (var connection = GetConnection(true)) { var list = new List<string>(); - using (var statement = PrepareStatement(connection, commandText)) + using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params GetPeopleWhereClauses(query, statement); @@ -5045,7 +5070,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People"; + var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People p"; var whereClauses = GetPeopleWhereClauses(query, null); @@ -5087,19 +5112,13 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (!query.ItemId.Equals(Guid.Empty)) { whereClauses.Add("ItemId=@ItemId"); - if (statement != null) - { - statement.TryBind("@ItemId", query.ItemId.ToByteArray()); - } + statement?.TryBind("@ItemId", query.ItemId.ToByteArray()); } if (!query.AppearsInItemId.Equals(Guid.Empty)) { - whereClauses.Add("Name in (Select Name from People where ItemId=@AppearsInItemId)"); - if (statement != null) - { - statement.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); - } + whereClauses.Add("p.Name in (Select Name from People where ItemId=@AppearsInItemId)"); + statement?.TryBind("@AppearsInItemId", query.AppearsInItemId.ToByteArray()); } var queryPersonTypes = query.PersonTypes.Where(IsValidPersonType).ToList(); @@ -5107,10 +5126,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryPersonTypes.Count == 1) { whereClauses.Add("PersonType=@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryPersonTypes[0]); - } + statement?.TryBind("@PersonType", queryPersonTypes[0]); } else if (queryPersonTypes.Count > 1) { @@ -5124,10 +5140,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (queryExcludePersonTypes.Count == 1) { whereClauses.Add("PersonType<>@PersonType"); - if (statement != null) - { - statement.TryBind("@PersonType", queryExcludePersonTypes[0]); - } + statement?.TryBind("@PersonType", queryExcludePersonTypes[0]); } else if (queryExcludePersonTypes.Count > 1) { @@ -5139,19 +5152,24 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type if (query.MaxListOrder.HasValue) { whereClauses.Add("ListOrder<=@MaxListOrder"); - if (statement != null) - { - statement.TryBind("@MaxListOrder", query.MaxListOrder.Value); - } + statement?.TryBind("@MaxListOrder", query.MaxListOrder.Value); } if (!string.IsNullOrWhiteSpace(query.NameContains)) { - whereClauses.Add("Name like @NameContains"); - if (statement != null) - { - statement.TryBind("@NameContains", "%" + query.NameContains + "%"); - } + whereClauses.Add("p.Name like @NameContains"); + statement?.TryBind("@NameContains", "%" + query.NameContains + "%"); + } + + if (query.IsFavorite.HasValue) + { + whereClauses.Add("isFavorite=@IsFavorite"); + statement?.TryBind("@IsFavorite", query.IsFavorite.Value); + } + + if (query.User != null) + { + statement?.TryBind("@UserId", query.User.InternalId); } return whereClauses; @@ -5420,6 +5438,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type NameStartsWithOrGreater = query.NameStartsWithOrGreater, Tags = query.Tags, OfficialRatings = query.OfficialRatings, + StudioIds = query.StudioIds, GenreIds = query.GenreIds, Genres = query.Genres, Years = query.Years, @@ -5592,7 +5611,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return counts; } - var allTypes = typeString.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + var allTypes = typeString.Split('|', StringSplitOptions.RemoveEmptyEntries) .ToLookup(x => x); foreach (var type in allTypes) diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs index f98c694c46..da5047d244 100644 --- a/Emby.Server.Implementations/Devices/DeviceManager.cs +++ b/Emby.Server.Implementations/Devices/DeviceManager.cs @@ -1,61 +1,38 @@ #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Linq; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Devices; using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Session; -using Microsoft.Extensions.Caching.Memory; namespace Emby.Server.Implementations.Devices { public class DeviceManager : IDeviceManager { - private readonly IMemoryCache _memoryCache; - private readonly IJsonSerializer _json; private readonly IUserManager _userManager; - private readonly IServerConfigurationManager _config; private readonly IAuthenticationRepository _authRepo; - private readonly object _capabilitiesSyncLock = new object(); + private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new (); - public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; - - public DeviceManager( - IAuthenticationRepository authRepo, - IJsonSerializer json, - IUserManager userManager, - IServerConfigurationManager config, - IMemoryCache memoryCache) + public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager) { - _json = json; _userManager = userManager; - _config = config; - _memoryCache = memoryCache; _authRepo = authRepo; } + public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated; + public void SaveCapabilities(string deviceId, ClientCapabilities capabilities) { - var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json"); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - lock (_capabilitiesSyncLock) - { - _memoryCache.Set(deviceId, capabilities); - _json.SerializeToFile(capabilities, path); - } + _capabilitiesMap[deviceId] = capabilities; } public void UpdateDeviceOptions(string deviceId, DeviceOptions options) @@ -72,32 +49,12 @@ namespace Emby.Server.Implementations.Devices public ClientCapabilities GetCapabilities(string id) { - if (_memoryCache.TryGetValue(id, out ClientCapabilities result)) - { - return result; - } - - lock (_capabilitiesSyncLock) - { - var path = Path.Combine(GetDevicePath(id), "capabilities.json"); - try - { - return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities(); - } - catch - { - } - } - - return new ClientCapabilities(); + return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result) + ? result + : new ClientCapabilities(); } public DeviceInfo GetDevice(string id) - { - return GetDevice(id, true); - } - - private DeviceInfo GetDevice(string id, bool includeCapabilities) { var session = _authRepo.Get(new AuthenticationInfoQuery { @@ -154,16 +111,6 @@ namespace Emby.Server.Implementations.Devices }; } - private string GetDevicesPath() - { - return Path.Combine(_config.ApplicationPaths.DataPath, "devices"); - } - - private string GetDevicePath(string id) - { - return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N", CultureInfo.InvariantCulture)); - } - public bool CanAccessDevice(User user, string deviceId) { if (user == null) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 73502c2c9f..f3e3a6397f 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -275,7 +275,7 @@ namespace Emby.Server.Implementations.Dto continue; } - var containers = container.Split(new[] { ',' }); + var containers = container.Split(','); if (containers.Length < 2) { continue; diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index c762aa0b84..d360bb00f2 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -32,13 +32,13 @@ <PackageReference Include="Microsoft.AspNetCore.ResponseCompression" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="3.1.9" /> - <PackageReference Include="Mono.Nat" Version="3.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" /> + <PackageReference Include="Mono.Nat" Version="3.0.1" /> <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.0" /> - <PackageReference Include="ServiceStack.Text.Core" Version="5.9.2" /> + <PackageReference Include="ServiceStack.Text.Core" Version="5.10.0" /> <PackageReference Include="sharpcompress" Version="0.26.0" /> <PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" /> <PackageReference Include="DotNet.Glob" Version="3.1.0" /> @@ -49,10 +49,12 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors> + <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> + <NoWarn>AD0001</NoWarn> </PropertyGroup> <!-- Code Analyzers--> diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 68d981ad1f..df7a034e8f 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; @@ -19,12 +20,12 @@ namespace Emby.Server.Implementations.HttpServer.Security public AuthorizationInfo Authenticate(HttpRequest request) { var auth = _authorizationContext.GetAuthorizationInfo(request); - if (auth?.User == null) + if (!auth.IsAuthenticated) { - return null; + throw new AuthenticationException("Invalid token."); } - if (auth.User.HasPermission(PermissionKind.IsDisabled)) + if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false) { throw new SecurityException("User account has been disabled."); } diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index 8140fe81be..fdf2e3908a 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -36,8 +36,7 @@ namespace Emby.Server.Implementations.HttpServer.Security public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext) { var auth = GetAuthorizationDictionary(requestContext); - var (authInfo, _) = - GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); + var authInfo = GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); return authInfo; } @@ -49,19 +48,13 @@ namespace Emby.Server.Implementations.HttpServer.Security private AuthorizationInfo GetAuthorization(HttpContext httpReq) { var auth = GetAuthorizationDictionary(httpReq); - var (authInfo, originalAuthInfo) = - GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); - - if (originalAuthInfo != null) - { - httpReq.Request.HttpContext.Items["OriginalAuthenticationInfo"] = originalAuthInfo; - } + var authInfo = GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query); httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; return authInfo; } - private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary( + private AuthorizationInfo GetAuthorizationInfoFromDictionary( in Dictionary<string, string> auth, in IHeaderDictionary headers, in IQueryCollection queryString) @@ -108,88 +101,102 @@ namespace Emby.Server.Implementations.HttpServer.Security Device = device, DeviceId = deviceId, Version = version, - Token = token + Token = token, + IsAuthenticated = false }; - AuthenticationInfo originalAuthenticationInfo = null; - if (!string.IsNullOrWhiteSpace(token)) + if (string.IsNullOrWhiteSpace(token)) { - var result = _authRepo.Get(new AuthenticationInfoQuery + // Request doesn't contain a token. + return authInfo; + } + + var result = _authRepo.Get(new AuthenticationInfoQuery + { + AccessToken = token + }); + + if (result.Items.Count > 0) + { + authInfo.IsAuthenticated = true; + } + + var originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; + + if (originalAuthenticationInfo != null) + { + var updateToken = false; + + // TODO: Remove these checks for IsNullOrWhiteSpace + if (string.IsNullOrWhiteSpace(authInfo.Client)) { - AccessToken = token - }); + authInfo.Client = originalAuthenticationInfo.AppName; + } - originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; - - if (originalAuthenticationInfo != null) + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) { - var updateToken = false; + authInfo.DeviceId = originalAuthenticationInfo.DeviceId; + } - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(authInfo.Client)) - { - authInfo.Client = originalAuthenticationInfo.AppName; - } + // Temporary. TODO - allow clients to specify that the token has been shared with a casting device + var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = originalAuthenticationInfo.DeviceName; + } + else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) { - authInfo.DeviceId = originalAuthenticationInfo.DeviceId; + updateToken = true; + originalAuthenticationInfo.DeviceName = authInfo.Device; } + } - // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; + if (string.IsNullOrWhiteSpace(authInfo.Version)) + { + authInfo.Version = originalAuthenticationInfo.AppVersion; + } + else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) + { + updateToken = true; + originalAuthenticationInfo.AppVersion = authInfo.Version; + } + } - if (string.IsNullOrWhiteSpace(authInfo.Device)) - { - authInfo.Device = originalAuthenticationInfo.DeviceName; - } - else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.DeviceName = authInfo.Device; - } - } + if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + { + originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; + updateToken = true; + } - if (string.IsNullOrWhiteSpace(authInfo.Version)) - { - authInfo.Version = originalAuthenticationInfo.AppVersion; - } - else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) - { - updateToken = true; - originalAuthenticationInfo.AppVersion = authInfo.Version; - } - } + if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) + { + authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); - if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) + if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) { - originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; + originalAuthenticationInfo.UserName = authInfo.User.Username; updateToken = true; } - if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) - { - authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); + authInfo.IsApiKey = true; + } + else + { + authInfo.IsApiKey = false; + } - if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) - { - originalAuthenticationInfo.UserName = authInfo.User.Username; - updateToken = true; - } - } - - if (updateToken) - { - _authRepo.Update(originalAuthenticationInfo); - } + if (updateToken) + { + _authRepo.Update(originalAuthenticationInfo); } } - return (authInfo, originalAuthenticationInfo); + return authInfo; } /// <summary> @@ -238,7 +245,7 @@ namespace Emby.Server.Implementations.HttpServer.Security return null; } - var parts = authorizationHeader.Split(new[] { ' ' }, 2); + var parts = authorizationHeader.Split(' ', 2); // There should be at least to parts if (parts.Length != 2) @@ -262,11 +269,11 @@ namespace Emby.Server.Implementations.HttpServer.Security foreach (var item in parts) { - var param = item.Trim().Split(new[] { '=' }, 2); + var param = item.Trim().Split('=', 2); if (param.Length == 2) { - var value = NormalizeValue(param[1].Trim(new[] { '"' })); + var value = NormalizeValue(param[1].Trim('"')); result[param[0]] = value; } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 00282b71a5..8ffb05e1c1 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Audio; @@ -1502,7 +1503,7 @@ namespace Emby.Server.Implementations.Library { if (query.AncestorIds.Length == 0 && query.ParentId.Equals(Guid.Empty) && - query.ChannelIds.Length == 0 && + query.ChannelIds.Count == 0 && query.TopParentIds.Length == 0 && string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) && string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) && @@ -2440,6 +2441,21 @@ namespace Emby.Server.Implementations.Library new SubtitleResolver(BaseItem.LocalizationManager).AddExternalSubtitleStreams(streams, videoPath, streams.Count, files); } + public BaseItem GetParentItem(string parentId, Guid? userId) + { + if (!string.IsNullOrEmpty(parentId)) + { + return GetItemById(new Guid(parentId)); + } + + if (userId.HasValue && userId != Guid.Empty) + { + return GetUserRootFolder(); + } + + return RootFolder; + } + /// <inheritdoc /> public bool IsVideoFile(string path) { @@ -2470,9 +2486,10 @@ namespace Emby.Server.Implementations.Library var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd; + // TODO nullable - what are we trying to do there with empty episodeInfo? var episodeInfo = episode.IsFileProtocol - ? resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming) ?? new Naming.TV.EpisodeInfo() - : new Naming.TV.EpisodeInfo(); + ? resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming) ?? new Naming.TV.EpisodeInfo(episode.Path) + : new Naming.TV.EpisodeInfo(episode.Path); try { @@ -2561,12 +2578,12 @@ namespace Emby.Server.Implementations.Library if (!episode.IndexNumberEnd.HasValue || forceRefresh) { - if (episode.IndexNumberEnd != episodeInfo.EndingEpsiodeNumber) + if (episode.IndexNumberEnd != episodeInfo.EndingEpisodeNumber) { changed = true; } - episode.IndexNumberEnd = episodeInfo.EndingEpsiodeNumber; + episode.IndexNumberEnd = episodeInfo.EndingEpisodeNumber; } if (!episode.ParentIndexNumber.HasValue || forceRefresh) @@ -2690,7 +2707,7 @@ namespace Emby.Server.Implementations.Library var videos = videoListResolver.Resolve(fileSystemChildren); - var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files.First().Path, StringComparison.OrdinalIgnoreCase)); + var currentVideo = videos.FirstOrDefault(i => string.Equals(owner.Path, i.Files[0].Path, StringComparison.OrdinalIgnoreCase)); if (currentVideo != null) { @@ -2892,7 +2909,7 @@ namespace Emby.Server.Implementations.Library return item.GetImageInfo(image.Type, imageIndex); } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue && (ex.StatusCode.Value == HttpStatusCode.NotFound || ex.StatusCode.Value == HttpStatusCode.Forbidden)) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 376a155705..928f5f88e4 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -849,7 +849,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("Key can't be empty.", nameof(key)); } - var keys = key.Split(new[] { LiveStreamIdDelimeter }, 2); + var keys = key.Split(LiveStreamIdDelimeter, 2); var provider = _providers.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), keys[0], StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index 179e0ed986..28fa062396 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -101,7 +101,7 @@ namespace Emby.Server.Implementations.Library private static IEnumerable<MediaStream> GetSortedStreams(IEnumerable<MediaStream> streams, MediaStreamType type, string[] languagePreferences) { - // Give some preferance to external text subs for better performance + // Give some preference to external text subs for better performance return streams.Where(i => i.Type == type) .OrderBy(i => { diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index 70be524116..2c4497c693 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -201,7 +201,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio continue; } - var firstMedia = resolvedItem.Files.First(); + var firstMedia = resolvedItem.Files[0]; var libraryItem = new T { diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs index 44560d1e21..341194f239 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -77,11 +77,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV _logger.LogInformation("Copying recording stream to file {0}", targetFile); // The media source if infinite so we need to handle stopping ourselves - var durationToken = new CancellationTokenSource(duration); - cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + using var durationToken = new CancellationTokenSource(duration); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + cancellationToken = linkedCancellationToken.Token; await _streamHelper.CopyUntilCancelled( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), output, IODefaults.CopyToBufferSize, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index fcc2d1eeb6..0dc045ee6f 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -1635,7 +1635,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) { - return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer); + return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, _config); } return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs index 3e5457dbd4..e6ee9819eb 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -8,7 +8,9 @@ using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; @@ -25,6 +27,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private readonly IServerApplicationPaths _appPaths; private readonly IJsonSerializer _json; private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); + private readonly IServerConfigurationManager _serverConfigurationManager; private bool _hasExited; private Stream _logFileStream; @@ -35,12 +38,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV ILogger logger, IMediaEncoder mediaEncoder, IServerApplicationPaths appPaths, - IJsonSerializer json) + IJsonSerializer json, + IServerConfigurationManager serverConfigurationManager) { _logger = logger; _mediaEncoder = mediaEncoder; _appPaths = appPaths; _json = json; + _serverConfigurationManager = serverConfigurationManager; } private static bool CopySubtitles => false; @@ -179,15 +184,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var outputParam = string.Empty; + var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); var commandLineArgs = string.Format( CultureInfo.InvariantCulture, - "-i \"{0}\" {2} -map_metadata -1 -threads 0 {3}{4}{5} -y \"{1}\"", + "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", inputTempFile, targetFile, videoArgs, GetAudioArgs(mediaSource), subtitleArgs, - outputParam); + outputParam, + threads); return inputModifier + " " + commandLineArgs; } diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 28aabc1599..5d17ba1de9 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -15,6 +15,7 @@ using System.Threading.Tasks; using MediaBrowser.Common; using MediaBrowser.Common.Net; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; @@ -33,17 +34,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); private readonly IApplicationHost _appHost; + private readonly ICryptoProvider _cryptoProvider; public SchedulesDirect( ILogger<SchedulesDirect> logger, IJsonSerializer jsonSerializer, IHttpClientFactory httpClientFactory, - IApplicationHost appHost) + IApplicationHost appHost, + ICryptoProvider cryptoProvider) { _logger = logger; _jsonSerializer = jsonSerializer; _httpClientFactory = httpClientFactory; _appHost = appHost; + _cryptoProvider = cryptoProvider; } private string UserAgent => _appHost.ApplicationUserAgent; @@ -108,7 +112,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings options.Content = new StringContent(requestString, Encoding.UTF8, MediaTypeNames.Application.Json); options.Headers.TryAddWithoutValidation("token", token); using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var dailySchedules = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Day>>(responseStream).ConfigureAwait(false); _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); @@ -119,7 +123,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings programRequestOptions.Content = new StringContent("[\"" + string.Join("\", \"", programsID) + "\"]", Encoding.UTF8, MediaTypeNames.Application.Json); using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); - await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var programDetails = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ProgramDetails>>(innerResponseStream).ConfigureAwait(false); var programDict = programDetails.ToDictionary(p => p.programID, y => y); @@ -257,7 +261,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings Id = newID, StartDate = startAt, EndDate = endAt, - Name = details.titles[0].title120 ?? "Unkown", + Name = details.titles[0].title120 ?? "Unknown", OfficialRating = null, CommunityRating = null, EpisodeTitle = episodeTitle, @@ -476,9 +480,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); - await using var response = await innerResponse2.Content.ReadAsStreamAsync().ConfigureAwait(false); - return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>( - response).ConfigureAwait(false); + await using var response = await innerResponse2.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>(response).ConfigureAwait(false); } catch (Exception ex) { @@ -505,7 +508,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); - await using var response = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var root = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Headends>>(response).ConfigureAwait(false); @@ -538,6 +541,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); private DateTime _lastErrorResponse; + private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) { var username = info.Username; @@ -587,7 +591,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); return result; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue) { @@ -617,7 +621,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings { return await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); } - catch (HttpException ex) + catch (HttpRequestException ex) { _tokens.Clear(); @@ -642,10 +646,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings CancellationToken cancellationToken) { using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); - options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); + var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>()); + string hashedPassword = Hex.Encode(hashedPasswordBytes); + options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false); if (root.message == "OK") { @@ -699,13 +705,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - await using var stream = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var response = httpResponse.Content; var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(stream).ConfigureAwait(false); return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase)); } - catch (HttpException ex) + catch (HttpRequestException ex) { // Apparently we're supposed to swallow this if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) @@ -774,7 +780,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings var list = new List<ChannelInfo>(); using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - await using var stream = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(stream).ConfigureAwait(false); _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count); _logger.LogInformation("Mapping Stations to Channel"); diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index 2d6f453bd4..76c8757370 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -79,7 +79,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(path, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew)) { await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs index 9c7d624ee1..8c9bb6ba01 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1429,7 +1429,7 @@ namespace Emby.Server.Implementations.LiveTv return result; } - public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, ItemFields[] fields, User user = null) + public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> tuples, IReadOnlyList<ItemFields> fields, User user = null) { var programTuples = new List<Tuple<BaseItemDto, string, string>>(); var hasChannelImage = fields.Contains(ItemFields.ChannelImage); @@ -2208,7 +2208,7 @@ namespace Emby.Server.Implementations.LiveTv /// <returns>Task.</returns> public Task ResetTuner(string id, CancellationToken cancellationToken) { - var parts = id.Split(new[] { '_' }, 2); + var parts = id.Split('_', 2); var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), parts[0], StringComparison.OrdinalIgnoreCase)); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 2f4c601172..c0a4d12285 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -72,7 +72,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, cancellationToken: cancellationToken) .ConfigureAwait(false) ?? new List<Channels>(); @@ -129,7 +129,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/discover.json", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, cancellationToken: cancellationToken) .ConfigureAwait(false); @@ -143,7 +143,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun return discoverResponse; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) { @@ -175,7 +175,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var sr = new StreamReader(stream, System.Text.Encoding.UTF8); var tuners = new List<LiveTvTunerInfo>(); while (!sr.EndOfStream) @@ -663,7 +663,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var modelInfo = await GetModelInfo(info, true, CancellationToken.None).ConfigureAwait(false); info.DeviceId = modelInfo.DeviceID; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound) { diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs index 0333e723bc..78e62ff0a5 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs @@ -182,7 +182,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (string.IsNullOrEmpty(currentFile)) { - return (files.Last(), true); + return (files[^1], true); } var nextIndex = files.FindIndex(i => string.Equals(i, currentFile, StringComparison.OrdinalIgnoreCase)) + 1; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index c064e2fe6c..1d6c26c130 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -63,7 +63,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts .SendAsync(requestMessage, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } return File.OpenRead(info.Url); @@ -163,7 +163,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl) { - var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries); var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty; string numberString = null; @@ -197,7 +197,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (string.IsNullOrWhiteSpace(numberString)) { // Using this as a fallback now as this leads to Problems with channels like "5 USA" - // where 5 isnt ment to be the channel number + // where 5 isn't ment to be the channel number // Check for channel number with the format from SatIp // #EXTINF:0,84. VOX Schweiz // #EXTINF:0,84.0 - VOX Schweiz @@ -273,8 +273,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts private static string GetChannelName(string extInf, Dictionary<string, string> attributes) { - var nameParts = extInf.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - var nameInExtInf = nameParts.Length > 1 ? nameParts.Last().Trim() : null; + var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries); + var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null; // Check for channel number with the format from SatIp // #EXTINF:0,84. VOX Schweiz diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs index 2e1b895096..2de447ad9a 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs @@ -135,7 +135,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { Logger.LogInformation("Beginning {0} stream to {1}", GetType().Name, TempFilePath); using var message = response; - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read); await StreamHelper.CopyToAsync( stream, diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index fb31b01ff8..7752671837 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -115,5 +115,8 @@ "TasksLibraryCategory": "Knihovna", "TasksMaintenanceCategory": "Údržba", "TaskCleanActivityLogDescription": "Smazat záznamy o aktivitě, které jsou starší než zadaná doba.", - "TaskCleanActivityLog": "Smazat záznam aktivity" + "TaskCleanActivityLog": "Smazat záznam aktivity", + "Undefined": "Nedefinované", + "Forced": "Vynucené", + "Default": "Výchozí" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 97ad1694a6..6ab22b8a4b 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -113,5 +113,10 @@ "TasksChannelsCategory": "Internet Kanäle", "TasksApplicationCategory": "Anwendung", "TasksLibraryCategory": "Bibliothek", - "TasksMaintenanceCategory": "Wartung" + "TasksMaintenanceCategory": "Wartung", + "TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.", + "TaskCleanActivityLog": "Aktivitätsprotokoll aufräumen", + "Undefined": "Undefiniert", + "Forced": "Erzwungen", + "Default": "Standard" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index c45cc11cb8..dcf3311cd2 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -113,5 +113,10 @@ "TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή", "TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.", "TaskUpdatePlugins": "Ενημέρωση Προσθηκών", - "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας." + "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.", + "TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής δραστηριοτήτων παλαιότερες από την ηλικία που έχει διαμορφωθεί.", + "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων", + "Undefined": "Απροσδιόριστο", + "Forced": "Εξαναγκασμένο", + "Default": "Προεπιλογή" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 57ff13219f..cd64cdde45 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -113,5 +113,7 @@ "TasksChannelsCategory": "Internet Channels", "TasksApplicationCategory": "Application", "TasksLibraryCategory": "Library", - "TasksMaintenanceCategory": "Maintenance" + "TasksMaintenanceCategory": "Maintenance", + "TaskCleanActivityLogDescription": "Deletes activity log entries older than the configured age.", + "TaskCleanActivityLog": "Clean Activity Log" } diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index 6d8b222b44..f8f595faa3 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -9,11 +9,13 @@ "Channels": "Channels", "ChapterNameValue": "Chapter {0}", "Collections": "Collections", + "Default": "Default", "DeviceOfflineWithName": "{0} has disconnected", "DeviceOnlineWithName": "{0} is connected", "FailedLoginAttemptWithUserName": "Failed login attempt from {0}", "Favorites": "Favorites", "Folders": "Folders", + "Forced": "Forced", "Genres": "Genres", "HeaderAlbumArtists": "Album Artists", "HeaderContinueWatching": "Continue Watching", @@ -77,6 +79,7 @@ "Sync": "Sync", "System": "System", "TvShows": "TV Shows", + "Undefined": "Undefined", "User": "User", "UserCreatedWithName": "User {0} has been created", "UserDeletedWithName": "User {0} has been deleted", diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 390074cdd7..0d4a14be00 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -113,5 +113,10 @@ "TasksChannelsCategory": "Canales de internet", "TasksApplicationCategory": "Aplicación", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Mantenimiento" + "TasksMaintenanceCategory": "Mantenimiento", + "TaskCleanActivityLogDescription": "Borrar log de actividades anteriores a la fecha establecida.", + "TaskCleanActivityLog": "Borrar log de actividades", + "Undefined": "Indefinido", + "Forced": "Forzado", + "Default": "Por Defecto" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index d4e0b299dd..fe674cf366 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -77,7 +77,7 @@ "SubtitleDownloadFailureFromForItem": "Fallo de descarga de subtítulos desde {0} para {1}", "Sync": "Sincronizar", "System": "Sistema", - "TvShows": "Programas de televisión", + "TvShows": "Series", "User": "Usuario", "UserCreatedWithName": "El usuario {0} ha sido creado", "UserDeletedWithName": "El usuario {0} ha sido borrado", @@ -113,5 +113,10 @@ "TaskRefreshChannels": "Actualizar canales", "TaskRefreshChannelsDescription": "Actualiza la información de los canales de internet.", "TaskDownloadMissingSubtitles": "Descargar los subtítulos que faltan", - "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos." + "TaskDownloadMissingSubtitlesDescription": "Busca en internet los subtítulos que falten en el contenido de tus bibliotecas, basándose en la configuración de los metadatos.", + "TaskCleanActivityLogDescription": "Elimina todos los registros de actividad anteriores a la fecha configurada.", + "TaskCleanActivityLog": "Limpiar registro de actividad", + "Undefined": "Indefinido", + "Forced": "Forzado", + "Default": "Predeterminado" } diff --git a/Emby.Server.Implementations/Localization/Core/fil.json b/Emby.Server.Implementations/Localization/Core/fil.json index 1a3e188327..e5ca676a48 100644 --- a/Emby.Server.Implementations/Localization/Core/fil.json +++ b/Emby.Server.Implementations/Localization/Core/fil.json @@ -1,7 +1,7 @@ { "VersionNumber": "Bersyon {0}", "ValueSpecialEpisodeName": "Espesyal - {0}", - "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong media library", + "ValueHasBeenAddedToLibrary": "Naidagdag na ang {0} sa iyong librerya ng medya", "UserStoppedPlayingItemWithValues": "Natapos ni {0} ang {1} sa {2}", "UserStartedPlayingItemWithValues": "Si {0} ay nagplaplay ng {1} sa {2}", "UserPolicyUpdatedWithName": "Ang user policy ay naiupdate para kay {0}", @@ -61,8 +61,8 @@ "Latest": "Pinakabago", "LabelRunningTimeValue": "Oras: {0}", "LabelIpAddressValue": "Ang IP Address ay {0}", - "ItemRemovedWithName": "Naitanggal ang {0} sa library", - "ItemAddedWithName": "Naidagdag ang {0} sa library", + "ItemRemovedWithName": "Naitanggal ang {0} sa librerya", + "ItemAddedWithName": "Naidagdag ang {0} sa librerya", "Inherit": "Manahin", "HeaderRecordingGroups": "Pagtatalang Grupo", "HeaderNextUp": "Susunod", @@ -90,12 +90,29 @@ "Application": "Aplikasyon", "AppDeviceValues": "Aplikasyon: {0}, Aparato: {1}", "Albums": "Albums", - "TaskRefreshLibrary": "Suriin ang nasa librerya", - "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata", + "TaskRefreshLibrary": "Suriin and Librerya ng Medya", + "TaskRefreshChapterImagesDescription": "Gumawa ng larawan para sa mga pelikula na may kabanata.", "TaskRefreshChapterImages": "Kunin ang mga larawan ng kabanata", "TaskCleanCacheDescription": "Tanggalin ang mga cache file na hindi na kailangan ng systema.", "TasksChannelsCategory": "Palabas sa internet", "TasksLibraryCategory": "Librerya", "TasksMaintenanceCategory": "Pagpapanatili", - "HomeVideos": "Sariling pelikula" + "HomeVideos": "Sariling pelikula", + "TaskRefreshPeopleDescription": "Ini-update ang metadata para sa mga aktor at direktor sa iyong librerya ng medya.", + "TaskRefreshPeople": "I-refresh ang Tauhan", + "TaskDownloadMissingSubtitlesDescription": "Hinahanap sa internet ang mga nawawalang subtiles base sa metadata configuration.", + "TaskDownloadMissingSubtitles": "I-download and nawawalang subtitles", + "TaskRefreshChannelsDescription": "Ni-rerefresh ang impormasyon sa internet channels.", + "TaskRefreshChannels": "I-refresh ang Channels", + "TaskCleanTranscodeDescription": "Binubura ang transcode files na mas matanda ng isang araw.", + "TaskUpdatePluginsDescription": "Nag download at install ng updates sa plugins na naka configure para sa automatikong pag update.", + "TaskUpdatePlugins": "I-update ang Plugins", + "TaskCleanLogsDescription": "Binubura and files ng talaan na mas mantanda ng {0} araw.", + "TaskCleanTranscode": "Linisin and Direktoryo ng Transcode", + "TaskCleanLogs": "Linisin and Direktoryo ng Talaan", + "TaskRefreshLibraryDescription": "Sinusuri ang iyong librerya ng medya para sa bagong files at irefresh ang metadata.", + "TaskCleanCache": "Linisin and Direktoryo ng Cache", + "TasksApplicationCategory": "Application", + "TaskCleanActivityLog": "Linisin ang Tala ng Aktibidad", + "TaskCleanActivityLogDescription": "Tanggalin ang mga tala ng aktibidad na mas matanda sa naka configure na edad." } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index cc9243f37b..3d5d69f36a 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -115,5 +115,8 @@ "TasksLibraryCategory": "Bibliothèque", "TasksMaintenanceCategory": "Maintenance", "TaskCleanActivityLogDescription": "Supprime les entrées du journal d'activité antérieures à l'âge configuré.", - "TaskCleanActivityLog": "Nettoyer le journal d'activité" + "TaskCleanActivityLog": "Nettoyer le journal d'activité", + "Undefined": "Non défini", + "Forced": "Forcé", + "Default": "Par défaut" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index f906d6e117..981e8a06ed 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -113,5 +113,10 @@ "TaskRefreshChannels": "רענן ערוץ", "TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.", "TaskCleanTranscode": "נקה תקיית Transcode", - "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי." + "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי.", + "TaskCleanActivityLogDescription": "מחק רשומת פעילות הישנה יותר מהגיל המוגדר.", + "TaskCleanActivityLog": "נקה רשומת פעילות", + "Undefined": "לא מוגדר", + "Forced": "כפוי", + "Default": "ברירת מחדל" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index a3c936240d..9be91b7240 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -5,13 +5,13 @@ "Artists": "Izvođači", "AuthenticationSucceededWithUserName": "{0} uspješno ovjerena", "Books": "Knjige", - "CameraImageUploadedFrom": "Nova fotografija sa kamere je uploadana iz {0}", + "CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}", "Channels": "Kanali", "ChapterNameValue": "Poglavlje {0}", "Collections": "Kolekcije", - "DeviceOfflineWithName": "{0} se odspojilo", - "DeviceOnlineWithName": "{0} je spojeno", - "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave za {0}", + "DeviceOfflineWithName": "{0} je prekinuo vezu", + "DeviceOnlineWithName": "{0} je povezan", + "FailedLoginAttemptWithUserName": "Neuspjeli pokušaj prijave od {0}", "Favorites": "Favoriti", "Folders": "Mape", "Genres": "Žanrovi", @@ -23,95 +23,97 @@ "HeaderFavoriteShows": "Omiljene serije", "HeaderFavoriteSongs": "Omiljene pjesme", "HeaderLiveTV": "TV uživo", - "HeaderNextUp": "Sljedeće je", + "HeaderNextUp": "Slijedi", "HeaderRecordingGroups": "Grupa snimka", - "HomeVideos": "Kućni videi", + "HomeVideos": "Kućni video", "Inherit": "Naslijedi", "ItemAddedWithName": "{0} je dodano u biblioteku", - "ItemRemovedWithName": "{0} je uklonjen iz biblioteke", + "ItemRemovedWithName": "{0} je uklonjeno iz biblioteke", "LabelIpAddressValue": "IP adresa: {0}", "LabelRunningTimeValue": "Vrijeme rada: {0}", "Latest": "Najnovije", - "MessageApplicationUpdated": "Jellyfin Server je ažuriran", - "MessageApplicationUpdatedTo": "Jellyfin Server je ažuriran na {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Odjeljak postavka servera {0} je ažuriran", - "MessageServerConfigurationUpdated": "Postavke servera su ažurirane", + "MessageApplicationUpdated": "Jellyfin server je ažuriran", + "MessageApplicationUpdatedTo": "Jellyfin server je ažuriran na {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "Dio konfiguracije servera {0} je ažuriran", + "MessageServerConfigurationUpdated": "Konfiguracija servera je ažurirana", "MixedContent": "Miješani sadržaj", "Movies": "Filmovi", "Music": "Glazba", "MusicVideos": "Glazbeni spotovi", "NameInstallFailed": "{0} neuspješnih instalacija", "NameSeasonNumber": "Sezona {0}", - "NameSeasonUnknown": "Nepoznata sezona", + "NameSeasonUnknown": "Sezona nepoznata", "NewVersionIsAvailable": "Nova verzija Jellyfin servera je dostupna za preuzimanje.", - "NotificationOptionApplicationUpdateAvailable": "Dostupno ažuriranje aplikacije", - "NotificationOptionApplicationUpdateInstalled": "Instalirano ažuriranje aplikacije", - "NotificationOptionAudioPlayback": "Reprodukcija glazbe započeta", - "NotificationOptionAudioPlaybackStopped": "Reprodukcija audiozapisa je zaustavljena", - "NotificationOptionCameraImageUploaded": "Slike kamere preuzete", - "NotificationOptionInstallationFailed": "Instalacija neuspješna", - "NotificationOptionNewLibraryContent": "Novi sadržaj je dodan", - "NotificationOptionPluginError": "Dodatak otkazao", + "NotificationOptionApplicationUpdateAvailable": "Dostupno je ažuriranje aplikacije", + "NotificationOptionApplicationUpdateInstalled": "Instalirano je ažuriranje aplikacije", + "NotificationOptionAudioPlayback": "Reprodukcija glazbe započela", + "NotificationOptionAudioPlaybackStopped": "Reprodukcija glazbe zaustavljena", + "NotificationOptionCameraImageUploaded": "Slika s kamere učitana", + "NotificationOptionInstallationFailed": "Instalacija nije uspjela", + "NotificationOptionNewLibraryContent": "Novi sadržaj dodan", + "NotificationOptionPluginError": "Dodatak zakazao", "NotificationOptionPluginInstalled": "Dodatak instaliran", - "NotificationOptionPluginUninstalled": "Dodatak uklonjen", - "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje za dodatak", - "NotificationOptionServerRestartRequired": "Potrebno ponovo pokretanje servera", - "NotificationOptionTaskFailed": "Zakazan zadatak nije izvršen", + "NotificationOptionPluginUninstalled": "Dodatak deinstaliran", + "NotificationOptionPluginUpdateInstalled": "Instalirano ažuriranje dodatka", + "NotificationOptionServerRestartRequired": "Ponovno pokrenite server", + "NotificationOptionTaskFailed": "Greška zakazanog zadatka", "NotificationOptionUserLockedOut": "Korisnik zaključan", - "NotificationOptionVideoPlayback": "Reprodukcija videa započeta", - "NotificationOptionVideoPlaybackStopped": "Reprodukcija videozapisa je zaustavljena", - "Photos": "Slike", - "Playlists": "Popis za reprodukciju", + "NotificationOptionVideoPlayback": "Reprodukcija videa započela", + "NotificationOptionVideoPlaybackStopped": "Reprodukcija videa zaustavljena", + "Photos": "Fotografije", + "Playlists": "Popisi za reprodukciju", "Plugin": "Dodatak", "PluginInstalledWithName": "{0} je instalirano", "PluginUninstalledWithName": "{0} je deinstalirano", "PluginUpdatedWithName": "{0} je ažurirano", - "ProviderValue": "Pružitelj: {0}", + "ProviderValue": "Pružatelj: {0}", "ScheduledTaskFailedWithName": "{0} neuspjelo", "ScheduledTaskStartedWithName": "{0} pokrenuto", - "ServerNameNeedsToBeRestarted": "{0} treba biti ponovno pokrenuto", + "ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti", "Shows": "Serije", "Songs": "Pjesme", - "StartupEmbyServerIsLoading": "Jellyfin Server se učitava. Pokušajte ponovo kasnije.", + "StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.", "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}", - "SubtitleDownloadFailureFromForItem": "Prijevodi nisu uspješno preuzeti {0} od {1}", - "Sync": "Sink.", - "System": "Sistem", + "SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}", + "Sync": "Sinkronizacija", + "System": "Sustav", "TvShows": "Serije", "User": "Korisnik", - "UserCreatedWithName": "Korisnik {0} je stvoren", + "UserCreatedWithName": "Korisnik {0} je kreiran", "UserDeletedWithName": "Korisnik {0} je obrisan", - "UserDownloadingItemWithValues": "{0} se preuzima {1}", + "UserDownloadingItemWithValues": "{0} preuzima {1}", "UserLockedOutWithName": "Korisnik {0} je zaključan", - "UserOfflineFromDevice": "{0} se odspojilo od {1}", - "UserOnlineFromDevice": "{0} je online od {1}", + "UserOfflineFromDevice": "{0} prekinuo vezu od {1}", + "UserOnlineFromDevice": "{0} povezan od {1}", "UserPasswordChangedWithName": "Lozinka je promijenjena za korisnika {0}", - "UserPolicyUpdatedWithName": "Pravila za korisnika su ažurirana za {0}", - "UserStartedPlayingItemWithValues": "{0} je pokrenuo {1}", - "UserStoppedPlayingItemWithValues": "{0} je zaustavio {1}", + "UserPolicyUpdatedWithName": "Pravila za korisnika ažurirana su za {0}", + "UserStartedPlayingItemWithValues": "{0} je pokrenuo reprodukciju {1} na {2}", + "UserStoppedPlayingItemWithValues": "{0} je završio reprodukciju {1} na {2}", "ValueHasBeenAddedToLibrary": "{0} je dodano u medijsku biblioteku", - "ValueSpecialEpisodeName": "Specijal - {0}", + "ValueSpecialEpisodeName": "Posebno - {0}", "VersionNumber": "Verzija {0}", - "TaskRefreshLibraryDescription": "Skenira vašu medijsku knjižnicu sa novim datotekama i osvježuje metapodatke.", - "TaskRefreshLibrary": "Skeniraj medijsku knjižnicu", - "TaskRefreshChapterImagesDescription": "Stvara sličice za videozapise koji imaju poglavlja.", - "TaskRefreshChapterImages": "Raspakiraj slike poglavlja", - "TaskCleanCacheDescription": "Briše priručne datoteke nepotrebne za sistem.", - "TaskCleanCache": "Očisti priručnu memoriju", + "TaskRefreshLibraryDescription": "Skenira medijsku biblioteku radi novih datoteka i osvježava metapodatke.", + "TaskRefreshLibrary": "Skeniraj medijsku biblioteku", + "TaskRefreshChapterImagesDescription": "Kreira sličice za videozapise koji imaju poglavlja.", + "TaskRefreshChapterImages": "Izdvoji slike poglavlja", + "TaskCleanCacheDescription": "Briše nepotrebne datoteke iz predmemorije.", + "TaskCleanCache": "Očisti mapu predmemorije", "TasksApplicationCategory": "Aplikacija", "TasksMaintenanceCategory": "Održavanje", - "TaskDownloadMissingSubtitlesDescription": "Pretraživanje interneta za prijevodima koji nedostaju bazirano na konfiguraciji meta podataka.", - "TaskDownloadMissingSubtitles": "Preuzimanje prijevoda koji nedostaju", - "TaskRefreshChannelsDescription": "Osvježava informacije o internet kanalima.", + "TaskDownloadMissingSubtitlesDescription": "Pretraži Internet za prijevodima koji nedostaju prema konfiguraciji metapodataka.", + "TaskDownloadMissingSubtitles": "Preuzmi prijevod koji nedostaje", + "TaskRefreshChannelsDescription": "Osvježava informacije Internet kanala.", "TaskRefreshChannels": "Osvježi kanale", - "TaskCleanTranscodeDescription": "Briše transkodirane fajlove starije od jednog dana.", - "TaskCleanTranscode": "Očisti direktorij za transkodiranje", - "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su podešeni da se ažuriraju automatski.", + "TaskCleanTranscodeDescription": "Briše transkodirane datoteke starije od jednog dana.", + "TaskCleanTranscode": "Očisti mapu transkodiranja", + "TaskUpdatePluginsDescription": "Preuzima i instalira ažuriranja za dodatke koji su konfigurirani da se ažuriraju automatski.", "TaskUpdatePlugins": "Ažuriraj dodatke", - "TaskRefreshPeopleDescription": "Ažurira meta podatke za glumce i redatelje u vašoj medijskoj biblioteci.", - "TaskRefreshPeople": "Osvježi ljude", - "TaskCleanLogsDescription": "Briši logove koji su stariji od {0} dana.", - "TaskCleanLogs": "Očisti direktorij sa logovima", + "TaskRefreshPeopleDescription": "Ažurira metapodatke za glumce i redatelje u medijskoj biblioteci.", + "TaskRefreshPeople": "Osvježi osobe", + "TaskCleanLogsDescription": "Briše zapise dnevnika koji su stariji od {0} dana.", + "TaskCleanLogs": "Očisti mapu dnevnika zapisa", "TasksChannelsCategory": "Internet kanali", - "TasksLibraryCategory": "Biblioteka" + "TasksLibraryCategory": "Biblioteka", + "TaskCleanActivityLogDescription": "Briše zapise dnevnika aktivnosti starije od navedenog vremena.", + "TaskCleanActivityLog": "Očisti dnevnik aktivnosti" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 343d213d41..e5707e78c9 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -113,5 +113,10 @@ "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése", "TaskRefreshChannelsDescription": "Frissíti az internetes csatornák adatait.", "TaskRefreshChannels": "Csatornák frissítése", - "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat." + "TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.", + "TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.", + "TaskCleanActivityLog": "Tevékenységnapló törlése", + "Undefined": "Meghatározatlan", + "Forced": "Kényszerített", + "Default": "Alapértelmezett" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 0a6238578d..9e37ddc27c 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -113,5 +113,7 @@ "TasksChannelsCategory": "Canali su Internet", "TasksApplicationCategory": "Applicazione", "TasksLibraryCategory": "Libreria", - "TasksMaintenanceCategory": "Manutenzione" + "TasksMaintenanceCategory": "Manutenzione", + "TaskCleanActivityLog": "Attività di Registro Completate", + "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata." } diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index 2857461794..b8b39833c8 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -27,7 +27,7 @@ "HeaderRecordingGroups": "녹화 그룹", "HomeVideos": "홈 비디오", "Inherit": "상속", - "ItemAddedWithName": "{0}가 라이브러리에 추가됨", + "ItemAddedWithName": "{0}가 라이브러리에 추가되었습니다", "ItemRemovedWithName": "{0}가 라이브러리에서 제거됨", "LabelIpAddressValue": "IP 주소: {0}", "LabelRunningTimeValue": "상영 시간: {0}", @@ -114,6 +114,6 @@ "TaskCleanCache": "캐시 폴더 청소", "TasksChannelsCategory": "인터넷 채널", "TasksLibraryCategory": "라이브러리", - "TaskCleanActivityLogDescription": "구성된 기간보다 오래된 활동내역 삭제", + "TaskCleanActivityLogDescription": "구성된 기간보다 오래된 활동내역 삭제.", "TaskCleanActivityLog": "활동내역청소" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 245c3cd636..3b016fe62a 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -113,5 +113,9 @@ "TaskRefreshPeople": "Oppfrisk personer", "TaskCleanLogsDescription": "Sletter loggfiler som er eldre enn {0} dager gamle.", "TaskCleanLogs": "Tøm loggmappe", - "TaskRefreshLibraryDescription": "Skanner mediebibliotekene dine for nye filer og oppdaterer metadata." + "TaskRefreshLibraryDescription": "Skanner mediebibliotekene dine for nye filer og oppdaterer metadata.", + "TaskCleanActivityLog": "Tøm aktivitetslogg", + "Undefined": "Udefinert", + "Forced": "Tvungen", + "Default": "Standard" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index e102b92b90..e1e88cc9ba 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -113,5 +113,7 @@ "TasksChannelsCategory": "Internet Kanalen", "TasksApplicationCategory": "Applicatie", "TasksLibraryCategory": "Bibliotheek", - "TasksMaintenanceCategory": "Onderhoud" + "TasksMaintenanceCategory": "Onderhoud", + "TaskCleanActivityLogDescription": "Verwijder activiteiten logs ouder dan de ingestelde tijd.", + "TaskCleanActivityLog": "Leeg activiteiten logboek" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index 003e591b37..e3da96a85a 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -113,5 +113,10 @@ "TasksChannelsCategory": "Kanały internetowe", "TasksApplicationCategory": "Aplikacja", "TasksLibraryCategory": "Biblioteka", - "TasksMaintenanceCategory": "Konserwacja" + "TasksMaintenanceCategory": "Konserwacja", + "TaskCleanActivityLogDescription": "Usuwa wpisy dziennika aktywności starsze niż skonfigurowany wiek.", + "TaskCleanActivityLog": "Czyść dziennik aktywności", + "Undefined": "Nieustalony", + "Forced": "Wymuszony", + "Default": "Domyślne" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 5e49ca702e..8d25e27f6b 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -113,5 +113,7 @@ "TasksChannelsCategory": "Canais da Internet", "TasksApplicationCategory": "Aplicativo", "TasksLibraryCategory": "Biblioteca", - "TasksMaintenanceCategory": "Manutenção" + "TasksMaintenanceCategory": "Manutenção", + "TaskCleanActivityLogDescription": "Apaga o registro de atividades mais antigo que a idade configurada.", + "TaskCleanActivityLog": "Limpar Registro de Atividades" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index bc008df3b4..5e4022292e 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -112,5 +112,7 @@ "TasksChannelsCategory": "Canale de pe Internet", "TasksApplicationCategory": "Aplicație", "TasksLibraryCategory": "Librărie", - "TasksMaintenanceCategory": "Mentenanță" + "TasksMaintenanceCategory": "Mentenanță", + "TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.", + "TaskCleanActivityLog": "Curăță Jurnalul de Activitate" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 95b93afb82..c0db2cf7f5 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -113,5 +113,7 @@ "TaskCleanLogsDescription": "Удаляются файлы журнала, возраст которых превышает {0} дн(я/ей).", "TaskRefreshLibraryDescription": "Сканируется медиатека на новые файлы и обновляются метаданные.", "TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.", - "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе." + "TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.", + "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.", + "TaskCleanActivityLog": "Очистить журнал активности" } diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json index 2b1eccfaf3..8da92f3099 100644 --- a/Emby.Server.Implementations/Localization/Core/sr.json +++ b/Emby.Server.Implementations/Localization/Core/sr.json @@ -112,5 +112,7 @@ "TasksChannelsCategory": "Интернет канали", "TasksApplicationCategory": "Апликација", "TasksLibraryCategory": "Библиотека", - "TasksMaintenanceCategory": "Одржавање" + "TasksMaintenanceCategory": "Одржавање", + "TaskCleanActivityLogDescription": "Брише историју активности старију од конфигурисаног броја година.", + "TaskCleanActivityLog": "Очисти историју активности" } diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index 8089fc3042..5fcdb1f748 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -112,5 +112,10 @@ "UserOnlineFromDevice": "{1} இருந்து {0} ஆன்லைன்", "HomeVideos": "முகப்பு வீடியோக்கள்", "UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது", - "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது" + "UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது", + "TaskCleanActivityLogDescription": "உள்ளமைக்கப்பட்ட வயதை விட பழைய செயல்பாட்டு பதிவு உள்ளீடுகளை நீக்குகிறது.", + "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி", + "Undefined": "வரையறுக்கப்படாத", + "Forced": "கட்டாயப்படுத்தப்பட்டது", + "Default": "இயல்புநிலை" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 1e5f2cf198..54d3a65f0d 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -8,7 +8,7 @@ "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", "Channels": "Kanallar", "ChapterNameValue": "Bölüm {0}", - "Collections": "Koleksiyonlar", + "Collections": "Koleksiyon", "DeviceOfflineWithName": "{0} bağlantısı kesildi", "DeviceOnlineWithName": "{0} bağlı", "FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu", @@ -23,7 +23,7 @@ "HeaderFavoriteShows": "Favori Diziler", "HeaderFavoriteSongs": "Favori Şarkılar", "HeaderLiveTV": "Canlı TV", - "HeaderNextUp": "Sonraki hafta", + "HeaderNextUp": "Gelecek Hafta", "HeaderRecordingGroups": "Kayıt Grupları", "HomeVideos": "Ev videoları", "Inherit": "Devral", @@ -113,5 +113,7 @@ "TaskRefreshLibrary": "Medya Kütüphanesini Tara", "TaskRefreshChapterImagesDescription": "Sahnelere ayrılmış videolar için küçük resimler oluştur.", "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar", - "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler." + "TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.", + "TaskCleanActivityLog": "İşlem Günlüğünü Temizle", + "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi." } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index ac74deff82..0549995c8c 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -112,5 +112,10 @@ "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", "Application": "Ứng Dụng", - "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}" + "AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}", + "TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.", + "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động", + "Undefined": "Không Xác Định", + "Forced": "Bắt Buộc", + "Default": "Mặc Định" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index 53a902de28..12803456e3 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -113,5 +113,10 @@ "TaskCleanCacheDescription": "删除系统不再需要的缓存文件。", "TaskCleanCache": "清理缓存目录", "TasksApplicationCategory": "应用程序", - "TasksMaintenanceCategory": "维护" + "TasksMaintenanceCategory": "维护", + "TaskCleanActivityLog": "清理程序日志", + "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。", + "Undefined": "未定义", + "Forced": "强制的", + "Default": "默认" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 30f7266300..d2e3d77a3a 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -112,5 +112,7 @@ "TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。", "TasksChannelsCategory": "網路頻道", "TasksApplicationCategory": "應用程式", - "TasksMaintenanceCategory": "維修" + "TasksMaintenanceCategory": "維護", + "TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。", + "TaskCleanActivityLog": "清除活動紀錄" } diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index d3b64fb318..932f721ab4 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -150,7 +150,7 @@ namespace Emby.Server.Implementations.Playlists await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None) .ConfigureAwait(false); - if (options.ItemIdList.Length > 0) + if (options.ItemIdList.Count > 0) { await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false) { @@ -184,7 +184,7 @@ namespace Emby.Server.Implementations.Playlists return Playlist.GetPlaylistItems(playlistMediaType, items, user, options); } - public Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId) + public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId) { var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId); @@ -194,7 +194,7 @@ namespace Emby.Server.Implementations.Playlists }); } - private async Task AddToPlaylistInternal(Guid playlistId, ICollection<Guid> newItemIds, User user, DtoOptions options) + private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options) { // Retrieve the existing playlist var playlist = _libraryManager.GetItemById(playlistId) as Playlist diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index 56f4133a06..3a9e284583 100644 --- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -653,7 +653,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { _logger.LogInformation(Name + ": Waiting on Task"); - var exited = Task.WaitAll(new[] { task }, 2000); + var exited = task.Wait(2000); if (exited) { diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs index 6f81bf49bb..cfbf03ddc0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs +++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs @@ -136,7 +136,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = scheduledTask.ScheduledTask.GetType(); - _logger.LogInformation("Queueing task {0}", type.Name); + _logger.LogInformation("Queuing task {0}", type.Name); lock (_taskQueue) { @@ -176,7 +176,7 @@ namespace Emby.Server.Implementations.ScheduledTasks { var type = task.ScheduledTask.GetType(); - _logger.LogInformation("Queueing task {0}", type.Name); + _logger.LogInformation("Queuing task {0}", type.Name); lock (_taskQueue) { diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index 8439f8a99d..171e442581 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -106,7 +106,7 @@ namespace Emby.Server.Implementations.ScheduledTasks try { previouslyFailedImages = File.ReadAllText(failHistoryPath) - .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries) + .Split('|', StringSplitOptions.RemoveEmptyEntries) .ToList(); } catch (IOException) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs index c5af68bcec..161fa05809 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Updates; @@ -101,7 +102,7 @@ namespace Emby.Server.Implementations.ScheduledTasks throw; } } - catch (HttpException ex) + catch (HttpRequestException ex) { _logger.LogError(ex, "Error downloading {0}", package.Name); } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 607b322f2e..afddfa856b 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -58,8 +58,7 @@ namespace Emby.Server.Implementations.Session /// <summary> /// The active connections. /// </summary> - private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = - new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase); private Timer _idleTimer; @@ -196,7 +195,7 @@ namespace Emby.Server.Implementations.Session { if (!string.IsNullOrEmpty(info.DeviceId)) { - var capabilities = GetSavedCapabilities(info.DeviceId); + var capabilities = _deviceManager.GetCapabilities(info.DeviceId); if (capabilities != null) { @@ -1677,27 +1676,10 @@ namespace Emby.Server.Implementations.Session SessionInfo = session }); - try - { - SaveCapabilities(session.DeviceId, capabilities); - } - catch (Exception ex) - { - _logger.LogError("Error saving device capabilities", ex); - } + _deviceManager.SaveCapabilities(session.DeviceId, capabilities); } } - private ClientCapabilities GetSavedCapabilities(string deviceId) - { - return _deviceManager.GetCapabilities(deviceId); - } - - private void SaveCapabilities(string deviceId, ClientCapabilities capabilities) - { - _deviceManager.SaveCapabilities(deviceId, capabilities); - } - /// <summary> /// Converts a BaseItem to a BaseItemInfo. /// </summary> diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs index b986ffa1cd..f9c6a13c69 100644 --- a/Emby.Server.Implementations/Session/WebSocketController.cs +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Net; @@ -55,9 +56,9 @@ namespace Emby.Server.Implementations.Session connection.Closed += OnConnectionClosed; } - private void OnConnectionClosed(object sender, EventArgs e) + private void OnConnectionClosed(object? sender, EventArgs e) { - var connection = (IWebSocketConnection)sender; + var connection = sender as IWebSocketConnection ?? throw new ArgumentException($"{nameof(sender)} is not of type {nameof(IWebSocketConnection)}", nameof(sender)); _logger.LogDebug("Removing websocket from session {Session}", _session.Id); _sockets.Remove(connection); connection.Closed -= OnConnectionClosed; diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 966ed5024e..7c4e003112 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.SyncPlay new Dictionary<Guid, ISyncPlayController>(); /// <summary> - /// Lock used for accesing any group. + /// Lock used for accessing any group. /// </summary> private readonly object _groupsLock = new object(); diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 6ead603ae3..851e7bd68b 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -16,7 +16,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; -using MediaBrowser.Common.System; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Events.Updates; @@ -25,7 +25,6 @@ using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Updates; using Microsoft.Extensions.Logging; -using MediaBrowser.Model.System; namespace Emby.Server.Implementations.Updates { @@ -49,7 +48,7 @@ namespace Emby.Server.Implementations.Updates /// Gets the application host. /// </summary> /// <value>The application host.</value> - private readonly IApplicationHost _applicationHost; + private readonly IServerApplicationHost _applicationHost; private readonly IZipClient _zipClient; @@ -67,7 +66,7 @@ namespace Emby.Server.Implementations.Updates public InstallationManager( ILogger<InstallationManager> logger, - IApplicationHost appHost, + IServerApplicationHost appHost, IApplicationPaths appPaths, IEventManager eventManager, IHttpClientFactory httpClientFactory, @@ -100,7 +99,7 @@ namespace Emby.Server.Implementations.Updates { using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(manifest, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); try { @@ -117,11 +116,6 @@ namespace Emby.Server.Implementations.Updates _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest); return Array.Empty<PackageInfo>(); } - catch (HttpException ex) - { - _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest); - return Array.Empty<PackageInfo>(); - } catch (HttpRequestException ex) { _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest); @@ -217,7 +211,8 @@ namespace Emby.Server.Implementations.Updates private IEnumerable<InstallationInfo> GetAvailablePluginUpdates(IReadOnlyList<PackageInfo> pluginCatalog) { - foreach (var plugin in _applicationHost.Plugins) + var plugins = _applicationHost.GetLocalPlugins(_appPaths.PluginsPath); + foreach (var plugin in plugins) { var compatibleVersions = GetCompatibleVersions(pluginCatalog, plugin.Name, plugin.Id, minVersion: plugin.Version); var version = compatibleVersions.FirstOrDefault(y => y.Version > plugin.Version); @@ -246,7 +241,8 @@ namespace Emby.Server.Implementations.Updates _currentInstallations.Add(tuple); } - var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token; + using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token); + var linkedToken = linkedTokenSource.Token; await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false); @@ -338,7 +334,7 @@ namespace Emby.Server.Implementations.Updates using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(package.SourceUrl, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs index d732b6bc6a..7d68aecf99 100644 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -50,6 +50,13 @@ namespace Jellyfin.Api.Auth bool localAccessOnly = false, bool requiredDownloadPermission = false) { + // ApiKey is currently global admin, always allow. + var isApiKey = ClaimHelpers.GetIsApiKey(claimsPrincipal); + if (isApiKey) + { + return true; + } + // Ensure claim has userId. var userId = ClaimHelpers.GetUserId(claimsPrincipal); if (!userId.HasValue) diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 733c6959eb..27a1f61be0 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -1,10 +1,10 @@ using System.Globalization; -using System.Security.Authentication; using System.Security.Claims; using System.Text.Encodings.Web; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; @@ -43,24 +43,23 @@ namespace Jellyfin.Api.Auth try { var authorizationInfo = _authService.Authenticate(Request); - if (authorizationInfo == null) + var role = UserRoles.User; + if (authorizationInfo.IsApiKey || authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) { - return Task.FromResult(AuthenticateResult.NoResult()); - // TODO return when legacy API is removed. - // Don't spam the log with "Invalid User" - // return Task.FromResult(AuthenticateResult.Fail("Invalid user")); + role = UserRoles.Administrator; } var claims = new[] { - new Claim(ClaimTypes.Name, authorizationInfo.User.Username), - new Claim(ClaimTypes.Role, authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User), + new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty), + new Claim(ClaimTypes.Role, role), new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId), new Claim(InternalClaimTypes.Device, authorizationInfo.Device), new Claim(InternalClaimTypes.Client, authorizationInfo.Client), new Claim(InternalClaimTypes.Version, authorizationInfo.Version), new Claim(InternalClaimTypes.Token, authorizationInfo.Token), + new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture)) }; var identity = new ClaimsIdentity(claims, Scheme.Name); diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index b5913daab9..be77b7a4e4 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement) { var validated = ValidateClaims(context.User); - if (!validated) + if (validated) + { + context.Succeed(requirement); + } + else { context.Fail(); - return Task.CompletedTask; } - context.Succeed(requirement); return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs index 5213bc4cb7..a7623556a9 100644 --- a/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs +++ b/Jellyfin.Api/Auth/IgnoreParentalControlPolicy/IgnoreParentalControlHandler.cs @@ -29,13 +29,15 @@ namespace Jellyfin.Api.Auth.IgnoreParentalControlPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreParentalControlRequirement requirement) { var validated = ValidateClaims(context.User, ignoreSchedule: true); - if (!validated) + if (validated) + { + context.Succeed(requirement); + } + else { context.Fail(); - return Task.CompletedTask; } - context.Succeed(requirement); return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs index af73352bcc..d772ec5542 100644 --- a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs +++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs @@ -29,13 +29,13 @@ namespace Jellyfin.Api.Auth.LocalAccessPolicy protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement) { var validated = ValidateClaims(context.User, localAccessOnly: true); - if (!validated) + if (validated) { - context.Fail(); + context.Succeed(requirement); } else { - context.Succeed(requirement); + context.Fail(); } return Task.CompletedTask; diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs index 4d7c7135d5..8323312e51 100644 --- a/Jellyfin.Api/Constants/InternalClaimTypes.cs +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -34,5 +34,10 @@ /// Token. /// </summary> public const string Token = "Jellyfin-Token"; + + /// <summary> + /// Is Api Key. + /// </summary> + public const string IsApiKey = "Jellyfin-IsApiKey"; } } diff --git a/Jellyfin.Api/Controllers/AlbumsController.cs b/Jellyfin.Api/Controllers/AlbumsController.cs deleted file mode 100644 index 357f646a2b..0000000000 --- a/Jellyfin.Api/Controllers/AlbumsController.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Jellyfin.Api.Controllers -{ - /// <summary> - /// The albums controller. - /// </summary> - [Route("")] - public class AlbumsController : BaseJellyfinApiController - { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="AlbumsController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public AlbumsController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - } - - /// <summary> - /// Finds albums similar to a given album. - /// </summary> - /// <param name="albumId">The album id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <response code="200">Similar albums returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar albums.</returns> - [HttpGet("Albums/{albumId}/Similar")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums( - [FromRoute, Required] string albumId, - [FromQuery] Guid? userId, - [FromQuery] string? excludeArtistIds, - [FromQuery] int? limit) - { - var dtoOptions = new DtoOptions().AddClientFields(Request); - - return SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _libraryManager, - _dtoService, - userId, - albumId, - excludeArtistIds, - limit, - new[] { typeof(MusicAlbum) }, - GetAlbumSimilarityScore); - } - - /// <summary> - /// Finds artists similar to a given artist. - /// </summary> - /// <param name="artistId">The artist id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="excludeArtistIds">Optional. Ids of artists to exclude.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <response code="200">Similar artists returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with similar artists.</returns> - [HttpGet("Artists/{artistId}/Similar")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists( - [FromRoute, Required] string artistId, - [FromQuery] Guid? userId, - [FromQuery] string? excludeArtistIds, - [FromQuery] int? limit) - { - var dtoOptions = new DtoOptions().AddClientFields(Request); - - return SimilarItemsHelper.GetSimilarItemsResult( - dtoOptions, - _userManager, - _libraryManager, - _dtoService, - userId, - artistId, - excludeArtistIds, - limit, - new[] { typeof(MusicArtist) }, - SimilarItemsHelper.GetSimiliarityScore); - } - - /// <summary> - /// Gets a similairty score of two albums. - /// </summary> - /// <param name="item1">The first item.</param> - /// <param name="item1People">The item1 people.</param> - /// <param name="allPeople">All people.</param> - /// <param name="item2">The second item.</param> - /// <returns>System.Int32.</returns> - private int GetAlbumSimilarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2) - { - var points = SimilarItemsHelper.GetSimiliarityScore(item1, item1People, allPeople, item2); - - var album1 = (MusicAlbum)item1; - var album2 = (MusicAlbum)item2; - - var artists1 = album1 - .GetAllArtists() - .DistinctNames() - .ToList(); - - var artists2 = new HashSet<string>( - album2.GetAllArtists().DistinctNames(), - StringComparer.OrdinalIgnoreCase); - - return points + artists1.Where(artists2.Contains).Sum(i => 5); - } - } -} diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index 826fce6b0c..f684c649af 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -53,7 +53,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">Optional. Search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> @@ -88,25 +88,25 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -114,8 +114,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -132,30 +131,26 @@ namespace Jellyfin.Api.Controllers parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); } - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); - var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, @@ -175,9 +170,9 @@ namespace Jellyfin.Api.Controllers } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -231,7 +226,7 @@ namespace Jellyfin.Api.Controllers var (baseItem, itemCounts) = i; var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (!string.IsNullOrWhiteSpace(includeItemTypes)) + if (includeItemTypes.Length != 0) { dto.ChildCount = itemCounts.ItemCount; dto.ProgramCount = itemCounts.ProgramCount; @@ -262,7 +257,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">Optional. Search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> @@ -297,25 +292,25 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -323,8 +318,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -341,30 +335,26 @@ namespace Jellyfin.Api.Controllers parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); } - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); - var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, @@ -384,9 +374,9 @@ namespace Jellyfin.Api.Controllers } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -440,7 +430,7 @@ namespace Jellyfin.Api.Controllers var (baseItem, itemCounts) = i; var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (!string.IsNullOrWhiteSpace(includeItemTypes)) + if (includeItemTypes.Length != 0) { dto.ChildCount = itemCounts.ItemCount; dto.ProgramCount = itemCounts.ProgramCount; diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index d4c6e4af94..ae8c05d858 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; @@ -42,7 +42,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -71,7 +71,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index 20fc96ba83..c4dc44cc36 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -108,7 +108,7 @@ namespace Jellyfin.Api.Controllers /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <response code="200">Channel items returned.</response> /// <returns> /// A <see cref="Task"/> representing the request to get the channel items. @@ -124,7 +124,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? sortOrder, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] string? sortBy, - [FromQuery] string? fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) @@ -137,8 +137,7 @@ namespace Jellyfin.Api.Controllers ChannelIds = new[] { channelId }, ParentId = folderId ?? Guid.Empty, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - DtoOptions = new DtoOptions() - .AddItemFields(fields) + DtoOptions = new DtoOptions { Fields = fields } }; foreach (var filter in filters) @@ -185,7 +184,7 @@ namespace Jellyfin.Api.Controllers /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param> /// <response code="200">Latest channel items returned.</response> /// <returns> @@ -198,8 +197,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] string? fields, - [FromQuery] string? channelIds) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) @@ -209,13 +208,8 @@ namespace Jellyfin.Api.Controllers { Limit = limit, StartIndex = startIndex, - ChannelIds = (channelIds ?? string.Empty) - .Split(',') - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Select(i => new Guid(i)) - .ToArray(), - DtoOptions = new DtoOptions() - .AddItemFields(fields) + ChannelIds = channelIds, + DtoOptions = new DtoOptions { Fields = fields } }; foreach (var filter in filters) diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index eae06b767c..2a342c2cbe 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Net; @@ -54,7 +55,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<CollectionCreationResult>> CreateCollection( [FromQuery] string? name, - [FromQuery] string? ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, [FromQuery] Guid? parentId, [FromQuery] bool isLocked = false) { @@ -65,7 +66,7 @@ namespace Jellyfin.Api.Controllers IsLocked = isLocked, Name = name, ParentId = parentId, - ItemIdList = RequestHelpers.Split(ids, ',', true), + ItemIdList = ids, UserIds = new[] { userId } }).ConfigureAwait(false); @@ -88,9 +89,11 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("{collectionId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids) + public async Task<ActionResult> AddToCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true); + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); return NoContent(); } @@ -103,9 +106,11 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpDelete("{collectionId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids) + public async Task<ActionResult> RemoveFromCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false); + await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 874467c75b..76f5717e30 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -81,6 +81,9 @@ namespace Jellyfin.Api.Controllers dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; + // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. + _displayPreferencesManager.SaveChanges(); + return dto; } diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 271ae293b4..4e6455eaa7 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -77,6 +77,7 @@ namespace Jellyfin.Api.Controllers /// Gets Dlna media receiver registrar xml. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> /// <returns>Dlna media receiver registrar xml.</returns> [HttpGet("{serverId}/MediaReceiverRegistrar")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] @@ -94,6 +95,7 @@ namespace Jellyfin.Api.Controllers /// Gets Dlna media receiver registrar xml. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> /// <returns>Dlna media receiver registrar xml.</returns> [HttpGet("{serverId}/ConnectionManager")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] @@ -111,8 +113,12 @@ namespace Jellyfin.Api.Controllers /// Process a content directory control request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/ContentDirectory/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) { return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); @@ -122,8 +128,12 @@ namespace Jellyfin.Api.Controllers /// Process a connection manager control request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/ConnectionManager/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) { return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); @@ -133,8 +143,12 @@ namespace Jellyfin.Api.Controllers /// Process a media receiver registrar control request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> /// <returns>Control response.</returns> [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) { return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); @@ -144,11 +158,15 @@ namespace Jellyfin.Api.Controllers /// Processes an event subscription request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) { return ProcessEventRequest(_mediaReceiverRegistrar); @@ -158,11 +176,15 @@ namespace Jellyfin.Api.Controllers /// Processes an event subscription request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/ContentDirectory/Events")] [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) { return ProcessEventRequest(_contentDirectory); @@ -172,11 +194,15 @@ namespace Jellyfin.Api.Controllers /// Processes an event subscription request. /// </summary> /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> /// <returns>Event subscription response.</returns> [HttpSubscribe("{serverId}/ConnectionManager/Events")] [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) { return ProcessEventRequest(_connectionManager); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 1153a601e7..22628a59d8 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -14,6 +15,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; @@ -40,6 +42,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] public class DynamicHlsController : BaseJellyfinApiController { + private const string DefaultEncoderPreset = "veryfast"; + private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IDlnaManager _dlnaManager; @@ -55,8 +60,7 @@ namespace Jellyfin.Api.Controllers private readonly ILogger<DynamicHlsController> _logger; private readonly EncodingHelper _encodingHelper; private readonly DynamicHlsHelper _dynamicHlsHelper; - - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls; + private readonly EncodingOptions _encodingOptions; /// <summary> /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. @@ -91,6 +95,8 @@ namespace Jellyfin.Api.Controllers ILogger<DynamicHlsController> logger, DynamicHlsHelper dynamicHlsHelper) { + _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); + _libraryManager = libraryManager; _userManager = userManager; _dlnaManager = dlnaManager; @@ -105,8 +111,7 @@ namespace Jellyfin.Api.Controllers _transcodingJobHelper = transcodingJobHelper; _logger = logger; _dynamicHlsHelper = dynamicHlsHelper; - - _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); } /// <summary> @@ -119,7 +124,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -148,7 +153,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> @@ -271,7 +276,7 @@ namespace Jellyfin.Api.Controllers EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// <summary> @@ -284,7 +289,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -295,6 +300,7 @@ namespace Jellyfin.Api.Controllers /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> @@ -313,7 +319,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> @@ -351,6 +357,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -403,7 +410,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, @@ -436,7 +443,7 @@ namespace Jellyfin.Api.Controllers EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// <summary> @@ -449,7 +456,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -478,7 +485,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> @@ -612,7 +619,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -623,6 +630,7 @@ namespace Jellyfin.Api.Controllers /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> @@ -641,7 +649,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> @@ -677,6 +685,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -729,7 +738,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, @@ -807,7 +816,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> @@ -948,7 +957,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -959,6 +968,7 @@ namespace Jellyfin.Api.Controllers /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> @@ -977,7 +987,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> @@ -1017,6 +1027,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? breakOnNonKeyFrames, [FromQuery] int? audioSampleRate, [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, [FromQuery] int? audioBitRate, [FromQuery] int? audioChannels, [FromQuery] int? maxAudioChannels, @@ -1069,7 +1080,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, AudioSampleRate = audioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = audioChannels, Profile = profile, @@ -1122,7 +1133,7 @@ namespace Jellyfin.Api.Controllers _dlnaManager, _deviceManager, _transcodingJobHelper, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -1130,11 +1141,19 @@ namespace Jellyfin.Api.Controllers var segmentLengths = GetSegmentLengths(state); + var segmentContainer = state.Request.SegmentContainer ?? "ts"; + + // http://ffmpeg.org/ffmpeg-all.html#toc-hls-2 + var isHlsInFmp4 = string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase); + var hlsVersion = isHlsInFmp4 ? "7" : "3"; + var builder = new StringBuilder(); builder.AppendLine("#EXTM3U") .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") - .AppendLine("#EXT-X-VERSION:3") + .Append("#EXT-X-VERSION:") + .Append(hlsVersion) + .AppendLine() .Append("#EXT-X-TARGETDURATION:") .Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength)) .AppendLine() @@ -1144,6 +1163,18 @@ namespace Jellyfin.Api.Controllers var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer); var queryString = Request.QueryString; + if (isHlsInFmp4) + { + builder.Append("#EXT-X-MAP:URI=\"") + .Append("hls1/") + .Append(name) + .Append("/-1") + .Append(segmentExtension) + .Append(queryString) + .Append('"') + .AppendLine(); + } + foreach (var length in segmentLengths) { builder.Append("#EXTINF:") @@ -1187,7 +1218,7 @@ namespace Jellyfin.Api.Controllers _dlnaManager, _deviceManager, _transcodingJobHelper, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -1201,7 +1232,7 @@ namespace Jellyfin.Api.Controllers if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } @@ -1215,7 +1246,7 @@ namespace Jellyfin.Api.Controllers { if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); transcodingLock.Release(); released = true; _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); @@ -1226,7 +1257,13 @@ namespace Jellyfin.Api.Controllers var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - if (currentTranscodingIndex == null) + if (segmentId == -1) + { + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; + } + else if (currentTranscodingIndex == null) { _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); startTranscoding = true; @@ -1258,13 +1295,12 @@ namespace Jellyfin.Api.Controllers streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId); state.WaitForPath = segmentPath; - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); job = await _transcodingJobHelper.StartFfMpeg( state, playlistPath, - GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId), + GetCommandLineArguments(playlistPath, state, true, segmentId), Request, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource).ConfigureAwait(false); } catch @@ -1277,7 +1313,7 @@ namespace Jellyfin.Api.Controllers } else { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job?.TranscodingThrottler != null) { await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); @@ -1294,7 +1330,7 @@ namespace Jellyfin.Api.Controllers } _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } @@ -1318,11 +1354,10 @@ namespace Jellyfin.Api.Controllers return result.ToArray(); } - private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber) + private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); - - var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); if (state.BaseRequest.BreakOnNonKeyFrames) { @@ -1334,34 +1369,57 @@ namespace Jellyfin.Api.Controllers state.BaseRequest.BreakOnNonKeyFrames = false; } - var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions); - // If isEncoding is true we're actually starting ffmpeg var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0"; - + var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer); + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer); + var outputTsArg = outputPrefix + "%d" + outputExtension; - var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + var segmentFormat = outputExtension.TrimStart('.'); if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) { segmentFormat = "mpegts"; } + else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = string.Empty; + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + // on Windows, the path of fmp4 header file needs to be configured + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\""; + } + else + { + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""; + } - var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128 - ? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: " + segmentFormat); + } + + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) : "128"; return string.Format( CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", inputModifier, - _encodingHelper.GetInputArgument(state, encodingOptions), + _encodingHelper.GetInputArgument(state, _encodingOptions), threads, mapArgs, - GetVideoArguments(state, encodingOptions, startNumber), - GetAudioArguments(state, encodingOptions), + GetVideoArguments(state, startNumber), + GetAudioArguments(state), maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), segmentFormat, @@ -1370,50 +1428,63 @@ namespace Jellyfin.Api.Controllers outputPath).Trim(); } - private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) + /// <summary> + /// Gets the audio arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The command line arguments for audio transcoding.</returns> + private string GetAudioArguments(StreamState state) { + if (state.AudioStream == null) + { + return string.Empty; + } + var audioCodec = _encodingHelper.GetAudioEncoder(state); if (!state.IsOutputVideo) { if (EncodingHelper.IsCopyCodec(audioCodec)) { - return "-acodec copy"; + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; } - var audioTranscodeParams = new List<string>(); + var audioTranscodeParams = string.Empty; - audioTranscodeParams.Add("-acodec " + audioCodec); + audioTranscodeParams += "-acodec " + audioCodec; if (state.OutputAudioBitrate.HasValue) { - audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioChannels.HasValue) { - audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioSampleRate.HasValue) { - audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - audioTranscodeParams.Add("-vn"); - return string.Join(' ', audioTranscodeParams); + audioTranscodeParams += " -vn"; + return audioTranscodeParams; } if (EncodingHelper.IsCopyCodec(audioCodec)) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) { - return "-codec:a:0 copy -copypriorss:a:0 0"; + return "-codec:a:0 copy -strict -2 -copypriorss:a:0 0" + bitStreamArgs; } - return "-codec:a:0 copy"; + return "-codec:a:0 copy -strict -2" + bitStreamArgs; } var args = "-codec:a:0 " + audioCodec; @@ -1437,94 +1508,89 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); return args; } - private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber) + /// <summary> + /// Gets the video arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="startNumber">The first number in the hls sequence.</param> + /// <returns>The command line arguments for video transcoding.</returns> + private string GetVideoArguments(StreamState state, int startNumber) { + if (state.VideoStream == null) + { + return string.Empty; + } + if (!state.IsOutputVideo) { return string.Empty; } - var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var args = "-codec:v:0 " + codec; + // Prefer hvc1 to hev1. + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + args += " -tag:v:0 hvc1"; + } + // if (state.EnableMpegtsM2TsMode) // { // args += " -mpegts_m2ts_mode 1"; // } - // See if we can save come cpu cycles by avoiding encoding + // See if we can save come cpu cycles by avoiding encoding. if (EncodingHelper.IsCopyCodec(codec)) { if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; } } + args += " -start_at_zero"; + // args += " -flags -global_header"; } else { - var gopArg = string.Empty; - var keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", - startNumber * state.SegmentLength, - state.SegmentLength); + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); - var framerate = state.VideoStream?.RealFrameRate; + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber); - if (framerate.HasValue) + // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) { - // This is to make sure keyframe interval is limited to our segment, - // as forcing keyframes is not enough. - // Example: we encoded half of desired length, then codec detected - // scene cut and inserted a keyframe; next forced keyframe would - // be created outside of segment, which breaks seeking - // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe - gopArg = string.Format( - CultureInfo.InvariantCulture, - " -g {0} -keyint_min {0} -sc_threshold 0", - Math.Ceiling(state.SegmentLength * framerate.Value)); - } - - args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast"); - - // Unable to force key frames using these hw encoders, set key frames by GOP - if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)) - { - args += " " + gopArg; - } - else - { - args += " " + keyFrameArg + gopArg; + args += " -bf 0"; } // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - // This is for graphical subs if (hasGraphicalSubs) { - args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); + // Graphical subs overlay and resolution params. + args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); } - - // Add resolution params, if specified else { - args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec); + // Resolution params. + args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); } // -start_at_zero is necessary to use with -ss when seeking, @@ -1559,8 +1625,7 @@ namespace Jellyfin.Api.Controllers private string GetSegmentPath(StreamState state, string playlist, int index) { - var folder = Path.GetDirectoryName(playlist); - + var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist)); var filename = Path.GetFileNameWithoutExtension(playlist); return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + GetSegmentFileExtension(state.Request.SegmentContainer)); @@ -1685,7 +1750,7 @@ namespace Jellyfin.Api.Controllers private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) { - var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType); + var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); if (job == null || job.HasExited) { diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs index ce88b0b995..b0b4b5af51 100644 --- a/Jellyfin.Api/Controllers/EnvironmentController.cs +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.EnvironmentDtos; +using MediaBrowser.Common.Extensions; using MediaBrowser.Model.IO; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -16,7 +17,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Environment Controller. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] public class EnvironmentController : BaseJellyfinApiController { private const char UncSeparator = '\\'; @@ -103,6 +104,11 @@ namespace Jellyfin.Api.Controllers if (validatePathDto.ValidateWritable) { + if (validatePathDto.Path == null) + { + throw new ResourceNotFoundException(nameof(validatePathDto.Path)); + } + var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); try { diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 2a567c8461..c97a1ed146 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Jellyfin.Api.Constants; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -50,8 +51,8 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( [FromQuery] Guid? userId, [FromQuery] string? parentId, - [FromQuery] string? includeItemTypes, - [FromQuery] string? mediaTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes) { var parentItem = string.IsNullOrEmpty(parentId) ? null @@ -61,10 +62,11 @@ namespace Jellyfin.Api.Controllers ? _userManager.GetUserById(userId.Value) : null; - if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + if (includeItemTypes.Length == 1 + && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase))) { parentItem = null; } @@ -78,8 +80,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery { User = user, - MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), - IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, Recursive = true, EnableTotalRecordCount = false, DtoOptions = new DtoOptions @@ -139,7 +141,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryFilters> GetQueryFilters( [FromQuery] Guid? userId, [FromQuery] string? parentId, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isAiring, [FromQuery] bool? isMovie, [FromQuery] bool? isSports, @@ -156,10 +158,11 @@ namespace Jellyfin.Api.Controllers ? _userManager.GetUserById(userId.Value) : null; - if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + if (includeItemTypes.Length == 1 + && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase))) { parentItem = null; } @@ -167,8 +170,7 @@ namespace Jellyfin.Api.Controllers var filters = new QueryFilters(); var genreQuery = new InternalItemsQuery(user) { - IncludeItemTypes = - (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries), + IncludeItemTypes = includeItemTypes, DtoOptions = new DtoOptions { Fields = Array.Empty<ItemFields>(), @@ -192,10 +194,11 @@ namespace Jellyfin.Api.Controllers genreQuery.Parent = parentItem; } - if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase)) + if (includeItemTypes.Length == 1 + && (string.Equals(includeItemTypes[0], nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Audio), StringComparison.OrdinalIgnoreCase))) { filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair { diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index aa7d02de00..2dd504770d 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; @@ -49,30 +48,16 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets all genres from a given item, folder, or the entire library. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">The search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="userId">User id.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> @@ -84,30 +69,16 @@ namespace Jellyfin.Api.Controllers [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetGenres( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, - [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -115,45 +86,24 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount @@ -171,87 +121,20 @@ namespace Jellyfin.Api.Controllers } } - // Studios - if (!string.IsNullOrEmpty(studios)) + QueryResult<(BaseItem, ItemCounts)> result; + if (parentItem is ICollectionFolder parentCollectionFolder + && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal) + || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal))) { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null) - .Select(i => i!.Id) - .ToArray(); + result = _libraryManager.GetMusicGenres(query); + } + else + { + result = _libraryManager.GetGenres(query); } - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - - var result = new QueryResult<(BaseItem, ItemCounts)>(); - - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto> - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// <summary> @@ -293,7 +176,7 @@ namespace Jellyfin.Api.Controllers return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) where T : BaseItem, new() { var result = libraryManager.GetItemList(new InternalItemsQuery diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 054e586ce3..ccdbbb2970 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; @@ -111,11 +112,13 @@ namespace Jellyfin.Api.Controllers /// <param name="segmentId">The segment id.</param> /// <param name="segmentContainer">The segment container.</param> /// <response code="200">Hls video segment returned.</response> + /// <response code="404">Hls segment not found.</response> /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> // Can't require authentication just yet due to seeing some requests come from Chrome without full query string // [Authenticated] [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesVideoFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsVideoSegmentLegacy( @@ -131,12 +134,25 @@ namespace Jellyfin.Api.Controllers var normalizedPlaylistId = playlistId; - var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath) - .FirstOrDefault(i => - string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase) - && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1); + var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); + // Add . to start of segment container for future use. + segmentContainer = segmentContainer.Insert(0, "."); + string? playlistPath = null; + foreach (var path in filePaths) + { + var pathExtension = Path.GetExtension(path); + if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) + || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) + && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1) + { + playlistPath = path; + break; + } + } - return GetFileResult(file, playlistPath); + return playlistPath == null + ? NotFound("Hls segment not found.") + : GetFileResult(file, playlistPath); } private ActionResult GetFileResult(string path, string playlistPath) diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs index 980c3273dd..198dbc51fc 100644 --- a/Jellyfin.Api/Controllers/ImageByNameController.cs +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -161,7 +161,7 @@ namespace Jellyfin.Api.Controllers /// <param name="theme">Theme to search.</param> /// <param name="name">File name to search for.</param> /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> - private ActionResult GetImageFile(string basePath, string? theme, string? name) + private ActionResult GetImageFile(string basePath, string theme, string? name) { var themeFolder = Path.Combine(basePath, theme); if (Directory.Exists(themeFolder)) diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 4a67c1aede..76e53b9a53 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -1268,7 +1269,7 @@ namespace Jellyfin.Api.Controllers Response.Headers.Add(key, value); } - Response.ContentType = imageContentType; + Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain; Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture)); Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 7f8a2be12e..d17a26db43 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -55,7 +56,7 @@ namespace Jellyfin.Api.Controllers /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -68,18 +69,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -92,7 +92,7 @@ namespace Jellyfin.Api.Controllers /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -105,18 +105,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var album = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); @@ -129,7 +128,7 @@ namespace Jellyfin.Api.Controllers /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -142,18 +141,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var playlist = (Playlist)_libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); @@ -166,7 +164,7 @@ namespace Jellyfin.Api.Controllers /// <param name="name">The genre name.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -179,17 +177,16 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] string name, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); @@ -202,7 +199,7 @@ namespace Jellyfin.Api.Controllers /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -215,18 +212,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -239,7 +235,7 @@ namespace Jellyfin.Api.Controllers /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -252,18 +248,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); @@ -276,7 +271,7 @@ namespace Jellyfin.Api.Controllers /// <param name="id">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -289,18 +284,17 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid id, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var item = _libraryManager.GetItemById(id); var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index ab73aa4286..a7c1a63882 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -334,10 +334,16 @@ namespace Jellyfin.Api.Controllers private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath) { using var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false); + if (result.Content.Headers.ContentType?.MediaType == null) + { + throw new ResourceNotFoundException(nameof(result.Content.Headers.ContentType)); + } + var ext = result.Content.Headers.ContentType.MediaType.Split('/')[^1]; var fullCachePath = GetFullCachePath(urlHash + "." + ext); - Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); + var directory = Path.GetDirectoryName(fullCachePath) ?? throw new ResourceNotFoundException($"Provided path ({fullCachePath}) is not valid."); + Directory.CreateDirectory(directory); using (var stream = result.Content) { await using var fileStream = new FileStream( @@ -351,7 +357,9 @@ namespace Jellyfin.Api.Controllers await stream.CopyToAsync(fileStream).ConfigureAwait(false); } - Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); + var pointerCacheDirectory = Path.GetDirectoryName(pointerCachePath) ?? throw new ArgumentException($"Provided path ({pointerCachePath}) is not valid.", nameof(pointerCachePath)); + + Directory.CreateDirectory(pointerCacheDirectory); await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath).ConfigureAwait(false); } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 7ca5775438..177df22cf5 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; @@ -73,8 +73,8 @@ namespace Jellyfin.Api.Controllers /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> /// <param name="isHd">Optional filter by items that are HD or not.</param> /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> @@ -87,42 +87,42 @@ namespace Jellyfin.Api.Controllers /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> /// <param name="searchTerm">Optional. Filter based on a search term.</param> /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> /// <param name="isLocked">Optional filter by items that are locked.</param> /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> @@ -133,12 +133,12 @@ namespace Jellyfin.Api.Controllers /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> @@ -159,7 +159,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery] string? locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, @@ -173,42 +173,42 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasImdbId, [FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTvdbId, - [FromQuery] string? excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, [FromQuery] string? sortOrder, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery] string? sortBy, [FromQuery] bool? isPlayed, - [FromQuery] string? genres, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? artists, - [FromQuery] string? excludeArtistIds, - [FromQuery] string? artistIds, - [FromQuery] string? albumArtistIds, - [FromQuery] string? contributingArtistIds, - [FromQuery] string? albums, - [FromQuery] string? albumIds, - [FromQuery] string? ids, - [FromQuery] string? videoTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, [FromQuery] string? minOfficialRating, [FromQuery] bool? isLocked, [FromQuery] bool? isPlaceHolder, @@ -219,12 +219,12 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery] string? seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery] string? studioIds, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { @@ -234,13 +234,13 @@ namespace Jellyfin.Api.Controllers var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase)) + if (includeItemTypes.Length == 1 + && (includeItemTypes[0].Equals("Playlist", StringComparison.OrdinalIgnoreCase) + || includeItemTypes[0].Equals("BoxSet", StringComparison.OrdinalIgnoreCase))) { parentId = null; } @@ -263,7 +263,7 @@ namespace Jellyfin.Api.Controllers && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) { recursive = true; - includeItemTypes = "Playlist"; + includeItemTypes = new[] { "Playlist" }; } bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id) @@ -292,14 +292,14 @@ namespace Jellyfin.Api.Controllers return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); } - if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder)) + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder)) { var query = new InternalItemsQuery(user!) { IsPlayed = isPlayed, - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, Recursive = recursive ?? false, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), IsFavorite = isFavorite, @@ -331,28 +331,28 @@ namespace Jellyfin.Api.Controllers HasTrailer = hasTrailer, IsHD = isHd, Is4K = is4K, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - ArtistIds = RequestHelpers.GetGuids(artistIds), - AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds), - ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + ArtistIds = artistIds, + AlbumArtistIds = albumArtistIds, + ContributingArtistIds = contributingArtistIds, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, ImageTypes = imageTypes, - VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(), + VideoTypes = videoTypes, AdjacentTo = adjacentTo, - ItemIds = RequestHelpers.GetGuids(ids), + ItemIds = ids, MinCommunityRating = minCommunityRating, MinCriticRating = minCriticRating, ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId), ParentIndexNumber = parentIndexNumber, EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds), + ExcludeItemIds = excludeItemIds, DtoOptions = dtoOptions, SearchTerm = searchTerm, MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), @@ -361,7 +361,7 @@ namespace Jellyfin.Api.Controllers MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), }; - if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm)) + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) { query.CollapseBoxSetItems = false; } @@ -401,9 +401,9 @@ namespace Jellyfin.Api.Controllers } // Filter by Series Status - if (!string.IsNullOrEmpty(seriesStatus)) + if (seriesStatus.Length != 0) { - query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray(); + query.SeriesStatuses = seriesStatus; } // ExcludeLocationTypes @@ -412,13 +412,9 @@ namespace Jellyfin.Api.Controllers query.IsVirtualItem = false; } - if (!string.IsNullOrEmpty(locationTypes)) + if (locationTypes.Length > 0 && locationTypes.Length < 4) { - var requestedLocationTypes = locationTypes.Split(','); - if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4) - { - query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString()); - } + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); } // Min official rating @@ -434,9 +430,9 @@ namespace Jellyfin.Api.Controllers } // Artists - if (!string.IsNullOrEmpty(artists)) + if (artists.Length != 0) { - query.ArtistIds = artists.Split('|').Select(i => + query.ArtistIds = artists.Select(i => { try { @@ -450,29 +446,29 @@ namespace Jellyfin.Api.Controllers } // ExcludeArtistIds - if (!string.IsNullOrWhiteSpace(excludeArtistIds)) + if (excludeArtistIds.Length != 0) { - query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + query.ExcludeArtistIds = excludeArtistIds; } - if (!string.IsNullOrWhiteSpace(albumIds)) + if (albumIds.Length != 0) { - query.AlbumIds = RequestHelpers.GetGuids(albumIds); + query.AlbumIds = albumIds; } // Albums - if (!string.IsNullOrEmpty(albums)) + if (albums.Length != 0) { - query.AlbumIds = albums.Split('|').SelectMany(i => + query.AlbumIds = albums.SelectMany(i => { return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 }); }).ToArray(); } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -514,13 +510,13 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">The item limit.</param> /// <param name="searchTerm">The search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <response code="200">Items returned.</response> @@ -533,20 +529,19 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { var user = _userManager.GetUserById(userId); var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -571,13 +566,13 @@ namespace Jellyfin.Api.Controllers ParentId = parentIdGuid, Recursive = true, DtoOptions = dtoOptions, - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + MediaTypes = mediaTypes, IsVirtualItem = false, CollapseBoxSetItems = false, EnableTotalRecordCount = enableTotalRecordCount, AncestorIds = ancestorIds, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, SearchTerm = searchTerm }); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 8a872ae133..3ff77e8e01 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -12,6 +12,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryDtos; using Jellyfin.Data.Entities; using MediaBrowser.Common.Progress; @@ -361,15 +362,14 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItems([FromQuery] string? ids) + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids) { - if (string.IsNullOrEmpty(ids)) + if (ids.Length == 0) { return NoContent(); } - var itemIds = RequestHelpers.Split(ids, ',', true); - foreach (var i in itemIds) + foreach (var i in ids) { var item = _libraryManager.GetItemById(i); var auth = _authContext.GetAuthorizationInfo(Request); @@ -455,7 +455,7 @@ namespace Jellyfin.Api.Controllers : null; var dtoOptions = new DtoOptions().AddClientFields(Request); - BaseItem parent = item.GetParent(); + BaseItem? parent = item.GetParent(); while (parent != null) { @@ -466,7 +466,7 @@ namespace Jellyfin.Api.Controllers baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - parent = parent.GetParent(); + parent = parent?.GetParent(); } return baseItemDtos; @@ -680,20 +680,20 @@ namespace Jellyfin.Api.Controllers /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> /// <response code="200">Similar items returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> - [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists2")] + [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] [HttpGet("Items/{itemId}/Similar")] - [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums2")] - [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows2")] - [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies2")] - [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers2")] + [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")] + [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")] + [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")] + [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( [FromRoute, Required] Guid itemId, - [FromQuery] string? excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, - [FromQuery] string? fields) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { var item = itemId.Equals(Guid.Empty) ? (!userId.Equals(Guid.Empty) @@ -701,33 +701,71 @@ namespace Jellyfin.Api.Controllers : _libraryManager.RootFolder) : _libraryManager.GetItemById(itemId); - var program = item as IHasProgramAttributes; - var isMovie = item is MediaBrowser.Controller.Entities.Movies.Movie || (program != null && program.IsMovie) || item is Trailer; - if (program != null && program.IsSeries) - { - return GetSimilarItemsResult( - item, - excludeArtistIds, - userId, - limit, - fields, - new[] { nameof(Series) }, - false); - } - - if (item is MediaBrowser.Controller.Entities.TV.Episode || (item is IItemByName && !(item is MusicArtist))) + if (item is Episode || (item is IItemByName && !(item is MusicArtist))) { return new QueryResult<BaseItemDto>(); } - return GetSimilarItemsResult( - item, - excludeArtistIds, - userId, - limit, - fields, - new[] { item.GetType().Name }, - isMovie); + var user = userId.HasValue && !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId.Value) + : null; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(Request); + + var program = item as IHasProgramAttributes; + bool? isMovie = item is Movie || (program != null && program.IsMovie) || item is Trailer; + bool? isSeries = item is Series || (program != null && program.IsSeries); + + var includeItemTypes = new List<string>(); + if (isMovie.Value) + { + includeItemTypes.Add(nameof(Movie)); + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + includeItemTypes.Add(nameof(Trailer)); + includeItemTypes.Add(nameof(LiveTvProgram)); + } + } + else if (isSeries.Value) + { + includeItemTypes.Add(nameof(Series)); + } + else + { + // For non series and movie types these columns are typically null + isSeries = null; + isMovie = null; + includeItemTypes.Add(item.GetType().Name); + } + + var query = new InternalItemsQuery(user) + { + Limit = limit, + IncludeItemTypes = includeItemTypes.ToArray(), + IsMovie = isMovie, + IsSeries = isSeries, + SimilarTo = item, + DtoOptions = dtoOptions, + EnableTotalRecordCount = !isMovie ?? true, + EnableGroupByMetadataKey = isMovie ?? false, + MinSimilarityScore = 2 // A remnant from album/artist scoring + }; + + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } + + List<BaseItem> itemsResult = _libraryManager.GetItemList(query); + + var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + Items = returnList, + TotalRecordCount = itemsResult.Count + }; } /// <summary> @@ -854,7 +892,7 @@ namespace Jellyfin.Api.Controllers return _libraryManager.GetItemsResult(query).TotalRecordCount; } - private BaseItem TranslateParentItem(BaseItem item, User user) + private BaseItem? TranslateParentItem(BaseItem item, User user) { return item.GetParent() is AggregateFolder ? _libraryManager.GetUserRootFolder().GetChildren(user, true) @@ -880,75 +918,6 @@ namespace Jellyfin.Api.Controllers } } - private QueryResult<BaseItemDto> GetSimilarItemsResult( - BaseItem item, - string? excludeArtistIds, - Guid? userId, - int? limit, - string? fields, - string[] includeItemTypes, - bool isMovie) - { - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? _userManager.GetUserById(userId.Value) - : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) - .AddClientFields(Request); - - var query = new InternalItemsQuery(user) - { - Limit = limit, - IncludeItemTypes = includeItemTypes, - IsMovie = isMovie, - SimilarTo = item, - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie, - EnableGroupByMetadataKey = isMovie - }; - - // ExcludeArtistIds - if (!string.IsNullOrEmpty(excludeArtistIds)) - { - query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); - } - - List<BaseItem> itemsResult; - - if (isMovie) - { - var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(nameof(Trailer)); - itemTypes.Add(nameof(LiveTvProgram)); - } - - query.IncludeItemTypes = itemTypes.ToArray(); - itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); - } - else if (item is MusicArtist) - { - query.IncludeItemTypes = Array.Empty<string>(); - - itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); - } - else - { - itemsResult = _libraryManager.GetItemList(query); - } - - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - - var result = new QueryResult<BaseItemDto> - { - Items = returnList, - TotalRecordCount = itemsResult.Count - }; - - return result; - } - private static string[] GetRepresentativeItemTypes(string? contentType) { return contentType switch diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 88a7542cee..410f3a3400 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; @@ -14,6 +14,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common; @@ -118,7 +119,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="sortBy">Optional. Key to sort by.</param> /// <param name="sortOrder">Optional. Sort order.</param> @@ -146,16 +147,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isDisliked, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, - [FromQuery] string? sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, [FromQuery] SortOrder? sortOrder, [FromQuery] bool enableFavoriteSorting = false, [FromQuery] bool addCurrentProgram = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -175,7 +175,7 @@ namespace Jellyfin.Api.Controllers IsNews = isNews, IsKids = isKids, IsSports = isSports, - SortBy = RequestHelpers.Split(sortBy, ',', true), + SortBy = sortBy, SortOrder = sortOrder ?? SortOrder.Ascending, AddCurrentProgram = addCurrentProgram }, @@ -239,7 +239,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="isMovie">Optional. Filter for movies.</param> /// <param name="isSeries">Optional. Filter for series.</param> @@ -263,8 +263,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, @@ -274,8 +274,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isLibraryItem, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -296,7 +295,7 @@ namespace Jellyfin.Api.Controllers IsKids = isKids, IsSports = isSports, IsLibraryItem = isLibraryItem, - Fields = RequestHelpers.GetItemFields(fields), + Fields = fields, ImageTypeLimit = imageTypeLimit, EnableImages = enableImages }, dtoOptions); @@ -316,7 +315,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> /// <response code="200">Live tv recordings returned.</response> @@ -350,8 +349,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? seriesTimerId, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { @@ -530,7 +529,7 @@ namespace Jellyfin.Api.Controllers /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="seriesTimerId">Optional. Filter by series timer id.</param> /// <param name="librarySeriesId">Optional. Filter by library series id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableTotalRecordCount">Retrieve total record count.</param> /// <response code="200">Live tv epgs returned.</response> /// <returns> @@ -540,7 +539,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.DefaultAuthorization)] public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( - [FromQuery] string? channelIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, [FromQuery] Guid? userId, [FromQuery] DateTime? minStartDate, [FromQuery] bool? hasAired, @@ -557,15 +556,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? sortBy, [FromQuery] string? sortOrder, - [FromQuery] string? genres, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] string? seriesTimerId, [FromQuery] Guid? librarySeriesId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool enableTotalRecordCount = true) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -574,8 +573,7 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ChannelIds = RequestHelpers.Split(channelIds, ',', true) - .Select(i => new Guid(i)).ToArray(), + ChannelIds = channelIds, HasAired = hasAired, IsAiring = isAiring, EnableTotalRecordCount = enableTotalRecordCount, @@ -592,8 +590,8 @@ namespace Jellyfin.Api.Controllers IsKids = isKids, IsSports = isSports, SeriesTimerId = seriesTimerId, - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds) + Genres = genres, + GenreIds = genreIds }; if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty)) @@ -606,8 +604,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); @@ -630,8 +627,7 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true) - .Select(i => new Guid(i)).ToArray(), + ChannelIds = body.ChannelIds, HasAired = body.HasAired, IsAiring = body.IsAiring, EnableTotalRecordCount = body.EnableTotalRecordCount, @@ -648,8 +644,8 @@ namespace Jellyfin.Api.Controllers IsKids = body.IsKids, IsSports = body.IsSports, SeriesTimerId = body.SeriesTimerId, - Genres = RequestHelpers.Split(body.Genres, '|', true), - GenreIds = RequestHelpers.GetGuids(body.GenreIds) + Genres = body.Genres, + GenreIds = body.GenreIds }; if (!body.LibrarySeriesId.Equals(Guid.Empty)) @@ -662,8 +658,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(body.Fields) + var dtoOptions = new DtoOptions { Fields = body.Fields } .AddClientFields(Request) .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); @@ -685,7 +680,7 @@ namespace Jellyfin.Api.Controllers /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="genreIds">The genres to return guide information for.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableUserData">Optional. include user data.</param> /// <param name="enableTotalRecordCount">Retrieve total record count.</param> /// <response code="200">Recommended epgs returned.</response> @@ -705,9 +700,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isSports, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? genreIds, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { @@ -726,11 +721,10 @@ namespace Jellyfin.Api.Controllers IsNews = isNews, IsSports = isSports, EnableTotalRecordCount = enableTotalRecordCount, - GenreIds = RequestHelpers.GetGuids(genreIds) + GenreIds = genreIds }; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None); @@ -1077,7 +1071,7 @@ namespace Jellyfin.Api.Controllers var client = _httpClientFactory.CreateClient(NamedClient.Default); // https://json.schedulesdirect.org/20141201/available/countries // Can't dispose the response as it's required up the call chain. - var response = await client.GetAsync("https://json.schedulesdirect.org/20141201/available/countries") + var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries")) .ConfigureAwait(false); return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); @@ -1159,7 +1153,8 @@ namespace Jellyfin.Api.Controllers /// <param name="newDevicesOnly">Only discover new tuners.</param> /// <response code="200">Tuners returned.</response> /// <returns>An <see cref="OkResult"/> containing the tuners.</returns> - [HttpGet("Tuners/Discvover")] + [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] + [HttpGet("Tuners/Discover")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 4c21999b1b..186024585b 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -104,7 +104,7 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, - [FromQuery] long? maxStreamingBitrate, + [FromQuery] int? maxStreamingBitrate, [FromQuery] long? startTimeTicks, [FromQuery] int? audioStreamIndex, [FromQuery] int? subtitleStreamIndex, @@ -234,7 +234,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? openToken, [FromQuery] Guid? userId, [FromQuery] string? playSessionId, - [FromQuery] long? maxStreamingBitrate, + [FromQuery] int? maxStreamingBitrate, [FromQuery] long? startTimeTicks, [FromQuery] int? audioStreamIndex, [FromQuery] int? subtitleStreamIndex, diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index afb536e747..ebc148fe56 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -65,15 +66,14 @@ namespace Jellyfin.Api.Controllers public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( [FromQuery] Guid? userId, [FromQuery] string? parentId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, [FromQuery] int itemLimit = 8) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request); var categories = new List<RecommendationDto>(); diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 4de7ef8c88..8c61043028 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; @@ -49,30 +48,16 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets all music genres from a given item, folder, or the entire library. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">The search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="userId">User id.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> @@ -82,31 +67,18 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Music genres returned.</response> /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> [HttpGet] + [Obsolete("Use GetGenres instead")] public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, - [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -114,45 +86,24 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount @@ -170,87 +121,10 @@ namespace Jellyfin.Api.Controllers } } - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null) - .Select(i => i!.Id) - .ToArray(); - } - - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - var result = _libraryManager.GetMusicGenres(query); - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto> - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// <summary> @@ -265,7 +139,7 @@ namespace Jellyfin.Api.Controllers { var dtoOptions = new DtoOptions().AddClientFields(Request); - MusicGenre item; + MusicGenre? item; if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) { @@ -286,7 +160,7 @@ namespace Jellyfin.Api.Controllers return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions) where T : BaseItem, new() { var result = libraryManager.GetItemList(new InternalItemsQuery diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 1d9de14d2d..1f797d6bc3 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -54,6 +54,11 @@ namespace Jellyfin.Api.Controllers string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)) .FirstOrDefault(); + if (result == null) + { + return NotFound(); + } + return result; } @@ -149,12 +154,13 @@ namespace Jellyfin.Api.Controllers /// <param name="repositoryInfos">The list of package repositories.</param> /// <response code="204">Package repositories saved.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpOptions("Repositories")] + [HttpPost("Repositories")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetRepositories([FromBody] List<RepositoryInfo> repositoryInfos) { _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; + _serverConfigurationManager.SaveConfiguration(); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index f173f75ba3..9dc79b3884 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; @@ -28,6 +27,7 @@ namespace Jellyfin.Api.Controllers private readonly ILibraryManager _libraryManager; private readonly IDtoService _dtoService; private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; /// <summary> /// Initializes a new instance of the <see cref="PersonsController"/> class. @@ -35,221 +35,81 @@ namespace Jellyfin.Api.Controllers /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> public PersonsController( ILibraryManager libraryManager, IDtoService dtoService, - IUserManager userManager) + IUserManager userManager, + IUserDataManager userDataManager) { _libraryManager = libraryManager; _dtoService = dtoService; _userManager = userManager; + _userDataManager = userDataManager; } /// <summary> - /// Gets all persons from a given item, folder, or the entire library. + /// Gets all persons. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param> /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> /// <response code="200">Persons returned.</response> /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetPersons( - [FromQuery] double? minCommunityRating, - [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery] string? appearsInItemId, [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) + [FromQuery] bool? enableImages = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); } - var query = new InternalItemsQuery(user) + var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); + var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(), - MinCommunityRating = minCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount - }; - - if (!string.IsNullOrWhiteSpace(parentId)) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { new Guid(parentId) }; - } - else - { - query.ItemIds = new[] { new Guid(parentId) }; - } - } - - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|') - .Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null) - .Select(i => i!.Id) - .ToArray(); - } - - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - - var result = new QueryResult<(BaseItem, ItemCounts)>(); - - var dtos = result.Items.Select(i => - { - var (baseItem, counts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; + PersonTypes = personTypes, + ExcludePersonTypes = excludePersonTypes, + NameContains = searchTerm, + User = user, + IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, + AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId), + Limit = limit ?? 0 }); return new QueryResult<BaseItemDto> { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount + Items = peopleItems.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)).ToArray(), + TotalRecordCount = peopleItems.Count }; } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 0419b2436b..bc47ecbd1c 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.PlaylistDtos; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Library; @@ -62,11 +63,10 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( [FromBody, Required] CreatePlaylistDto createPlaylistRequest) { - Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids); var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { Name = createPlaylistRequest.Name, - ItemIdList = idGuidArray, + ItemIdList = createPlaylistRequest.Ids, UserId = createPlaylistRequest.UserId, MediaType = createPlaylistRequest.MediaType }).ConfigureAwait(false); @@ -86,10 +86,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddToPlaylist( [FromRoute, Required] Guid playlistId, - [FromQuery] string? ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, [FromQuery] Guid? userId) { - await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false); + await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); return NoContent(); } @@ -121,9 +121,11 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="NoContentResult"/> on success.</returns> [HttpDelete("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds) + public async Task<ActionResult> RemoveFromPlaylist( + [FromRoute, Required] string playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false); + await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); return NoContent(); } @@ -134,7 +136,7 @@ namespace Jellyfin.Api.Controllers /// <param name="userId">User id.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="enableImages">Optional. Include image information in output.</param> /// <param name="enableUserData">Optional. Include user data.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -148,11 +150,11 @@ namespace Jellyfin.Api.Controllers [FromQuery, Required] Guid userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableImages, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { var playlist = (Playlist)_libraryManager.GetItemById(playlistId); if (playlist == null) @@ -176,8 +178,7 @@ namespace Jellyfin.Api.Controllers items = items.Take(limit.Value).ToArray(); } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index 5f095443b9..5284888d82 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -157,9 +157,9 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] - public async Task<ActionResult> GetRemoteImage([FromQuery, Required] string imageUrl) + public async Task<ActionResult> GetRemoteImage([FromQuery, Required] Uri imageUrl) { - var urlHash = imageUrl.GetMD5(); + var urlHash = imageUrl.ToString().GetMD5(); var pointerCachePath = GetFullCachePath(urlHash.ToString()); string? contentPath = null; @@ -245,17 +245,25 @@ namespace Jellyfin.Api.Controllers /// <param name="urlHash">The URL hash.</param> /// <param name="pointerCachePath">The pointer cache path.</param> /// <returns>Task.</returns> - private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath) + private async Task DownloadImage(Uri url, Guid urlHash, string pointerCachePath) { var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); using var response = await httpClient.GetAsync(url).ConfigureAwait(false); - var ext = response.Content.Headers.ContentType.MediaType.Split('/').Last(); + if (response.Content.Headers.ContentType?.MediaType == null) + { + throw new ResourceNotFoundException(nameof(response.Content.Headers.ContentType)); + } + + var ext = response.Content.Headers.ContentType.MediaType.Split('/')[^1]; var fullCachePath = GetFullCachePath(urlHash + "." + ext); - Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); + var fullCacheDirectory = Path.GetDirectoryName(fullCachePath) ?? throw new ResourceNotFoundException($"Provided path ({fullCachePath}) is not valid."); + Directory.CreateDirectory(fullCacheDirectory); await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); await response.Content.CopyToAsync(fileStream).ConfigureAwait(false); - Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); + + var pointerCacheDirectory = Path.GetDirectoryName(pointerCachePath) ?? throw new ArgumentException($"Provided path ({pointerCachePath}) is not valid.", nameof(pointerCachePath)); + Directory.CreateDirectory(pointerCacheDirectory); await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None) .ConfigureAwait(false); } diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 62c870cb1f..076fe58f11 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -82,9 +83,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] Guid? userId, [FromQuery, Required] string searchTerm, - [FromQuery] string? includeItemTypes, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery] string? parentId, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, @@ -108,9 +109,9 @@ namespace Jellyfin.Api.Controllers IncludeStudios = includeStudios, StartIndex = startIndex, UserId = userId ?? Guid.Empty, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + MediaTypes = mediaTypes, ParentId = parentId, IsKids = isKids, @@ -260,7 +261,7 @@ namespace Jellyfin.Api.Controllers } } - private T GetParentWithImage<T>(BaseItem item, ImageType type) + private T? GetParentWithImage<T>(BaseItem item, ImageType type) where T : BaseItem { return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type)); diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index e506ac7bf1..6c9b9050e6 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -160,12 +160,12 @@ namespace Jellyfin.Api.Controllers public ActionResult Play( [FromRoute, Required] string sessionId, [FromQuery, Required] PlayCommand playCommand, - [FromQuery, Required] string itemIds, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, [FromQuery] long? startPositionTicks) { var playRequest = new PlayRequest { - ItemIds = RequestHelpers.GetGuids(itemIds), + ItemIds = itemIds, StartPositionTicks = startPositionTicks, PlayCommand = playCommand }; @@ -378,7 +378,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostCapabilities( [FromQuery] string? id, - [FromQuery] string? playableMediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, [FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsSync = false, @@ -391,7 +391,7 @@ namespace Jellyfin.Api.Controllers _sessionManager.ReportCapabilities(id, new ClientCapabilities { - PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true), + PlayableMediaTypes = playableMediaTypes, SupportedCommands = supportedCommands, SupportsMediaControl = supportsMediaControl, SupportsSync = supportsSync, diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 94eb3f7faa..af28b4f597 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; @@ -47,30 +46,17 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets all studios from a given item, folder, or the entire library. /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="searchTerm">Optional. Search term.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> /// <param name="userId">User id.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> @@ -82,30 +68,17 @@ namespace Jellyfin.Api.Controllers [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetStudios( - [FromQuery] double? minCommunityRating, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, @@ -113,49 +86,24 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages = true, [FromQuery] bool enableTotalRecordCount = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = null; - BaseItem parentItem; + User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null; - if (userId.HasValue && !userId.Equals(Guid.Empty)) - { - user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } - - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); + var parentItem = _libraryManager.GetParentItem(parentId, userId); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), - Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), - MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount @@ -173,84 +121,9 @@ namespace Jellyfin.Api.Controllers } } - // Studios - if (!string.IsNullOrEmpty(studios)) - { - query.StudioIds = studios.Split('|').Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i != null).Select(i => i!.Id) - .ToArray(); - } - - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } - - var result = new QueryResult<(BaseItem, ItemCounts)>(); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (!string.IsNullOrWhiteSpace(includeItemTypes)) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto> - { - Items = dtos.ToArray(), - TotalRecordCount = result.TotalRecordCount - }; + var result = _libraryManager.GetStudios(query); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index cc682ed542..a01ae31a0e 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -11,6 +11,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; +using Jellyfin.Api.Models.SubtitleDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -21,6 +24,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; +using MediaBrowser.Model.Subtitles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -34,6 +38,7 @@ namespace Jellyfin.Api.Controllers [Route("")] public class SubtitleController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ILibraryManager _libraryManager; private readonly ISubtitleManager _subtitleManager; private readonly ISubtitleEncoder _subtitleEncoder; @@ -46,6 +51,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Initializes a new instance of the <see cref="SubtitleController"/> class. /// </summary> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> @@ -55,6 +61,7 @@ namespace Jellyfin.Api.Controllers /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param> /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> public SubtitleController( + IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, ISubtitleManager subtitleManager, ISubtitleEncoder subtitleEncoder, @@ -64,6 +71,7 @@ namespace Jellyfin.Api.Controllers IAuthorizationContext authContext, ILogger<SubtitleController> logger) { + _serverConfigurationManager = serverConfigurationManager; _libraryManager = libraryManager; _subtitleManager = subtitleManager; _subtitleEncoder = subtitleEncoder; @@ -319,6 +327,33 @@ namespace Jellyfin.Api.Controllers return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } + /// <summary> + /// Upload an external subtitle file. + /// </summary> + /// <param name="itemId">The item the subtitle belongs to.</param> + /// <param name="body">The request body.</param> + /// <response code="204">Subtitle uploaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Videos/{itemId}/Subtitles")] + public async Task<ActionResult> UploadSubtitle( + [FromRoute, Required] Guid itemId, + [FromBody, Required] UploadSubtitleDto body) + { + var video = (Video)_libraryManager.GetItemById(itemId); + var data = Convert.FromBase64String(body.Data); + await using var memoryStream = new MemoryStream(data); + await _subtitleManager.UploadSubtitle( + video, + new SubtitleResponse + { + Format = body.Format, + Language = body.Language, + IsForced = body.IsForced, + Stream = memoryStream + }).ConfigureAwait(false); + return NoContent(); + } + /// <summary> /// Encodes a subtitle in the specified format. /// </summary> @@ -351,5 +386,95 @@ namespace Jellyfin.Api.Controllers copyTimestamps, CancellationToken.None); } + + /// <summary> + /// Gets a list of available fallback font files. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns> + [HttpGet("FallbackFont/Fonts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FontFile> GetFallbackFontList() + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; + + if (!string.IsNullOrEmpty(fallbackFontPath)) + { + var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false); + var fontFiles = files + .Select(i => new FontFile + { + Name = i.Name, + Size = i.Length, + DateCreated = _fileSystem.GetCreationTimeUtc(i), + DateModified = _fileSystem.GetLastWriteTimeUtc(i) + }) + .OrderBy(i => i.Size) + .ThenBy(i => i.Name) + .ThenByDescending(i => i.DateModified) + .ThenByDescending(i => i.DateCreated); + // max total size 20M + const int MaxSize = 20971520; + var sizeCounter = 0L; + foreach (var fontFile in fontFiles) + { + sizeCounter += fontFile.Size; + if (sizeCounter >= MaxSize) + { + _logger.LogWarning("Some fonts will not be sent due to size limitations"); + yield break; + } + + yield return fontFile; + } + } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + } + + /// <summary> + /// Gets a fallback font file. + /// </summary> + /// <param name="name">The name of the fallback font file to get.</param> + /// <response code="200">Fallback font file retrieved.</response> + /// <returns>The fallback font file.</returns> + [HttpGet("FallbackFont/Fonts/{name}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetFallbackFont([FromRoute, Required] string name) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; + + if (!string.IsNullOrEmpty(fallbackFontPath)) + { + var fontFile = _fileSystem.GetFiles(fallbackFontPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var fileSize = fontFile?.Length; + + if (fontFile != null && fileSize != null && fileSize > 0) + { + _logger.LogDebug("Fallback font size is {fileSize} Bytes", fileSize); + return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); + } + else + { + _logger.LogWarning("The selected font is null or empty"); + } + } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + + // returning HTTP 204 will break the SubtitlesOctopus + return Ok(); + } } } diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index ad64adfbad..69292186e5 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -4,6 +4,7 @@ using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -58,8 +59,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( [FromRoute, Required] Guid userId, - [FromQuery] string? mediaType, - [FromQuery] string? type, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool enableTotalRecordCount = false) @@ -70,8 +71,8 @@ namespace Jellyfin.Api.Controllers var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), - MediaTypes = RequestHelpers.Split(mediaType!, ',', true), - IncludeItemTypes = RequestHelpers.Split(type!, ',', true), + MediaTypes = mediaType, + IncludeItemTypes = type, IsVirtualItem = false, StartIndex = startIndex, Limit = limit, diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 281c0016e9..41e1d0812c 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,4 +1,4 @@ -using System; +using System; using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using MediaBrowser.Model.Dto; @@ -42,8 +42,8 @@ namespace Jellyfin.Api.Controllers /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> /// <param name="isHd">Optional filter by items that are HD or not.</param> /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> @@ -56,41 +56,41 @@ namespace Jellyfin.Api.Controllers /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> /// <param name="searchTerm">Optional. Filter based on a search term.</param> /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> /// <param name="enableUserData">Optional, include user data.</param> /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> /// <param name="isLocked">Optional filter by items that are locked.</param> /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> @@ -101,12 +101,12 @@ namespace Jellyfin.Api.Controllers /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> /// <param name="enableImages">Optional, include image information in output.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> @@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery] string? locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, @@ -139,41 +139,41 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasImdbId, [FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTvdbId, - [FromQuery] string? excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, [FromQuery] string? sortOrder, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery] string? sortBy, [FromQuery] bool? isPlayed, - [FromQuery] string? genres, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] string? person, - [FromQuery] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? artists, - [FromQuery] string? excludeArtistIds, - [FromQuery] string? artistIds, - [FromQuery] string? albumArtistIds, - [FromQuery] string? contributingArtistIds, - [FromQuery] string? albums, - [FromQuery] string? albumIds, - [FromQuery] string? ids, - [FromQuery] string? videoTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, [FromQuery] string? minOfficialRating, [FromQuery] bool? isLocked, [FromQuery] bool? isPlaceHolder, @@ -184,16 +184,16 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery] string? seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery] string? studioIds, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { - var includeItemTypes = "Trailer"; + var includeItemTypes = new[] { "Trailer" }; return _itemsController .GetItems( diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 49a6c386fe..57b056f50b 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; @@ -58,7 +59,7 @@ namespace Jellyfin.Api.Controllers /// <param name="userId">The user id of the user to get the next up episodes for.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="seriesId">Optional. Filter by series id.</param> /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> /// <param name="enableImges">Optional. Include image information in output.</param> @@ -73,17 +74,16 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? seriesId, [FromQuery] string? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) { - var options = new DtoOptions() - .AddItemFields(fields!) + var options = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); @@ -118,7 +118,7 @@ namespace Jellyfin.Api.Controllers /// <param name="userId">The user id of the user to get the upcoming episodes for.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> /// <param name="enableImges">Optional. Include image information in output.</param> /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> @@ -131,11 +131,11 @@ namespace Jellyfin.Api.Controllers [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -146,8 +146,7 @@ namespace Jellyfin.Api.Controllers var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); - var options = new DtoOptions() - .AddItemFields(fields!) + var options = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImges, enableUserData, imageTypeLimit, enableImageTypes!); @@ -177,7 +176,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="seriesId">The series id.</param> /// <param name="userId">The user id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> /// <param name="season">Optional filter by season number.</param> /// <param name="seasonId">Optional. Filter by season id.</param> /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> @@ -189,7 +188,7 @@ namespace Jellyfin.Api.Controllers /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> [HttpGet("{seriesId}/Episodes")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -197,7 +196,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( [FromRoute, Required] string seriesId, [FromQuery] Guid? userId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int? season, [FromQuery] string? seasonId, [FromQuery] bool? isMissing, @@ -207,7 +206,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] string? sortBy) { @@ -217,8 +216,7 @@ namespace Jellyfin.Api.Controllers List<BaseItem> episodes; - var dtoOptions = new DtoOptions() - .AddItemFields(fields!) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); @@ -305,7 +303,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="seriesId">The series id.</param> /// <param name="userId">The user id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> /// <param name="isSpecialSeason">Optional. Filter by special season.</param> /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> @@ -320,13 +318,13 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetSeasons( [FromRoute, Required] string seriesId, [FromQuery] Guid? userId, - [FromQuery] string? fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? isSpecialSeason, [FromQuery] bool? isMissing, [FromQuery] string? adjacentTo, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData) { var user = userId.HasValue && !userId.Equals(Guid.Empty) @@ -345,8 +343,7 @@ namespace Jellyfin.Api.Controllers AdjacentTo = adjacentTo }); - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 924ae04778..2555323077 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; @@ -76,6 +77,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param> /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param> /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> /// <param name="transcodingContainer">Optional. The container to transcode to.</param> /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param> @@ -95,14 +97,15 @@ namespace Jellyfin.Api.Controllers [ProducesAudioFile] public async Task<ActionResult> GetUniversalAudioStream( [FromRoute, Required] Guid itemId, - [FromQuery] string? container, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, [FromQuery] Guid? userId, [FromQuery] string? audioCodec, [FromQuery] int? maxAudioChannels, [FromQuery] int? transcodingAudioChannels, - [FromQuery] long? maxStreamingBitrate, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, [FromQuery] long? startTimeTicks, [FromQuery] string? transcodingContainer, [FromQuery] string? transcodingProtocol, @@ -189,8 +192,11 @@ namespace Jellyfin.Api.Controllers if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) { // hls segment container can only be mpegts or fmp4 per ffmpeg documentation + // ffmpeg option -> file extension + // mpegts -> ts + // fmp4 -> mp4 // TODO: remove this when we switch back to the segment muxer - var supportedHlsContainers = new[] { "mpegts", "fmp4" }; + var supportedHlsContainers = new[] { "ts", "mp4" }; var dynamicHlsRequestDto = new HlsAudioRequestDto { @@ -199,7 +205,7 @@ namespace Jellyfin.Api.Controllers Static = isStatic, PlaySessionId = info.PlaySessionId, // fallback to mpegts if device reports some weird value unsupported by hls - SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts", + SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", MediaSourceId = mediaSourceId, DeviceId = deviceId, AudioCodec = audioCodec, @@ -210,7 +216,7 @@ namespace Jellyfin.Api.Controllers AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + AudioBitRate = audioBitRate ?? maxStreamingBitrate, StartTimeTicks = startTimeTicks, SubtitleMethod = SubtitleDeliveryMethod.Hls, RequireAvc = true, @@ -242,7 +248,7 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)), + AudioBitRate = isStatic ? (int?)null : (audioBitRate ?? maxStreamingBitrate), MaxAudioBitDepth = maxAudioBitDepth, AudioChannels = maxAudioChannels, CopyTimestamps = true, @@ -256,7 +262,7 @@ namespace Jellyfin.Api.Controllers } private DeviceProfile GetDeviceProfile( - string? container, + string[] containers, string? transcodingContainer, string? audioCodec, string? transcodingProtocol, @@ -268,7 +274,6 @@ namespace Jellyfin.Api.Controllers { var deviceProfile = new DeviceProfile(); - var containers = RequestHelpers.Split(container, ',', true); int len = containers.Length; var directPlayProfiles = new DirectPlayProfile[len]; for (int i = 0; i < len; i++) @@ -325,7 +330,7 @@ namespace Jellyfin.Api.Controllers if (conditions.Count > 0) { // codec profile - codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = container, Conditions = conditions.ToArray() }); + codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = string.Join(',', containers), Conditions = conditions.ToArray() }); } deviceProfile.CodecProfiles = codecProfiles.ToArray(); diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index a52af17815..0e65591cce 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -251,8 +252,8 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="userId">User id.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> /// <param name="isPlayed">Filter by items that are played, or not.</param> /// <param name="enableImages">Optional. include image information in output.</param> /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param> @@ -267,12 +268,12 @@ namespace Jellyfin.Api.Controllers public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia( [FromRoute, Required] Guid userId, [FromQuery] Guid? parentId, - [FromQuery] string? fields, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] int limit = 20, [FromQuery] bool groupItems = true) @@ -287,8 +288,7 @@ namespace Jellyfin.Api.Controllers } } - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers new LatestItemsQuery { GroupItems = groupItems, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + IncludeItemTypes = includeItemTypes, IsPlayed = isPlayed, Limit = limit, ParentId = parentId ?? Guid.Empty, diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index d575bfc3b8..60fd1df013 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.UserViewDtos; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -67,7 +68,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetUserViews( [FromRoute, Required] Guid userId, [FromQuery] bool? includeExternalContent, - [FromQuery] string? presetViews, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, [FromQuery] bool includeHidden = false) { var query = new UserViewQuery @@ -81,9 +82,9 @@ namespace Jellyfin.Api.Controllers query.IncludeExternalContent = includeExternalContent.Value; } - if (!string.IsNullOrWhiteSpace(presetViews)) + if (presetViews.Length != 0) { - query.PresetViews = RequestHelpers.Split(presetViews, ',', true); + query.PresetViews = presetViews; } var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index 09a1c93e6a..418c0c1230 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -46,7 +46,7 @@ namespace Jellyfin.Api.Controllers [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<FileStreamResult>> GetAttachment( + public async Task<ActionResult> GetAttachment( [FromRoute, Required] Guid videoId, [FromRoute, Required] string mediaSourceId, [FromRoute, Required] int index) diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs index d7bcf79c1f..2ac16de6b9 100644 --- a/Jellyfin.Api/Controllers/VideoHlsController.cs +++ b/Jellyfin.Api/Controllers/VideoHlsController.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -11,6 +12,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; @@ -144,7 +146,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> @@ -295,23 +297,23 @@ namespace Jellyfin.Api.Controllers .ConfigureAwait(false); TranscodingJobDto? job = null; - var playlist = state.OutputFilePath; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - if (!System.IO.File.Exists(playlist)) + if (!System.IO.File.Exists(playlistPath)) { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist); + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); try { - if (!System.IO.File.Exists(playlist)) + if (!System.IO.File.Exists(playlistPath)) { // If the playlist doesn't already exist, startup ffmpeg try { job = await _transcodingJobHelper.StartFfMpeg( state, - playlist, - GetCommandLineArguments(playlist, state), + playlistPath, + GetCommandLineArguments(playlistPath, state), Request, TranscodingJobType, cancellationTokenSource) @@ -327,7 +329,7 @@ namespace Jellyfin.Api.Controllers minSegments = state.MinSegments; if (minSegments > 0) { - await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); } } } @@ -337,14 +339,14 @@ namespace Jellyfin.Api.Controllers } } - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job != null) { _transcodingJobHelper.OnTranscodeEndRequest(job); } - var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength); + var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); } @@ -358,16 +360,46 @@ namespace Jellyfin.Api.Controllers private string GetCommandLineArguments(string outputPath, StreamState state) { var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static. var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); - var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts"; - var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format; + var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - var segmentFormat = format.TrimStart('.'); + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + var outputTsArg = outputPrefix + "%d" + outputExtension; + + var segmentFormat = outputExtension.TrimStart('.'); if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) { segmentFormat = "mpegts"; } + else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = string.Empty; + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + // on Windows, the path of fmp4 header file needs to be configured + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\""; + } + else + { + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""; + } + + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat); + } + + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + : "128"; var baseUrlParam = string.Format( CultureInfo.InvariantCulture, @@ -376,20 +408,19 @@ namespace Jellyfin.Api.Controllers return string.Format( CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"", + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"", inputModifier, _encodingHelper.GetInputArgument(state, _encodingOptions), threads, - _encodingHelper.GetMapArgs(state), + mapArgs, GetVideoArguments(state), GetAudioArguments(state), + maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), - string.Empty, segmentFormat, baseUrlParam, - outputPath, - outputTsArg) - .Trim(); + outputTsArg, + outputPath).Trim(); } /// <summary> @@ -399,14 +430,53 @@ namespace Jellyfin.Api.Controllers /// <returns>The command line arguments for audio transcoding.</returns> private string GetAudioArguments(StreamState state) { - var codec = _encodingHelper.GetAudioEncoder(state); - - if (EncodingHelper.IsCopyCodec(codec)) + if (state.AudioStream == null) { - return "-codec:a:0 copy"; + return string.Empty; } - var args = "-codec:a:0 " + codec; + var audioCodec = _encodingHelper.GetAudioEncoder(state); + + if (!state.IsOutputVideo) + { + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; + } + + var audioTranscodeParams = string.Empty; + + audioTranscodeParams += "-acodec " + audioCodec; + + if (state.OutputAudioBitrate.HasValue) + { + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioChannels.HasValue) + { + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioSampleRate.HasValue) + { + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } + + audioTranscodeParams += " -vn"; + return audioTranscodeParams; + } + + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; + } + + var args = "-codec:a:0 " + audioCodec; var channels = state.OutputAudioChannels; @@ -427,7 +497,7 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); return args; } @@ -439,6 +509,11 @@ namespace Jellyfin.Api.Controllers /// <returns>The command line arguments for video transcoding.</returns> private string GetVideoArguments(StreamState state) { + if (state.VideoStream == null) + { + return string.Empty; + } + if (!state.IsOutputVideo) { return string.Empty; @@ -448,46 +523,64 @@ namespace Jellyfin.Api.Controllers var args = "-codec:v:0 " + codec; + // Prefer hvc1 to hev1. + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + args += " -tag:v:0 hvc1"; + } + // if (state.EnableMpegtsM2TsMode) // { // args += " -mpegts_m2ts_mode 1"; // } - // See if we can save come cpu cycles by avoiding encoding - if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase)) + // See if we can save come cpu cycles by avoiding encoding. + if (EncodingHelper.IsCopyCodec(codec)) { - // if h264_mp4toannexb is ever added, do not use it for live tv - if (state.VideoStream != null && - !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + // If h264_mp4toannexb is ever added, do not use it for live tv. + if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; } } + + args += " -start_at_zero"; } else { - var keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames \"expr:gte(t,n_forced*{0})\"", - state.SegmentLength.ToString(CultureInfo.InvariantCulture)); + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); + + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null); + + // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) + { + args += " -bf 0"; + } var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg; - - // Add resolution params, if specified - if (!hasGraphicalSubs) + if (hasGraphicalSubs) { + // Graphical subs overlay and resolution params. + args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); + } + else + { + // Resolution params. args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); } - // This is for internal graphical subs - if (hasGraphicalSubs) + if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream) { - args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); + args += " -start_at_zero"; } } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 4de7aac71d..7e326f410f 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -10,6 +10,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -203,9 +204,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds) + public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds) { - var items = RequestHelpers.Split(itemIds, ',', true) + var items = itemIds .Select(i => _libraryManager.GetItemById(i)) .OfType<Video>() .OrderBy(i => i.Id) @@ -283,7 +284,7 @@ namespace Jellyfin.Api.Controllers /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> /// <param name="playSessionId">The play session id.</param> /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment lenght.</param> + /// <param name="segmentLength">The segment length.</param> /// <param name="minSegments">The minimum number of segments.</param> /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> @@ -312,7 +313,7 @@ namespace Jellyfin.Api.Controllers /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> /// <param name="requireAvc">Optional. Whether to require avc.</param> /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> /// <param name="liveStreamId">The live stream id.</param> diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index 2c685309a6..9c3ecb4cee 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -5,6 +5,7 @@ using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -51,7 +52,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param> /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param> /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> @@ -71,20 +72,19 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? sortOrder, [FromQuery] string? parentId, - [FromQuery] string? fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] string? mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery] string? sortBy, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, - [FromQuery] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] Guid? userId, [FromQuery] bool recursive = true, [FromQuery] bool? enableImages = true) { - var dtoOptions = new DtoOptions() - .AddItemFields(fields) + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -103,19 +103,15 @@ namespace Jellyfin.Api.Controllers IList<BaseItem> items; - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); - var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, DtoOptions = dtoOptions }; - bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr); + bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); if (parentItem.IsFolder) { diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 85f8d789e4..f2abd515d3 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq; using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -13,42 +15,6 @@ namespace Jellyfin.Api.Extensions /// </summary> public static class DtoExtensions { - /// <summary> - /// Add Dto Item fields. - /// </summary> - /// <remarks> - /// Converted from IHasItemFields. - /// Legacy order: 1. - /// </remarks> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="fields">Comma delimited string of fields.</param> - /// <returns>Modified DtoOptions object.</returns> - internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields) - { - if (string.IsNullOrEmpty(fields)) - { - dtoOptions.Fields = Array.Empty<ItemFields>(); - } - else - { - dtoOptions.Fields = fields.Split(',') - .Select(v => - { - if (Enum.TryParse(v, true, out ItemFields value)) - { - return (ItemFields?)value; - } - - return null; - }) - .Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); - } - - return dtoOptions; - } - /// <summary> /// Add additional fields depending on client. /// </summary> @@ -79,7 +45,7 @@ namespace Jellyfin.Api.Extensions client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) { - int oldLen = dtoOptions.Fields.Length; + int oldLen = dtoOptions.Fields.Count; var arr = new ItemFields[oldLen + 1]; dtoOptions.Fields.CopyTo(arr, 0); arr[oldLen] = ItemFields.RecursiveItemCount; @@ -97,7 +63,7 @@ namespace Jellyfin.Api.Extensions client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) { - int oldLen = dtoOptions.Fields.Length; + int oldLen = dtoOptions.Fields.Count; var arr = new ItemFields[oldLen + 1]; dtoOptions.Fields.CopyTo(arr, 0); arr[oldLen] = ItemFields.ChildCount; @@ -126,7 +92,7 @@ namespace Jellyfin.Api.Extensions bool? enableImages, bool? enableUserData, int? imageTypeLimit, - ImageType[] enableImageTypes) + IReadOnlyList<ImageType> enableImageTypes) { dtoOptions.EnableImages = enableImages ?? true; @@ -140,7 +106,7 @@ namespace Jellyfin.Api.Extensions dtoOptions.EnableUserData = enableUserData.Value; } - if (enableImageTypes.Length != 0) + if (enableImageTypes.Count != 0) { dtoOptions.ImageTypes = enableImageTypes; } diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index a3f2d88ce5..21ec2d32f9 100644 --- a/Jellyfin.Api/Helpers/AudioHelper.cs +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -1,8 +1,10 @@ -using System.Net.Http; +using System; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Devices; @@ -98,6 +100,11 @@ namespace Jellyfin.Api.Helpers TranscodingJobType transcodingJobType, StreamingRequestDto streamingRequest) { + if (_httpContextAccessor.HttpContext == null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; var cancellationTokenSource = new CancellationTokenSource(); diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs index df235ced25..29e6b4193e 100644 --- a/Jellyfin.Api/Helpers/ClaimHelpers.cs +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -63,6 +63,19 @@ namespace Jellyfin.Api.Helpers public static string? GetToken(in ClaimsPrincipal user) => GetClaimValue(user, InternalClaimTypes.Token); + /// <summary> + /// Gets a flag specifying whether the request is using an api key. + /// </summary> + /// <param name="user">Current claims principal.</param> + /// <returns>The flag specifying whether the request is using an api key.</returns> + public static bool GetIsApiKey(in ClaimsPrincipal user) + { + var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey); + return !string.IsNullOrEmpty(claimValue) + && bool.TryParse(claimValue, out var parsedClaimValue) + && parsedClaimValue; + } + private static string? GetClaimValue(in ClaimsPrincipal user, string name) { return user?.Identities diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index ea012f8376..a4da54cfdf 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -113,7 +113,7 @@ namespace Jellyfin.Api.Helpers StreamingRequestDto streamingRequest, bool enableAdaptiveBitrateStreaming) { - var isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == WebRequestMethods.Http.Head; + var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head; var cancellationTokenSource = new CancellationTokenSource(); return await GetMasterPlaylistInternal( streamingRequest, @@ -130,6 +130,11 @@ namespace Jellyfin.Api.Helpers TranscodingJobType transcodingJobType, CancellationTokenSource cancellationTokenSource) { + if (_httpContextAccessor.HttpContext == null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + using var state = await StreamingHelpers.GetStreamingState( streamingRequest, _httpContextAccessor.HttpContext.Request, @@ -202,7 +207,61 @@ namespace Jellyfin.Api.Helpers AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); } - AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + + if (state.VideoStream != null && state.VideoRequest != null) + { + // Provide SDR HEVC entrance for backward compatibility. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); + if (requestedVideoProfiles != null && requestedVideoProfiles.Length > 0) + { + // Force HEVC Main Profile and disable video stream copy. + state.OutputVideoCodec = "hevc"; + var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(",", requestedVideoProfiles), "main"); + sdrVideoUrl += "&AllowVideoStreamCopy=false"; + + EncodingHelper encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + var sdrOutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec) ?? 0; + var sdrOutputAudioBitrate = encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; + var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; + + AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); + + // Restore the video codec + state.OutputVideoCodec = "copy"; + } + } + + // Provide Level 5.0 entrance for backward compatibility. + // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, + // but in fact it is capable of playing videos up to Level 6.1. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.Level.HasValue + && state.VideoStream.Level > 150 + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + var playlistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(playlistCodecsField, state); + + // Force the video level to 5.0. + var originalLevel = state.VideoStream.Level; + state.VideoStream.Level = 150; + var newPlaylistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(newPlaylistCodecsField, state); + + // Restore the video level. + state.VideoStream.Level = originalLevel; + var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); + builder.Append(newPlaylist); + } + } if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) { @@ -212,40 +271,77 @@ namespace Jellyfin.Api.Helpers var variation = GetBitrateVariation(totalBitrate); var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); variation *= 2; newBitrate = totalBitrate - variation; - variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); } return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } - private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) { - builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)) .Append(",AVERAGE-BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)); - AppendPlaylistCodecsField(builder, state); + AppendPlaylistVideoRangeField(playlistBuilder, state); - AppendPlaylistResolutionField(builder, state); + AppendPlaylistCodecsField(playlistBuilder, state); - AppendPlaylistFramerateField(builder, state); + AppendPlaylistResolutionField(playlistBuilder, state); + + AppendPlaylistFramerateField(playlistBuilder, state); if (!string.IsNullOrWhiteSpace(subtitleGroup)) { - builder.Append(",SUBTITLES=\"") + playlistBuilder.Append(",SUBTITLES=\"") .Append(subtitleGroup) .Append('"'); } - builder.Append(Environment.NewLine); - builder.AppendLine(url); + playlistBuilder.Append(Environment.NewLine); + playlistBuilder.AppendLine(url); + builder.Append(playlistBuilder); + + return playlistBuilder; + } + + /// <summary> + /// Appends a VIDEO-RANGE field containing the range of the output video stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state) + { + if (state.VideoStream != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) + { + var videoRange = state.VideoStream.VideoRange; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(",VIDEO-RANGE=SDR"); + } + + if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(",VIDEO-RANGE=PQ"); + } + } + else + { + // Currently we only encode to SDR. + builder.Append(",VIDEO-RANGE=SDR"); + } + } } /// <summary> @@ -414,15 +510,27 @@ namespace Jellyfin.Api.Helpers /// <returns>H.26X level of the output video stream.</returns> private int? GetOutputVideoCodecLevel(StreamState state) { - string? levelString; + string levelString = string.Empty; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream != null && state.VideoStream.Level.HasValue) { - levelString = state.VideoStream?.Level.ToString(); + levelString = state.VideoStream.Level.ToString() ?? string.Empty; } else { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); + } + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); + } } if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) @@ -433,6 +541,38 @@ namespace Jellyfin.Api.Helpers return null; } + /// <summary> + /// Get the H.26X profile of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <returns>H.26X profile of the output video stream.</returns> + private string GetOutputVideoCodecProfile(StreamState state, string codec) + { + string profileString = string.Empty; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.Profile)) + { + profileString = state.VideoStream.Profile; + } + else if (!string.IsNullOrEmpty(codec)) + { + profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + profileString = profileString ?? "high"; + } + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + profileString = profileString ?? "main"; + } + } + + return profileString; + } + /// <summary> /// Gets a formatted string of the output audio codec, for use in the CODECS field. /// </summary> @@ -463,6 +603,16 @@ namespace Jellyfin.Api.Helpers return HlsCodecStringHelpers.GetEAC3String(); } + if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetFLACString(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetALACString(); + } + return string.Empty; } @@ -487,15 +637,14 @@ namespace Jellyfin.Api.Helpers if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) { - string profile = state.GetRequestedProfiles("h264").FirstOrDefault(); + string profile = GetOutputVideoCodecProfile(state, "h264"); return HlsCodecStringHelpers.GetH264String(profile, level); } if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { - string profile = state.GetRequestedProfiles("h265").FirstOrDefault(); - + string profile = GetOutputVideoCodecProfile(state, "hevc"); return HlsCodecStringHelpers.GetH265String(profile, level); } @@ -539,12 +688,30 @@ namespace Jellyfin.Api.Helpers return variation; } - private string ReplaceBitrate(string url, int oldValue, int newValue) + private string ReplaceVideoBitrate(string url, int oldValue, int newValue) { return url.Replace( "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); } + + private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + { + string profileStr = codec + "-profile="; + return url.Replace( + profileStr + oldValue, + profileStr + newValue, + StringComparison.OrdinalIgnoreCase); + } + + private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + { + var oldPlaylist = playlist.ToString(); + return oldPlaylist.Replace( + oldValue.ToString(), + newValue.ToString(), + StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 366301d3ee..cfa2c1229a 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -24,12 +24,14 @@ namespace Jellyfin.Api.Helpers /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param> /// <param name="httpContext">The current http context.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns> public static async Task<ActionResult> GetStaticRemoteStreamResult( StreamState state, bool isHeadRequest, HttpClient httpClient, - HttpContext httpContext) + HttpContext httpContext, + CancellationToken cancellationToken = default) { if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { @@ -37,8 +39,8 @@ namespace Jellyfin.Api.Helpers } // Can't dispose the response as it's required up the call chain. - var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false); - var contentType = response.Content.Headers.ContentType.ToString(); + var response = await httpClient.GetAsync(new Uri(state.MediaPath)).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.ToString(); httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; @@ -47,7 +49,7 @@ namespace Jellyfin.Api.Helpers return new FileContentResult(Array.Empty<byte>(), contentType); } - return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); + return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); } /// <summary> diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 95f1906ef0..a5369c441c 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -9,13 +9,38 @@ namespace Jellyfin.Api.Helpers /// </summary> public static class HlsCodecStringHelpers { + /// <summary> + /// Codec name for MP3. + /// </summary> + public const string MP3 = "mp4a.40.34"; + + /// <summary> + /// Codec name for AC-3. + /// </summary> + public const string AC3 = "mp4a.a5"; + + /// <summary> + /// Codec name for E-AC-3. + /// </summary> + public const string EAC3 = "mp4a.a6"; + + /// <summary> + /// Codec name for FLAC. + /// </summary> + public const string FLAC = "fLaC"; + + /// <summary> + /// Codec name for ALAC. + /// </summary> + public const string ALAC = "alac"; + /// <summary> /// Gets a MP3 codec string. /// </summary> /// <returns>MP3 codec string.</returns> public static string GetMP3String() { - return "mp4a.40.34"; + return MP3; } /// <summary> @@ -23,7 +48,7 @@ namespace Jellyfin.Api.Helpers /// </summary> /// <param name="profile">AAC profile.</param> /// <returns>AAC codec string.</returns> - public static string GetAACString(string profile) + public static string GetAACString(string? profile) { StringBuilder result = new StringBuilder("mp4a", 9); @@ -40,13 +65,49 @@ namespace Jellyfin.Api.Helpers return result.ToString(); } + /// <summary> + /// Gets an AC-3 codec string. + /// </summary> + /// <returns>AC-3 codec string.</returns> + public static string GetAC3String() + { + return AC3; + } + + /// <summary> + /// Gets an E-AC-3 codec string. + /// </summary> + /// <returns>E-AC-3 codec string.</returns> + public static string GetEAC3String() + { + return EAC3; + } + + /// <summary> + /// Gets an FLAC codec string. + /// </summary> + /// <returns>FLAC codec string.</returns> + public static string GetFLACString() + { + return FLAC; + } + + /// <summary> + /// Gets an ALAC codec string. + /// </summary> + /// <returns>ALAC codec string.</returns> + public static string GetALACString() + { + return ALAC; + } + /// <summary> /// Gets a H.264 codec string. /// </summary> /// <param name="profile">H.264 profile.</param> /// <param name="level">H.264 level.</param> /// <returns>H.264 string.</returns> - public static string GetH264String(string profile, int level) + public static string GetH264String(string? profile, int level) { StringBuilder result = new StringBuilder("avc1", 11); @@ -80,46 +141,29 @@ namespace Jellyfin.Api.Helpers /// <param name="profile">H.265 profile.</param> /// <param name="level">H.265 level.</param> /// <returns>H.265 string.</returns> - public static string GetH265String(string profile, int level) + public static string GetH265String(string? profile, int level) { // The h265 syntax is a bit of a mystery at the time this comment was written. // This is what I've found through various sources: // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] - StringBuilder result = new StringBuilder("hev1", 16); + StringBuilder result = new StringBuilder("hvc1", 16); - if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) { - result.Append(".2.6"); + result.Append(".2.4"); } else { // Default to main if profile is invalid - result.Append(".1.6"); + result.Append(".1.4"); } result.Append(".L") - .Append(level * 3) + .Append(level) .Append(".B0"); return result.ToString(); } - - /// <summary> - /// Gets an AC-3 codec string. - /// </summary> - /// <returns>AC-3 codec string.</returns> - public static string GetAC3String() - { - return "mp4a.a5"; - } - - /// <summary> - /// Gets an E-AC-3 codec string. - /// </summary> - /// <returns>E-AC-3 codec string.</returns> - public static string GetEAC3String() - { - return "mp4a.a6"; - } } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index 2424966973..18e23fb5c8 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -1,8 +1,11 @@ using System; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -45,6 +48,11 @@ namespace Jellyfin.Api.Helpers while (!reader.EndOfStream) { var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (line == null) + { + // Nothing currently in buffer. + break; + } if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) { @@ -69,25 +77,65 @@ namespace Jellyfin.Api.Helpers } } + /// <summary> + /// Gets the #EXT-X-MAP string. + /// </summary> + /// <param name="outputPath">The output path of the file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="isOsDepends">Get a normal string or depends on OS.</param> + /// <returns>The string text of #EXT-X-MAP.</returns> + public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + + // on Linux/Unix + // #EXT-X-MAP:URI="prefix-1.mp4" + var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; + if (!isOsDepends) + { + return fmp4InitFileName; + } + + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + // on Windows + // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" + fmp4InitFileName = outputPrefix + "-1" + outputExtension; + } + + return fmp4InitFileName; + } + /// <summary> /// Gets the hls playlist text. /// </summary> /// <param name="path">The path to the playlist file.</param> - /// <param name="segmentLength">The segment length.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> /// <returns>The playlist text as a string.</returns> - public static string GetLivePlaylistText(string path, int segmentLength) + public static string GetLivePlaylistText(string path, StreamState state) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); var text = reader.ReadToEnd(); - text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture); + var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var fmp4InitFileName = GetFmp4InitFileName(path, state, true); + var baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + "hls/{0}/", + Path.GetFileNameWithoutExtension(path)); + var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); - var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture); - - text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); - // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); + // Replace fMP4 init file URI. + text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); + } return text; } diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index e78f63b256..0d8315dee1 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -166,7 +166,7 @@ namespace Jellyfin.Api.Helpers MediaSourceInfo mediaSource, DeviceProfile profile, AuthorizationInfo auth, - long? maxBitrate, + int? maxBitrate, long startTimeTicks, string mediaSourceId, int? audioStreamIndex, @@ -551,7 +551,7 @@ namespace Jellyfin.Api.Helpers } } - private long? GetMaxBitrate(long? clientMaxBitrate, User user, string ipAddress) + private int? GetMaxBitrate(int? clientMaxBitrate, User user, string ipAddress) { var maxBitrate = clientMaxBitrate; var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; diff --git a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs index e00ed33042..8bddf00d5f 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileCopier.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; @@ -90,6 +91,11 @@ namespace Jellyfin.Api.Helpers allowAsyncFileRead = true; } + if (_path == null) + { + throw new ResourceNotFoundException(nameof(_path)); + } + await using var inputStream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions); var eofCount = 0; diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 78d2b831c8..efce11f8a7 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,11 +1,13 @@ using System; -using System.Collections.Generic; using System.Linq; -using System.Net; +using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Http; @@ -72,7 +74,7 @@ namespace Jellyfin.Api.Helpers } return removeEmpty - ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) + ? value.Split(separator, StringSplitOptions.RemoveEmptyEntries) : value.Split(separator); } @@ -120,74 +122,39 @@ namespace Jellyfin.Api.Helpers return session; } - /// <summary> - /// Get Guid array from string. - /// </summary> - /// <param name="value">String value.</param> - /// <returns>Guid array.</returns> - internal static Guid[] GetGuids(string? value) + internal static QueryResult<BaseItemDto> CreateQueryResult( + QueryResult<(BaseItem, ItemCounts)> result, + DtoOptions dtoOptions, + IDtoService dtoService, + bool includeItemTypes, + User? user) { - if (value == null) + var dtos = result.Items.Select(i => { - return Array.Empty<Guid>(); - } + var (baseItem, counts) = i; + var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - return Split(value, ',', true) - .Select(i => new Guid(i)) - .ToArray(); - } - - /// <summary> - /// Gets the item fields. - /// </summary> - /// <param name="fields">The fields string.</param> - /// <returns>IEnumerable{ItemFields}.</returns> - internal static ItemFields[] GetItemFields(string? fields) - { - if (string.IsNullOrEmpty(fields)) - { - return Array.Empty<ItemFields>(); - } - - return Split(fields, ',', true) - .Select(v => + if (includeItemTypes) { - if (Enum.TryParse(v, true, out ItemFields value)) - { - return (ItemFields?)value; - } + dto.ChildCount = counts.ItemCount; + dto.ProgramCount = counts.ProgramCount; + dto.SeriesCount = counts.SeriesCount; + dto.EpisodeCount = counts.EpisodeCount; + dto.MovieCount = counts.MovieCount; + dto.TrailerCount = counts.TrailerCount; + dto.AlbumCount = counts.AlbumCount; + dto.SongCount = counts.SongCount; + dto.ArtistCount = counts.ArtistCount; + } - return null; - }).Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); - } + return dto; + }); - /// <summary> - /// Gets the item fields. - /// </summary> - /// <param name="imageTypes">The image types string.</param> - /// <returns>IEnumerable{ItemFields}.</returns> - internal static ImageType[] GetImageTypes(string? imageTypes) - { - if (string.IsNullOrEmpty(imageTypes)) + return new QueryResult<BaseItemDto> { - return Array.Empty<ImageType>(); - } - - return Split(imageTypes, ',', true) - .Select(v => - { - if (Enum.TryParse(v, true, out ImageType value)) - { - return (ImageType?)value; - } - - return null; - }) - .Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); + Items = dtos.ToArray(), + TotalRecordCount = result.TotalRecordCount + }; } } } diff --git a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs deleted file mode 100644 index 6b06f87cd7..0000000000 --- a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; - -namespace Jellyfin.Api.Helpers -{ - /// <summary> - /// The similar items helper class. - /// </summary> - public static class SimilarItemsHelper - { - internal static QueryResult<BaseItemDto> GetSimilarItemsResult( - DtoOptions dtoOptions, - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - Guid? userId, - string id, - string? excludeArtistIds, - int? limit, - Type[] includeTypes, - Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) - { - var user = userId.HasValue && !userId.Equals(Guid.Empty) - ? userManager.GetUserById(userId.Value) - : null; - - var item = string.IsNullOrEmpty(id) ? - (!userId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() : - libraryManager.RootFolder) : libraryManager.GetItemById(id); - - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = includeTypes.Select(i => i.Name).ToArray(), - Recursive = true, - DtoOptions = dtoOptions, - ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds) - }; - - var inputItems = libraryManager.GetItemList(query); - - var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore) - .ToList(); - - var returnItems = items; - - if (limit.HasValue && limit < returnItems.Count) - { - returnItems = returnItems.GetRange(0, limit.Value); - } - - var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); - - return new QueryResult<BaseItemDto> - { - Items = dtos, - TotalRecordCount = items.Count - }; - } - - /// <summary> - /// Gets the similaritems. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="libraryManager">The library manager.</param> - /// <param name="inputItems">The input items.</param> - /// <param name="getSimilarityScore">The get similarity score.</param> - /// <returns>IEnumerable{BaseItem}.</returns> - private static IEnumerable<BaseItem> GetSimilaritems( - BaseItem item, - ILibraryManager libraryManager, - IEnumerable<BaseItem> inputItems, - Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) - { - var itemId = item.Id; - inputItems = inputItems.Where(i => i.Id != itemId); - var itemPeople = libraryManager.GetPeople(item); - var allPeople = libraryManager.GetPeople(new InternalPeopleQuery - { - AppearsInItemId = item.Id - }); - - return inputItems.Select(i => new Tuple<BaseItem, int>(i, getSimilarityScore(item, itemPeople, allPeople, i))) - .Where(i => i.Item2 > 2) - .OrderByDescending(i => i.Item2) - .Select(i => i.Item1); - } - - private static IEnumerable<string> GetTags(BaseItem item) - { - return item.Tags; - } - - /// <summary> - /// Gets the similiarity score. - /// </summary> - /// <param name="item1">The item1.</param> - /// <param name="item1People">The item1 people.</param> - /// <param name="allPeople">All people.</param> - /// <param name="item2">The item2.</param> - /// <returns>System.Int32.</returns> - internal static int GetSimiliarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2) - { - var points = 0; - - if (!string.IsNullOrEmpty(item1.OfficialRating) && string.Equals(item1.OfficialRating, item2.OfficialRating, StringComparison.OrdinalIgnoreCase)) - { - points += 10; - } - - // Find common genres - points += item1.Genres.Where(i => item2.Genres.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); - - // Find common tags - points += GetTags(item1).Where(i => GetTags(item2).Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); - - // Find common studios - points += item1.Studios.Where(i => item2.Studios.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 3); - - var item2PeopleNames = allPeople.Where(i => i.ItemId == item2.Id) - .Select(i => i.Name) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .DistinctNames() - .ToDictionary(i => i, StringComparer.OrdinalIgnoreCase); - - points += item1People.Where(i => item2PeopleNames.ContainsKey(i.Name)).Sum(i => - { - if (string.Equals(i.Type, PersonType.Director, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Director, StringComparison.OrdinalIgnoreCase)) - { - return 5; - } - - if (string.Equals(i.Type, PersonType.Actor, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Actor, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.Composer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Composer, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (string.Equals(i.Type, PersonType.Writer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Writer, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - - return 1; - }); - - if (item1.ProductionYear.HasValue && item2.ProductionYear.HasValue) - { - var diff = Math.Abs(item1.ProductionYear.Value - item2.ProductionYear.Value); - - // Add if they came out within the same decade - if (diff < 10) - { - points += 2; - } - - // And more if within five years - if (diff < 5) - { - points += 2; - } - } - - return points; - } - } -} diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index f4ec29bdef..c6d844c4fe 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -83,8 +83,12 @@ namespace Jellyfin.Api.Helpers } streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); + if (httpRequest.Path.Value == null) + { + throw new ResourceNotFoundException(nameof(httpRequest.Path)); + } - var url = httpRequest.Path.Value.Split('.').Last(); + var url = httpRequest.Path.Value.Split('.')[^1]; if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) { @@ -165,7 +169,9 @@ namespace Jellyfin.Api.Helpers state.DirectStreamProvider = liveStreamInfo.Item2; } - encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); + var encodingOptions = serverConfigurationManager.GetEncodingOptions(); + + encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); string? containerInternal = Path.GetExtension(state.RequestedUrl); @@ -183,7 +189,7 @@ namespace Jellyfin.Api.Helpers state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); - state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream); + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); state.OutputAudioCodec = streamingRequest.AudioCodec; @@ -196,20 +202,41 @@ namespace Jellyfin.Api.Helpers encodingHelper.TryStreamCopy(state); - if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { - var resolution = ResolutionNormalizer.Normalize( - state.VideoStream?.BitRate, - state.VideoStream?.Width, - state.VideoStream?.Height, - state.OutputVideoBitrate.Value, - state.VideoStream?.Codec, - state.OutputVideoCodec, - state.VideoRequest.MaxWidth, - state.VideoRequest.MaxHeight); + var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue + && !state.VideoRequest.Height.HasValue + && !state.VideoRequest.MaxWidth.HasValue + && !state.VideoRequest.MaxHeight.HasValue; - state.VideoRequest.MaxWidth = resolution.MaxWidth; - state.VideoRequest.MaxHeight = resolution.MaxHeight; + if (isVideoResolutionNotRequested + && state.VideoRequest.VideoBitRate.HasValue + && state.VideoStream.BitRate.HasValue + && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) + { + // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, + // and the requested video bitrate is higher than source video bitrate. + if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) + { + state.VideoRequest.MaxWidth = state.VideoStream?.Width; + state.VideoRequest.MaxHeight = state.VideoStream?.Height; + } + } + else + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream?.BitRate, + state.VideoStream?.Width, + state.VideoStream?.Height, + state.OutputVideoBitrate.Value, + state.VideoStream?.Codec, + state.OutputVideoCodec, + state.VideoRequest.MaxWidth, + state.VideoRequest.MaxHeight); + + state.VideoRequest.MaxWidth = resolution.MaxWidth; + state.VideoRequest.MaxHeight = resolution.MaxHeight; + } } } diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 0db1fabffe..99c90c315e 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -12,6 +12,7 @@ using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -102,7 +103,7 @@ namespace Jellyfin.Api.Helpers /// </summary> /// <param name="playSessionId">Playback session id.</param> /// <returns>The transcoding job.</returns> - public TranscodingJobDto GetTranscodingJob(string playSessionId) + public TranscodingJobDto? GetTranscodingJob(string playSessionId) { lock (_activeTranscodingJobs) { @@ -116,7 +117,7 @@ namespace Jellyfin.Api.Helpers /// <param name="path">Path to the transcoding file.</param> /// <param name="type">The <see cref="TranscodingJobType"/>.</param> /// <returns>The transcoding job.</returns> - public TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type) + public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) { lock (_activeTranscodingJobs) { @@ -144,7 +145,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); } @@ -193,10 +194,9 @@ namespace Jellyfin.Api.Helpers /// Called when [transcode kill timer stopped]. /// </summary> /// <param name="state">The state.</param> - private async void OnTranscodeKillTimerStopped(object state) + private async void OnTranscodeKillTimerStopped(object? state) { - var job = (TranscodingJobDto)state; - + var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); if (!job.HasExited && job.Type != TranscodingJobType.Progressive) { var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; @@ -241,7 +241,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs.AddRange(_activeTranscodingJobs.Where(killJob)); } @@ -304,10 +304,10 @@ namespace Jellyfin.Api.Helpers process!.StandardInput.WriteLine("q"); - // Need to wait because killing is asynchronous + // Need to wait because killing is asynchronous. if (!process.WaitForExit(5000)) { - _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); + _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); process.Kill(); } } @@ -470,11 +470,11 @@ namespace Jellyfin.Api.Helpers } /// <summary> - /// Starts the FFMPEG. + /// Starts FFmpeg. /// </summary> /// <param name="state">The state.</param> /// <param name="outputPath">The output path.</param> - /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param> + /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param> /// <param name="request">The <see cref="HttpRequest"/>.</param> /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> /// <param name="cancellationTokenSource">The cancellation token source.</param> @@ -489,7 +489,8 @@ namespace Jellyfin.Api.Helpers CancellationTokenSource cancellationTokenSource, string? workingDirectory = null) { - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); @@ -500,13 +501,13 @@ namespace Jellyfin.Api.Helpers { this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - throw new ArgumentException("User does not have access to video transcoding"); + throw new ArgumentException("User does not have access to video transcoding."); } } if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath)) { - throw new ArgumentException("FFMPEG path not set."); + throw new ArgumentException("FFmpeg path not set."); } var process = new Process @@ -523,7 +524,7 @@ namespace Jellyfin.Api.Helpers RedirectStandardInput = true, FileName = _mediaEncoder.EncoderPath, Arguments = commandLineArguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, ErrorDialog = false }, EnableRaisingEvents = true @@ -543,18 +544,20 @@ namespace Jellyfin.Api.Helpers var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; _logger.LogInformation(commandLineLogMessage); - var logFilePrefix = "ffmpeg-transcode"; + var logFilePrefix = "FFmpeg.Transcode-"; if (state.VideoRequest != null && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "ffmpeg-remux" - : "ffmpeg-directstream"; + ? "FFmpeg.Remux-" + : "FFmpeg.DirectStream-"; } - var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); + var logFilePath = Path.Combine( + _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, + $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); @@ -568,20 +571,20 @@ namespace Jellyfin.Api.Helpers } catch (Exception ex) { - _logger.LogError(ex, "Error starting ffmpeg"); + _logger.LogError(ex, "Error starting FFmpeg"); this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); throw; } - _logger.LogDebug("Launched ffmpeg process"); + _logger.LogDebug("Launched FFmpeg process"); state.TranscodingJob = transcodingJob; - // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); - // Wait for the file to exist before proceeeding + // Wait for the file to exist before proceeding var ffmpegTargetFile = state.WaitForPath ?? outputPath; _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) @@ -747,11 +750,11 @@ namespace Jellyfin.Api.Helpers if (process.ExitCode == 0) { - _logger.LogInformation("FFMpeg exited with code 0"); + _logger.LogInformation("FFmpeg exited with code 0"); } else { - _logger.LogError("FFMpeg exited with code {0}", process.ExitCode); + _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); } process.Dispose(); @@ -770,8 +773,9 @@ namespace Jellyfin.Api.Helpers new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, cancellationTokenSource.Token) .ConfigureAwait(false); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); + _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); if (state.VideoRequest != null) { @@ -827,7 +831,7 @@ namespace Jellyfin.Api.Helpers { lock (_transcodingLocks) { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result)) + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) { result = new SemaphoreSlim(1, 1); _transcodingLocks[outputPath] = result; @@ -837,7 +841,7 @@ namespace Jellyfin.Api.Helpers } } - private void OnPlaybackProgress(object sender, PlaybackProgressEventArgs e) + private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) { if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) { diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index da0852cebc..da6e5fa2d4 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -6,17 +6,19 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <Nullable>enable</Nullable> + <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> + <NoWarn>AD0001</NoWarn> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" /> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.9" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="5.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.9" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" /> <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="5.6.3" /> </ItemGroup> diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs index 4f012cab20..e90f48d2fe 100644 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.ModelBinders { @@ -11,6 +13,17 @@ namespace Jellyfin.Api.ModelBinders /// </summary> public class CommaDelimitedArrayModelBinder : IModelBinder { + private readonly ILogger<CommaDelimitedArrayModelBinder> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param> + public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + /// <inheritdoc/> public Task BindModelAsync(ModelBindingContext bindingContext) { @@ -20,16 +33,8 @@ namespace Jellyfin.Api.ModelBinders if (valueProviderResult.Length > 1) { - var result = Array.CreateInstance(elementType, valueProviderResult.Length); - - for (int i = 0; i < valueProviderResult.Length; i++) - { - var value = converter.ConvertFromString(valueProviderResult.Values[i].Trim()); - - result.SetValue(value, i); - } - - bindingContext.Result = ModelBindingResult.Success(result); + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); } else { @@ -37,13 +42,8 @@ namespace Jellyfin.Api.ModelBinders if (value != null) { - var values = Array.ConvertAll( - value.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries), - x => converter.ConvertFromString(x?.Trim())); - - var typedValues = Array.CreateInstance(elementType, values.Length); - values.CopyTo(typedValues, 0); - + var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); bindingContext.Result = ModelBindingResult.Success(typedValues); } else @@ -55,5 +55,36 @@ namespace Jellyfin.Api.ModelBinders return Task.CompletedTask; } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) + { + try + { + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; + } + catch (FormatException e) + { + _logger.LogWarning(e, "Error converting value."); + } + } + + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } + } + + return typedValues; + } } } diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs new file mode 100644 index 0000000000..a42e0e4da8 --- /dev/null +++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.ModelBinders +{ + /// <summary> + /// Comma delimited array model binder. + /// Returns an empty array of specified type if there is no query parameter. + /// </summary> + public class PipeDelimitedArrayModelBinder : IModelBinder + { + private readonly ILogger<PipeDelimitedArrayModelBinder> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param> + public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + + /// <inheritdoc/> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); + + if (valueProviderResult.Length > 1) + { + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); + } + else + { + var value = valueProviderResult.FirstValue; + + if (value != null) + { + var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); + } + else + { + var emptyResult = Array.CreateInstance(elementType, 0); + bindingContext.Result = ModelBindingResult.Success(emptyResult); + } + } + + return Task.CompletedTask; + } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) + { + try + { + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; + } + catch (FormatException e) + { + _logger.LogWarning(e, "Error converting value."); + } + } + + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } + } + + return typedValues; + } + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs index 33eda33cb9..7de44aa659 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; namespace Jellyfin.Api.Models.LibraryDtos { @@ -10,25 +11,21 @@ namespace Jellyfin.Api.Models.LibraryDtos /// <summary> /// Gets or sets the metadata savers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataSavers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] MetadataSavers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> MetadataSavers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the metadata readers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataReaders", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] MetadataReaders { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> MetadataReaders { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the subtitle fetchers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SubtitleFetchers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] SubtitleFetchers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> SubtitleFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the type options. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "TypeOptions", Justification = "Imported from ServiceStack")] - public LibraryTypeOptionsDto[] TypeOptions { get; set; } = null!; + public IReadOnlyList<LibraryTypeOptionsDto> TypeOptions { get; set; } = Array.Empty<LibraryTypeOptionsDto>(); } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs index ad031e95e5..20f45196d2 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -17,25 +18,21 @@ namespace Jellyfin.Api.Models.LibraryDtos /// <summary> /// Gets or sets the metadata fetchers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataFetchers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] MetadataFetchers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the image fetchers. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "ImageFetchers", Justification = "Imported from ServiceStack")] - public LibraryOptionInfoDto[] ImageFetchers { get; set; } = null!; + public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); /// <summary> /// Gets or sets the supported image types. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SupportedImageTypes", Justification = "Imported from ServiceStack")] - public ImageType[] SupportedImageTypes { get; set; } = null!; + public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); /// <summary> /// Gets or sets the default image options. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "DefaultImageOptions", Justification = "Imported from ServiceStack")] - public ImageOption[] DefaultImageOptions { get; set; } = null!; + public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); } } diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs index 970d8acdbc..f43822da77 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; @@ -25,8 +26,7 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// <summary> /// Gets or sets list of mappings. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:DontReturnArrays", MessageId = "Mappings", Justification = "Imported from ServiceStack")] - public NameValuePair[] Mappings { get; set; } = null!; + public IReadOnlyList<NameValuePair> Mappings { get; set; } = Array.Empty<NameValuePair>(); /// <summary> /// Gets or sets provider name. diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index 5811533936..a47ae926c1 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using MediaBrowser.Common.Json.Converters; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; namespace Jellyfin.Api.Models.LiveTvDtos { @@ -14,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// <summary> /// Gets or sets the channels to return guide information for. /// </summary> - public string? ChannelIds { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets optional. Filter by user id. @@ -113,12 +116,14 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// <summary> /// Gets or sets the genres to return guide information for. /// </summary> - public string? Genres { get; set; } + [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))] + public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets the genre ids to return guide information for. /// </summary> - public string? GenreIds { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets include image information in output. @@ -142,8 +147,7 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// Optional. /// </summary> [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "EnableImageTypes", Justification = "Imported from ServiceStack")] - public ImageType[] EnableImageTypes { get; set; } = Array.Empty<ImageType>(); + public IReadOnlyList<ImageType> EnableImageTypes { get; set; } = Array.Empty<ImageType>(); /// <summary> /// Gets or sets include user data. @@ -167,6 +171,7 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// Gets or sets specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. /// Optional. /// </summary> - public string? Fields { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<ItemFields> Fields { get; set; } = Array.Empty<ItemFields>(); } } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs index f797a38076..b0b3de8553 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; @@ -17,8 +18,6 @@ namespace Jellyfin.Api.Models.MediaInfoDtos /// <summary> /// Gets or sets the device play protocols. /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1819:DontReturnArrays", MessageId = "DevicePlayProtocols", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "SA1011:ClosingBracketsSpace", MessageId = "DevicePlayProtocols", Justification = "Imported from ServiceStack")] - public MediaProtocol[]? DirectPlayProtocols { get; set; } + public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } = Array.Empty<MediaProtocol>(); } } diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs index b9507a4e50..9edc19bb6d 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs @@ -196,7 +196,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos /// Start kill timer. /// </summary> /// <param name="callback">Callback action.</param> - public void StartKillTimer(Action<object> callback) + public void StartKillTimer(Action<object?> callback) { StartKillTimer(callback, PingTimeout); } @@ -206,7 +206,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos /// </summary> /// <param name="callback">Callback action.</param> /// <param name="intervalMs">Callback interval.</param> - public void StartKillTimer(Action<object> callback, int intervalMs) + public void StartKillTimer(Action<object?> callback, int intervalMs) { if (HasExited) { diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs index b5e42ea299..872a468245 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs @@ -101,7 +101,7 @@ namespace Jellyfin.Api.Models.PlaybackDtos return _config.GetConfiguration<EncodingOptions>("encoding"); } - private async void TimerCallback(object state) + private async void TimerCallback(object? state) { if (_job.HasExited) { diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index 0d67c86f71..d0d6889fc8 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; namespace Jellyfin.Api.Models.PlaylistDtos { @@ -15,7 +18,8 @@ namespace Jellyfin.Api.Models.PlaylistDtos /// <summary> /// Gets or sets item ids to add to the playlist. /// </summary> - public string? Ids { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets the user id. diff --git a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs new file mode 100644 index 0000000000..30473255e8 --- /dev/null +++ b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Api.Models.SubtitleDtos +{ + /// <summary> + /// Upload subtitles dto. + /// </summary> + public class UploadSubtitleDto + { + /// <summary> + /// Gets or sets the subtitle language. + /// </summary> + [Required] + public string Language { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the subtitle format. + /// </summary> + [Required] + public string Format { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether the subtitle is forced. + /// </summary> + [Required] + public bool IsForced { get; set; } + + /// <summary> + /// Gets or sets the subtitle data. + /// </summary> + [Required] + public string Data { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index 77d55828d1..ce54651166 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -56,7 +56,7 @@ namespace Jellyfin.Api.WebSocketListeners base.Dispose(dispose); } - private void OnEntryCreated(object sender, GenericEventArgs<ActivityLogEntry> e) + private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) { SendData(true); } diff --git a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs index 80314b9236..94df23e569 100644 --- a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs @@ -64,19 +64,19 @@ namespace Jellyfin.Api.WebSocketListeners base.Dispose(dispose); } - private void OnTaskCompleted(object sender, TaskCompletionEventArgs e) + private void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) { SendData(true); e.Task.TaskProgress -= OnTaskProgress; } - private void OnTaskExecuting(object sender, GenericEventArgs<IScheduledTaskWorker> e) + private void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) { SendData(true); e.Argument.TaskProgress += OnTaskProgress; } - private void OnTaskProgress(object sender, GenericEventArgs<double> e) + private void OnTaskProgress(object? sender, GenericEventArgs<double> e) { SendData(false); } diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index 1cf43a0053..d996ac69f9 100644 --- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -66,37 +66,37 @@ namespace Jellyfin.Api.WebSocketListeners base.Dispose(dispose); } - private async void OnSessionManagerSessionActivity(object sender, SessionEventArgs e) + private async void OnSessionManagerSessionActivity(object? sender, SessionEventArgs e) { await SendData(false).ConfigureAwait(false); } - private async void OnSessionManagerCapabilitiesChanged(object sender, SessionEventArgs e) + private async void OnSessionManagerCapabilitiesChanged(object? sender, SessionEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerPlaybackProgress(object sender, PlaybackProgressEventArgs e) + private async void OnSessionManagerPlaybackProgress(object? sender, PlaybackProgressEventArgs e) { await SendData(!e.IsAutomated).ConfigureAwait(false); } - private async void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) + private async void OnSessionManagerPlaybackStopped(object? sender, PlaybackStopEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerPlaybackStart(object sender, PlaybackProgressEventArgs e) + private async void OnSessionManagerPlaybackStart(object? sender, PlaybackProgressEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) + private async void OnSessionManagerSessionEnded(object? sender, SessionEventArgs e) { await SendData(true).ConfigureAwait(false); } - private async void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) + private async void OnSessionManagerSessionStarted(object? sender, SessionEventArgs e) { await SendData(true).ConfigureAwait(false); } diff --git a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs index 4467c9bbdc..f9539964d0 100644 --- a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs +++ b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs @@ -73,7 +73,7 @@ namespace Jellyfin.Data.Entities.Libraries /// Gets or sets the next item in the collection. /// </summary> /// <remarks> - /// TODO check if this properly updated dependant and has the proper principal relationship. + /// TODO check if this properly updated Dependant and has the proper principal relationship. /// </remarks> public virtual CollectionItem Next { get; set; } @@ -81,7 +81,7 @@ namespace Jellyfin.Data.Entities.Libraries /// Gets or sets the previous item in the collection. /// </summary> /// <remarks> - /// TODO check if this properly updated dependant and has the proper principal relationship. + /// TODO check if this properly updated Dependant and has the proper principal relationship. /// </remarks> public virtual CollectionItem Previous { get; set; } diff --git a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs index 1d2dc0f669..d74330c051 100644 --- a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs +++ b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs @@ -141,7 +141,7 @@ namespace Jellyfin.Data.Entities.Libraries public virtual ICollection<PersonRole> PersonRoles { get; protected set; } /// <summary> - /// Gets or sets a collection containing the generes for this item. + /// Gets or sets a collection containing the genres for this item. /// </summary> public virtual ICollection<Genre> Genres { get; protected set; } diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj index 5038988f96..9ae129d072 100644 --- a/Jellyfin.Data/Jellyfin.Data.csproj +++ b/Jellyfin.Data/Jellyfin.Data.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> @@ -41,8 +41,8 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="3.1.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.9" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="5.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.0" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index c11ac5fb37..466a12e676 100644 --- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> diff --git a/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 6a9dbdae42..ee60748c71 100644 --- a/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using BlurHashSharp.SkiaSharp; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Extensions; using MediaBrowser.Model.Drawing; @@ -227,8 +228,8 @@ namespace Jellyfin.Drawing.Skia } var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path)); - - Directory.CreateDirectory(Path.GetDirectoryName(tempPath)); + var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); + Directory.CreateDirectory(directory); File.Copy(path, tempPath, true); return tempPath; @@ -493,7 +494,8 @@ namespace Jellyfin.Drawing.Skia // If all we're doing is resizing then we can stop now if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) { - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var outputDirectory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(outputDirectory); using var outputStream = new SKFileWStream(outputPath); using var pixmap = new SKPixmap(new SKImageInfo(width, height), resizedBitmap.GetPixels()); resizedBitmap.Encode(outputStream, skiaOutputFormat, quality); @@ -540,7 +542,8 @@ namespace Jellyfin.Drawing.Skia DrawIndicator(canvas, width, height, options); } - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); using (var outputStream = new SKFileWStream(outputPath)) { using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index c52be3b8a9..e663798da0 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> @@ -24,12 +24,12 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="System.Linq.Async" Version="4.1.1" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.9"> + <PackageReference Include="System.Linq.Async" Version="5.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.9"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index f79e433a67..662b4bf651 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -53,7 +53,7 @@ namespace Jellyfin.Server.Implementations.Users bool success = false; - // As long as jellyfin supports passwordless users, we need this little block here to accommodate + // As long as jellyfin supports password-less users, we need this little block here to accommodate if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password)) { return Task.FromResult(new ProviderAuthenticationResult diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs index 6cb13cd23e..334f27f859 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs @@ -57,7 +57,8 @@ namespace Jellyfin.Server.Implementations.Users SerializablePasswordReset spr; await using (var str = File.OpenRead(resetFile)) { - spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false); + spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false) + ?? throw new ResourceNotFoundException($"Provided path ({resetFile}) is not valid."); } if (spr.ExpirationDate < DateTime.UtcNow) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 40b89ed284..f684d151d6 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -158,7 +158,6 @@ namespace Jellyfin.Server.Implementations.Users user.Username = newName; await UpdateUserAsync(user).ConfigureAwait(false); - OnUserUpdated?.Invoke(this, new GenericEventArgs<User>(user)); } @@ -167,6 +166,7 @@ namespace Jellyfin.Server.Implementations.Users { using var dbContext = _dbProvider.CreateContext(); dbContext.Users.Update(user); + _users[user.Id] = user; dbContext.SaveChanges(); } @@ -175,7 +175,7 @@ namespace Jellyfin.Server.Implementations.Users { await using var dbContext = _dbProvider.CreateContext(); dbContext.Users.Update(user); - + _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } @@ -642,6 +642,7 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); dbContext.Update(user); + _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } @@ -713,6 +714,7 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); dbContext.Update(user); + _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } @@ -723,6 +725,7 @@ namespace Jellyfin.Server.Implementations.Users dbContext.Remove(user.ProfileImage); await dbContext.SaveChangesAsync().ConfigureAwait(false); user.ProfileImage = null; + _users[user.Id] = user; } private static bool IsValidUsername(string name) diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index c44736447f..cb8ae91f56 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -13,6 +13,7 @@ using Jellyfin.Server.Implementations.Events; using Jellyfin.Server.Implementations.Users; using MediaBrowser.Common.Net; using MediaBrowser.Controller; +using MediaBrowser.Controller.BaseItemManager; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; @@ -76,6 +77,7 @@ namespace Jellyfin.Server options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}")); ServiceCollection.AddEventServices(); + ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); ServiceCollection.AddSingleton<IEventManager, EventManager>(); ServiceCollection.AddSingleton<JellyfinDbProvider>(); diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index e180d0cd7a..cc98955df7 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net; using System.Reflection; +using Emby.Server.Implementations; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.DownloadPolicy; @@ -27,6 +28,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes; @@ -209,7 +212,19 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + c.SwaggerDoc("api-docs", new OpenApiInfo + { + Title = "Jellyfin API", + Version = "v1", + Extensions = new Dictionary<string, IOpenApiExtension> + { + { + "x-jellyfin-version", + new OpenApiString(typeof(ApplicationHost).Assembly.GetName().Version?.ToString()) + } + } + }); + c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme { Type = SecuritySchemeType.ApiKey, diff --git a/Jellyfin.Server/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs index 8ea35c2818..7ad9466c17 100644 --- a/Jellyfin.Server/Filters/FileResponseFilter.cs +++ b/Jellyfin.Server/Filters/FileResponseFilter.cs @@ -26,22 +26,22 @@ namespace Jellyfin.Server.Filters if (attribute is ProducesFileAttribute producesFileAttribute) { // Get operation response values. - var (_, value) = operation.Responses + var response = operation.Responses .FirstOrDefault(o => o.Key.Equals(SuccessCode, StringComparison.Ordinal)); // Operation doesn't have a response. - if (value == null) + if (response.Value == null) { continue; } // Clear existing responses. - value.Content.Clear(); + response.Value.Content.Clear(); // Add all content-types as file. foreach (var contentType in producesFileAttribute.GetContentTypes()) { - value.Content.Add(contentType, _openApiMediaType); + response.Value.Content.Add(contentType, _openApiMediaType); } break; diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs index b3771b7fe6..e8dd48e4e6 100644 --- a/Jellyfin.Server/Formatters/CssOutputFormatter.cs +++ b/Jellyfin.Server/Formatters/CssOutputFormatter.cs @@ -30,7 +30,8 @@ namespace Jellyfin.Server.Formatters /// <returns>Write stream task.</returns> public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) { - return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + var stringResponse = context.Object?.ToString(); + return stringResponse == null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } } diff --git a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs index 01d99d7c87..be0baea2d2 100644 --- a/Jellyfin.Server/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Server/Formatters/XmlOutputFormatter.cs @@ -26,7 +26,8 @@ namespace Jellyfin.Server.Formatters /// <inheritdoc /> public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) { - return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + var stringResponse = context.Object?.ToString(); + return stringResponse == null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index a64d2e1cdb..03d06fdff3 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -8,7 +8,7 @@ <PropertyGroup> <AssemblyName>jellyfin</AssemblyName> <OutputType>Exe</OutputType> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> @@ -38,10 +38,10 @@ <ItemGroup> <PackageReference Include="CommandLineParser" Version="2.8.0" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="3.1.9" /> + <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="5.0.0" /> <PackageReference Include="prometheus-net" Version="4.0.0" /> <PackageReference Include="prometheus-net.AspNetCore" Version="4.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" /> @@ -50,7 +50,7 @@ <PackageReference Include="Serilog.Sinks.Async" Version="1.4.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" /> <PackageReference Include="Serilog.Sinks.File" Version="4.1.0" /> - <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.1" /> + <PackageReference Include="Serilog.Sinks.Graylog" Version="2.2.2" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.0.4" /> <PackageReference Include="SQLitePCLRaw.provider.sqlite3.netstandard11" Version="1.1.14" /> </ItemGroup> diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 844dae61f6..aca1654084 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -23,7 +23,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.AddDefaultPluginRepository), typeof(Routines.MigrateUserDb), typeof(Routines.ReaddDefaultPluginRepository), - typeof(Routines.MigrateDisplayPreferencesDb) + typeof(Routines.MigrateDisplayPreferencesDb), + typeof(Routines.RemoveDownloadImagesInAdvance) }; /// <summary> diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 7f57358ec0..8992c281db 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -9,6 +9,7 @@ using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Server.Implementations; using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -26,6 +27,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly IServerApplicationPaths _paths; private readonly JellyfinDbProvider _provider; private readonly JsonSerializerOptions _jsonOptions; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="MigrateDisplayPreferencesDb"/> class. @@ -33,11 +35,17 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="logger">The logger.</param> /// <param name="paths">The server application paths.</param> /// <param name="provider">The database provider.</param> - public MigrateDisplayPreferencesDb(ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, JellyfinDbProvider provider) + /// <param name="userManager">The user manager.</param> + public MigrateDisplayPreferencesDb( + ILogger<MigrateDisplayPreferencesDb> logger, + IServerApplicationPaths paths, + JellyfinDbProvider provider, + IUserManager userManager) { _logger = logger; _paths = paths; _provider = provider; + _userManager = userManager; _jsonOptions = new JsonSerializerOptions(); _jsonOptions.Converters.Add(new JsonStringEnumConverter()); } @@ -86,11 +94,19 @@ namespace Jellyfin.Server.Migrations.Routines continue; } + var dtoUserId = new Guid(result[1].ToBlob()); + var existingUser = _userManager.GetUserById(dtoUserId); + if (existingUser == null) + { + _logger.LogWarning("User with ID {UserId} does not exist in the database, skipping migration.", dtoUserId); + continue; + } + var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version) ? chromecastDict[version] : ChromecastVersion.Stable; - var displayPreferences = new DisplayPreferences(new Guid(result[1].ToBlob()), result[2].ToString()) + var displayPreferences = new DisplayPreferences(dtoUserId, result[2].ToString()) { IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null, ShowBackdrop = dto.ShowBackdrop, diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs new file mode 100644 index 0000000000..42b87ec5f5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs @@ -0,0 +1,46 @@ +using System; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// <summary> + /// Removes the old 'RemoveDownloadImagesInAdvance' from library options. + /// </summary> + internal class RemoveDownloadImagesInAdvance : IMigrationRoutine + { + private readonly ILogger<RemoveDownloadImagesInAdvance> _logger; + private readonly ILibraryManager _libraryManager; + + public RemoveDownloadImagesInAdvance(ILogger<RemoveDownloadImagesInAdvance> logger, ILibraryManager libraryManager) + { + _logger = logger; + _libraryManager = libraryManager; + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("{A81F75E0-8F43-416F-A5E8-516CCAB4D8CC}"); + + /// <inheritdoc/> + public string Name => "RemoveDownloadImagesInAdvance"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; + + /// <inheritdoc/> + public void Perform() + { + var virtualFolders = _libraryManager.GetVirtualFolders(false); + _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries"); + foreach (var virtualFolder in virtualFolders) + { + var libraryOptions = virtualFolder.LibraryOptions; + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(virtualFolder.ItemId); + // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed. + collectionFolder.UpdateLibraryOptions(libraryOptions); + _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name); + } + } + } +} diff --git a/MediaBrowser.Common/Configuration/IConfigurationManager.cs b/MediaBrowser.Common/Configuration/IConfigurationManager.cs index fe726090d6..fc63d93503 100644 --- a/MediaBrowser.Common/Configuration/IConfigurationManager.cs +++ b/MediaBrowser.Common/Configuration/IConfigurationManager.cs @@ -46,6 +46,13 @@ namespace MediaBrowser.Common.Configuration /// <param name="newConfiguration">The new configuration.</param> void ReplaceConfiguration(BaseApplicationConfiguration newConfiguration); + /// <summary> + /// Manually pre-loads a factory so that it is available pre system initialisation. + /// </summary> + /// <typeparam name="T">Class to register.</typeparam> + void RegisterConfiguration<T>() + where T : IConfigurationFactory; + /// <summary> /// Gets the configuration. /// </summary> diff --git a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs index bf7048c370..a259cb7bcb 100644 --- a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs @@ -26,19 +26,41 @@ namespace MediaBrowser.Common.Json.Converters { if (reader.TokenType == JsonTokenType.String) { - var stringEntries = reader.GetString()?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + var stringEntries = reader.GetString()?.Split(',', StringSplitOptions.RemoveEmptyEntries); if (stringEntries == null || stringEntries.Length == 0) { return Array.Empty<T>(); } - var entries = new T[stringEntries.Length]; + var parsedValues = new object[stringEntries.Length]; + var convertedCount = 0; for (var i = 0; i < stringEntries.Length; i++) { - entries[i] = (T)_typeConverter.ConvertFrom(stringEntries[i].Trim()); + try + { + parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim()); + convertedCount++; + } + catch (FormatException) + { + // TODO log when upgraded to .Net6 + // https://github.com/dotnet/runtime/issues/42975 + // _logger.LogWarning(e, "Error converting value."); + } } - return entries; + var typedValues = new T[convertedCount]; + var typedValueIndex = 0; + for (var i = 0; i < stringEntries.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } + } + + return typedValues; } return JsonSerializer.Deserialize<T[]>(ref reader, options); @@ -50,4 +72,4 @@ namespace MediaBrowser.Common.Json.Converters JsonSerializer.Serialize(writer, value, options); } } -} \ No newline at end of file +} diff --git a/MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs b/MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs new file mode 100644 index 0000000000..63b344a9d6 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Returns an ISO8601 formatted datetime. + /// </summary> + /// <remarks> + /// Used for legacy compatibility. + /// </remarks> + public class JsonDateTimeIso8601Converter : JsonConverter<DateTime> + { + /// <inheritdoc /> + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetDateTime(); + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture)); + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs new file mode 100644 index 0000000000..75fbcea1f0 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs @@ -0,0 +1,75 @@ +using System; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Convert Pipe delimited string to array of type. + /// </summary> + /// <typeparam name="T">Type to convert to.</typeparam> + public class JsonPipeDelimitedArrayConverter<T> : JsonConverter<T[]> + { + private readonly TypeConverter _typeConverter; + + /// <summary> + /// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class. + /// </summary> + public JsonPipeDelimitedArrayConverter() + { + _typeConverter = TypeDescriptor.GetConverter(typeof(T)); + } + + /// <inheritdoc /> + public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var stringEntries = reader.GetString()?.Split('|', StringSplitOptions.RemoveEmptyEntries); + if (stringEntries == null || stringEntries.Length == 0) + { + return Array.Empty<T>(); + } + + var parsedValues = new object[stringEntries.Length]; + var convertedCount = 0; + for (var i = 0; i < stringEntries.Length; i++) + { + try + { + parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim()); + convertedCount++; + } + catch (FormatException) + { + // TODO log when upgraded to .Net6 + // https://github.com/dotnet/runtime/issues/42975 + // _logger.LogWarning(e, "Error converting value."); + } + } + + var typedValues = new T[convertedCount]; + var typedValueIndex = 0; + for (var i = 0; i < stringEntries.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } + } + + return typedValues; + } + + return JsonSerializer.Deserialize<T[]>(ref reader, options); + } + + /// <inheritdoc /> + public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, options); + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs new file mode 100644 index 0000000000..5e77223ef2 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// <summary> + /// Json Pipe delimited array converter factory. + /// </summary> + /// <remarks> + /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow. + /// </remarks> + public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory + { + /// <inheritdoc /> + public override bool CanConvert(Type typeToConvert) + { + return true; + } + + /// <inheritdoc /> + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0]; + return (JsonConverter)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType)); + } + } +} diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index 6605ae9624..9a94664acc 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -42,6 +42,7 @@ namespace MediaBrowser.Common.Json options.Converters.Add(new JsonGuidConverter()); options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new JsonNullableStructConverterFactory()); + options.Converters.Add(new JsonDateTimeIso8601Converter()); return options; } diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index e716a6610f..be5e7f5b45 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -1,4 +1,4 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> <PropertyGroup> @@ -18,9 +18,9 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" /> </ItemGroup> @@ -29,7 +29,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> diff --git a/MediaBrowser.Common/Net/DefaultHttpClientHandler.cs b/MediaBrowser.Common/Net/DefaultHttpClientHandler.cs index e189d6e706..f1c5f24772 100644 --- a/MediaBrowser.Common/Net/DefaultHttpClientHandler.cs +++ b/MediaBrowser.Common/Net/DefaultHttpClientHandler.cs @@ -13,8 +13,7 @@ namespace MediaBrowser.Common.Net /// </summary> public DefaultHttpClientHandler() { - // TODO change to DecompressionMethods.All with .NET5 - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + AutomaticDecompression = DecompressionMethods.All; } } } diff --git a/MediaBrowser.Common/Net/INetworkManager.cs b/MediaBrowser.Common/Net/INetworkManager.cs index a0330afeff..12966a474f 100644 --- a/MediaBrowser.Common/Net/INetworkManager.cs +++ b/MediaBrowser.Common/Net/INetworkManager.cs @@ -58,7 +58,7 @@ namespace MediaBrowser.Common.Net /// <summary> /// Investigates an caches a list of interface addresses, excluding local link and LAN excluded addresses. /// </summary> - /// <returns>The list of ipaddresses.</returns> + /// <returns>The list of ip addresses.</returns> IPAddress[] GetLocalIpAddresses(); /// <summary> @@ -73,7 +73,7 @@ namespace MediaBrowser.Common.Net /// Returns true if address is in the LAN list in the config file. /// </summary> /// <param name="address">The address to check.</param> - /// <param name="excludeInterfaces">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param> + /// <param name="excludeInterfaces">If true, check against addresses in the LAN settings which have [] around and return true if it matches the address give in address.</param> /// <param name="excludeRFC">If true, returns false if address is in the 127.x.x.x or 169.128.x.x range.</param> /// <returns><c>false</c>if the address isn't in the LAN list, <c>true</c> if the address has been defined as a LAN address.</returns> bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC); diff --git a/MediaBrowser.Common/Plugins/BasePlugin.cs b/MediaBrowser.Common/Plugins/BasePlugin.cs index 8545fd5dcf..084e91d500 100644 --- a/MediaBrowser.Common/Plugins/BasePlugin.cs +++ b/MediaBrowser.Common/Plugins/BasePlugin.cs @@ -83,16 +83,6 @@ namespace MediaBrowser.Common.Plugins { } - /// <inheritdoc /> - public virtual void RegisterServices(IServiceCollection serviceCollection) - { - } - - /// <inheritdoc /> - public virtual void UnregisterServices(IServiceCollection serviceCollection) - { - } - /// <inheritdoc /> public void SetAttributes(string assemblyFilePath, string dataFolderPath, Version assemblyVersion) { @@ -185,6 +175,11 @@ namespace MediaBrowser.Common.Plugins /// <value>The type of the configuration.</value> public Type ConfigurationType => typeof(TConfigurationType); + /// <summary> + /// Gets or sets the event handler that is triggered when this configuration changes. + /// </summary> + public EventHandler<BasePluginConfiguration> ConfigurationChanged { get; set; } + /// <summary> /// Gets the name the assembly file. /// </summary> @@ -252,7 +247,23 @@ namespace MediaBrowser.Common.Plugins } catch { - return (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType)); + var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType)); + SaveConfiguration(config); + return config; + } + } + + /// <summary> + /// Saves the current configuration to the file system. + /// </summary> + /// <param name="config">Configuration to save.</param> + public virtual void SaveConfiguration(TConfigurationType config) + { + lock (_configurationSaveLock) + { + _directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath)); + + XmlSerializer.SerializeToFile(config, ConfigurationFilePath); } } @@ -261,12 +272,7 @@ namespace MediaBrowser.Common.Plugins /// </summary> public virtual void SaveConfiguration() { - lock (_configurationSaveLock) - { - _directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath)); - - XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath); - } + SaveConfiguration(Configuration); } /// <inheritdoc /> @@ -279,7 +285,9 @@ namespace MediaBrowser.Common.Plugins Configuration = (TConfigurationType)configuration; - SaveConfiguration(); + SaveConfiguration(Configuration); + + ConfigurationChanged?.Invoke(this, configuration); } /// <inheritdoc /> diff --git a/MediaBrowser.Common/Plugins/IPlugin.cs b/MediaBrowser.Common/Plugins/IPlugin.cs index 1844eb124f..d583a58878 100644 --- a/MediaBrowser.Common/Plugins/IPlugin.cs +++ b/MediaBrowser.Common/Plugins/IPlugin.cs @@ -62,18 +62,6 @@ namespace MediaBrowser.Common.Plugins /// Called when just before the plugin is uninstalled from the server. /// </summary> void OnUninstalling(); - - /// <summary> - /// Registers the plugin's services to the service collection. - /// </summary> - /// <param name="serviceCollection">The service collection.</param> - void RegisterServices(IServiceCollection serviceCollection); - - /// <summary> - /// Unregisters the plugin's services from the service collection. - /// </summary> - /// <param name="serviceCollection">The service collection.</param> - void UnregisterServices(IServiceCollection serviceCollection); } public interface IHasPluginConfiguration diff --git a/MediaBrowser.Common/Plugins/IPluginServiceRegistrator.cs b/MediaBrowser.Common/Plugins/IPluginServiceRegistrator.cs new file mode 100644 index 0000000000..3afe874c52 --- /dev/null +++ b/MediaBrowser.Common/Plugins/IPluginServiceRegistrator.cs @@ -0,0 +1,19 @@ +namespace MediaBrowser.Common.Plugins +{ + using Microsoft.Extensions.DependencyInjection; + + /// <summary> + /// Defines the <see cref="IPluginServiceRegistrator" />. + /// </summary> + public interface IPluginServiceRegistrator + { + /// <summary> + /// Registers the plugin's services with the service collection. + /// </summary> + /// <remarks> + /// This interface is only used for service registration and requires a parameterless constructor. + /// </remarks> + /// <param name="serviceCollection">The service collection.</param> + void RegisterServices(IServiceCollection serviceCollection); + } +} diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs new file mode 100644 index 0000000000..7927c663d4 --- /dev/null +++ b/MediaBrowser.Common/Plugins/LocalPlugin.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace MediaBrowser.Common.Plugins +{ + /// <summary> + /// Local plugin struct. + /// </summary> + public class LocalPlugin : IEquatable<LocalPlugin> + { + /// <summary> + /// Initializes a new instance of the <see cref="LocalPlugin"/> class. + /// </summary> + /// <param name="id">The plugin id.</param> + /// <param name="name">The plugin name.</param> + /// <param name="version">The plugin version.</param> + /// <param name="path">The plugin path.</param> + public LocalPlugin(Guid id, string name, Version version, string path) + { + Id = id; + Name = name; + Version = version; + Path = path; + DllFiles = new List<string>(); + } + + /// <summary> + /// Gets the plugin id. + /// </summary> + public Guid Id { get; } + + /// <summary> + /// Gets the plugin name. + /// </summary> + public string Name { get; } + + /// <summary> + /// Gets the plugin version. + /// </summary> + public Version Version { get; } + + /// <summary> + /// Gets the plugin path. + /// </summary> + public string Path { get; } + + /// <summary> + /// Gets the list of dll files for this plugin. + /// </summary> + public List<string> DllFiles { get; } + + /// <summary> + /// == operator. + /// </summary> + /// <param name="left">Left item.</param> + /// <param name="right">Right item.</param> + /// <returns>Comparison result.</returns> + public static bool operator ==(LocalPlugin left, LocalPlugin right) + { + return left.Equals(right); + } + + /// <summary> + /// != operator. + /// </summary> + /// <param name="left">Left item.</param> + /// <param name="right">Right item.</param> + /// <returns>Comparison result.</returns> + public static bool operator !=(LocalPlugin left, LocalPlugin right) + { + return !left.Equals(right); + } + + /// <summary> + /// Compare two <see cref="LocalPlugin"/>. + /// </summary> + /// <param name="a">The first item.</param> + /// <param name="b">The second item.</param> + /// <returns>Comparison result.</returns> + public static int Compare(LocalPlugin a, LocalPlugin b) + { + var compare = string.Compare(a.Name, b.Name, true, CultureInfo.InvariantCulture); + + // Id is not equal but name is. + if (a.Id != b.Id && compare == 0) + { + compare = a.Id.CompareTo(b.Id); + } + + return compare == 0 ? a.Version.CompareTo(b.Version) : compare; + } + + /// <inheritdoc /> + public override bool Equals(object obj) + { + return obj is LocalPlugin other && this.Equals(other); + } + + /// <inheritdoc /> + public override int GetHashCode() + { + return Name.GetHashCode(StringComparison.OrdinalIgnoreCase); + } + + /// <inheritdoc /> + public bool Equals(LocalPlugin other) + { + return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase) + && Id.Equals(other.Id); + } + } +} diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs new file mode 100644 index 0000000000..67aa7f3383 --- /dev/null +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.BaseItemManager +{ + /// <inheritdoc /> + public class BaseItemManager : IBaseItemManager + { + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="BaseItemManager"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public BaseItemManager(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name) + { + if (baseItem is Channel) + { + // Hack alert. + return true; + } + + if (baseItem.SourceType == SourceType.Channel) + { + // Hack alert. + return !baseItem.EnableMediaSourceDisplay; + } + + var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); + if (typeOptions != null) + { + return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); + } + + if (!libraryOptions.EnableInternetProviders) + { + return false; + } + + var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); + + return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); + } + + /// <inheritdoc /> + public bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name) + { + if (baseItem is Channel) + { + // Hack alert. + return true; + } + + if (baseItem.SourceType == SourceType.Channel) + { + // Hack alert. + return !baseItem.EnableMediaSourceDisplay; + } + + var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); + if (typeOptions != null) + { + return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); + } + + if (!libraryOptions.EnableInternetProviders) + { + return false; + } + + var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); + + return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs new file mode 100644 index 0000000000..ee4d3dcdcc --- /dev/null +++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs @@ -0,0 +1,29 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.BaseItemManager +{ + /// <summary> + /// The <c>BaseItem</c> manager. + /// </summary> + public interface IBaseItemManager + { + /// <summary> + /// Is metadata fetcher enabled. + /// </summary> + /// <param name="baseItem">The base item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="name">The metadata fetcher name.</param> + /// <returns><c>true</c> if metadata fetcher is enabled, else false.</returns> + bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name); + + /// <summary> + /// Is image fetcher enabled. + /// </summary> + /// <param name="baseItem">The base item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <param name="name">The image fetcher name.</param> + /// <returns><c>true</c> if image fetcher is enabled, else false.</returns> + bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name); + } +} \ No newline at end of file diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index 76f20ace2a..3567837507 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using System.Linq; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -15,9 +16,9 @@ namespace MediaBrowser.Controller.Dto ItemFields.RefreshState }; - public ItemFields[] Fields { get; set; } + public IReadOnlyList<ItemFields> Fields { get; set; } - public ImageType[] ImageTypes { get; set; } + public IReadOnlyList<ImageType> ImageTypes { get; set; } public int ImageTypeLimit { get; set; } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 2fc7d45c90..1b25fbdbb7 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -87,6 +87,8 @@ namespace MediaBrowser.Controller.Entities public const string InterviewFolderName = "interviews"; public const string SceneFolderName = "scenes"; public const string SampleFolderName = "samples"; + public const string ShortsFolderName = "shorts"; + public const string FeaturettesFolderName = "featurettes"; public static readonly string[] AllExtrasTypesFolderNames = { ExtrasFolderName, @@ -94,7 +96,9 @@ namespace MediaBrowser.Controller.Entities DeletedScenesFolderName, InterviewFolderName, SceneFolderName, - SampleFolderName + SampleFolderName, + ShortsFolderName, + FeaturettesFolderName }; [JsonIgnore] @@ -459,60 +463,6 @@ namespace MediaBrowser.Controller.Entities [JsonIgnore] public string PrimaryImagePath => this.GetImagePath(ImageType.Primary); - public bool IsMetadataFetcherEnabled(LibraryOptions libraryOptions, string name) - { - if (SourceType == SourceType.Channel) - { - // hack alert - return !EnableMediaSourceDisplay; - } - - var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); - if (typeOptions != null) - { - return typeOptions.MetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - - if (!libraryOptions.EnableInternetProviders) - { - return false; - } - - var itemConfig = ConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); - - return itemConfig == null || !itemConfig.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - - public bool IsImageFetcherEnabled(LibraryOptions libraryOptions, string name) - { - if (this is Channel) - { - // hack alert - return true; - } - - if (SourceType == SourceType.Channel) - { - // hack alert - return !EnableMediaSourceDisplay; - } - - var typeOptions = libraryOptions.GetTypeOptions(GetType().Name); - if (typeOptions != null) - { - return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - - if (!libraryOptions.EnableInternetProviders) - { - return false; - } - - var itemConfig = ConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase)); - - return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase); - } - public virtual bool CanDelete() { if (SourceType == SourceType.Channel) @@ -2607,7 +2557,7 @@ namespace MediaBrowser.Controller.Entities { if (!AllowsMultipleImages(type)) { - throw new ArgumentException("The change index operation is only applicable to backdrops and screenshots"); + throw new ArgumentException("The change index operation is only applicable to backdrops and screen shots"); } var info1 = GetImageInfo(type, index1); diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index a76c8a376b..3087b1d1e7 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -212,7 +212,7 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Loads our children. Validation will occur externally. - /// We want this sychronous. + /// We want this synchronous. /// </summary> protected virtual List<BaseItem> LoadChildren() { @@ -1067,12 +1067,12 @@ namespace MediaBrowser.Controller.Entities return false; } - if (request.Genres.Length > 0) + if (request.Genres.Count > 0) { return false; } - if (request.GenreIds.Length > 0) + if (request.GenreIds.Count > 0) { return false; } @@ -1177,7 +1177,7 @@ namespace MediaBrowser.Controller.Entities return false; } - if (request.GenreIds.Length > 0) + if (request.GenreIds.Count > 0) { return false; } diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 904752a229..270217356f 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -46,7 +46,7 @@ namespace MediaBrowser.Controller.Entities public string[] ExcludeInheritedTags { get; set; } - public string[] Genres { get; set; } + public IReadOnlyList<string> Genres { get; set; } public bool? IsSpecialSeason { get; set; } @@ -116,7 +116,7 @@ namespace MediaBrowser.Controller.Entities public Guid[] StudioIds { get; set; } - public Guid[] GenreIds { get; set; } + public IReadOnlyList<Guid> GenreIds { get; set; } public ImageType[] ImageTypes { get; set; } @@ -162,7 +162,7 @@ namespace MediaBrowser.Controller.Entities public double? MinCommunityRating { get; set; } - public Guid[] ChannelIds { get; set; } + public IReadOnlyList<Guid> ChannelIds { get; set; } public int? ParentIndexNumber { get; set; } diff --git a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs index 4e09ee5736..5b96a5af65 100644 --- a/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalPeopleQuery.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System; +using Jellyfin.Data.Entities; namespace MediaBrowser.Controller.Entities { @@ -23,6 +24,10 @@ namespace MediaBrowser.Controller.Entities public string NameContains { get; set; } + public User User { get; set; } + + public bool? IsFavorite { get; set; } + public InternalPeopleQuery() { PersonTypes = Array.Empty<string>(); diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index a262fee15d..4e33a6bbde 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -791,7 +791,7 @@ namespace MediaBrowser.Controller.Entities } // Apply genre filter - if (query.Genres.Length > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase))) + if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase))) { return false; } @@ -822,7 +822,7 @@ namespace MediaBrowser.Controller.Entities } // Apply genre filter - if (query.GenreIds.Length > 0 && !query.GenreIds.Any(id => + if (query.GenreIds.Count > 0 && !query.GenreIds.Any(id => { var genreItem = libraryManager.GetItemById(id); return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase); diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs index b35f830960..6658269bdb 100644 --- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs +++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs @@ -12,6 +12,9 @@ namespace MediaBrowser.Controller /// <summary> /// Gets the display preferences for the user and client. /// </summary> + /// <remarks> + /// This will create the display preferences if it does not exist, but it will not save automatically. + /// </remarks> /// <param name="userId">The user's id.</param> /// <param name="client">The client string.</param> /// <returns>The associated display preferences.</returns> @@ -20,6 +23,9 @@ namespace MediaBrowser.Controller /// <summary> /// Gets the default item display preferences for the user and client. /// </summary> + /// <remarks> + /// This will create the item display preferences if it does not exist, but it will not save automatically. + /// </remarks> /// <param name="userId">The user id.</param> /// <param name="itemId">The item id.</param> /// <param name="client">The client string.</param> diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index 649b0eaec8..ffbb147b0d 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -6,8 +6,8 @@ using System.Net; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common; +using MediaBrowser.Common.Plugins; using MediaBrowser.Model.System; -using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller { @@ -120,5 +120,13 @@ namespace MediaBrowser.Controller string ExpandVirtualPath(string path); string ReverseVirtualPath(string path); + + /// <summary> + /// Gets the list of local plugins. + /// </summary> + /// <param name="path">Plugin base directory.</param> + /// <param name="cleanup">Cleanup old plugins.</param> + /// <returns>Enumerable of local plugins.</returns> + IEnumerable<LocalPlugin> GetLocalPlugins(string path, bool cleanup = true); } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 32703c2fd1..c7c79df76d 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -570,5 +570,7 @@ namespace MediaBrowser.Controller.Library List<MediaStream> streams, string videoPath, string[] files); + + BaseItem GetParentItem(string parentId, Guid? userId); } } diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 6c365caa46..54495c1c40 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -225,7 +225,7 @@ namespace MediaBrowser.Controller.LiveTv /// <param name="fields">The fields.</param> /// <param name="user">The user.</param> /// <returns>Task.</returns> - Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, ItemFields[] fields, User user = null); + Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null); /// <summary> /// Saves the tuner host. diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 4374317d67..9acc98dcec 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -14,8 +14,8 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.9" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="5.0.0" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> </ItemGroup> @@ -29,7 +29,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 5846a603a2..9a6f1231f1 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; @@ -23,7 +24,7 @@ namespace MediaBrowser.Controller.MediaEncoding { public class EncodingHelper { - private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + private static readonly CultureInfo _usCulture = new CultureInfo("en-US"); private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; @@ -63,7 +64,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // Only use alternative encoders for video files. // When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully - // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this. + // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this. if (state.VideoType == VideoType.VideoFile) { var hwType = encodingOptions.HardwareAccelerationType; @@ -247,7 +248,7 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } - // Seeing reported failures here, not sure yet if this is related to specfying input format + // Seeing reported failures here, not sure yet if this is related to specifying input format if (string.Equals(container, "m4v", StringComparison.OrdinalIgnoreCase)) { return null; @@ -440,6 +441,12 @@ namespace MediaBrowser.Controller.MediaEncoding return "libopus"; } + if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)) + { + // flac is experimental in mp4 muxer + return "flac -strict -2"; + } + return codec.ToLowerInvariant(); } @@ -573,7 +580,7 @@ namespace MediaBrowser.Controller.MediaEncoding /// </summary> /// <param name="stream">The stream.</param> /// <returns><c>true</c> if the specified stream is H264; otherwise, <c>false</c>.</returns> - public bool IsH264(MediaStream stream) + public static bool IsH264(MediaStream stream) { var codec = stream.Codec ?? string.Empty; @@ -581,7 +588,7 @@ namespace MediaBrowser.Controller.MediaEncoding || codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1; } - public bool IsH265(MediaStream stream) + public static bool IsH265(MediaStream stream) { var codec = stream.Codec ?? string.Empty; @@ -589,10 +596,17 @@ namespace MediaBrowser.Controller.MediaEncoding || codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1; } - // TODO This is auto inserted into the mpegts mux so it might not be needed - // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb - public string GetBitStreamArgs(MediaStream stream) + public static bool IsAAC(MediaStream stream) { + var codec = stream.Codec ?? string.Empty; + + return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1; + } + + public static string GetBitStreamArgs(MediaStream stream) + { + // TODO This is auto inserted into the mpegts mux so it might not be needed. + // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb if (IsH264(stream)) { return "-bsf:v h264_mp4toannexb"; @@ -601,12 +615,44 @@ namespace MediaBrowser.Controller.MediaEncoding { return "-bsf:v hevc_mp4toannexb"; } + else if (IsAAC(stream)) + { + // Convert adts header(mpegts) to asc header(mp4). + return "-bsf:a aac_adtstoasc"; + } else { return null; } } + public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer) + { + var bitStreamArgs = string.Empty; + var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.'); + + // Apply aac_adtstoasc bitstream filter when media source is in mpegts. + if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase) + && (string.Equals(mediaSourceContainer, "mpegts", StringComparison.OrdinalIgnoreCase) + || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase))) + { + bitStreamArgs = GetBitStreamArgs(state.AudioStream); + bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs; + } + + return bitStreamArgs; + } + + public static string GetSegmentFileExtension(string segmentContainer) + { + if (!string.IsNullOrWhiteSpace(segmentContainer)) + { + return "." + segmentContainer; + } + + return ".ts"; + } + public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec) { var bitrate = state.OutputVideoBitrate; @@ -654,16 +700,30 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - public string NormalizeTranscodingLevel(string videoCodec, string level) + public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level) { - // Clients may direct play higher than level 41, but there's no reason to transcode higher - if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel) - && requestLevel > 41 - && (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase))) + if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel)) { - return "41"; + if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)) + { + // Transcode to level 5.0 and lower for maximum compatibility. + // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it. + // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels + // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880. + if (requestLevel >= 150) + { + return "150"; + } + } + else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + // Clients may direct play higher than level 41, but there's no reason to transcode higher. + if (requestLevel >= 41) + { + return "41"; + } + } } return level; @@ -766,6 +826,72 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + public string GetHlsVideoKeyFrameArguments( + EncodingJobInfo state, + string codec, + int segmentLength, + bool isEventPlaylist, + int? startNumber) + { + var args = string.Empty; + var gopArg = string.Empty; + var keyFrameArg = string.Empty; + if (isEventPlaylist) + { + keyFrameArg = string.Format( + CultureInfo.InvariantCulture, + " -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"", + segmentLength); + } + else if (startNumber.HasValue) + { + keyFrameArg = string.Format( + CultureInfo.InvariantCulture, + " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", + startNumber.Value * segmentLength, + segmentLength); + } + + var framerate = state.VideoStream?.RealFrameRate; + if (framerate.HasValue) + { + // This is to make sure keyframe interval is limited to our segment, + // as forcing keyframes is not enough. + // Example: we encoded half of desired length, then codec detected + // scene cut and inserted a keyframe; next forced keyframe would + // be created outside of segment, which breaks seeking. + // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe. + gopArg = string.Format( + CultureInfo.InvariantCulture, + " -g:v:0 {0} -keyint_min:v:0 {0} -sc_threshold:v:0 0", + Math.Ceiling(segmentLength * framerate.Value)); + } + + // Unable to force key frames using these encoders, set key frames by GOP. + if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + args += gopArg; + } + else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) + { + args += " " + keyFrameArg; + } + else + { + args += " " + keyFrameArg + gopArg; + } + + return args; + } + /// <summary> /// Gets the video bitrate to specify on the command line. /// </summary> @@ -773,6 +899,47 @@ namespace MediaBrowser.Controller.MediaEncoding { var param = string.Empty; + if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + && !string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -pix_fmt yuv420p"; + } + + if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + var videoStream = state.VideoStream; + var isColorDepth10 = IsColorDepth10(state); + + if (isColorDepth10 + && _mediaEncoder.SupportsHwaccel("opencl") + && encodingOptions.EnableTonemapping + && !string.IsNullOrEmpty(videoStream.VideoRange) + && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) + { + param += " -pix_fmt nv12"; + } + else + { + param += " -pix_fmt yuv420p"; + } + } + + if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) + { + param += " -pix_fmt nv21"; + } + var isVc1 = state.VideoStream != null && string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase); var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase); @@ -781,11 +948,11 @@ namespace MediaBrowser.Controller.MediaEncoding { if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset)) { - param += "-preset " + encodingOptions.EncoderPreset; + param += " -preset " + encodingOptions.EncoderPreset; } else { - param += "-preset " + defaultPreset; + param += " -preset " + defaultPreset; } int encodeCrf = encodingOptions.H264Crf; @@ -809,38 +976,40 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -crf " + defaultCrf; } } - else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) // h264 (h264_qsv) + else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_qsv) { string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" }; if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase)) { - param += "-preset " + encodingOptions.EncoderPreset; + param += " -preset " + encodingOptions.EncoderPreset; } else { - param += "-preset 7"; + param += " -preset 7"; } param += " -look_ahead 0"; } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc) { + // following preset will be deprecated in ffmpeg 4.4, use p1~p7 instead. switch (encodingOptions.EncoderPreset) { case "veryslow": - param += "-preset slow"; // lossless is only supported on maxwell and newer(2014+) + param += " -preset slow"; // lossless is only supported on maxwell and newer(2014+) break; case "slow": case "slower": - param += "-preset slow"; + param += " -preset slow"; break; case "medium": - param += "-preset medium"; + param += " -preset medium"; break; case "fast": @@ -848,27 +1017,27 @@ namespace MediaBrowser.Controller.MediaEncoding case "veryfast": case "superfast": case "ultrafast": - param += "-preset fast"; + param += " -preset fast"; break; default: - param += "-preset default"; + param += " -preset default"; break; } } - else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_amf) { switch (encodingOptions.EncoderPreset) { case "veryslow": case "slow": case "slower": - param += "-quality quality"; + param += " -quality quality"; break; case "medium": - param += "-quality balanced"; + param += " -quality balanced"; break; case "fast": @@ -876,11 +1045,11 @@ namespace MediaBrowser.Controller.MediaEncoding case "veryfast": case "superfast": case "ultrafast": - param += "-quality speed"; + param += " -quality speed"; break; default: - param += "-quality speed"; + param += " -quality speed"; break; } @@ -896,6 +1065,11 @@ namespace MediaBrowser.Controller.MediaEncoding // Enhance workload when tone mapping with AMF on some APUs param += " -preanalysis true"; } + + if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) + { + param += " -header_insertion_mode gop -gops_per_idr 1"; + } } else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm { @@ -917,7 +1091,7 @@ namespace MediaBrowser.Controller.MediaEncoding profileScore = Math.Min(profileScore, 2); // http://www.webmproject.org/docs/encoder-parameters/ - param += string.Format(CultureInfo.InvariantCulture, "-speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}", + param += string.Format(CultureInfo.InvariantCulture, " -speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}", profileScore.ToString(_usCulture), crf, qmin, @@ -925,15 +1099,15 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase)) { - param += "-mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2"; + param += " -mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2"; } else if (string.Equals(videoEncoder, "wmv2", StringComparison.OrdinalIgnoreCase)) // asf/wmv { - param += "-qmin 2"; + param += " -qmin 2"; } else if (string.Equals(videoEncoder, "msmpeg4", StringComparison.OrdinalIgnoreCase)) { - param += "-mbd 2"; + param += " -mbd 2"; } param += GetVideoBitrateParam(state, videoEncoder); @@ -945,11 +1119,25 @@ namespace MediaBrowser.Controller.MediaEncoding } var targetVideoCodec = state.ActualOutputVideoCodec; + if (string.Equals(targetVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(targetVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + targetVideoCodec = "hevc"; + } var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault(); + profile = Regex.Replace(profile, @"\s+", String.Empty); - // vaapi does not support Baseline profile, force Constrained Baseline in this case, - // which is compatible (and ugly) + // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile. + if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) + && profile != null + && profile.IndexOf("high 10", StringComparison.OrdinalIgnoreCase) != -1) + { + profile = "high"; + } + + // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case, + // which is compatible (and ugly). if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) && profile != null && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1) @@ -957,13 +1145,31 @@ namespace MediaBrowser.Controller.MediaEncoding profile = "constrained_baseline"; } + // libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case. + if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)) + && profile != null + && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1) + { + profile = "baseline"; + } + + // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile. + if (!string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase) + && profile != null + && profile.IndexOf("main 10", StringComparison.OrdinalIgnoreCase) != -1) + { + profile = "main"; + } + if (!string.IsNullOrEmpty(profile)) { if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) { // not supported by h264_omx - param += " -profile:v " + profile; + param += " -profile:v:0 " + profile; } } @@ -971,55 +1177,35 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(level)) { - level = NormalizeTranscodingLevel(state.OutputVideoCodec, level); + level = NormalizeTranscodingLevel(state, level); - // h264_qsv and h264_nvenc expect levels to be expressed as a decimal. libx264 supports decimal and non-decimal format - // also needed for libx264 due to https://trac.ffmpeg.org/ticket/3307 + // libx264, QSV, AMF, VAAPI can adjust the given level to match the output. if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)) { - switch (level) + param += " -level " + level; + } + else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) + { + // hevc_qsv use -level 51 instead of -level 153. + if (double.TryParse(level, NumberStyles.Any, _usCulture, out double hevcLevel)) { - case "30": - param += " -level 3.0"; - break; - case "31": - param += " -level 3.1"; - break; - case "32": - param += " -level 3.2"; - break; - case "40": - param += " -level 4.0"; - break; - case "41": - param += " -level 4.1"; - break; - case "42": - param += " -level 4.2"; - break; - case "50": - param += " -level 5.0"; - break; - case "51": - param += " -level 5.1"; - break; - case "52": - param += " -level 5.2"; - break; - default: - param += " -level " + level; - break; + param += " -level " + hevcLevel / 3; } } - else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) { - // nvenc doesn't decode with param -level set ?! - // TODO: + param += " -level " + level; } - else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) + { + // level option may cause NVENC to fail. + // NVENC cannot adjust the given level, just throw an error. + } + else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) + || !string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) { param += " -level " + level; } @@ -1032,42 +1218,11 @@ namespace MediaBrowser.Controller.MediaEncoding if (string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase)) { - // todo - } - - if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) - && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) - { - param = "-pix_fmt yuv420p " + param; - } - - if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)) - { - var videoStream = state.VideoStream; - var isColorDepth10 = IsColorDepth10(state); - - if (isColorDepth10 - && _mediaEncoder.SupportsHwaccel("opencl") - && encodingOptions.EnableTonemapping - && !string.IsNullOrEmpty(videoStream.VideoRange) - && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase)) - { - param = "-pix_fmt nv12 " + param; - } - else - { - param = "-pix_fmt yuv420p " + param; - } - } - - if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) - { - param = "-pix_fmt nv21 " + param; + // libx265 only accept level option in -x265-params. + // level option may cause libx265 to fail. + // libx265 cannot adjust the given level, just throw an error. + // TODO: set fine tuned params. + param += " -x265-params:0 no-info=1"; } return param; @@ -1346,7 +1501,7 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) { - return .5; + return .6; } return 1; @@ -1380,36 +1535,48 @@ namespace MediaBrowser.Controller.MediaEncoding public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream) { - if (audioStream == null) - { - return null; - } - - if (request.AudioBitRate.HasValue) - { - // Don't encode any higher than this - return Math.Min(384000, request.AudioBitRate.Value); - } - - // Empty bitrate area is not allow on iOS - // Default audio bitrate to 128K if it is not being requested - // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options - return 128000; + return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream); } - public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream) + public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream) { if (audioStream == null) { return null; } - if (audioBitRate.HasValue) + if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec)) { - // Don't encode any higher than this return Math.Min(384000, audioBitRate.Value); } + if (audioBitRate.HasValue && !string.IsNullOrEmpty(audioCodec)) + { + if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + if ((audioStream.Channels ?? 0) >= 6) + { + return Math.Min(640000, audioBitRate.Value); + } + + return Math.Min(384000, audioBitRate.Value); + } + + if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + if ((audioStream.Channels ?? 0) >= 6) + { + return Math.Min(3584000, audioBitRate.Value); + } + + return Math.Min(1536000, audioBitRate.Value); + } + } + // Empty bitrate area is not allow on iOS // Default audio bitrate to 128K if it is not being requested // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options @@ -1447,7 +1614,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (filters.Count > 0) { - return "-af \"" + string.Join(",", filters) + "\""; + return " -af \"" + string.Join(",", filters) + "\""; } return string.Empty; @@ -1462,6 +1629,11 @@ namespace MediaBrowser.Controller.MediaEncoding /// <returns>System.Nullable{System.Int32}.</returns> public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec) { + if (audioStream == null) + { + return null; + } + var request = state.BaseRequest; var inputChannels = audioStream?.Channels; @@ -1484,6 +1656,11 @@ namespace MediaBrowser.Controller.MediaEncoding // libmp3lame currently only supports two channel output transcoderChannelLimit = 2; } + else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1) + { + // aac is able to handle 8ch(7.1 layout) + transcoderChannelLimit = 8; + } else { // If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels @@ -1708,7 +1885,8 @@ namespace MediaBrowser.Controller.MediaEncoding } // For QSV, feed it into hardware encoder now - if (isLinux && string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + if (isLinux && (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))) { videoSizeParam += ",hwupload=extra_hw_frames=64"; } @@ -1729,7 +1907,8 @@ namespace MediaBrowser.Controller.MediaEncoding : " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\""; // When the input may or may not be hardware VAAPI decodable - if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) { /* [base]: HW scaling video to OutputSize @@ -1741,7 +1920,8 @@ namespace MediaBrowser.Controller.MediaEncoding // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1 - && string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "libx265", StringComparison.OrdinalIgnoreCase))) { /* [base]: SW scaling video to OutputSize @@ -1750,7 +1930,8 @@ namespace MediaBrowser.Controller.MediaEncoding */ retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\""; } - else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) { /* QSV in FFMpeg can now setup hardware overlay for transcodes. @@ -1776,7 +1957,7 @@ namespace MediaBrowser.Controller.MediaEncoding videoSizeParam); } - private (int? width, int? height) GetFixedOutputSize( + public static (int? width, int? height) GetFixedOutputSize( int? videoWidth, int? videoHeight, int? requestedWidth, @@ -1836,7 +2017,9 @@ namespace MediaBrowser.Controller.MediaEncoding requestedMaxHeight); if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) && width.HasValue && height.HasValue) { @@ -1845,7 +2028,8 @@ namespace MediaBrowser.Controller.MediaEncoding // output dimensions. Output dimensions are guaranteed to be even. var outputWidth = width.Value; var outputHeight = height.Value; - var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase); + var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase); var isDeintEnabled = state.DeInterlace("h264", true) || state.DeInterlace("avc", true) || state.DeInterlace("h265", true) @@ -2107,10 +2291,13 @@ namespace MediaBrowser.Controller.MediaEncoding var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1; var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1; + var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1; var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1; + var isQsvHevcEncoder = outputVideoCodec.IndexOf("hevc_qsv", StringComparison.OrdinalIgnoreCase) != -1; var isNvdecH264Decoder = videoDecoder.IndexOf("h264_cuvid", StringComparison.OrdinalIgnoreCase) != -1; var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1; var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1; + var isLibX265Encoder = outputVideoCodec.IndexOf("libx265", StringComparison.OrdinalIgnoreCase) != -1; var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); var isColorDepth10 = IsColorDepth10(state); @@ -2185,6 +2372,7 @@ namespace MediaBrowser.Controller.MediaEncoding filters.Add("hwdownload"); if (isLibX264Encoder + || isLibX265Encoder || hasGraphicalSubs || (isNvdecHevcDecoder && isDeinterlaceHevc) || (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc)) @@ -2195,20 +2383,20 @@ namespace MediaBrowser.Controller.MediaEncoding } // When the input may or may not be hardware VAAPI decodable - if (isVaapiH264Encoder) + if (isVaapiH264Encoder || isVaapiHevcEncoder) { filters.Add("format=nv12|vaapi"); filters.Add("hwupload"); } // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context - else if (isLinux && hasGraphicalSubs && isQsvH264Encoder) + else if (isLinux && hasGraphicalSubs && (isQsvH264Encoder || isQsvHevcEncoder)) { filters.Add("hwupload=extra_hw_frames=64"); } // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first - else if (IsVaapiSupported(state) && isVaapiDecoder && isLibX264Encoder) + else if (IsVaapiSupported(state) && isVaapiDecoder && (isLibX264Encoder || isLibX265Encoder)) { var codec = videoStream.Codec.ToLowerInvariant(); @@ -2250,7 +2438,9 @@ namespace MediaBrowser.Controller.MediaEncoding // Add software deinterlace filter before scaling filter if ((isDeinterlaceH264 || isDeinterlaceHevc) && !isVaapiH264Encoder + && !isVaapiHevcEncoder && !isQsvH264Encoder + && !isQsvHevcEncoder && !isNvdecH264Decoder) { if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase)) @@ -2289,7 +2479,7 @@ namespace MediaBrowser.Controller.MediaEncoding } // Add parameters to use VAAPI with burn-in text subtitles (GH issue #642) - if (isVaapiH264Encoder) + if (isVaapiH264Encoder || isVaapiHevcEncoder) { if (hasTextSubs) { @@ -2329,7 +2519,8 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets the number of threads. /// </summary> - public int GetNumberOfThreads(EncodingJobInfo state, EncodingOptions encodingOptions, string outputVideoCodec) +#nullable enable + public static int GetNumberOfThreads(EncodingJobInfo? state, EncodingOptions encodingOptions, string? outputVideoCodec) { if (string.Equals(outputVideoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)) { @@ -2339,17 +2530,21 @@ namespace MediaBrowser.Controller.MediaEncoding return Math.Max(Environment.ProcessorCount - 1, 1); } - var threads = state.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount; + var threads = state?.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount; // Automatic - if (threads <= 0 || threads >= Environment.ProcessorCount) + if (threads <= 0) { return 0; + } + else if (threads >= Environment.ProcessorCount) + { + return Environment.ProcessorCount; } return threads; } - +#nullable disable public void TryStreamCopy(EncodingJobInfo state) { if (state.VideoStream != null && CanStreamCopyVideo(state, state.VideoStream)) @@ -2557,6 +2752,7 @@ namespace MediaBrowser.Controller.MediaEncoding public void AttachMediaSourceInfo( EncodingJobInfo state, + EncodingOptions encodingOptions, MediaSourceInfo mediaSource, string requestedUrl) { @@ -2687,11 +2883,23 @@ namespace MediaBrowser.Controller.MediaEncoding request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i)) ?? state.SupportedAudioCodecs.FirstOrDefault(); } + + var supportedVideoCodecs = state.SupportedVideoCodecs; + if (request != null && supportedVideoCodecs != null && supportedVideoCodecs.Length > 0) + { + var supportedVideoCodecsList = supportedVideoCodecs.ToList(); + + ShiftVideoCodecsIfNeeded(supportedVideoCodecsList, encodingOptions); + + state.SupportedVideoCodecs = supportedVideoCodecsList.ToArray(); + + request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); + } } private void ShiftAudioCodecsIfNeeded(List<string> audioCodecs, MediaStream audioStream) { - // Nothing to do here + // No need to shift if there is only one supported audio codec. if (audioCodecs.Count < 2) { return; @@ -2719,6 +2927,34 @@ namespace MediaBrowser.Controller.MediaEncoding } } + private void ShiftVideoCodecsIfNeeded(List<string> videoCodecs, EncodingOptions encodingOptions) + { + // Shift hevc/h265 to the end of list if hevc encoding is not allowed. + if (encodingOptions.AllowHevcEncoding) + { + return; + } + + // No need to shift if there is only one supported video codec. + if (videoCodecs.Count < 2) + { + return; + } + + var shiftVideoCodecs = new[] { "hevc", "h265" }; + if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparer.OrdinalIgnoreCase))) + { + return; + } + + while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparer.OrdinalIgnoreCase)) + { + var removed = shiftVideoCodecs[0]; + videoCodecs.RemoveAt(0); + videoCodecs.Add(removed); + } + } + private void NormalizeSubtitleEmbed(EncodingJobInfo state) { if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed) @@ -2752,7 +2988,7 @@ namespace MediaBrowser.Controller.MediaEncoding var videoType = state.MediaSource.VideoType ?? VideoType.VideoFile; // Only use alternative encoders for video files. // When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully - // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this. + // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this. if (videoType != VideoType.VideoFile) { return null; @@ -3352,7 +3588,7 @@ namespace MediaBrowser.Controller.MediaEncoding args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture); } - args += " " + GetAudioFilterParam(state, encodingOptions, false); + args += GetAudioFilterParam(state, encodingOptions, false); return args; } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs index 6e9362cd14..52794a69b3 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs @@ -409,7 +409,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // Don't exceed what the encoder supports // Seeing issues of attempting to encode to 88200 - return Math.Min(44100, BaseRequest.AudioSampleRate.Value); + return BaseRequest.AudioSampleRate.Value; } return null; @@ -593,6 +593,11 @@ namespace MediaBrowser.Controller.MediaEncoding { get { + if (VideoStream == null) + { + return null; + } + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) { return VideoStream?.Codec; @@ -606,6 +611,11 @@ namespace MediaBrowser.Controller.MediaEncoding { get { + if (AudioStream == null) + { + return null; + } + if (EncodingHelper.IsCopyCodec(OutputAudioCodec)) { return AudioStream?.Codec; diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs index ac520c5c44..cc8820f393 100644 --- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs +++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs @@ -93,7 +93,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (part.StartsWith("fps=", StringComparison.OrdinalIgnoreCase)) { - var rate = part.Split(new[] { '=' }, 2)[^1]; + var rate = part.Split('=', 2)[^1]; if (float.TryParse(rate, NumberStyles.Any, _usCulture, out var val)) { @@ -103,7 +103,7 @@ namespace MediaBrowser.Controller.MediaEncoding else if (state.RunTimeTicks.HasValue && part.StartsWith("time=", StringComparison.OrdinalIgnoreCase)) { - var time = part.Split(new[] { '=' }, 2).Last(); + var time = part.Split('=', 2)[^1]; if (TimeSpan.TryParse(time, _usCulture, out var val)) { @@ -116,7 +116,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (part.StartsWith("size=", StringComparison.OrdinalIgnoreCase)) { - var size = part.Split(new[] { '=' }, 2).Last(); + var size = part.Split('=', 2)[^1]; int? scale = null; if (size.IndexOf("kb", StringComparison.OrdinalIgnoreCase) != -1) @@ -135,7 +135,7 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (part.StartsWith("bitrate=", StringComparison.OrdinalIgnoreCase)) { - var rate = part.Split(new[] { '=' }, 2).Last(); + var rate = part.Split('=', 2)[^1]; int? scale = null; if (rate.IndexOf("kbits/s", StringComparison.OrdinalIgnoreCase) != -1) diff --git a/MediaBrowser.Controller/Net/AuthorizationInfo.cs b/MediaBrowser.Controller/Net/AuthorizationInfo.cs index 735c46ef86..0194c596f1 100644 --- a/MediaBrowser.Controller/Net/AuthorizationInfo.cs +++ b/MediaBrowser.Controller/Net/AuthorizationInfo.cs @@ -1,10 +1,11 @@ -#pragma warning disable CS1591 - using System; using Jellyfin.Data.Entities; namespace MediaBrowser.Controller.Net { + /// <summary> + /// The request authorization info. + /// </summary> public class AuthorizationInfo { /// <summary> @@ -43,6 +44,19 @@ namespace MediaBrowser.Controller.Net /// <value>The token.</value> public string Token { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the authorization is from an api key. + /// </summary> + public bool IsApiKey { get; set; } + + /// <summary> + /// Gets or sets the user making the request. + /// </summary> public User User { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the token is authenticated. + /// </summary> + public bool IsAuthenticated { get; set; } } } diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index fbf2c52131..f6c5920709 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -31,7 +31,7 @@ namespace MediaBrowser.Controller.Playlists /// <param name="itemIds">The item ids.</param> /// <param name="userId">The user identifier.</param> /// <returns>Task.</returns> - Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId); + Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId); /// <summary> /// Removes from playlist. diff --git a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs index f43d523a63..feb26bc101 100644 --- a/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs +++ b/MediaBrowser.Controller/Subtitles/ISubtitleManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -52,6 +53,14 @@ namespace MediaBrowser.Controller.Subtitles /// </summary> Task DownloadSubtitles(Video video, LibraryOptions libraryOptions, string subtitleId, CancellationToken cancellationToken); + /// <summary> + /// Upload new subtitle. + /// </summary> + /// <param name="video">The video the subtitle belongs to.</param> + /// <param name="response">The subtitle response.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task UploadSubtitle(Video video, SubtitleResponse response); + /// <summary> /// Gets the remote subtitles. /// </summary> diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs index 914db53059..84c3ed8b0b 100644 --- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs @@ -486,7 +486,7 @@ namespace MediaBrowser.LocalMetadata.Images return false; } - private FileSystemMetadata GetImage(IEnumerable<FileSystemMetadata> files, string name) + private FileSystemMetadata? GetImage(IEnumerable<FileSystemMetadata> files, string name) { return files.FirstOrDefault(i => !i.IsDirectory && string.Equals(name, _fileSystem.GetFileNameWithoutExtension(i), StringComparison.OrdinalIgnoreCase) && i.Length > 0); } diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj index 529e7065cd..3ce9ff4cc4 100644 --- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj +++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj @@ -11,7 +11,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index 4ac2498400..5d3ab30d39 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -683,7 +683,7 @@ namespace MediaBrowser.LocalMetadata.Parsers default: { string readerName = reader.Name; - if (_validProviderIds!.TryGetValue(readerName, out string providerIdValue)) + if (_validProviderIds!.TryGetValue(readerName, out string? providerIdValue)) { var id = reader.ReadElementContentAsString(); if (!string.IsNullOrWhiteSpace(id)) diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index 7a4823e1b8..396206658d 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Xml; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -127,7 +128,8 @@ namespace MediaBrowser.LocalMetadata.Savers private void SaveToFile(Stream stream, string path) { - Directory.CreateDirectory(Path.GetDirectoryName(path)); + var directory = Path.GetDirectoryName(path) ?? throw new ArgumentException($"Provided path ({path}) is not valid.", nameof(path)); + Directory.CreateDirectory(directory); // On Windows, savint the file will fail if the file is hidden or readonly FileSystem.SetAttributes(path, false, false); diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 21b5d0c5be..bc940d0b8d 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -178,7 +178,7 @@ namespace MediaBrowser.MediaEncoding.Attachments process.Start(); - var ranToCompletion = await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + var ranToCompletion = await ProcessExtensions.WaitForExitAsync(process, cancellationToken).ConfigureAwait(false); if (!ranToCompletion) { diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 3287f9814e..92f16ab95c 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -25,6 +25,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "ac3", "aac", "mp3", + "flac", "h264_qsv", "hevc_qsv", "mpeg2_qsv", @@ -71,6 +72,7 @@ namespace MediaBrowser.MediaEncoding.Encoder "libmp3lame", "libopus", "libvorbis", + "flac", "srt", "h264_amf", "hevc_amf", diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 5a3a9185d7..5f60c09ae7 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -64,6 +64,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private string _ffmpegPath = string.Empty; private string _ffprobePath; + private int threads; public MediaEncoder( ILogger<MediaEncoder> logger, @@ -129,6 +130,7 @@ namespace MediaBrowser.MediaEncoding.Encoder SetAvailableDecoders(validator.GetDecoders()); SetAvailableEncoders(validator.GetEncoders()); SetAvailableHwaccels(validator.GetHwaccels()); + threads = EncodingHelper.GetNumberOfThreads(null, _configurationManager.GetEncodingOptions(), null); } _logger.LogInformation("FFmpeg: {EncoderLocation}: {FfmpegPath}", EncoderLocation, _ffmpegPath ?? string.Empty); @@ -377,9 +379,9 @@ namespace MediaBrowser.MediaEncoding.Encoder CancellationToken cancellationToken) { var args = extractChapters - ? "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_chapters -show_format" - : "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_format"; - args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath).Trim(); + ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format" + : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format"; + args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath, threads).Trim(); var process = new Process { @@ -520,29 +522,29 @@ namespace MediaBrowser.MediaEncoding.Encoder var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".jpg"); Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath)); - // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar then scale to width 600. + // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar. // This filter chain may have adverse effects on recorded tv thumbnails if ar changes during presentation ex. commercials @ diff ar - var vf = "scale=600:trunc(600/dar/2)*2"; + var vf = string.Empty; if (threedFormat.HasValue) { switch (threedFormat.Value) { case Video3DFormat.HalfSideBySide: - vf = "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. + vf = "-vf crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1"; + // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. break; case Video3DFormat.FullSideBySide: - vf = "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // fsbs crop width in half,set the display aspect,crop out any black bars we may have made the scale width to 600. + vf = "-vf crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1"; + // fsbs crop width in half,set the display aspect,crop out any black bars we may have made break; case Video3DFormat.HalfTopAndBottom: - vf = "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600 + vf = "-vf crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1"; + // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made break; case Video3DFormat.FullTopAndBottom: - vf = "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2"; - // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made the scale width to 600 + vf = "-vf crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1"; + // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made break; default: break; @@ -555,8 +557,8 @@ namespace MediaBrowser.MediaEncoding.Encoder // Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case. var thumbnail = enableThumbnail ? ",thumbnail=24" : string.Empty; - var args = useIFrame ? string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}{4}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, thumbnail) : - string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg); + var args = useIFrame ? string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {5} -v quiet -vframes 1 {2}{4} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, thumbnail, threads) : + string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, threads); var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1); var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1); @@ -693,7 +695,7 @@ namespace MediaBrowser.MediaEncoding.Encoder Directory.CreateDirectory(targetDirectory); var outputPath = Path.Combine(targetDirectory, filenamePrefix + "%05d.jpg"); - var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads 0 -v quiet -vf \"{2}\" -f image2 \"{1}\"", inputArgument, outputPath, vf); + var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads {3} -v quiet {2} -f image2 \"{1}\"", inputArgument, outputPath, vf, threads); var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1); var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1); diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 6ead93e09b..7bb2a7d03f 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> @@ -25,7 +25,7 @@ <ItemGroup> <PackageReference Include="BDInfo" Version="0.7.6.1" /> <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.6" /> - <PackageReference Include="System.Text.Encoding.CodePages" Version="4.7.1" /> + <PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" /> <PackageReference Include="UTF.Unknown" Version="2.3.0" /> </ItemGroup> diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index cdeefbbbdf..3d3d1eb48f 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -149,7 +149,7 @@ namespace MediaBrowser.MediaEncoding.Probing var iTunEXTC = FFProbeHelpers.GetDictionaryValue(tags, "iTunEXTC"); if (!string.IsNullOrWhiteSpace(iTunEXTC)) { - var parts = iTunEXTC.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + var parts = iTunEXTC.Split('|', StringSplitOptions.RemoveEmptyEntries); // Example // mpaa|G|100|For crude humor if (parts.Length > 1) @@ -234,8 +234,8 @@ namespace MediaBrowser.MediaEncoding.Probing var channelsValue = channels.Value; - if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) || - string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) { if (channelsValue <= 2) { @@ -248,6 +248,34 @@ namespace MediaBrowser.MediaEncoding.Probing } } + if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + if (channelsValue <= 2) + { + return 192000; + } + + if (channelsValue >= 5) + { + return 640000; + } + } + + if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase)) + { + if (channelsValue <= 2) + { + return 960000; + } + + if (channelsValue >= 5) + { + return 2880000; + } + } + return null; } @@ -774,6 +802,35 @@ namespace MediaBrowser.MediaEncoding.Probing stream.BitRate = bitrate; } + // Extract bitrate info from tag "BPS" if possible. + if (!stream.BitRate.HasValue + && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase) + || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase))) + { + var bps = GetBPSFromTags(streamInfo); + if (bps != null && bps > 0) + { + stream.BitRate = bps; + } + } + + // Get average bitrate info from tag "NUMBER_OF_BYTES" and "DURATION" if possible. + if (!stream.BitRate.HasValue + && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase) + || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase))) + { + var durationInSeconds = GetRuntimeSecondsFromTags(streamInfo); + var bytes = GetNumberOfBytesFromTags(streamInfo); + if (durationInSeconds != null && bytes != null) + { + var bps = Convert.ToInt32(bytes * 8 / durationInSeconds, CultureInfo.InvariantCulture); + if (bps > 0) + { + stream.BitRate = bps; + } + } + } + var disposition = streamInfo.Disposition; if (disposition != null) { @@ -963,6 +1020,50 @@ namespace MediaBrowser.MediaEncoding.Probing } } + private int? GetBPSFromTags(MediaStreamInfo streamInfo) + { + if (streamInfo != null && streamInfo.Tags != null) + { + var bps = GetDictionaryValue(streamInfo.Tags, "BPS-eng") ?? GetDictionaryValue(streamInfo.Tags, "BPS"); + if (!string.IsNullOrEmpty(bps) + && int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps)) + { + return parsedBps; + } + } + + return null; + } + + private double? GetRuntimeSecondsFromTags(MediaStreamInfo streamInfo) + { + if (streamInfo != null && streamInfo.Tags != null) + { + var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION"); + if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration)) + { + return parsedDuration.TotalSeconds; + } + } + + return null; + } + + private long? GetNumberOfBytesFromTags(MediaStreamInfo streamInfo) + { + if (streamInfo != null && streamInfo.Tags != null) + { + var numberOfBytes = GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES-eng") ?? GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES"); + if (!string.IsNullOrEmpty(numberOfBytes) + && long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes)) + { + return parsedBytes; + } + } + + return null; + } + private void SetSize(InternalMediaInfoResult data, MediaInfo info) { if (data.Format != null) @@ -1139,7 +1240,7 @@ namespace MediaBrowser.MediaEncoding.Probing return null; } - return value.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries) + return value.Split('/', StringSplitOptions.RemoveEmptyEntries) .Select(i => i.Trim()) .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)); } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 0a9958b9eb..b61b8a0e04 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -758,9 +758,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles case MediaProtocol.Http: { using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(path, cancellationToken) + .GetAsync(new Uri(path), cancellationToken) .ConfigureAwait(false); - return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); } case MediaProtocol.File: diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs index 2cd637c5b0..100756c24f 100644 --- a/MediaBrowser.Model/Configuration/EncodingOptions.cs +++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs @@ -9,6 +9,10 @@ namespace MediaBrowser.Model.Configuration public string TranscodingTempPath { get; set; } + public string FallbackFontPath { get; set; } + + public bool EnableFallbackFont { get; set; } + public double DownMixAudioBoost { get; set; } public int MaxMuxingQueueSize { get; set; } @@ -63,12 +67,15 @@ namespace MediaBrowser.Model.Configuration public bool EnableHardwareEncoding { get; set; } + public bool AllowHevcEncoding { get; set; } + public bool EnableSubtitleExtraction { get; set; } public string[] HardwareDecodingCodecs { get; set; } public EncodingOptions() { + EnableFallbackFont = false; DownMixAudioBoost = 2; MaxMuxingQueueSize = 2048; EnableThrottling = false; @@ -94,6 +101,7 @@ namespace MediaBrowser.Model.Configuration EnableDecodingColorDepth10Hevc = true; EnableDecodingColorDepth10Vp9 = true; EnableHardwareEncoding = true; + AllowHevcEncoding = true; EnableSubtitleExtraction = true; HardwareDecodingCodecs = new string[] { "h264", "vc1" }; } diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 54ef49ea62..77ac11d69f 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -17,8 +17,6 @@ namespace MediaBrowser.Model.Configuration public bool ExtractChapterImagesDuringLibraryScan { get; set; } - public bool DownloadImagesInAdvance { get; set; } - public MediaPathInfo[] PathInfos { get; set; } public bool SaveLocalMetadata { get; set; } diff --git a/MediaBrowser.Model/Dlna/AudioOptions.cs b/MediaBrowser.Model/Dlna/AudioOptions.cs index 67e4ffe03e..bbb8bf4263 100644 --- a/MediaBrowser.Model/Dlna/AudioOptions.cs +++ b/MediaBrowser.Model/Dlna/AudioOptions.cs @@ -49,7 +49,7 @@ namespace MediaBrowser.Model.Dlna /// <summary> /// The application's configured quality setting. /// </summary> - public long? MaxBitrate { get; set; } + public int? MaxBitrate { get; set; } /// <summary> /// Gets or sets the context. @@ -67,7 +67,7 @@ namespace MediaBrowser.Model.Dlna /// Gets the maximum bitrate. /// </summary> /// <returns>System.Nullable<System.Int32>.</returns> - public long? GetMaxBitrate(bool isAudio) + public int? GetMaxBitrate(bool isAudio) { if (MaxBitrate.HasValue) { diff --git a/MediaBrowser.Model/Dlna/ContainerProfile.cs b/MediaBrowser.Model/Dlna/ContainerProfile.cs index f77d9b2675..09afa64bbd 100644 --- a/MediaBrowser.Model/Dlna/ContainerProfile.cs +++ b/MediaBrowser.Model/Dlna/ContainerProfile.cs @@ -34,7 +34,7 @@ namespace MediaBrowser.Model.Dlna return Array.Empty<string>(); } - return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + return value.Split(',', StringSplitOptions.RemoveEmptyEntries); } public bool ContainsContainer(string container) diff --git a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs index 8b73ecbd4d..50e3374f77 100644 --- a/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs +++ b/MediaBrowser.Model/Dlna/ContentFeatureBuilder.cs @@ -186,7 +186,7 @@ namespace MediaBrowser.Model.Dlna if (mediaProfile != null && !string.IsNullOrEmpty(mediaProfile.OrgPn)) { - orgPnValues.AddRange(mediaProfile.OrgPn.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + orgPnValues.AddRange(mediaProfile.OrgPn.Split(',', StringSplitOptions.RemoveEmptyEntries)); } else { diff --git a/MediaBrowser.Model/Dlna/DeviceIdentification.cs b/MediaBrowser.Model/Dlna/DeviceIdentification.cs index 43407383a8..c511801f4f 100644 --- a/MediaBrowser.Model/Dlna/DeviceIdentification.cs +++ b/MediaBrowser.Model/Dlna/DeviceIdentification.cs @@ -11,59 +11,54 @@ namespace MediaBrowser.Model.Dlna /// Gets or sets the name of the friendly. /// </summary> /// <value>The name of the friendly.</value> - public string FriendlyName { get; set; } + public string FriendlyName { get; set; } = string.Empty; /// <summary> /// Gets or sets the model number. /// </summary> /// <value>The model number.</value> - public string ModelNumber { get; set; } + public string ModelNumber { get; set; } = string.Empty; /// <summary> /// Gets or sets the serial number. /// </summary> /// <value>The serial number.</value> - public string SerialNumber { get; set; } + public string SerialNumber { get; set; } = string.Empty; /// <summary> /// Gets or sets the name of the model. /// </summary> /// <value>The name of the model.</value> - public string ModelName { get; set; } + public string ModelName { get; set; } = string.Empty; /// <summary> /// Gets or sets the model description. /// </summary> /// <value>The model description.</value> - public string ModelDescription { get; set; } + public string ModelDescription { get; set; } = string.Empty; /// <summary> /// Gets or sets the model URL. /// </summary> /// <value>The model URL.</value> - public string ModelUrl { get; set; } + public string ModelUrl { get; set; } = string.Empty; /// <summary> /// Gets or sets the manufacturer. /// </summary> /// <value>The manufacturer.</value> - public string Manufacturer { get; set; } + public string Manufacturer { get; set; } = string.Empty; /// <summary> /// Gets or sets the manufacturer URL. /// </summary> /// <value>The manufacturer URL.</value> - public string ManufacturerUrl { get; set; } + public string ManufacturerUrl { get; set; } = string.Empty; /// <summary> /// Gets or sets the headers. /// </summary> /// <value>The headers.</value> - public HttpHeaderInfo[] Headers { get; set; } - - public DeviceIdentification() - { - Headers = Array.Empty<HttpHeaderInfo>(); - } + public HttpHeaderInfo[] Headers { get; set; } = Array.Empty<HttpHeaderInfo>(); } } diff --git a/MediaBrowser.Model/Dlna/DeviceProfile.cs b/MediaBrowser.Model/Dlna/DeviceProfile.cs index 44412f3e41..ff51866587 100644 --- a/MediaBrowser.Model/Dlna/DeviceProfile.cs +++ b/MediaBrowser.Model/Dlna/DeviceProfile.cs @@ -1,6 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - +#pragma warning disable CA1819 // Properties should not return arrays using System; using System.Linq; using System.Xml.Serialization; @@ -8,107 +7,15 @@ using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.Model.Dlna { + /// <summary> + /// Defines the <see cref="DeviceProfile" />. + /// </summary> [XmlRoot("Profile")] public class DeviceProfile { /// <summary> - /// Gets or sets the name. + /// Initializes a new instance of the <see cref="DeviceProfile"/> class. /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - - [XmlIgnore] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the identification. - /// </summary> - /// <value>The identification.</value> - public DeviceIdentification Identification { get; set; } - - public string FriendlyName { get; set; } - - public string Manufacturer { get; set; } - - public string ManufacturerUrl { get; set; } - - public string ModelName { get; set; } - - public string ModelDescription { get; set; } - - public string ModelNumber { get; set; } - - public string ModelUrl { get; set; } - - public string SerialNumber { get; set; } - - public bool EnableAlbumArtInDidl { get; set; } - - public bool EnableSingleAlbumArtLimit { get; set; } - - public bool EnableSingleSubtitleLimit { get; set; } - - public string SupportedMediaTypes { get; set; } - - public string UserId { get; set; } - - public string AlbumArtPn { get; set; } - - public int MaxAlbumArtWidth { get; set; } - - public int MaxAlbumArtHeight { get; set; } - - public int? MaxIconWidth { get; set; } - - public int? MaxIconHeight { get; set; } - - public long? MaxStreamingBitrate { get; set; } - - public long? MaxStaticBitrate { get; set; } - - public int? MusicStreamingTranscodingBitrate { get; set; } - - public int? MaxStaticMusicBitrate { get; set; } - - /// <summary> - /// Controls the content of the aggregationFlags element in the urn:schemas-sonycom:av namespace. - /// </summary> - public string SonyAggregationFlags { get; set; } - - public string ProtocolInfo { get; set; } - - public int TimelineOffsetSeconds { get; set; } - - public bool RequiresPlainVideoItems { get; set; } - - public bool RequiresPlainFolders { get; set; } - - public bool EnableMSMediaReceiverRegistrar { get; set; } - - public bool IgnoreTranscodeByteRangeRequests { get; set; } - - public XmlAttribute[] XmlRootAttributes { get; set; } - - /// <summary> - /// Gets or sets the direct play profiles. - /// </summary> - /// <value>The direct play profiles.</value> - public DirectPlayProfile[] DirectPlayProfiles { get; set; } - - /// <summary> - /// Gets or sets the transcoding profiles. - /// </summary> - /// <value>The transcoding profiles.</value> - public TranscodingProfile[] TranscodingProfiles { get; set; } - - public ContainerProfile[] ContainerProfiles { get; set; } - - public CodecProfile[] CodecProfiles { get; set; } - - public ResponseProfile[] ResponseProfiles { get; set; } - - public SubtitleProfile[] SubtitleProfiles { get; set; } - public DeviceProfile() { DirectPlayProfiles = Array.Empty<DirectPlayProfile>(); @@ -126,11 +33,217 @@ namespace MediaBrowser.Model.Dlna MusicStreamingTranscodingBitrate = 128000; } + /// <summary> + /// Gets or sets the Name. + /// </summary> + public string Name { get; set; } + + /// <summary> + /// Gets or sets the Id. + /// </summary> + [XmlIgnore] + public string Id { get; set; } + + /// <summary> + /// Gets or sets the Identification. + /// </summary> + public DeviceIdentification Identification { get; set; } + + /// <summary> + /// Gets or sets the FriendlyName. + /// </summary> + public string FriendlyName { get; set; } + + /// <summary> + /// Gets or sets the Manufacturer. + /// </summary> + public string Manufacturer { get; set; } + + /// <summary> + /// Gets or sets the ManufacturerUrl. + /// </summary> + public string ManufacturerUrl { get; set; } + + /// <summary> + /// Gets or sets the ModelName. + /// </summary> + public string ModelName { get; set; } + + /// <summary> + /// Gets or sets the ModelDescription. + /// </summary> + public string ModelDescription { get; set; } + + /// <summary> + /// Gets or sets the ModelNumber. + /// </summary> + public string ModelNumber { get; set; } + + /// <summary> + /// Gets or sets the ModelUrl. + /// </summary> + public string ModelUrl { get; set; } + + /// <summary> + /// Gets or sets the SerialNumber. + /// </summary> + public string SerialNumber { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether EnableAlbumArtInDidl. + /// </summary> + public bool EnableAlbumArtInDidl { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether EnableSingleAlbumArtLimit. + /// </summary> + public bool EnableSingleAlbumArtLimit { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether EnableSingleSubtitleLimit. + /// </summary> + public bool EnableSingleSubtitleLimit { get; set; } + + /// <summary> + /// Gets or sets the SupportedMediaTypes. + /// </summary> + public string SupportedMediaTypes { get; set; } + + /// <summary> + /// Gets or sets the UserId. + /// </summary> + public string UserId { get; set; } + + /// <summary> + /// Gets or sets the AlbumArtPn. + /// </summary> + public string AlbumArtPn { get; set; } + + /// <summary> + /// Gets or sets the MaxAlbumArtWidth. + /// </summary> + public int MaxAlbumArtWidth { get; set; } + + /// <summary> + /// Gets or sets the MaxAlbumArtHeight. + /// </summary> + public int MaxAlbumArtHeight { get; set; } + + /// <summary> + /// Gets or sets the MaxIconWidth. + /// </summary> + public int? MaxIconWidth { get; set; } + + /// <summary> + /// Gets or sets the MaxIconHeight. + /// </summary> + public int? MaxIconHeight { get; set; } + + /// <summary> + /// Gets or sets the MaxStreamingBitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } + + /// <summary> + /// Gets or sets the MaxStaticBitrate. + /// </summary> + public int? MaxStaticBitrate { get; set; } + + /// <summary> + /// Gets or sets the MusicStreamingTranscodingBitrate. + /// </summary> + public int? MusicStreamingTranscodingBitrate { get; set; } + + /// <summary> + /// Gets or sets the MaxStaticMusicBitrate. + /// </summary> + public int? MaxStaticMusicBitrate { get; set; } + + /// <summary> + /// Gets or sets the content of the aggregationFlags element in the urn:schemas-sonycom:av namespace. + /// </summary> + public string SonyAggregationFlags { get; set; } + + /// <summary> + /// Gets or sets the ProtocolInfo. + /// </summary> + public string ProtocolInfo { get; set; } + + /// <summary> + /// Gets or sets the TimelineOffsetSeconds. + /// </summary> + public int TimelineOffsetSeconds { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether RequiresPlainVideoItems. + /// </summary> + public bool RequiresPlainVideoItems { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether RequiresPlainFolders. + /// </summary> + public bool RequiresPlainFolders { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether EnableMSMediaReceiverRegistrar. + /// </summary> + public bool EnableMSMediaReceiverRegistrar { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether IgnoreTranscodeByteRangeRequests. + /// </summary> + public bool IgnoreTranscodeByteRangeRequests { get; set; } + + /// <summary> + /// Gets or sets the XmlRootAttributes. + /// </summary> + public XmlAttribute[] XmlRootAttributes { get; set; } + + /// <summary> + /// Gets or sets the direct play profiles. + /// </summary> + public DirectPlayProfile[] DirectPlayProfiles { get; set; } + + /// <summary> + /// Gets or sets the transcoding profiles. + /// </summary> + public TranscodingProfile[] TranscodingProfiles { get; set; } + + /// <summary> + /// Gets or sets the ContainerProfiles. + /// </summary> + public ContainerProfile[] ContainerProfiles { get; set; } + + /// <summary> + /// Gets or sets the CodecProfiles. + /// </summary> + public CodecProfile[] CodecProfiles { get; set; } + + /// <summary> + /// Gets or sets the ResponseProfiles. + /// </summary> + public ResponseProfile[] ResponseProfiles { get; set; } + + /// <summary> + /// Gets or sets the SubtitleProfiles. + /// </summary> + public SubtitleProfile[] SubtitleProfiles { get; set; } + + /// <summary> + /// The GetSupportedMediaTypes. + /// </summary> + /// <returns>The .</returns> public string[] GetSupportedMediaTypes() { return ContainerProfile.SplitValue(SupportedMediaTypes); } + /// <summary> + /// Gets the audio transcoding profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio Codec.</param> + /// <returns>A <see cref="TranscodingProfile"/>.</returns> public TranscodingProfile GetAudioTranscodingProfile(string container, string audioCodec) { container = (container ?? string.Empty).TrimStart('.'); @@ -158,6 +271,13 @@ namespace MediaBrowser.Model.Dlna return null; } + /// <summary> + /// Gets the video transcoding profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio Codec.</param> + /// <param name="videoCodec">The video Codec.</param> + /// <returns>The <see cref="TranscodingProfile"/>.</returns> public TranscodingProfile GetVideoTranscodingProfile(string container, string audioCodec, string videoCodec) { container = (container ?? string.Empty).TrimStart('.'); @@ -190,6 +310,16 @@ namespace MediaBrowser.Model.Dlna return null; } + /// <summary> + /// Gets the audio media profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio codec.</param> + /// <param name="audioChannels">The audio channels.</param> + /// <param name="audioBitrate">The audio bitrate.</param> + /// <param name="audioSampleRate">The audio sample rate.</param> + /// <param name="audioBitDepth">The audio bit depth.</param> + /// <returns>The <see cref="ResponseProfile"/>.</returns> public ResponseProfile GetAudioMediaProfile(string container, string audioCodec, int? audioChannels, int? audioBitrate, int? audioSampleRate, int? audioBitDepth) { foreach (var i in ResponseProfiles) @@ -231,6 +361,11 @@ namespace MediaBrowser.Model.Dlna return null; } + /// <summary> + /// Gets the model profile condition. + /// </summary> + /// <param name="c">The c<see cref="ProfileCondition"/>.</param> + /// <returns>The <see cref="ProfileCondition"/>.</returns> private ProfileCondition GetModelProfileCondition(ProfileCondition c) { return new ProfileCondition @@ -242,6 +377,13 @@ namespace MediaBrowser.Model.Dlna }; } + /// <summary> + /// Gets the image media profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="width">The width.</param> + /// <param name="height">The height.</param> + /// <returns>The <see cref="ResponseProfile"/>.</returns> public ResponseProfile GetImageMediaProfile(string container, int? width, int? height) { foreach (var i in ResponseProfiles) @@ -277,6 +419,29 @@ namespace MediaBrowser.Model.Dlna return null; } + /// <summary> + /// Gets the video media profile. + /// </summary> + /// <param name="container">The container.</param> + /// <param name="audioCodec">The audio codec.</param> + /// <param name="videoCodec">The video codec.</param> + /// <param name="width">The width.</param> + /// <param name="height">The height.</param> + /// <param name="bitDepth">The bit depth.</param> + /// <param name="videoBitrate">The video bitrate.</param> + /// <param name="videoProfile">The video profile.</param> + /// <param name="videoLevel">The video level.</param> + /// <param name="videoFramerate">The video framerate.</param> + /// <param name="packetLength">The packet length.</param> + /// <param name="timestamp">The timestamp<see cref="TransportStreamTimestamp"/>.</param> + /// <param name="isAnamorphic">True if anamorphic.</param> + /// <param name="isInterlaced">True if interlaced.</param> + /// <param name="refFrames">The ref frames.</param> + /// <param name="numVideoStreams">The number of video streams.</param> + /// <param name="numAudioStreams">The number of audio streams.</param> + /// <param name="videoCodecTag">The video Codec tag.</param> + /// <param name="isAvc">True if Avc.</param> + /// <returns>The <see cref="ResponseProfile"/>.</returns> public ResponseProfile GetVideoMediaProfile( string container, string audioCodec, diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs index a4305c8104..65fccbdd41 100644 --- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs +++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs @@ -15,7 +15,7 @@ namespace MediaBrowser.Model.Dlna new ResolutionConfiguration(720, 950000), new ResolutionConfiguration(1280, 2500000), new ResolutionConfiguration(1920, 4000000), - new ResolutionConfiguration(2560, 8000000), + new ResolutionConfiguration(2560, 20000000), new ResolutionConfiguration(3840, 35000000) }; @@ -29,7 +29,7 @@ namespace MediaBrowser.Model.Dlna int? maxWidth, int? maxHeight) { - // If the bitrate isn't changing, then don't downlscale the resolution + // If the bitrate isn't changing, then don't downscale the resolution if (inputBitrate.HasValue && outputBitrate >= inputBitrate.Value) { if (maxWidth.HasValue || maxHeight.HasValue) @@ -80,11 +80,11 @@ namespace MediaBrowser.Model.Dlna private static double GetVideoBitrateScaleFactor(string codec) { - if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || - string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) || - string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) { - return .5; + return .6; } return 1; diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 4959a9b922..59c9810009 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -872,11 +872,34 @@ namespace MediaBrowser.Model.Dlna return playlistItem; } - private static int GetDefaultAudioBitrateIfUnknown(MediaStream audioStream) + private static int GetDefaultAudioBitrate(string audioCodec, int? audioChannels) { - if ((audioStream.Channels ?? 0) >= 6) + if (!string.IsNullOrEmpty(audioCodec)) { - return 384000; + // Default to a higher bitrate for stream copy + if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + if ((audioChannels ?? 0) < 2) + { + return 128000; + } + + return (audioChannels ?? 0) >= 6 ? 640000 : 384000; + } + + if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + if ((audioChannels ?? 0) < 2) + { + return 768000; + } + + return (audioChannels ?? 0) >= 6 ? 3584000 : 1536000; + } } return 192000; @@ -897,14 +920,27 @@ namespace MediaBrowser.Model.Dlna } else { - if (targetAudioChannels.HasValue && audioStream.Channels.HasValue && targetAudioChannels.Value < audioStream.Channels.Value) + if (targetAudioChannels.HasValue + && audioStream.Channels.HasValue + && audioStream.Channels.Value > targetAudioChannels.Value) { - // Reduce the bitrate if we're downmixing - defaultBitrate = targetAudioChannels.Value < 2 ? 128000 : 192000; + // Reduce the bitrate if we're downmixing. + defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels); + } + else if (targetAudioChannels.HasValue + && audioStream.Channels.HasValue + && audioStream.Channels.Value <= targetAudioChannels.Value + && !string.IsNullOrEmpty(audioStream.Codec) + && targetAudioCodecs != null + && targetAudioCodecs.Length > 0 + && !Array.Exists(targetAudioCodecs, elem => string.Equals(audioStream.Codec, elem, StringComparison.OrdinalIgnoreCase))) + { + // Shift the bitrate if we're transcoding to a different audio codec. + defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, audioStream.Channels.Value); } else { - defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrateIfUnknown(audioStream); + defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels); } // Seeing webm encoding failures when source has 1 audio channel and 22k bitrate. @@ -938,8 +974,28 @@ namespace MediaBrowser.Model.Dlna { return 448000; } + else if (totalBitrate <= 4000000) + { + return 640000; + } + else if (totalBitrate <= 5000000) + { + return 768000; + } + else if (totalBitrate <= 10000000) + { + return 1536000; + } + else if (totalBitrate <= 15000000) + { + return 2304000; + } + else if (totalBitrate <= 20000000) + { + return 3584000; + } - return 640000; + return 7168000; } private (PlayMethod?, List<TranscodeReason>) GetVideoDirectPlayProfile( @@ -1438,6 +1494,32 @@ namespace MediaBrowser.Model.Dlna break; } + case ProfileConditionValue.AudioSampleRate: + { + if (!enableNonQualifiedConditions) + { + continue; + } + + if (int.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var num)) + { + if (condition.Condition == ProfileConditionType.Equals) + { + item.AudioSampleRate = num; + } + else if (condition.Condition == ProfileConditionType.LessThanEqual) + { + item.AudioSampleRate = Math.Min(num, item.AudioSampleRate ?? num); + } + else if (condition.Condition == ProfileConditionType.GreaterThanEqual) + { + item.AudioSampleRate = Math.Max(num, item.AudioSampleRate ?? num); + } + } + + break; + } + case ProfileConditionValue.AudioChannels: { if (string.IsNullOrEmpty(qualifier)) @@ -1647,7 +1729,7 @@ namespace MediaBrowser.Model.Dlna // strip spaces to avoid having to encode var values = value - .Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + .Split('|', StringSplitOptions.RemoveEmptyEntries); if (condition.Condition == ProfileConditionType.Equals || condition.Condition == ProfileConditionType.EqualsAny) { diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 9399d21f16..55b12ae810 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -110,6 +110,8 @@ namespace MediaBrowser.Model.Dlna public int? AudioBitrate { get; set; } + public int? AudioSampleRate { get; set; } + public int? VideoBitrate { get; set; } public int? MaxWidth { get; set; } @@ -183,8 +185,10 @@ namespace MediaBrowser.Model.Dlna continue; } + // Be careful, IsDirectStream==true by default (Static != false or not in query). + // See initialization of StreamingRequestDto in AudioController.GetAudioStream() method : Static = @static ?? true. if (string.Equals(pair.Name, "Static", StringComparison.OrdinalIgnoreCase) && - string.Equals(pair.Value, "false", StringComparison.OrdinalIgnoreCase)) + string.Equals(pair.Value, "true", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -250,6 +254,7 @@ namespace MediaBrowser.Model.Dlna list.Add(new NameValuePair("SubtitleStreamIndex", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleStreamIndex.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); list.Add(new NameValuePair("VideoBitrate", item.VideoBitrate.HasValue ? item.VideoBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); list.Add(new NameValuePair("AudioBitrate", item.AudioBitrate.HasValue ? item.AudioBitrate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); + list.Add(new NameValuePair("AudioSampleRate", item.AudioSampleRate.HasValue ? item.AudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); list.Add(new NameValuePair("MaxFramerate", item.MaxFramerate.HasValue ? item.MaxFramerate.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); list.Add(new NameValuePair("MaxWidth", item.MaxWidth.HasValue ? item.MaxWidth.Value.ToString(CultureInfo.InvariantCulture) : string.Empty)); @@ -521,7 +526,9 @@ namespace MediaBrowser.Model.Dlna get { var stream = TargetAudioStream; - return stream == null ? null : stream.SampleRate; + return AudioSampleRate.HasValue && !IsDirectStream + ? AudioSampleRate + : stream == null ? null : stream.SampleRate; } } @@ -787,7 +794,7 @@ namespace MediaBrowser.Model.Dlna public int? GetTargetAudioChannels(string codec) { - var defaultValue = GlobalMaxAudioChannels; + var defaultValue = GlobalMaxAudioChannels ?? TranscodingMaxAudioChannels; var value = GetOption(codec, "audiochannels"); if (string.IsNullOrEmpty(value)) diff --git a/MediaBrowser.Model/Dlna/XmlAttribute.cs b/MediaBrowser.Model/Dlna/XmlAttribute.cs index 3a8939a797..03bb2e4b11 100644 --- a/MediaBrowser.Model/Dlna/XmlAttribute.cs +++ b/MediaBrowser.Model/Dlna/XmlAttribute.cs @@ -5,11 +5,20 @@ using System.Xml.Serialization; namespace MediaBrowser.Model.Dlna { + /// <summary> + /// Defines the <see cref="XmlAttribute" />. + /// </summary> public class XmlAttribute { + /// <summary> + /// Gets or sets the name of the attribute. + /// </summary> [XmlAttribute("name")] public string Name { get; set; } + /// <summary> + /// Gets or sets the value of the attribute. + /// </summary> [XmlAttribute("value")] public string Value { get; set; } } diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index fa3c9aaa2f..ca0b93c30f 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -191,6 +191,11 @@ namespace MediaBrowser.Model.Entities attributes.Add(Codec.ToUpperInvariant()); } + if (!string.IsNullOrEmpty(VideoRange)) + { + attributes.Add(VideoRange.ToUpperInvariant()); + } + if (!string.IsNullOrEmpty(Title)) { var result = new StringBuilder(Title); diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 9c11fe0ad2..1782b42e21 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -48,7 +48,7 @@ namespace MediaBrowser.Model.Entities return null; } - instance.ProviderIds.TryGetValue(name, out string id); + instance.ProviderIds.TryGetValue(name, out string? id); return id; } diff --git a/MediaBrowser.Model/Extensions/StringHelper.cs b/MediaBrowser.Model/Extensions/StringHelper.cs index 8ffa3c4ba6..2d9a6c4dbc 100644 --- a/MediaBrowser.Model/Extensions/StringHelper.cs +++ b/MediaBrowser.Model/Extensions/StringHelper.cs @@ -22,11 +22,6 @@ namespace MediaBrowser.Model.Extensions return str; } -#if NETSTANDARD2_0 - char[] a = str.ToCharArray(); - a[0] = char.ToUpperInvariant(a[0]); - return new string(a); -#else return string.Create( str.Length, str, @@ -38,7 +33,6 @@ namespace MediaBrowser.Model.Extensions chars[i] = buf[i]; } }); -#endif } } } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 253ee7e795..b86187f9be 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -14,7 +14,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release' ">true</TreatWarningsAsErrors> @@ -32,11 +32,11 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/> + <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" /> <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" /> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="3.1.9" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" /> <PackageReference Include="System.Globalization" Version="4.3.0" /> - <PackageReference Include="System.Text.Json" Version="5.0.0-preview.8.20407.11" /> + <PackageReference Include="System.Text.Json" Version="5.0.0" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs index 83bda5d569..36a2407067 100644 --- a/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs +++ b/MediaBrowser.Model/MediaInfo/LiveStreamRequest.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using MediaBrowser.Model.Dlna; namespace MediaBrowser.Model.MediaInfo @@ -37,7 +38,7 @@ namespace MediaBrowser.Model.MediaInfo public string PlaySessionId { get; set; } - public long? MaxStreamingBitrate { get; set; } + public int? MaxStreamingBitrate { get; set; } public long? StartTimeTicks { get; set; } @@ -55,6 +56,6 @@ namespace MediaBrowser.Model.MediaInfo public bool EnableDirectStream { get; set; } - public MediaProtocol[] DirectPlayProtocols { get; set; } + public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } } } diff --git a/MediaBrowser.Model/Net/HttpException.cs b/MediaBrowser.Model/Net/HttpException.cs deleted file mode 100644 index 48ff5d51cc..0000000000 --- a/MediaBrowser.Model/Net/HttpException.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Net; - -namespace MediaBrowser.Model.Net -{ - /// <summary> - /// Class HttpException. - /// </summary> - public class HttpException : Exception - { - /// <summary> - /// Gets or sets the status code. - /// </summary> - /// <value>The status code.</value> - public HttpStatusCode? StatusCode { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether this instance is timed out. - /// </summary> - /// <value><c>true</c> if this instance is timed out; otherwise, <c>false</c>.</value> - public bool IsTimedOut { get; set; } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpException" /> class. - /// </summary> - /// <param name="message">The message.</param> - /// <param name="innerException">The inner exception.</param> - public HttpException(string message, Exception innerException) - : base(message, innerException) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpException" /> class. - /// </summary> - /// <param name="message">The message.</param> - public HttpException(string message) - : base(message) - { - } - } -} diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs index afe7351d32..902db1e9e5 100644 --- a/MediaBrowser.Model/Net/MimeTypes.cs +++ b/MediaBrowser.Model/Net/MimeTypes.cs @@ -177,7 +177,7 @@ namespace MediaBrowser.Model.Net var ext = Path.GetExtension(path); - if (_mimeTypeLookup.TryGetValue(ext, out string result)) + if (_mimeTypeLookup.TryGetValue(ext, out string? result)) { return result; } @@ -210,9 +210,9 @@ namespace MediaBrowser.Model.Net return enableStreamDefault ? "application/octet-stream" : null; } - public static string? ToExtension(string mimeType) + public static string? ToExtension(string? mimeType) { - if (mimeType.Length == 0) + if (string.IsNullOrEmpty(mimeType)) { throw new ArgumentException("String can't be empty.", nameof(mimeType)); } @@ -220,7 +220,7 @@ namespace MediaBrowser.Model.Net // handle text/html; charset=UTF-8 mimeType = mimeType.Split(';')[0]; - if (_extensionLookup.TryGetValue(mimeType, out string result)) + if (_extensionLookup.TryGetValue(mimeType, out string? result)) { return result; } diff --git a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs index ef435b21ec..e8ee494034 100644 --- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs +++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; namespace MediaBrowser.Model.Playlists { @@ -9,15 +10,10 @@ namespace MediaBrowser.Model.Playlists { public string Name { get; set; } - public Guid[] ItemIdList { get; set; } + public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>(); public string MediaType { get; set; } public Guid UserId { get; set; } - - public PlaylistCreationRequest() - { - ItemIdList = Array.Empty<Guid>(); - } } } diff --git a/MediaBrowser.Model/Subtitles/FontFile.cs b/MediaBrowser.Model/Subtitles/FontFile.cs new file mode 100644 index 0000000000..115c492957 --- /dev/null +++ b/MediaBrowser.Model/Subtitles/FontFile.cs @@ -0,0 +1,34 @@ +using System; + +namespace MediaBrowser.Model.Subtitles +{ + /// <summary> + /// Class FontFile. + /// </summary> + public class FontFile + { + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the size. + /// </summary> + /// <value>The size.</value> + public long Size { get; set; } + + /// <summary> + /// Gets or sets the date created. + /// </summary> + /// <value>The date created.</value> + public DateTime DateCreated { get; set; } + + /// <summary> + /// Gets or sets the date modified. + /// </summary> + /// <value>The date modified.</value> + public DateTime DateModified { get; set; } + } +} diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index d0bdbd7c95..ffc6889fa2 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -468,7 +469,7 @@ namespace MediaBrowser.Providers.Manager try { using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await _providerManager.SaveImage( item, @@ -481,7 +482,7 @@ namespace MediaBrowser.Providers.Manager result.UpdateType |= ItemUpdateType.ImageUpdate; return true; } - catch (HttpException ex) + catch (HttpRequestException ex) { // Sometimes providers send back bad url's. Just move to the next image if (ex.StatusCode.HasValue @@ -517,13 +518,8 @@ namespace MediaBrowser.Providers.Manager return true; } } - - if (libraryOptions.DownloadImagesInAdvance) - { - return false; - } - - return true; + // We always want to use prefetched images + return false; } private void SaveImageStub(BaseItem item, ImageType imageType, IEnumerable<string> urls) @@ -590,7 +586,7 @@ namespace MediaBrowser.Providers.Manager } } - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await _providerManager.SaveImage( item, stream, @@ -600,7 +596,7 @@ namespace MediaBrowser.Providers.Manager cancellationToken).ConfigureAwait(false); result.UpdateType = result.UpdateType | ItemUpdateType.ImageUpdate; } - catch (HttpException ex) + catch (HttpRequestException ex) { // Sometimes providers send back bad urls. Just move onto the next image if (ex.StatusCode.HasValue diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index f110eafa5a..dca8acb7d8 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -252,7 +252,13 @@ namespace MediaBrowser.Providers.Manager if (!string.IsNullOrWhiteSpace(person.ImageUrl) && !personEntity.HasImage(ImageType.Primary)) { - await AddPersonImageAsync(personEntity, libraryOptions, person.ImageUrl, cancellationToken).ConfigureAwait(false); + personEntity.SetImage( + new ItemImageInfo + { + Path = person.ImageUrl, + Type = ImageType.Primary + }, + 0); saveEntity = true; updateType |= ItemUpdateType.ImageUpdate; @@ -266,30 +272,6 @@ namespace MediaBrowser.Providers.Manager } } - private async Task AddPersonImageAsync(Person personEntity, LibraryOptions libraryOptions, string imageUrl, CancellationToken cancellationToken) - { - if (libraryOptions.DownloadImagesInAdvance) - { - try - { - await ProviderManager.SaveImage(personEntity, imageUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false); - return; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error in AddPersonImage"); - } - } - - personEntity.SetImage( - new ItemImageInfo - { - Path = imageUrl, - Type = ImageType.Primary - }, - 0); - } - protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken) { item.AfterMetadataRefresh(); diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index a0c7d4ad09..e7e44876db 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -13,6 +13,7 @@ using Jellyfin.Data.Events; using MediaBrowser.Common.Net; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; +using MediaBrowser.Controller.BaseItemManager; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -51,6 +52,7 @@ namespace MediaBrowser.Providers.Manager private readonly ILibraryManager _libraryManager; private readonly ISubtitleManager _subtitleManager; private readonly IServerConfigurationManager _configurationManager; + private readonly IBaseItemManager _baseItemManager; private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new ConcurrentDictionary<Guid, double>(); private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue = @@ -74,6 +76,7 @@ namespace MediaBrowser.Providers.Manager /// <param name="fileSystem">The filesystem.</param> /// <param name="appPaths">The server application paths.</param> /// <param name="libraryManager">The library manager.</param> + /// <param name="baseItemManager">The BaseItem manager.</param> public ProviderManager( IHttpClientFactory httpClientFactory, ISubtitleManager subtitleManager, @@ -82,7 +85,8 @@ namespace MediaBrowser.Providers.Manager ILogger<ProviderManager> logger, IFileSystem fileSystem, IServerApplicationPaths appPaths, - ILibraryManager libraryManager) + ILibraryManager libraryManager, + IBaseItemManager baseItemManager) { _logger = logger; _httpClientFactory = httpClientFactory; @@ -92,6 +96,7 @@ namespace MediaBrowser.Providers.Manager _appPaths = appPaths; _libraryManager = libraryManager; _subtitleManager = subtitleManager; + _baseItemManager = baseItemManager; } /// <inheritdoc/> @@ -160,10 +165,7 @@ namespace MediaBrowser.Providers.Manager if (response.StatusCode != HttpStatusCode.OK) { - throw new HttpException("Invalid image received.") - { - StatusCode = response.StatusCode - }; + throw new HttpRequestException("Invalid image received.", null, response.StatusCode); } var contentType = response.Content.Headers.ContentType.MediaType; @@ -181,13 +183,10 @@ namespace MediaBrowser.Providers.Manager // thetvdb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons... if (contentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase)) { - throw new HttpException("Invalid image received.") - { - StatusCode = HttpStatusCode.NotFound - }; + throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound); } - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await SaveImage( item, stream, @@ -398,7 +397,7 @@ namespace MediaBrowser.Providers.Manager if (provider is IRemoteMetadataProvider) { - if (!forceEnableInternetMetadata && !item.IsMetadataFetcherEnabled(libraryOptions, provider.Name)) + if (!forceEnableInternetMetadata && !_baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, provider.Name)) { return false; } @@ -442,7 +441,7 @@ namespace MediaBrowser.Providers.Manager if (provider is IRemoteImageProvider || provider is IDynamicImageProvider) { - if (!item.IsImageFetcherEnabled(libraryOptions, provider.Name)) + if (!_baseItemManager.IsImageFetcherEnabled(item, libraryOptions, provider.Name)) { return false; } diff --git a/MediaBrowser.Providers/Manager/ProviderUtils.cs b/MediaBrowser.Providers/Manager/ProviderUtils.cs index 70a5a6ac13..5621d2b86c 100644 --- a/MediaBrowser.Providers/Manager/ProviderUtils.cs +++ b/MediaBrowser.Providers/Manager/ProviderUtils.cs @@ -38,7 +38,7 @@ namespace MediaBrowser.Providers.Manager { if (replaceData || string.IsNullOrEmpty(target.Name)) { - // Safeguard against incoming data having an emtpy name + // Safeguard against incoming data having an empty name if (!string.IsNullOrWhiteSpace(source.Name)) { target.Name = source.Name; @@ -48,7 +48,7 @@ namespace MediaBrowser.Providers.Manager if (replaceData || string.IsNullOrEmpty(target.OriginalTitle)) { - // Safeguard against incoming data having an emtpy name + // Safeguard against incoming data having an empty name if (!string.IsNullOrWhiteSpace(source.OriginalTitle)) { target.OriginalTitle = source.OriginalTitle; diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 9465fe42c2..accdea36e4 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -16,17 +16,17 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.9" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="OptimizedPriorityQueue" Version="5.0.0" /> - <PackageReference Include="PlaylistsNET" Version="1.1.2" /> + <PackageReference Include="PlaylistsNET" Version="1.1.3" /> <PackageReference Include="TMDbLib" Version="1.7.3-alpha" /> <PackageReference Include="TvDbSharper" Version="3.2.2" /> </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors Condition=" '$(Configuration)' == 'Release'">true</TreatWarningsAsErrors> diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs index e6d89e6880..6536b303fe 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs @@ -175,7 +175,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb Directory.CreateDirectory(Path.GetDirectoryName(path)); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs index 72dad8a25a..85c92fa7b9 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs @@ -156,7 +156,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); Directory.CreateDirectory(Path.GetDirectoryName(path)); diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs index f27da7ce61..dc755b600b 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs @@ -38,7 +38,7 @@ namespace MediaBrowser.Providers.Music var url = "/ws/2/artist/?query=arid:{0}" + musicBrainzId.ToString(CultureInfo.InvariantCulture); using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return GetResultsFromResponse(stream); } else @@ -49,7 +49,7 @@ namespace MediaBrowser.Providers.Music var url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=\"{0}\"&dismax=true", UrlEncode(nameToSearch)); using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) - await using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + await using (var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) { var results = GetResultsFromResponse(stream).ToList(); @@ -65,7 +65,7 @@ namespace MediaBrowser.Providers.Music url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=artistaccent:\"{0}\"", UrlEncode(nameToSearch)); using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return GetResultsFromResponse(stream); } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 31f0123dcf..93178d64a8 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -125,7 +125,7 @@ namespace MediaBrowser.Providers.Music if (!string.IsNullOrWhiteSpace(url)) { using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return GetResultsFromResponse(stream); } @@ -284,7 +284,7 @@ namespace MediaBrowser.Providers.Music artistId); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings { @@ -307,7 +307,7 @@ namespace MediaBrowser.Providers.Music WebUtility.UrlEncode(artistName)); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings() { @@ -622,7 +622,7 @@ namespace MediaBrowser.Providers.Music var url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings { @@ -649,7 +649,7 @@ namespace MediaBrowser.Providers.Music var url = "/ws/2/release-group/?query=reid:" + releaseEntryId.ToString(CultureInfo.InvariantCulture); using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var oReader = new StreamReader(stream, Encoding.UTF8); var settings = new XmlReaderSettings { diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index 705359d2c7..43d8af75f0 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -133,7 +133,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var url = OmdbProvider.GetOmdbUrl(urlQuery); using var response = await OmdbProvider.GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var resultList = new List<SearchResult>(); if (isSearch) diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 32dab60a6b..332479ff8a 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -50,7 +50,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = await GetRootObject(imdbId, cancellationToken).ConfigureAwait(false); - // Only take the name and rating if the user's language is set to english, since Omdb has no localization + // Only take the name and rating if the user's language is set to English, since Omdb has no localization if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) { item.Name = result.Title; @@ -151,7 +151,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb return false; } - // Only take the name and rating if the user's language is set to english, since Omdb has no localization + // Only take the name and rating if the user's language is set to English, since Omdb has no localization if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport) { item.Name = result.Title; @@ -298,7 +298,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb imdbParam)); using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<RootObject>(stream).ConfigureAwait(false); Directory.CreateDirectory(Path.GetDirectoryName(path)); _jsonSerializer.SerializeToFile(rootObject, path); @@ -336,7 +336,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb seasonId)); using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<SeasonRootObject>(stream).ConfigureAwait(false); Directory.CreateDirectory(Path.GetDirectoryName(path)); _jsonSerializer.SerializeToFile(rootObject, path); @@ -385,13 +385,13 @@ namespace MediaBrowser.Providers.Plugins.Omdb var isConfiguredForEnglish = IsConfiguredForEnglish(item) || _configurationManager.Configuration.EnableNewOmdbSupport; // Grab series genres because IMDb data is better than TVDB. Leave movies alone - // But only do it if english is the preferred language because this data will not be localized + // But only do it if English is the preferred language because this data will not be localized if (isConfiguredForEnglish && !string.IsNullOrWhiteSpace(result.Genre)) { item.Genres = Array.Empty<string>(); foreach (var genre in result.Genre - .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(i => i.Trim()) .Where(i => !string.IsNullOrWhiteSpace(i))) { @@ -401,7 +401,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb if (isConfiguredForEnglish) { - // Omdb is currently english only, so for other languages skip this and let secondary providers fill it in + // Omdb is currently English only, so for other languages skip this and let secondary providers fill it in item.Overview = result.Plot; } @@ -455,7 +455,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb { var lang = item.GetPreferredMetadataLanguage(); - // The data isn't localized and so can only be used for english users + // The data isn't localized and so can only be used for English users return string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase); } diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs deleted file mode 100644 index 690a52c4d3..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs +++ /dev/null @@ -1,10 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Model.Plugins; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class PluginConfiguration : BasePluginConfiguration - { - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs b/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs deleted file mode 100644 index e7079ed3cd..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs +++ /dev/null @@ -1,29 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Model.Serialization; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class Plugin : BasePlugin<PluginConfiguration> - { - public static Plugin Instance { get; private set; } - - public override Guid Id => new Guid("a677c0da-fac5-4cde-941a-7134223f14c8"); - - public override string Name => "TheTVDB"; - - public override string Description => "Get metadata for movies and other video content from TheTVDB."; - - // TODO remove when plugin removed from server. - public override string ConfigurationFileName => "Jellyfin.Plugin.TheTvdb.xml"; - - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs deleted file mode 100644 index ce0dab701d..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs +++ /dev/null @@ -1,289 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Caching.Memory; -using TvDbSharper; -using TvDbSharper.Dto; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbClientManager - { - private const string DefaultLanguage = "en"; - - private readonly IMemoryCache _cache; - private readonly TvDbClient _tvDbClient; - private DateTime _tokenCreatedAt; - - public TvdbClientManager(IMemoryCache memoryCache) - { - _cache = memoryCache; - _tvDbClient = new TvDbClient(); - } - - private TvDbClient TvDbClient - { - get - { - if (string.IsNullOrEmpty(_tvDbClient.Authentication.Token)) - { - _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult(); - _tokenCreatedAt = DateTime.Now; - } - - // Refresh if necessary - if (_tokenCreatedAt < DateTime.Now.Subtract(TimeSpan.FromHours(20))) - { - try - { - _tvDbClient.Authentication.RefreshTokenAsync().GetAwaiter().GetResult(); - } - catch - { - _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult(); - } - - _tokenCreatedAt = DateTime.Now; - } - - return _tvDbClient; - } - } - - public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByNameAsync(string name, string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", name, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByNameAsync(name, cancellationToken)); - } - - public Task<TvDbResponse<Series>> GetSeriesByIdAsync(int tvdbId, string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", tvdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetAsync(tvdbId, cancellationToken)); - } - - public Task<TvDbResponse<EpisodeRecord>> GetEpisodesAsync(int episodeTvdbId, string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("episode", episodeTvdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Episodes.GetAsync(episodeTvdbId, cancellationToken)); - } - - public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByImdbIdAsync( - string imdbId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", imdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByImdbIdAsync(imdbId, cancellationToken)); - } - - public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByZap2ItIdAsync( - string zap2ItId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("series", zap2ItId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByZap2ItIdAsync(zap2ItId, cancellationToken)); - } - - public Task<TvDbResponse<Actor[]>> GetActorsAsync( - int tvdbId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("actors", tvdbId, language); - return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetActorsAsync(tvdbId, cancellationToken)); - } - - public Task<TvDbResponse<Image[]>> GetImagesAsync( - int tvdbId, - ImagesQuery imageQuery, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("images", tvdbId, language, imageQuery); - return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesAsync(tvdbId, imageQuery, cancellationToken)); - } - - public Task<TvDbResponse<Language[]>> GetLanguagesAsync(CancellationToken cancellationToken) - { - return TryGetValue("languages", null, () => TvDbClient.Languages.GetAllAsync(cancellationToken)); - } - - public Task<TvDbResponse<EpisodesSummary>> GetSeriesEpisodeSummaryAsync( - int tvdbId, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey("seriesepisodesummary", tvdbId, language); - return TryGetValue(cacheKey, language, - () => TvDbClient.Series.GetEpisodesSummaryAsync(tvdbId, cancellationToken)); - } - - public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync( - int tvdbId, - int page, - EpisodeQuery episodeQuery, - string language, - CancellationToken cancellationToken) - { - var cacheKey = GenerateKey(language, tvdbId, episodeQuery); - - return TryGetValue(cacheKey, language, - () => TvDbClient.Series.GetEpisodesAsync(tvdbId, page, episodeQuery, cancellationToken)); - } - - public Task<string> GetEpisodeTvdbId( - EpisodeInfo searchInfo, - string language, - CancellationToken cancellationToken) - { - searchInfo.SeriesProviderIds.TryGetValue( - nameof(MetadataProvider.Tvdb), - out var seriesTvdbId); - - var episodeQuery = new EpisodeQuery(); - - // Prefer SxE over premiere date as it is more robust - if (searchInfo.IndexNumber.HasValue && searchInfo.ParentIndexNumber.HasValue) - { - switch (searchInfo.SeriesDisplayOrder) - { - case "dvd": - episodeQuery.DvdEpisode = searchInfo.IndexNumber.Value; - episodeQuery.DvdSeason = searchInfo.ParentIndexNumber.Value; - break; - case "absolute": - episodeQuery.AbsoluteNumber = searchInfo.IndexNumber.Value; - break; - default: - // aired order - episodeQuery.AiredEpisode = searchInfo.IndexNumber.Value; - episodeQuery.AiredSeason = searchInfo.ParentIndexNumber.Value; - break; - } - } - else if (searchInfo.PremiereDate.HasValue) - { - // tvdb expects yyyy-mm-dd format - episodeQuery.FirstAired = searchInfo.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - } - - return GetEpisodeTvdbId(Convert.ToInt32(seriesTvdbId, CultureInfo.InvariantCulture), episodeQuery, language, cancellationToken); - } - - public async Task<string> GetEpisodeTvdbId( - int seriesTvdbId, - EpisodeQuery episodeQuery, - string language, - CancellationToken cancellationToken) - { - var episodePage = - await GetEpisodesPageAsync(Convert.ToInt32(seriesTvdbId), episodeQuery, language, cancellationToken) - .ConfigureAwait(false); - return episodePage.Data.FirstOrDefault()?.Id.ToString(CultureInfo.InvariantCulture); - } - - public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync( - int tvdbId, - EpisodeQuery episodeQuery, - string language, - CancellationToken cancellationToken) - { - return GetEpisodesPageAsync(tvdbId, 1, episodeQuery, language, cancellationToken); - } - - public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeriesAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken) - { - var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId); - var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false); - - if (imagesSummary.Data.Fanart > 0) - { - yield return KeyType.Fanart; - } - - if (imagesSummary.Data.Series > 0) - { - yield return KeyType.Series; - } - - if (imagesSummary.Data.Poster > 0) - { - yield return KeyType.Poster; - } - } - - public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeasonAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken) - { - var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId); - var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false); - - if (imagesSummary.Data.Season > 0) - { - yield return KeyType.Season; - } - - if (imagesSummary.Data.Fanart > 0) - { - yield return KeyType.Fanart; - } - - // TODO seasonwide is not supported in TvDbSharper - } - - private async Task<T> TryGetValue<T>(string key, string language, Func<Task<T>> resultFactory) - { - if (_cache.TryGetValue(key, out T cachedValue)) - { - return cachedValue; - } - - _tvDbClient.AcceptedLanguage = TvdbUtils.NormalizeLanguage(language) ?? DefaultLanguage; - var result = await resultFactory.Invoke().ConfigureAwait(false); - _cache.Set(key, result, TimeSpan.FromHours(1)); - return result; - } - - private static string GenerateKey(params object[] objects) - { - var key = string.Empty; - - foreach (var obj in objects) - { - var objType = obj.GetType(); - if (objType.IsPrimitive || objType == typeof(string)) - { - key += obj + ";"; - } - else - { - foreach (PropertyInfo propertyInfo in objType.GetProperties()) - { - var currentValue = propertyInfo.GetValue(obj, null); - if (currentValue == null) - { - continue; - } - - key += propertyInfo.Name + "=" + currentValue + ";"; - } - } - } - - return key; - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs deleted file mode 100644 index 50a876d6c5..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs +++ /dev/null @@ -1,130 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Globalization; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbEpisodeImageProvider : IRemoteImageProvider - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbEpisodeImageProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbEpisodeImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public string Name => "TheTVDB"; - - public bool Supports(BaseItem item) - { - return item is Episode; - } - - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new List<ImageType> - { - ImageType.Primary - }; - } - - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - var episode = (Episode)item; - var series = episode.Series; - var imageResult = new List<RemoteImageInfo>(); - var language = item.GetPreferredMetadataLanguage(); - if (series != null && TvdbSeriesProvider.IsValidSeries(series.ProviderIds)) - { - // Process images - try - { - string episodeTvdbId = null; - - if (episode.IndexNumber.HasValue && episode.ParentIndexNumber.HasValue) - { - var episodeInfo = new EpisodeInfo - { - IndexNumber = episode.IndexNumber.Value, - ParentIndexNumber = episode.ParentIndexNumber.Value, - SeriesProviderIds = series.ProviderIds, - SeriesDisplayOrder = series.DisplayOrder - }; - - episodeTvdbId = await _tvdbClientManager - .GetEpisodeTvdbId(episodeInfo, language, cancellationToken).ConfigureAwait(false); - } - - if (string.IsNullOrEmpty(episodeTvdbId)) - { - _logger.LogError( - "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}", - episode.ParentIndexNumber, - episode.IndexNumber, - series.GetProviderId(MetadataProvider.Tvdb)); - return imageResult; - } - - var episodeResult = - await _tvdbClientManager - .GetEpisodesAsync(Convert.ToInt32(episodeTvdbId, CultureInfo.InvariantCulture), language, cancellationToken) - .ConfigureAwait(false); - - var image = GetImageInfo(episodeResult.Data); - if (image != null) - { - imageResult.Add(image); - } - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve episode images for series {TvDbId}", series.GetProviderId(MetadataProvider.Tvdb)); - } - } - - return imageResult; - } - - private RemoteImageInfo GetImageInfo(EpisodeRecord episode) - { - if (string.IsNullOrEmpty(episode.Filename)) - { - return null; - } - - return new RemoteImageInfo - { - Width = Convert.ToInt32(episode.ThumbWidth, CultureInfo.InvariantCulture), - Height = Convert.ToInt32(episode.ThumbHeight, CultureInfo.InvariantCulture), - ProviderName = Name, - Url = TvdbUtils.BannerUrl + episode.Filename, - Type = ImageType.Primary - }; - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs deleted file mode 100644 index fd72ea4a81..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs +++ /dev/null @@ -1,262 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - /// <summary> - /// Class RemoteEpisodeProvider. - /// </summary> - public class TvdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbEpisodeProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbEpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) - { - var list = new List<RemoteSearchResult>(); - - // Either an episode number or date must be provided; and the dictionary of provider ids must be valid - if ((searchInfo.IndexNumber == null && searchInfo.PremiereDate == null) - || !TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds)) - { - return list; - } - - var metadataResult = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false); - - if (!metadataResult.HasMetadata) - { - return list; - } - - var item = metadataResult.Item; - - list.Add(new RemoteSearchResult - { - IndexNumber = item.IndexNumber, - Name = item.Name, - ParentIndexNumber = item.ParentIndexNumber, - PremiereDate = item.PremiereDate, - ProductionYear = item.ProductionYear, - ProviderIds = item.ProviderIds, - SearchProviderName = Name, - IndexNumberEnd = item.IndexNumberEnd - }); - - return list; - } - - public string Name => "TheTVDB"; - - public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo searchInfo, CancellationToken cancellationToken) - { - var result = new MetadataResult<Episode> - { - QueriedById = true - }; - - if (TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds) && - (searchInfo.IndexNumber.HasValue || searchInfo.PremiereDate.HasValue)) - { - result = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false); - } - else - { - _logger.LogDebug("No series identity found for {EpisodeName}", searchInfo.Name); - } - - return result; - } - - private async Task<MetadataResult<Episode>> GetEpisode(EpisodeInfo searchInfo, CancellationToken cancellationToken) - { - var result = new MetadataResult<Episode> - { - QueriedById = true - }; - - string seriesTvdbId = searchInfo.GetProviderId(MetadataProvider.Tvdb); - string episodeTvdbId = null; - try - { - episodeTvdbId = await _tvdbClientManager - .GetEpisodeTvdbId(searchInfo, searchInfo.MetadataLanguage, cancellationToken) - .ConfigureAwait(false); - if (string.IsNullOrEmpty(episodeTvdbId)) - { - _logger.LogError( - "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}", - searchInfo.ParentIndexNumber, searchInfo.IndexNumber, seriesTvdbId); - return result; - } - - var episodeResult = await _tvdbClientManager.GetEpisodesAsync( - Convert.ToInt32(episodeTvdbId), searchInfo.MetadataLanguage, - cancellationToken).ConfigureAwait(false); - - result = MapEpisodeToResult(searchInfo, episodeResult.Data); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve episode with id {EpisodeTvDbId}, series id {SeriesTvdbId}", episodeTvdbId, seriesTvdbId); - } - - return result; - } - - private static MetadataResult<Episode> MapEpisodeToResult(EpisodeInfo id, EpisodeRecord episode) - { - var result = new MetadataResult<Episode> - { - HasMetadata = true, - Item = new Episode - { - IndexNumber = id.IndexNumber, - ParentIndexNumber = id.ParentIndexNumber, - IndexNumberEnd = id.IndexNumberEnd, - AirsBeforeEpisodeNumber = episode.AirsBeforeEpisode, - AirsAfterSeasonNumber = episode.AirsAfterSeason, - AirsBeforeSeasonNumber = episode.AirsBeforeSeason, - Name = episode.EpisodeName, - Overview = episode.Overview, - CommunityRating = (float?)episode.SiteRating, - OfficialRating = episode.ContentRating, - } - }; - result.ResetPeople(); - - var item = result.Item; - item.SetProviderId(MetadataProvider.Tvdb, episode.Id.ToString()); - item.SetProviderId(MetadataProvider.Imdb, episode.ImdbId); - - if (string.Equals(id.SeriesDisplayOrder, "dvd", StringComparison.OrdinalIgnoreCase)) - { - item.IndexNumber = Convert.ToInt32(episode.DvdEpisodeNumber ?? episode.AiredEpisodeNumber); - item.ParentIndexNumber = episode.DvdSeason ?? episode.AiredSeason; - } - else if (string.Equals(id.SeriesDisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase)) - { - if (episode.AbsoluteNumber.GetValueOrDefault() != 0) - { - item.IndexNumber = episode.AbsoluteNumber; - } - } - else if (episode.AiredEpisodeNumber.HasValue) - { - item.IndexNumber = episode.AiredEpisodeNumber; - } - else if (episode.AiredSeason.HasValue) - { - item.ParentIndexNumber = episode.AiredSeason; - } - - if (DateTime.TryParse(episode.FirstAired, out var date)) - { - // dates from tvdb are UTC but without offset or Z - item.PremiereDate = date; - item.ProductionYear = date.Year; - } - - foreach (var director in episode.Directors) - { - result.AddPerson(new PersonInfo - { - Name = director, - Type = PersonType.Director - }); - } - - // GuestStars is a weird list of names and roles - // Example: - // 1: Some Actor (Role1 - // 2: Role2 - // 3: Role3) - // 4: Another Actor (Role1 - // ... - for (var i = 0; i < episode.GuestStars.Length; ++i) - { - var currentActor = episode.GuestStars[i]; - var roleStartIndex = currentActor.IndexOf('(', StringComparison.Ordinal); - - if (roleStartIndex == -1) - { - result.AddPerson(new PersonInfo - { - Type = PersonType.GuestStar, - Name = currentActor, - Role = string.Empty - }); - continue; - } - - var roles = new List<string> { currentActor.Substring(roleStartIndex + 1) }; - - // Fetch all roles - for (var j = i + 1; j < episode.GuestStars.Length; ++j) - { - var currentRole = episode.GuestStars[j]; - var roleEndIndex = currentRole.IndexOf(')', StringComparison.Ordinal); - - if (roleEndIndex == -1) - { - roles.Add(currentRole); - continue; - } - - roles.Add(currentRole.TrimEnd(')')); - // Update the outer index (keep in mind it adds 1 after the iteration) - i = j; - break; - } - - result.AddPerson(new PersonInfo - { - Type = PersonType.GuestStar, - Name = currentActor.Substring(0, roleStartIndex).Trim(), - Role = string.Join(", ", roles) - }); - } - - foreach (var writer in episode.Writers) - { - result.AddPerson(new PersonInfo - { - Name = writer, - Type = PersonType.Writer - }); - } - - result.ResultLanguage = episode.Language.EpisodeName; - return result; - } - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - - public int Order => 0; - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs deleted file mode 100644 index a5cd425f6d..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs +++ /dev/null @@ -1,113 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbPersonImageProvider : IRemoteImageProvider, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbPersonImageProvider> _logger; - private readonly ILibraryManager _libraryManager; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbPersonImageProvider(ILibraryManager libraryManager, IHttpClientFactory httpClientFactory, ILogger<TvdbPersonImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _libraryManager = libraryManager; - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - /// <inheritdoc /> - public string Name => "TheTVDB"; - - /// <inheritdoc /> - public int Order => 1; - - /// <inheritdoc /> - public bool Supports(BaseItem item) => item is Person; - - /// <inheritdoc /> - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - yield return ImageType.Primary; - } - - /// <inheritdoc /> - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - var seriesWithPerson = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { nameof(Series) }, - PersonIds = new[] { item.Id }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }).Cast<Series>() - .Where(i => TvdbSeriesProvider.IsValidSeries(i.ProviderIds)) - .ToList(); - - var infos = (await Task.WhenAll(seriesWithPerson.Select(async i => - await GetImageFromSeriesData(i, item.Name, cancellationToken).ConfigureAwait(false))) - .ConfigureAwait(false)) - .Where(i => i != null) - .Take(1); - - return infos; - } - - private async Task<RemoteImageInfo> GetImageFromSeriesData(Series series, string personName, CancellationToken cancellationToken) - { - var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb)); - - try - { - var actorsResult = await _tvdbClientManager - .GetActorsAsync(tvdbId, series.GetPreferredMetadataLanguage(), cancellationToken) - .ConfigureAwait(false); - var actor = actorsResult.Data.FirstOrDefault(a => - string.Equals(a.Name, personName, StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(a.Image)); - if (actor == null) - { - return null; - } - - return new RemoteImageInfo - { - Url = TvdbUtils.BannerUrl + actor.Image, - Type = ImageType.Primary, - ProviderName = Name - }; - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve actor {ActorName} from series {SeriesTvdbId}", personName, tvdbId); - return null; - } - } - - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs deleted file mode 100644 index 49576d488d..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs +++ /dev/null @@ -1,155 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; -using RatingType = MediaBrowser.Model.Dto.RatingType; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbSeasonImageProvider : IRemoteImageProvider, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbSeasonImageProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbSeasonImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeasonImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public string Name => ProviderName; - - public static string ProviderName => "TheTVDB"; - - public bool Supports(BaseItem item) - { - return item is Season; - } - - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new List<ImageType> - { - ImageType.Primary, - ImageType.Banner, - ImageType.Backdrop - }; - } - - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - var season = (Season)item; - var series = season.Series; - - if (series == null || !season.IndexNumber.HasValue || !TvdbSeriesProvider.IsValidSeries(series.ProviderIds)) - { - return Array.Empty<RemoteImageInfo>(); - } - - var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb)); - var seasonNumber = season.IndexNumber.Value; - var language = item.GetPreferredMetadataLanguage(); - var remoteImages = new List<RemoteImageInfo>(); - - var keyTypes = _tvdbClientManager.GetImageKeyTypesForSeasonAsync(tvdbId, language, cancellationToken).ConfigureAwait(false); - await foreach (var keyType in keyTypes) - { - var imageQuery = new ImagesQuery - { - KeyType = keyType, - SubKey = seasonNumber.ToString() - }; - try - { - var imageResults = await _tvdbClientManager - .GetImagesAsync(tvdbId, imageQuery, language, cancellationToken).ConfigureAwait(false); - remoteImages.AddRange(GetImages(imageResults.Data, language)); - } - catch (TvDbServerException) - { - _logger.LogDebug("No images of type {KeyType} found for series {TvdbId}", keyType, tvdbId); - } - } - - return remoteImages; - } - - private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage) - { - var list = new List<RemoteImageInfo>(); - // any languages with null ids are ignored - var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data.Where(x => x.Id.HasValue); - foreach (Image image in images) - { - var imageInfo = new RemoteImageInfo - { - RatingType = RatingType.Score, - CommunityRating = (double?)image.RatingsInfo.Average, - VoteCount = image.RatingsInfo.Count, - Url = TvdbUtils.BannerUrl + image.FileName, - ProviderName = ProviderName, - Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation, - ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail - }; - - var resolution = image.Resolution.Split('x'); - if (resolution.Length == 2) - { - imageInfo.Width = Convert.ToInt32(resolution[0]); - imageInfo.Height = Convert.ToInt32(resolution[1]); - } - - imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType); - list.Add(imageInfo); - } - - var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase); - return list.OrderByDescending(i => - { - if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (!isLanguageEn) - { - if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - } - - if (string.IsNullOrEmpty(i.Language)) - { - return isLanguageEn ? 3 : 2; - } - - return 0; - }) - .ThenByDescending(i => i.CommunityRating ?? 0) - .ThenByDescending(i => i.VoteCount ?? 0); - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs deleted file mode 100644 index d96840e51c..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs +++ /dev/null @@ -1,153 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; -using RatingType = MediaBrowser.Model.Dto.RatingType; -using Series = MediaBrowser.Controller.Entities.TV.Series; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbSeriesImageProvider : IRemoteImageProvider, IHasOrder - { - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbSeriesImageProvider> _logger; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbSeriesImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesImageProvider> logger, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _tvdbClientManager = tvdbClientManager; - } - - public string Name => ProviderName; - - public static string ProviderName => "TheTVDB"; - - public bool Supports(BaseItem item) - { - return item is Series; - } - - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new List<ImageType> - { - ImageType.Primary, - ImageType.Banner, - ImageType.Backdrop - }; - } - - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - if (!TvdbSeriesProvider.IsValidSeries(item.ProviderIds)) - { - return Array.Empty<RemoteImageInfo>(); - } - - var language = item.GetPreferredMetadataLanguage(); - var remoteImages = new List<RemoteImageInfo>(); - var tvdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tvdb)); - var allowedKeyTypes = _tvdbClientManager.GetImageKeyTypesForSeriesAsync(tvdbId, language, cancellationToken) - .ConfigureAwait(false); - await foreach (KeyType keyType in allowedKeyTypes) - { - var imageQuery = new ImagesQuery - { - KeyType = keyType - }; - try - { - var imageResults = - await _tvdbClientManager.GetImagesAsync(tvdbId, imageQuery, language, cancellationToken) - .ConfigureAwait(false); - - remoteImages.AddRange(GetImages(imageResults.Data, language)); - } - catch (TvDbServerException) - { - _logger.LogDebug("No images of type {KeyType} exist for series {TvDbId}", keyType, - tvdbId); - } - } - - return remoteImages; - } - - private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage) - { - var list = new List<RemoteImageInfo>(); - var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data; - - foreach (Image image in images) - { - var imageInfo = new RemoteImageInfo - { - RatingType = RatingType.Score, - CommunityRating = (double?)image.RatingsInfo.Average, - VoteCount = image.RatingsInfo.Count, - Url = TvdbUtils.BannerUrl + image.FileName, - ProviderName = Name, - Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation, - ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail - }; - - var resolution = image.Resolution.Split('x'); - if (resolution.Length == 2) - { - imageInfo.Width = Convert.ToInt32(resolution[0]); - imageInfo.Height = Convert.ToInt32(resolution[1]); - } - - imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType); - list.Add(imageInfo); - } - - var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase); - return list.OrderByDescending(i => - { - if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 3; - } - - if (!isLanguageEn) - { - if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase)) - { - return 2; - } - } - - if (string.IsNullOrEmpty(i.Language)) - { - return isLanguageEn ? 3 : 2; - } - - return 0; - }) - .ThenByDescending(i => i.CommunityRating ?? 0) - .ThenByDescending(i => i.VoteCount ?? 0); - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs deleted file mode 100644 index b34e52235a..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs +++ /dev/null @@ -1,419 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using TvDbSharper; -using TvDbSharper.Dto; -using Series = MediaBrowser.Controller.Entities.TV.Series; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public class TvdbSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder - { - internal static TvdbSeriesProvider Current { get; private set; } - - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<TvdbSeriesProvider> _logger; - private readonly ILibraryManager _libraryManager; - private readonly ILocalizationManager _localizationManager; - private readonly TvdbClientManager _tvdbClientManager; - - public TvdbSeriesProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesProvider> logger, ILibraryManager libraryManager, ILocalizationManager localizationManager, TvdbClientManager tvdbClientManager) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - _libraryManager = libraryManager; - _localizationManager = localizationManager; - Current = this; - _tvdbClientManager = tvdbClientManager; - } - - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) - { - if (IsValidSeries(searchInfo.ProviderIds)) - { - var metadata = await GetMetadata(searchInfo, cancellationToken).ConfigureAwait(false); - - if (metadata.HasMetadata) - { - return new List<RemoteSearchResult> - { - new RemoteSearchResult - { - Name = metadata.Item.Name, - PremiereDate = metadata.Item.PremiereDate, - ProductionYear = metadata.Item.ProductionYear, - ProviderIds = metadata.Item.ProviderIds, - SearchProviderName = Name - } - }; - } - } - - return await FindSeries(searchInfo.Name, searchInfo.Year, searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false); - } - - public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo itemId, CancellationToken cancellationToken) - { - var result = new MetadataResult<Series> - { - QueriedById = true - }; - - if (!IsValidSeries(itemId.ProviderIds)) - { - result.QueriedById = false; - await Identify(itemId).ConfigureAwait(false); - } - - cancellationToken.ThrowIfCancellationRequested(); - - if (IsValidSeries(itemId.ProviderIds)) - { - result.Item = new Series(); - result.HasMetadata = true; - - await FetchSeriesData(result, itemId.MetadataLanguage, itemId.ProviderIds, cancellationToken) - .ConfigureAwait(false); - } - - return result; - } - - private async Task FetchSeriesData(MetadataResult<Series> result, string metadataLanguage, Dictionary<string, string> seriesProviderIds, CancellationToken cancellationToken) - { - var series = result.Item; - - if (seriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var tvdbId) && !string.IsNullOrEmpty(tvdbId)) - { - series.SetProviderId(MetadataProvider.Tvdb, tvdbId); - } - - if (seriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out var imdbId) && !string.IsNullOrEmpty(imdbId)) - { - series.SetProviderId(MetadataProvider.Imdb, imdbId); - tvdbId = await GetSeriesByRemoteId(imdbId, MetadataProvider.Imdb.ToString(), metadataLanguage, - cancellationToken).ConfigureAwait(false); - } - - if (seriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out var zap2It) && !string.IsNullOrEmpty(zap2It)) - { - series.SetProviderId(MetadataProvider.Zap2It, zap2It); - tvdbId = await GetSeriesByRemoteId(zap2It, MetadataProvider.Zap2It.ToString(), metadataLanguage, - cancellationToken).ConfigureAwait(false); - } - - try - { - var seriesResult = - await _tvdbClientManager - .GetSeriesByIdAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken) - .ConfigureAwait(false); - await MapSeriesToResult(result, seriesResult.Data, metadataLanguage).ConfigureAwait(false); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve series with id {TvdbId}", tvdbId); - return; - } - - cancellationToken.ThrowIfCancellationRequested(); - - result.ResetPeople(); - - try - { - var actorsResult = await _tvdbClientManager - .GetActorsAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken).ConfigureAwait(false); - MapActorsToResult(result, actorsResult.Data); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve actors for series {TvdbId}", tvdbId); - } - } - - private async Task<string> GetSeriesByRemoteId(string id, string idType, string language, CancellationToken cancellationToken) - { - TvDbResponse<SeriesSearchResult[]> result = null; - - try - { - if (string.Equals(idType, MetadataProvider.Zap2It.ToString(), StringComparison.OrdinalIgnoreCase)) - { - result = await _tvdbClientManager.GetSeriesByZap2ItIdAsync(id, language, cancellationToken) - .ConfigureAwait(false); - } - else - { - result = await _tvdbClientManager.GetSeriesByImdbIdAsync(id, language, cancellationToken) - .ConfigureAwait(false); - } - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to retrieve series with remote id {RemoteId}", id); - } - - return result?.Data.First().Id.ToString(); - } - - /// <summary> - /// Check whether a dictionary of provider IDs includes an entry for a valid TV metadata provider. - /// </summary> - /// <param name="seriesProviderIds">The dictionary to check.</param> - /// <returns>True, if the dictionary contains a valid TV provider ID, otherwise false.</returns> - internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds) - { - return seriesProviderIds.ContainsKey(MetadataProvider.Tvdb.ToString()) || - seriesProviderIds.ContainsKey(MetadataProvider.Imdb.ToString()) || - seriesProviderIds.ContainsKey(MetadataProvider.Zap2It.ToString()); - } - - /// <summary> - /// Finds the series. - /// </summary> - /// <param name="name">The name.</param> - /// <param name="year">The year.</param> - /// <param name="language">The language.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{System.String}.</returns> - private async Task<IEnumerable<RemoteSearchResult>> FindSeries(string name, int? year, string language, CancellationToken cancellationToken) - { - var results = await FindSeriesInternal(name, language, cancellationToken).ConfigureAwait(false); - - if (results.Count == 0) - { - var parsedName = _libraryManager.ParseName(name); - var nameWithoutYear = parsedName.Name; - - if (!string.IsNullOrWhiteSpace(nameWithoutYear) && !string.Equals(nameWithoutYear, name, StringComparison.OrdinalIgnoreCase)) - { - results = await FindSeriesInternal(nameWithoutYear, language, cancellationToken).ConfigureAwait(false); - } - } - - return results.Where(i => - { - if (year.HasValue && i.ProductionYear.HasValue) - { - // Allow one year tolerance - return Math.Abs(year.Value - i.ProductionYear.Value) <= 1; - } - - return true; - }); - } - - private async Task<List<RemoteSearchResult>> FindSeriesInternal(string name, string language, CancellationToken cancellationToken) - { - var comparableName = GetComparableName(name); - var list = new List<Tuple<List<string>, RemoteSearchResult>>(); - TvDbResponse<SeriesSearchResult[]> result; - try - { - result = await _tvdbClientManager.GetSeriesByNameAsync(comparableName, language, cancellationToken) - .ConfigureAwait(false); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "No series results found for {Name}", comparableName); - return new List<RemoteSearchResult>(); - } - - foreach (var seriesSearchResult in result.Data) - { - var tvdbTitles = new List<string> - { - GetComparableName(seriesSearchResult.SeriesName) - }; - tvdbTitles.AddRange(seriesSearchResult.Aliases.Select(GetComparableName)); - - DateTime.TryParse(seriesSearchResult.FirstAired, out var firstAired); - var remoteSearchResult = new RemoteSearchResult - { - Name = tvdbTitles.FirstOrDefault(), - ProductionYear = firstAired.Year, - SearchProviderName = Name - }; - - if (!string.IsNullOrEmpty(seriesSearchResult.Banner)) - { - // Results from their Search endpoints already include the /banners/ part in the url, because reasons... - remoteSearchResult.ImageUrl = TvdbUtils.TvdbImageBaseUrl + seriesSearchResult.Banner; - } - - try - { - var seriesSesult = - await _tvdbClientManager.GetSeriesByIdAsync(seriesSearchResult.Id, language, cancellationToken) - .ConfigureAwait(false); - remoteSearchResult.SetProviderId(MetadataProvider.Imdb, seriesSesult.Data.ImdbId); - remoteSearchResult.SetProviderId(MetadataProvider.Zap2It, seriesSesult.Data.Zap2itId); - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Unable to retrieve series with id {TvdbId}", seriesSearchResult.Id); - } - - remoteSearchResult.SetProviderId(MetadataProvider.Tvdb, seriesSearchResult.Id.ToString()); - list.Add(new Tuple<List<string>, RemoteSearchResult>(tvdbTitles, remoteSearchResult)); - } - - return list - .OrderBy(i => i.Item1.Contains(comparableName, StringComparer.OrdinalIgnoreCase) ? 0 : 1) - .ThenBy(i => list.IndexOf(i)) - .Select(i => i.Item2) - .ToList(); - } - - /// <summary> - /// Gets the name of the comparable. - /// </summary> - /// <param name="name">The name.</param> - /// <returns>System.String.</returns> - private string GetComparableName(string name) - { - name = name.ToLowerInvariant(); - name = name.Normalize(NormalizationForm.FormKD); - name = name.Replace(", the", string.Empty).Replace("the ", " ").Replace(" the ", " "); - name = name.Replace("&", " and " ); - name = Regex.Replace(name, @"[\p{Lm}\p{Mn}]", string.Empty); // Remove diacritics, etc - name = Regex.Replace(name, @"[\W\p{Pc}]+", " "); // Replace sequences of non-word characters and _ with " " - return name.Trim(); - } - - private async Task MapSeriesToResult(MetadataResult<Series> result, TvDbSharper.Dto.Series tvdbSeries, string metadataLanguage) - { - Series series = result.Item; - series.SetProviderId(MetadataProvider.Tvdb, tvdbSeries.Id.ToString()); - series.Name = tvdbSeries.SeriesName; - series.Overview = (tvdbSeries.Overview ?? string.Empty).Trim(); - result.ResultLanguage = metadataLanguage; - series.AirDays = TVUtils.GetAirDays(tvdbSeries.AirsDayOfWeek); - series.AirTime = tvdbSeries.AirsTime; - series.CommunityRating = (float?)tvdbSeries.SiteRating; - series.SetProviderId(MetadataProvider.Imdb, tvdbSeries.ImdbId); - series.SetProviderId(MetadataProvider.Zap2It, tvdbSeries.Zap2itId); - if (Enum.TryParse(tvdbSeries.Status, true, out SeriesStatus seriesStatus)) - { - series.Status = seriesStatus; - } - - if (DateTime.TryParse(tvdbSeries.FirstAired, out var date)) - { - // dates from tvdb are UTC but without offset or Z - series.PremiereDate = date; - series.ProductionYear = date.Year; - } - - if (!string.IsNullOrEmpty(tvdbSeries.Runtime) && double.TryParse(tvdbSeries.Runtime, out double runtime)) - { - series.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; - } - - foreach (var genre in tvdbSeries.Genre) - { - series.AddGenre(genre); - } - - if (!string.IsNullOrEmpty(tvdbSeries.Network)) - { - series.AddStudio(tvdbSeries.Network); - } - - if (result.Item.Status.HasValue && result.Item.Status.Value == SeriesStatus.Ended) - { - try - { - var episodeSummary = await _tvdbClientManager.GetSeriesEpisodeSummaryAsync(tvdbSeries.Id, metadataLanguage, CancellationToken.None).ConfigureAwait(false); - - if (episodeSummary.Data.AiredSeasons.Length != 0) - { - var maxSeasonNumber = episodeSummary.Data.AiredSeasons.Max(s => Convert.ToInt32(s, CultureInfo.InvariantCulture)); - var episodeQuery = new EpisodeQuery - { - AiredSeason = maxSeasonNumber - }; - var episodesPage = await _tvdbClientManager.GetEpisodesPageAsync(tvdbSeries.Id, episodeQuery, metadataLanguage, CancellationToken.None).ConfigureAwait(false); - - result.Item.EndDate = episodesPage.Data - .Select(e => DateTime.TryParse(e.FirstAired, out var firstAired) ? firstAired : (DateTime?)null) - .Max(); - } - } - catch (TvDbServerException e) - { - _logger.LogError(e, "Failed to find series end date for series {TvdbId}", tvdbSeries.Id); - } - } - } - - private static void MapActorsToResult(MetadataResult<Series> result, IEnumerable<Actor> actors) - { - foreach (Actor actor in actors) - { - var personInfo = new PersonInfo - { - Type = PersonType.Actor, - Name = (actor.Name ?? string.Empty).Trim(), - Role = actor.Role, - SortOrder = actor.SortOrder - }; - - if (!string.IsNullOrEmpty(actor.Image)) - { - personInfo.ImageUrl = TvdbUtils.BannerUrl + actor.Image; - } - - if (!string.IsNullOrWhiteSpace(personInfo.Name)) - { - result.AddPerson(personInfo); - } - } - } - - public string Name => "TheTVDB"; - - public async Task Identify(SeriesInfo info) - { - if (!string.IsNullOrWhiteSpace(info.GetProviderId(MetadataProvider.Tvdb))) - { - return; - } - - var srch = await FindSeries(info.Name, info.Year, info.MetadataLanguage, CancellationToken.None) - .ConfigureAwait(false); - - var entry = srch.FirstOrDefault(); - - if (entry != null) - { - var id = entry.GetProviderId(MetadataProvider.Tvdb); - info.SetProviderId(MetadataProvider.Tvdb, id); - } - } - - public int Order => 0; - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs deleted file mode 100644 index 37a8d04a6f..0000000000 --- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs +++ /dev/null @@ -1,39 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Model.Entities; - -namespace MediaBrowser.Providers.Plugins.TheTvdb -{ - public static class TvdbUtils - { - public const string TvdbApiKey = "OG4V3YJ3FAP7FP2K"; - public const string TvdbBaseUrl = "https://www.thetvdb.com/"; - public const string TvdbImageBaseUrl = "https://www.thetvdb.com"; - public const string BannerUrl = TvdbImageBaseUrl + "/banners/"; - - public static ImageType GetImageTypeFromKeyType(string keyType) - { - switch (keyType.ToLowerInvariant()) - { - case "poster": - case "season": return ImageType.Primary; - case "series": - case "seasonwide": return ImageType.Banner; - case "fanart": return ImageType.Backdrop; - default: throw new ArgumentException($"Invalid or unknown keytype: {keyType}", nameof(keyType)); - } - } - - public static string NormalizeLanguage(string language) - { - if (string.IsNullOrWhiteSpace(language)) - { - return null; - } - - // pt-br is just pt to tvdb - return language.Split('-')[0].ToLowerInvariant(); - } - } -} diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index b754a07953..0e8a5baab6 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -98,7 +98,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb if (preferredLanguage.Length == 5) // like en-US { - // Currenty, TMDB supports 2-letter language codes only + // Currently, TMDB supports 2-letter language codes only // They are planning to change this in the future, thus we're // supplying both codes if we're having a 5-letter code. languages.Add(preferredLanguage.Substring(0, 2)); diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index f25d3d5ee7..47e9d5ee8c 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -147,40 +147,14 @@ namespace MediaBrowser.Providers.Subtitles string subtitleId, CancellationToken cancellationToken) { - var parts = subtitleId.Split(new[] { '_' }, 2); + var parts = subtitleId.Split('_', 2); var provider = GetProvider(parts[0]); - var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; - try { var response = await GetRemoteSubtitles(subtitleId, cancellationToken).ConfigureAwait(false); - using (var stream = response.Stream) - using (var memoryStream = new MemoryStream()) - { - await stream.CopyToAsync(memoryStream).ConfigureAwait(false); - memoryStream.Position = 0; - - var savePaths = new List<string>(); - var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); - - if (response.IsForced) - { - saveFileName += ".forced"; - } - - saveFileName += "." + response.Format.ToLowerInvariant(); - - if (saveInMediaFolder) - { - savePaths.Add(Path.Combine(video.ContainingFolderPath, saveFileName)); - } - - savePaths.Add(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); - - await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); - } + await TrySaveSubtitle(video, libraryOptions, response).ConfigureAwait(false); } catch (RateLimitExceededException) { @@ -199,6 +173,47 @@ namespace MediaBrowser.Providers.Subtitles } } + /// <inheritdoc /> + public Task UploadSubtitle(Video video, SubtitleResponse response) + { + var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video); + return TrySaveSubtitle(video, libraryOptions, response); + } + + private async Task TrySaveSubtitle( + Video video, + LibraryOptions libraryOptions, + SubtitleResponse response) + { + var saveInMediaFolder = libraryOptions.SaveSubtitlesWithMedia; + + using (var stream = response.Stream) + using (var memoryStream = new MemoryStream()) + { + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; + + var savePaths = new List<string>(); + var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant(); + + if (response.IsForced) + { + saveFileName += ".forced"; + } + + saveFileName += "." + response.Format.ToLowerInvariant(); + + if (saveInMediaFolder) + { + savePaths.Add(Path.Combine(video.ContainingFolderPath, saveFileName)); + } + + savePaths.Add(Path.Combine(video.GetInternalMetadataPath(), saveFileName)); + + await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false); + } + } + private async Task TrySaveToFiles(Stream stream, List<string> savePaths) { Exception exceptionToThrow = null; @@ -314,7 +329,7 @@ namespace MediaBrowser.Providers.Subtitles Index = index, ItemId = item.Id, Type = MediaStreamType.Subtitle - }).First(); + })[0]; var path = stream.Path; _monitor.ReportFileSystemChangeBeginning(path); @@ -334,10 +349,10 @@ namespace MediaBrowser.Providers.Subtitles /// <inheritdoc /> public Task<SubtitleResponse> GetRemoteSubtitles(string id, CancellationToken cancellationToken) { - var parts = id.Split(new[] { '_' }, 2); + var parts = id.Split('_', 2); var provider = GetProvider(parts[0]); - id = parts.Last(); + id = parts[^1]; return provider.GetSubtitles(id, cancellationToken); } diff --git a/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs b/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs deleted file mode 100644 index 40c5f2d785..0000000000 --- a/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs +++ /dev/null @@ -1,28 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; - -namespace MediaBrowser.Providers.TV -{ - public class TvdbEpisodeExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "TheTVDB"; - - /// <inheritdoc /> - public string Key => MetadataProvider.Tvdb.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Episode; - - /// <inheritdoc /> - public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=episode&id={0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Episode; - } -} diff --git a/MediaBrowser.Providers/TV/TvdbExternalId.cs b/MediaBrowser.Providers/TV/TvdbExternalId.cs deleted file mode 100644 index 4c54de9f82..0000000000 --- a/MediaBrowser.Providers/TV/TvdbExternalId.cs +++ /dev/null @@ -1,28 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; - -namespace MediaBrowser.Providers.TV -{ - public class TvdbExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "TheTVDB"; - - /// <inheritdoc /> - public string Key => MetadataProvider.Tvdb.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => null; - - /// <inheritdoc /> - public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=series&id={0}"; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Series; - } -} diff --git a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs b/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs deleted file mode 100644 index 807ebb3eee..0000000000 --- a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs +++ /dev/null @@ -1,28 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; - -namespace MediaBrowser.Providers.TV -{ - public class TvdbSeasonExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "TheTVDB"; - - /// <inheritdoc /> - public string Key => MetadataProvider.Tvdb.ToString(); - - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Season; - - /// <inheritdoc /> - public string UrlFormatString => null; - - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Season; - } -} diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs index c9f314af94..3cb18e4248 100644 --- a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs +++ b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs @@ -4,7 +4,6 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.TheTvdb; namespace MediaBrowser.Providers.TV { diff --git a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj index 45fd9add92..87d1e9464c 100644 --- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj +++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj @@ -15,7 +15,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs index 9cc0344c1c..bce4cf0093 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs @@ -134,7 +134,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeEpisodeNumber = rval; @@ -150,7 +150,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsAfterSeasonNumber = rval; @@ -166,7 +166,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeSeasonNumber = rval; @@ -182,7 +182,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeSeasonNumber = rval; @@ -198,7 +198,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(val)) { - // int.TryParse is local aware, so it can be probamatic, force us culture + // int.TryParse is local aware, so it can be problematic, force us culture if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval)) { item.AirsBeforeEpisodeNumber = rval; diff --git a/README.md b/README.md index 435e709b33..1ab246f84f 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ These instructions will help you get set up with a local development environment ### Prerequisites -Before the project can be built, you must first install the [.NET Core 3.1 SDK](https://dotnet.microsoft.com/download) on your system. +Before the project can be built, you must first install the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) on your system. Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET Core development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2017) and [Visual Studio Code](https://code.visualstudio.com/Download). @@ -142,7 +142,7 @@ A second option is to build the project and then run the resulting executable fi ```bash dotnet build # Build the project - cd bin/Debug/netcoreapp3.1 # Change into the build output directory + cd bin/Debug/net5.0 # Change into the build output directory ``` 2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`. diff --git a/RSSDP/DisposableManagedObjectBase.cs b/RSSDP/DisposableManagedObjectBase.cs index 745ec359c9..7d6a471f95 100644 --- a/RSSDP/DisposableManagedObjectBase.cs +++ b/RSSDP/DisposableManagedObjectBase.cs @@ -5,7 +5,7 @@ using System.Text; namespace Rssdp.Infrastructure { /// <summary> - /// Correclty implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceities not on the interface such as an <see cref="IsDisposed"/> property. + /// Correctly implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceties not on the interface such as an <see cref="IsDisposed"/> property. /// </summary> public abstract class DisposableManagedObjectBase : IDisposable { @@ -61,10 +61,10 @@ namespace Rssdp.Infrastructure /// Disposes this object instance and all internally managed resources. /// </summary> /// <remarks> - /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behaviour of derived classes.</para> + /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behavior of derived classes.</para> /// </remarks> /// <seealso cref="IsDisposed"/> - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfer with the dispose process.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfere with the dispose process.")] public void Dispose() { IsDisposed = true; diff --git a/RSSDP/HttpParserBase.cs b/RSSDP/HttpParserBase.cs index a40612bc29..c56249523d 100644 --- a/RSSDP/HttpParserBase.cs +++ b/RSSDP/HttpParserBase.cs @@ -105,7 +105,7 @@ namespace Rssdp.Infrastructure var headerName = line.Substring(0, headerKeySeparatorIndex).Trim(); var headerValue = line.Substring(headerKeySeparatorIndex + 1).Trim(); - // Not sure how to determine where request headers and and content headers begin, + // Not sure how to determine where request headers and content headers begin, // at least not without a known set of headers (general headers first the content headers) // which seems like a bad way of doing it. So we'll assume if it's a known content header put it there // else use request headers. @@ -119,7 +119,7 @@ namespace Rssdp.Infrastructure } else { - headersToAddTo.TryAddWithoutValidation(headerName, values.First()); + headersToAddTo.TryAddWithoutValidation(headerName, values[0]); } } @@ -151,7 +151,7 @@ namespace Rssdp.Infrastructure return lineIndex; } - private IList<string> ParseValues(string headerValue) + private List<string> ParseValues(string headerValue) { // This really should be better and match the HTTP 1.1 spec, // but this should actually be good enough for SSDP implementations @@ -160,7 +160,7 @@ namespace Rssdp.Infrastructure if (headerValue == "\"\"") { - values.Add(String.Empty); + values.Add(string.Empty); return values; } @@ -172,7 +172,7 @@ namespace Rssdp.Infrastructure else { var segments = headerValue.Split(SeparatorCharacters); - if (headerValue.Contains("\"")) + if (headerValue.Contains('"')) { for (int segmentIndex = 0; segmentIndex < segments.Length; segmentIndex++) { diff --git a/RSSDP/ISsdpDeviceLocator.cs b/RSSDP/ISsdpDeviceLocator.cs index 4130556434..4df166cd26 100644 --- a/RSSDP/ISsdpDeviceLocator.cs +++ b/RSSDP/ISsdpDeviceLocator.cs @@ -52,7 +52,7 @@ namespace Rssdp.Infrastructure } /// <summary> - /// Aynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results. + /// Asynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results. /// </summary> /// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync(); @@ -83,7 +83,7 @@ namespace Rssdp.Infrastructure /// </param> /// <param name="searchWaitTime">The amount of time to wait for network responses to the search request. Longer values will likely return more devices, but increase search time. A value between 1 and 5 is recommended by the UPnP 1.1 specification. Specify TimeSpan.Zero to return only devices already in the cache.</param> /// <remarks> - /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implemetning these services if you know the service type.</para> + /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implementing these services if you know the service type.</para> /// </remarks> /// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync(string searchTarget, TimeSpan searchWaitTime); diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj index 664663bd76..d0962e82c8 100644 --- a/RSSDP/RSSDP.csproj +++ b/RSSDP/RSSDP.csproj @@ -10,7 +10,7 @@ </ItemGroup> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs index 1a8577d8da..90925b9e0a 100644 --- a/RSSDP/SsdpDevicePublisher.cs +++ b/RSSDP/SsdpDevicePublisher.cs @@ -102,7 +102,7 @@ namespace Rssdp.Infrastructure /// <param name="device">The <see cref="SsdpDevice"/> instance to add.</param> /// <exception cref="ArgumentNullException">Thrown if the <paramref name="device"/> argument is null.</exception> /// <exception cref="InvalidOperationException">Thrown if the <paramref name="device"/> contains property values that are not acceptable to the UPnP 1.0 specification.</exception> - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable supresses compiler warning, but task is not really needed.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable suppresses compiler warning, but task is not really needed.")] public void AddDevice(SsdpRootDevice device) { if (device == null) @@ -180,7 +180,7 @@ namespace Rssdp.Infrastructure /// </summary> /// <remarks> /// <para>Enabling this option will cause devices to show up in Microsoft Windows Explorer's network screens (if discovery is enabled etc.). Windows Explorer appears to search only for pnp:rootdeivce and not upnp:rootdevice.</para> - /// <para>If false, the system will only use upnp:rootdevice for notifiation broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para> + /// <para>If false, the system will only use upnp:rootdevice for notification broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para> /// </remarks> public bool SupportPnpRootDevice { diff --git a/RSSDP/SsdpRootDevice.cs b/RSSDP/SsdpRootDevice.cs index 8937ec331f..4084b31ca6 100644 --- a/RSSDP/SsdpRootDevice.cs +++ b/RSSDP/SsdpRootDevice.cs @@ -25,7 +25,7 @@ namespace Rssdp /// Specifies how long clients can cache this device's details for. Optional but defaults to <see cref="TimeSpan.Zero"/> which means no-caching. Recommended value is half an hour. /// </summary> /// <remarks> - /// <para>Specifiy <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para> + /// <para>Specify <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para> /// <para>Also used to specify how often to rebroadcast alive notifications.</para> /// <para>The UPnP/SSDP specifications indicate this should not be less than 1800 seconds (half an hour), but this is not enforced by this library.</para> /// </remarks> @@ -50,7 +50,7 @@ namespace Rssdp public IPAddress SubnetMask { get; set; } /// <summary> - /// The base URL to use for all relative url's provided in other propertise (and those of child devices). Optional. + /// The base URL to use for all relative url's provided in other properties (and those of child devices). Optional. /// </summary> /// <remarks> /// <para>Defines the base URL. Used to construct fully-qualified URLs. All relative URLs that appear elsewhere in the description are combined with this base URL. If URLBase is empty or not given, the base URL is the URL from which the device description was retrieved (which is the preferred implementation; use of URLBase is no longer recommended). Specified by UPnP vendor. Single URL.</para> diff --git a/apiclient/templates/typescript/axios/generate.sh b/apiclient/templates/typescript/axios/generate.sh index 8c4d742825..9599f85dbd 100644 --- a/apiclient/templates/typescript/axios/generate.sh +++ b/apiclient/templates/typescript/axios/generate.sh @@ -1,14 +1,6 @@ #!/bin/bash artifactsDirectory="${1}" -buildNumber="${2}" -if [[ -n ${buildNumber} ]]; then - # Unstable build - additionalProperties=",snapshotVersion=-SNAPSHOT.${buildNumber},npmRepository=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/" -else - # Stable build - additionalProperties="" -fi java -jar openapi-generator-cli.jar generate \ --input-spec ${artifactsDirectory}/openapispec/openapi.json \ @@ -16,4 +8,4 @@ java -jar openapi-generator-cli.jar generate \ --output ./apiclient/generated/typescript/axios \ --template-dir ./apiclient/templates/typescript/axios \ --ignore-file-override ./apiclient/.openapi-generator-ignore \ - --additional-properties=useSingleRequestParameter="true",withSeparateModelsAndApi="true",modelPackage="models",apiPackage="api",npmName="axios"${additionalProperties} + --additional-properties=useSingleRequestParameter="true",withSeparateModelsAndApi="true",modelPackage="models",apiPackage="api",npmName="axios" diff --git a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj b/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj index 47aeed05ef..c564e86e9e 100644 --- a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj +++ b/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <OutputType>Exe</OutputType> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> </PropertyGroup> <ItemGroup> diff --git a/debian/control b/debian/control index 9216d24fe3..9675d36ca6 100644 --- a/debian/control +++ b/debian/control @@ -3,7 +3,7 @@ Section: misc Priority: optional Maintainer: Jellyfin Team <team@jellyfin.org> Build-Depends: debhelper (>= 9), - dotnet-sdk-3.1, + dotnet-sdk-5.0, libc6-dev, libcurl4-openssl-dev, libfontconfig1-dev, diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 39788cc0ee..01fc1aaac2 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -2,7 +2,7 @@ FROM centos:7 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist diff --git a/deployment/Dockerfile.debian.amd64 b/deployment/Dockerfile.debian.amd64 index aaca8fe01e..f0d9188c17 100644 --- a/deployment/Dockerfile.debian.amd64 +++ b/deployment/Dockerfile.debian.amd64 @@ -2,7 +2,7 @@ FROM debian:10 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.debian.arm64 b/deployment/Dockerfile.debian.arm64 index 594da04ceb..8132ee8873 100644 --- a/deployment/Dockerfile.debian.arm64 +++ b/deployment/Dockerfile.debian.arm64 @@ -2,7 +2,7 @@ FROM debian:10 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.debian.armhf b/deployment/Dockerfile.debian.armhf index 3e6e2d0d70..31f534838b 100644 --- a/deployment/Dockerfile.debian.armhf +++ b/deployment/Dockerfile.debian.armhf @@ -2,7 +2,7 @@ FROM debian:10 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.docker.amd64 b/deployment/Dockerfile.docker.amd64 index e04442606e..0b1a57014f 100644 --- a/deployment/Dockerfile.docker.amd64 +++ b/deployment/Dockerfile.docker.amd64 @@ -1,6 +1,6 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.arm64 b/deployment/Dockerfile.docker.arm64 index a7ac40492a..583f53ca09 100644 --- a/deployment/Dockerfile.docker.arm64 +++ b/deployment/Dockerfile.docker.arm64 @@ -1,6 +1,6 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.docker.armhf b/deployment/Dockerfile.docker.armhf index b5a42f55ff..177c117134 100644 --- a/deployment/Dockerfile.docker.armhf +++ b/deployment/Dockerfile.docker.armhf @@ -1,6 +1,6 @@ -ARG DOTNET_VERSION=3.1 +ARG DOTNET_VERSION=5.0 -FROM mcr.microsoft.com/dotnet/core/sdk:${DOTNET_VERSION}-buster +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-buster-slim ARG SOURCE_DIR=/src ARG ARTIFACT_DIR=/jellyfin diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 01b99deb6d..2549f25ee7 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -1,8 +1,8 @@ -FROM fedora:31 +FROM fedora:33 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -10,7 +10,7 @@ ENV IS_DOCKER=YES # Prepare Fedora environment RUN dnf update -y \ - && dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel + && dnf install -y @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd # Install DotNET SDK RUN rpm --import https://packages.microsoft.com/keys/microsoft.asc \ diff --git a/deployment/Dockerfile.linux.amd64 b/deployment/Dockerfile.linux.amd64 index f98881ebfe..2bedafcc5a 100644 --- a/deployment/Dockerfile.linux.amd64 +++ b/deployment/Dockerfile.linux.amd64 @@ -2,7 +2,7 @@ FROM debian:10 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.macos b/deployment/Dockerfile.macos index ec9d2d8c77..d470f9b749 100644 --- a/deployment/Dockerfile.macos +++ b/deployment/Dockerfile.macos @@ -2,7 +2,7 @@ FROM debian:10 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.portable b/deployment/Dockerfile.portable index 3523f8aceb..d2007c0751 100644 --- a/deployment/Dockerfile.portable +++ b/deployment/Dockerfile.portable @@ -2,7 +2,7 @@ FROM debian:10 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -15,7 +15,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 0a365e1aee..084159d459 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -2,7 +2,7 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index ab3ec9b9f8..c2caf4cf8e 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -2,7 +2,7 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index fa41bdf48a..719b3a85b6 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -2,7 +2,7 @@ FROM ubuntu:bionic # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -16,7 +16,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.windows.amd64 b/deployment/Dockerfile.windows.amd64 index 7216b2363b..e6905c906d 100644 --- a/deployment/Dockerfile.windows.amd64 +++ b/deployment/Dockerfile.windows.amd64 @@ -2,7 +2,7 @@ FROM debian:10 # Docker build arguments ARG SOURCE_DIR=/jellyfin ARG ARTIFACT_DIR=/dist -ARG SDK_VERSION=3.1 +ARG SDK_VERSION=5.0 # Docker run environment ENV SOURCE_DIR=/jellyfin ENV ARTIFACT_DIR=/dist @@ -15,7 +15,7 @@ RUN apt-get update \ # Install dotnet repository # https://dotnet.microsoft.com/download/linux-package-manager/debian9/sdk-current -RUN wget https://download.visualstudio.microsoft.com/download/pr/fdd9ecec-56b4-40f4-b762-d7efe24fc3cd/ffef51844c92afa6714528e10609a30f/dotnet-sdk-3.1.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget https://download.visualstudio.microsoft.com/download/pr/820db713-c9a5-466e-b72a-16f2f5ed00e2/628aa2a75f6aa270e77f4a83b3742fb8/dotnet-sdk-5.0.100-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/build.debian.amd64 b/deployment/build.debian.amd64 index 012e1cebf6..145e28d871 100755 --- a/deployment/build.debian.amd64 +++ b/deployment/build.debian.amd64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.debian.arm64 b/deployment/build.debian.arm64 index 12ce3e874d..5699133a01 100755 --- a/deployment/build.debian.arm64 +++ b/deployment/build.debian.arm64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.debian.armhf b/deployment/build.debian.armhf index 3089eab585..20af2ddfbe 100755 --- a/deployment/build.debian.armhf +++ b/deployment/build.debian.armhf @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.ubuntu.amd64 b/deployment/build.ubuntu.amd64 index 0eac9cdd10..0c29286c02 100755 --- a/deployment/build.ubuntu.amd64 +++ b/deployment/build.ubuntu.amd64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.ubuntu.arm64 b/deployment/build.ubuntu.arm64 index 5b11fd543b..65d67f80f7 100755 --- a/deployment/build.ubuntu.arm64 +++ b/deployment/build.ubuntu.arm64 @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/deployment/build.ubuntu.armhf b/deployment/build.ubuntu.armhf index 4734cf6588..370370abc1 100755 --- a/deployment/build.ubuntu.armhf +++ b/deployment/build.ubuntu.armhf @@ -9,9 +9,9 @@ set -o xtrace pushd ${SOURCE_DIR} if [[ ${IS_DOCKER} == YES ]]; then - # Remove build-dep for dotnet-sdk-3.1, since it's installed manually + # Remove build-dep for dotnet-sdk-5.0, since it's installed manually cp -a debian/control /tmp/control.orig - sed -i '/dotnet-sdk-3.1,/d' debian/control + sed -i '/dotnet-sdk-5.0,/d' debian/control fi # Modify changelog to unstable configuration if IS_UNSTABLE diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index 93fb9fb411..13305488e2 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -27,7 +27,7 @@ BuildRequires: libcurl-devel, fontconfig-devel, freetype-devel, openssl-devel, # Requirements not packaged in main repos # COPR @dotnet-sig/dotnet or # https://packages.microsoft.com/rhel/7/prod/ -BuildRequires: dotnet-runtime-3.1, dotnet-sdk-3.1 +BuildRequires: dotnet-runtime-5.0, dotnet-sdk-5.0 Requires: %{name}-server = %{version}-%{release}, %{name}-web >= 10.6, %{name}-web < 10.7 # Disable Automatic Dependency Processing AutoReqProv: no @@ -82,7 +82,6 @@ EOF %{_libdir}/jellyfin/* # Needs 755 else only root can run it since binary build by dotnet is 722 %attr(755,root,root) %{_libdir}/jellyfin/jellyfin -%{_libdir}/jellyfin/SOS_README.md %{_unitdir}/jellyfin.service %{_libexecdir}/jellyfin/restart.sh %{_prefix}/lib/firewalld/services/jellyfin.xml diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs index 4ea5094b66..90c4916668 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -8,6 +8,7 @@ using Jellyfin.Api.Auth; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -68,19 +69,19 @@ namespace Jellyfin.Api.Tests.Auth } [Fact] - public async Task HandleAuthenticateAsyncShouldFailOnSecurityException() + public async Task HandleAuthenticateAsyncShouldFailOnAuthenticationException() { var errorMessage = _fixture.Create<string>(); _jellyfinAuthServiceMock.Setup( a => a.Authenticate( It.IsAny<HttpRequest>())) - .Throws(new SecurityException(errorMessage)); + .Throws(new AuthenticationException(errorMessage)); var authenticateResult = await _sut.AuthenticateAsync(); Assert.False(authenticateResult.Succeeded); - Assert.Equal(errorMessage, authenticateResult.Failure.Message); + Assert.Equal(errorMessage, authenticateResult.Failure?.Message); } [Fact] @@ -99,7 +100,7 @@ namespace Jellyfin.Api.Tests.Auth var authorizationInfo = SetupUser(); var authenticateResult = await _sut.AuthenticateAsync(); - Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Name, authorizationInfo.User.Username)); + Assert.True(authenticateResult.Principal?.HasClaim(ClaimTypes.Name, authorizationInfo.User.Username)); } [Theory] @@ -111,7 +112,7 @@ namespace Jellyfin.Api.Tests.Auth var authenticateResult = await _sut.AuthenticateAsync(); var expectedRole = authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User; - Assert.True(authenticateResult.Principal.HasClaim(ClaimTypes.Role, expectedRole)); + Assert.True(authenticateResult.Principal?.HasClaim(ClaimTypes.Role, expectedRole)); } [Fact] @@ -120,7 +121,7 @@ namespace Jellyfin.Api.Tests.Auth SetupUser(); var authenticatedResult = await _sut.AuthenticateAsync(); - Assert.Equal(_scheme.Name, authenticatedResult.Ticket.AuthenticationScheme); + Assert.Equal(_scheme.Name, authenticatedResult.Ticket?.AuthenticationScheme); } private AuthorizationInfo SetupUser(bool isAdmin = false) @@ -128,6 +129,7 @@ namespace Jellyfin.Api.Tests.Auth var authorizationInfo = _fixture.Create<AuthorizationInfo>(); authorizationInfo.User = _fixture.Create<User>(); authorizationInfo.User.SetPermission(PermissionKind.IsAdministrator, isAdmin); + authorizationInfo.IsApiKey = false; _jellyfinAuthServiceMock.Setup( a => a.Authenticate( diff --git a/tests/Jellyfin.Api.Tests/BrandingServiceTests.cs b/tests/Jellyfin.Api.Tests/BrandingServiceTests.cs index 6fc287420b..1cbe94c5b9 100644 --- a/tests/Jellyfin.Api.Tests/BrandingServiceTests.cs +++ b/tests/Jellyfin.Api.Tests/BrandingServiceTests.cs @@ -25,7 +25,7 @@ namespace Jellyfin.Api.Tests // Assert response.EnsureSuccessStatusCode(); - Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); + Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString()); var responseBody = await response.Content.ReadAsStreamAsync(); _ = await JsonSerializer.DeserializeAsync<BrandingOptions>(responseBody); } @@ -43,7 +43,7 @@ namespace Jellyfin.Api.Tests // Assert response.EnsureSuccessStatusCode(); - Assert.Equal("text/css; charset=utf-8", response.Content.Headers.ContentType.ToString()); + Assert.Equal("text/css; charset=utf-8", response.Content.Headers.ContentType?.ToString()); } } } diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 0236f2ac1e..14eed30e03 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <Nullable>enable</Nullable> @@ -16,13 +16,13 @@ <PackageReference Include="AutoFixture" Version="4.14.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" /> <PackageReference Include="AutoFixture.Xunit2" Version="4.14.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="3.1.9" /> - <PackageReference Include="Microsoft.Extensions.Options" Version="3.1.9" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="5.0.0" /> + <PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> - <PackageReference Include="Moq" Version="4.14.7" /> + <PackageReference Include="Moq" Version="4.15.1" /> </ItemGroup> <!-- Code Analyzers --> diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs index 89c7d62f79..3ae6ae5bdd 100644 --- a/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs +++ b/tests/Jellyfin.Api.Tests/ModelBinders/CommaDelimitedArrayModelBinderTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Jellyfin.Api.ModelBinders; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Primitives; using Moq; using Xunit; @@ -17,11 +18,11 @@ namespace Jellyfin.Api.Tests.ModelBinders public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedStringArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { "lol", "xd" }; + IReadOnlyList<string> queryParamValues = new[] { "lol", "xd" }; var queryParamString = "lol,xd"; var queryParamType = typeof(string[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -35,18 +36,18 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((string[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<string>?)bindingContextMock.Object?.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedIntArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { 42, 0 }; + IReadOnlyList<int> queryParamValues = new[] { 42, 0 }; var queryParamString = "42,0"; var queryParamType = typeof(int[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -60,18 +61,18 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((int[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<int>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedEnumArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { TestType.How, TestType.Much }; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; var queryParamString = "How,Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -85,18 +86,18 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedEnumArrayQueryWithDoubleCommas() { var queryParamName = "test"; - var queryParamValues = new[] { TestType.How, TestType.Much }; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; var queryParamString = "How,,Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -110,19 +111,19 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery() { var queryParamName = "test"; - var queryParamValues = new[] { TestType.How, TestType.Much }; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; var queryParamString1 = "How"; var queryParamString2 = "Much"; var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -140,17 +141,17 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery() { var queryParamName = "test"; - var queryParamValues = Array.Empty<TestType>(); + IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>(); var queryParamType = typeof(TestType[]); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -168,17 +169,17 @@ namespace Jellyfin.Api.Tests.ModelBinders await modelBinder.BindModelAsync(bindingContextMock.Object); Assert.True(bindingContextMock.Object.Result.IsModelSet); - Assert.Equal((TestType[])bindingContextMock.Object.Result.Model, queryParamValues); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); } [Fact] - public async Task BindModelAsync_ThrowsIfCommaDelimitedEnumArrayQueryIsInvalid() + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly() { var queryParamName = "test"; var queryParamString = "🔥,😢"; - var queryParamType = typeof(TestType[]); + var queryParamType = typeof(IReadOnlyList<TestType>); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), @@ -189,20 +190,20 @@ namespace Jellyfin.Api.Tests.ModelBinders bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); bindingContextMock.SetupProperty(b => b.Result); - Func<Task> act = async () => await modelBinder.BindModelAsync(bindingContextMock.Object); - - await Assert.ThrowsAsync<FormatException>(act); + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Empty((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); } [Fact] - public async Task BindModelAsync_ThrowsIfCommaDelimitedEnumArrayQueryIsInvalid2() + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2() { var queryParamName = "test"; var queryParamString1 = "How"; var queryParamString2 = "😱"; - var queryParamType = typeof(TestType[]); + var queryParamType = typeof(IReadOnlyList<TestType>); - var modelBinder = new CommaDelimitedArrayModelBinder(); + var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger<CommaDelimitedArrayModelBinder>()); var valueProvider = new QueryStringValueProvider( new BindingSource(string.Empty, string.Empty, false, false), @@ -217,9 +218,9 @@ namespace Jellyfin.Api.Tests.ModelBinders bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); bindingContextMock.SetupProperty(b => b.Result); - Func<Task> act = async () => await modelBinder.BindModelAsync(bindingContextMock.Object); - - await Assert.ThrowsAsync<FormatException>(act); + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Single((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); } } } diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs new file mode 100644 index 0000000000..938d19a154 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using Jellyfin.Api.ModelBinders; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.ModelBinders +{ + public sealed class PipeDelimitedArrayModelBinderTests + { + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedStringArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<string> queryParamValues = new[] { "lol", "xd" }; + var queryParamString = "lol|xd"; + var queryParamType = typeof(string[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<string>?)bindingContextMock.Object?.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidDelimitedIntArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<int> queryParamValues = new[] { 42, 0 }; + var queryParamString = "42|0"; + var queryParamType = typeof(int[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<int>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; + var queryParamString = "How|Much"; + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQueryWithDoublePipes() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; + var queryParamString = "How||Much"; + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much }; + var queryParamString1 = "How"; + var queryParamString2 = "Much"; + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> + { + { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) }, + }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery() + { + var queryParamName = "test"; + IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>(); + var queryParamType = typeof(TestType[]); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> + { + { queryParamName, new StringValues(value: null) }, + }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues); + } + + [Fact] + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly() + { + var queryParamName = "test"; + var queryParamString = "🔥|😢"; + var queryParamType = typeof(IReadOnlyList<TestType>); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Empty((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); + } + + [Fact] + public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2() + { + var queryParamName = "test"; + var queryParamString1 = "How"; + var queryParamString2 = "😱"; + var queryParamType = typeof(IReadOnlyList<TestType>); + + var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>()); + + var valueProvider = new QueryStringValueProvider( + new BindingSource(string.Empty, string.Empty, false, false), + new QueryCollection(new Dictionary<string, StringValues> + { + { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) }, + }), + CultureInfo.InvariantCulture); + var bindingContextMock = new Mock<ModelBindingContext>(); + bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider); + bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName); + bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType); + bindingContextMock.SetupProperty(b => b.Result); + + await modelBinder.BindModelAsync(bindingContextMock.Object); + Assert.True(bindingContextMock.Object.Result.IsModelSet); + Assert.Single((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/OpenApiSpecTests.cs b/tests/Jellyfin.Api.Tests/OpenApiSpecTests.cs index 3a85b55141..03ab56d1f4 100644 --- a/tests/Jellyfin.Api.Tests/OpenApiSpecTests.cs +++ b/tests/Jellyfin.Api.Tests/OpenApiSpecTests.cs @@ -30,7 +30,7 @@ namespace Jellyfin.Api.Tests // Assert response.EnsureSuccessStatusCode(); - Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); + Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString()); // Write out for publishing var responseBody = await response.Content.ReadAsStringAsync(); diff --git a/tests/Jellyfin.Api.Tests/TestHelpers.cs b/tests/Jellyfin.Api.Tests/TestHelpers.cs index a4dd4e4092..f27cdf7b63 100644 --- a/tests/Jellyfin.Api.Tests/TestHelpers.cs +++ b/tests/Jellyfin.Api.Tests/TestHelpers.cs @@ -45,7 +45,7 @@ namespace Jellyfin.Api.Tests { new Claim(ClaimTypes.Role, role), new Claim(ClaimTypes.Name, "jellyfin"), - new Claim(InternalClaimTypes.UserId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.UserId, Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.DeviceId, Guid.Empty.ToString("N", CultureInfo.InvariantCulture)), new Claim(InternalClaimTypes.Device, "test"), new Claim(InternalClaimTypes.Client, "test"), @@ -60,7 +60,7 @@ namespace Jellyfin.Api.Tests .Returns(user); httpContextAccessorMock - .Setup(h => h.HttpContext.Connection.RemoteIpAddress) + .Setup(h => h.HttpContext!.Connection.RemoteIpAddress) .Returns(new IPAddress(0)); return new ClaimsPrincipal(identity); diff --git a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj index e3f87d29b7..e8eca67600 100644 --- a/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj +++ b/tests/Jellyfin.Common.Tests/Jellyfin.Common.Tests.csproj @@ -6,14 +6,14 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj index 5de02a29ba..6e3fac43d4 100644 --- a/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj +++ b/tests/Jellyfin.Controller.Tests/Jellyfin.Controller.Tests.csproj @@ -6,14 +6,14 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj index 3ac60819b4..e88de38112 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj +++ b/tests/Jellyfin.MediaEncoding.Tests/Jellyfin.MediaEncoding.Tests.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <Nullable>enable</Nullable> @@ -19,7 +19,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj index e933851366..64d51e0638 100644 --- a/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj +++ b/tests/Jellyfin.Model.Tests/Jellyfin.Model.Tests.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <Nullable>enable</Nullable> diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs index a214bc57c4..cf21f964e2 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookFileInfoTests.cs @@ -1,4 +1,4 @@ -using Emby.Naming.AudioBook; +using Emby.Naming.AudioBook; using Xunit; namespace Jellyfin.Naming.Tests.AudioBook @@ -8,22 +8,22 @@ namespace Jellyfin.Naming.Tests.AudioBook [Fact] public void CompareTo_Same_Success() { - var info = new AudioBookFileInfo(); + var info = new AudioBookFileInfo(string.Empty, string.Empty); Assert.Equal(0, info.CompareTo(info)); } [Fact] public void CompareTo_Null_Success() { - var info = new AudioBookFileInfo(); + var info = new AudioBookFileInfo(string.Empty, string.Empty); Assert.Equal(1, info.CompareTo(null)); } [Fact] public void CompareTo_Empty_Success() { - var info1 = new AudioBookFileInfo(); - var info2 = new AudioBookFileInfo(); + var info1 = new AudioBookFileInfo(string.Empty, string.Empty); + var info2 = new AudioBookFileInfo(string.Empty, string.Empty); Assert.Equal(0, info1.CompareTo(info2)); } } diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs index 1084e20bda..e5768b6209 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookListResolverTests.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using Emby.Naming.AudioBook; using Emby.Naming.Common; using MediaBrowser.Model.IO; @@ -18,11 +20,22 @@ namespace Jellyfin.Naming.Tests.AudioBook { "Harry Potter and the Deathly Hallows/Part 1.mp3", "Harry Potter and the Deathly Hallows/Part 2.mp3", - "Harry Potter and the Deathly Hallows/book.nfo", + "Harry Potter and the Deathly Hallows/Extra.mp3", "Batman/Chapter 1.mp3", "Batman/Chapter 2.mp3", "Batman/Chapter 3.mp3", + + "Badman/audiobook.mp3", + "Badman/extra.mp3", + + "Superman (2020)/Part 1.mp3", + "Superman (2020)/extra.mp3", + + "Ready Player One (2020)/audiobook.mp3", + "Ready Player One (2020)/extra.mp3", + + ".mp3" }; var resolver = GetResolver(); @@ -33,13 +46,141 @@ namespace Jellyfin.Naming.Tests.AudioBook FullName = i })).ToList(); + Assert.Equal(5, result.Count); + Assert.Equal(2, result[0].Files.Count); - // Assert.Empty(result[0].Extras); FIXME: AudioBookListResolver should resolve extra files properly + Assert.Single(result[0].Extras); Assert.Equal("Harry Potter and the Deathly Hallows", result[0].Name); Assert.Equal(3, result[1].Files.Count); Assert.Empty(result[1].Extras); Assert.Equal("Batman", result[1].Name); + + Assert.Single(result[2].Files); + Assert.Single(result[2].Extras); + Assert.Equal("Badman", result[2].Name); + + Assert.Single(result[3].Files); + Assert.Single(result[3].Extras); + Assert.Equal("Superman", result[3].Name); + + Assert.Single(result[4].Files); + Assert.Single(result[4].Extras); + Assert.Equal("Ready Player One", result[4].Name); + } + + [Fact] + public void TestAlternativeVersions() + { + var files = new[] + { + "Harry Potter and the Deathly Hallows/Chapter 1.ogg", + "Harry Potter and the Deathly Hallows/Chapter 1.mp3", + + "Deadpool.mp3", + "Deadpool [HQ].mp3", + + "Superman/audiobook.mp3", + "Superman/Superman.mp3", + "Superman/Superman [HQ].mp3", + "Superman/extra.mp3", + + "Batman/ Chapter 1 .mp3", + "Batman/Chapter 1[loss-less].mp3" + }; + + var resolver = GetResolver(); + + var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + })).ToList(); + + Assert.Equal(5, result.Count); + // HP - Same name so we don't care which file is alternative + Assert.Single(result[0].AlternateVersions); + // DP + Assert.Empty(result[1].AlternateVersions); + // DP HQ (directory missing so we do not group deadpools together) + Assert.Empty(result[2].AlternateVersions); + // Superman + // Priority: + // 1. Name + // 2. audiobook + // 3. Names with modifiers + Assert.Equal(2, result[3].AlternateVersions.Count); + var paths = result[3].AlternateVersions.Select(x => x.Path).ToList(); + Assert.Contains("Superman/audiobook.mp3", paths); + Assert.Contains("Superman/Superman [HQ].mp3", paths); + // Batman + Assert.Single(result[4].AlternateVersions); + } + + [Fact] + public void TestNameYearExtraction() + { + var data = new[] + { + new NameYearPath + { + Name = "Harry Potter and the Deathly Hallows", + Path = "Harry Potter and the Deathly Hallows (2007)/Chapter 1.ogg", + Year = 2007 + }, + new NameYearPath + { + Name = "Batman", + Path = "Batman (2020).ogg", + Year = 2020 + }, + new NameYearPath + { + Name = "Batman", + Path = "Batman( 2021 ).mp3", + Year = 2021 + }, + new NameYearPath + { + Name = "Batman(*2021*)", + Path = "Batman(*2021*).mp3", + Year = null + }, + new NameYearPath + { + Name = "Batman", + Path = "Batman.mp3", + Year = null + }, + new NameYearPath + { + Name = "+ Batman .", + Path = " + Batman . .mp3", + Year = null + }, + new NameYearPath + { + Name = " ", + Path = " .mp3", + Year = null + } + }; + + var resolver = GetResolver(); + + var result = resolver.Resolve(data.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i.Path + })).ToList(); + + Assert.Equal(data.Length, result.Count); + + for (int i = 0; i < data.Length; i++) + { + Assert.Equal(data[i].Name, result[i].Name); + Assert.Equal(data[i].Year, result[i].Year); + } } [Fact] @@ -82,9 +223,51 @@ namespace Jellyfin.Naming.Tests.AudioBook Assert.Single(result); } + [Fact] + public void TestWithoutFolder() + { + var files = new[] + { + "Harry Potter and the Deathly Hallows trailer.mp3" + }; + + var resolver = GetResolver(); + + var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + })).ToList(); + + Assert.Single(result); + } + + [Fact] + public void TestEmpty() + { + var files = Array.Empty<string>(); + + var resolver = GetResolver(); + + var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + })).ToList(); + + Assert.Empty(result); + } + private AudioBookListResolver GetResolver() { return new AudioBookListResolver(_namingOptions); } + + internal struct NameYearPath + { + public string Name; + public string Path; + public int? Year; + } } } diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs index 673289436d..b3257ace3b 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Emby.Naming.AudioBook; using Emby.Naming.Common; @@ -14,30 +14,24 @@ namespace Jellyfin.Naming.Tests.AudioBook { yield return new object[] { - new AudioBookFileInfo() - { - Path = @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", - Container = "mp3", - } + new AudioBookFileInfo( + @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", + "mp3") }; yield return new object[] { - new AudioBookFileInfo() - { - Path = @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", - Container = "ogg", - ChapterNumber = 1 - } + new AudioBookFileInfo( + @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", + "ogg", + chapterNumber: 1) }; yield return new object[] { - new AudioBookFileInfo() - { - Path = @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", - Container = "mp3", - ChapterNumber = 2, - PartNumber = 3 - } + new AudioBookFileInfo( + @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", + "mp3", + chapterNumber: 2, + partNumber: 3) }; } @@ -52,13 +46,22 @@ namespace Jellyfin.Naming.Tests.AudioBook Assert.Equal(result!.Container, expectedResult.Container); Assert.Equal(result!.ChapterNumber, expectedResult.ChapterNumber); Assert.Equal(result!.PartNumber, expectedResult.PartNumber); - Assert.Equal(result!.IsDirectory, expectedResult.IsDirectory); } [Fact] - public void Resolve_EmptyFileName_ArgumentException() + public void Resolve_InvalidExtension() { - Assert.Throws<ArgumentException>(() => new AudioBookResolver(_namingOptions).Resolve(string.Empty)); + var result = new AudioBookResolver(_namingOptions).Resolve(@"/server/AudioBooks/Larry Potter/Larry Potter.mp9"); + + Assert.Null(result); + } + + [Fact] + public void Resolve_EmptyFileName() + { + var result = new AudioBookResolver(_namingOptions).Resolve(string.Empty); + + Assert.Null(result); } } } diff --git a/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs new file mode 100644 index 0000000000..3892d00f61 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/Common/NamingOptionsTest.cs @@ -0,0 +1,36 @@ +using Emby.Naming.Common; +using Xunit; + +namespace Jellyfin.Naming.Tests.Common +{ + public class NamingOptionsTest + { + [Fact] + public void TestNamingOptionsCompile() + { + var options = new NamingOptions(); + + Assert.NotEmpty(options.VideoFileStackingRegexes); + Assert.NotEmpty(options.CleanDateTimeRegexes); + Assert.NotEmpty(options.CleanStringRegexes); + Assert.NotEmpty(options.EpisodeWithoutSeasonRegexes); + Assert.NotEmpty(options.EpisodeMultiPartRegexes); + } + + [Fact] + public void TestNamingOptionsEpisodeExpressions() + { + var exp = new EpisodeExpression(string.Empty); + + Assert.False(exp.IsOptimistic); + exp.IsOptimistic = true; + Assert.True(exp.IsOptimistic); + + Assert.Equal(string.Empty, exp.Expression); + Assert.NotNull(exp.Regex); + exp.Expression = "test"; + Assert.Equal("test", exp.Expression); + Assert.NotNull(exp.Regex); + } + } +} diff --git a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj index 37d0a9929a..567cf34ef3 100644 --- a/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj +++ b/tests/Jellyfin.Naming.Tests/Jellyfin.Naming.Tests.csproj @@ -6,14 +6,14 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> <Nullable>enable</Nullable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" /> diff --git a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs b/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs index d11809de11..f3abacb4f9 100644 --- a/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/Subtitles/SubtitleParserTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using Emby.Naming.Common; using Emby.Naming.Subtitles; using Xunit; @@ -26,21 +26,17 @@ namespace Jellyfin.Naming.Tests.Subtitles Assert.Equal(language, result?.Language, true); Assert.Equal(isDefault, result?.IsDefault); Assert.Equal(isForced, result?.IsForced); + Assert.Equal(input, result?.Path); } [Theory] [InlineData("The Skin I Live In (2011).mp4")] + [InlineData("")] public void SubtitleParser_InvalidFileName_ReturnsNull(string input) { var parser = new SubtitleParser(_namingOptions); Assert.Null(parser.ParseFile(input)); } - - [Fact] - public void SubtitleParser_EmptyFileName_ThrowsArgumentException() - { - Assert.Throws<ArgumentException>(() => new SubtitleParser(_namingOptions).ParseFile(string.Empty)); - } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs index 03aeb7f76b..12fc19bc48 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs @@ -1,4 +1,4 @@ -using Emby.Naming.Common; +using Emby.Naming.Common; using Emby.Naming.TV; using Xunit; @@ -7,43 +7,98 @@ namespace Jellyfin.Naming.Tests.TV public class EpisodePathParserTest { [Theory] - [InlineData("/media/Foo/Foo-S01E01", "Foo", 1, 1)] - [InlineData("/media/Foo - S04E011", "Foo", 4, 11)] - [InlineData("/media/Foo/Foo s01x01", "Foo", 1, 1)] - [InlineData("/media/Foo (2019)/Season 4/Foo (2019).S04E03", "Foo (2019)", 4, 3)] - [InlineData("D:\\media\\Foo\\Foo-S01E01", "Foo", 1, 1)] - [InlineData("D:\\media\\Foo - S04E011", "Foo", 4, 11)] - [InlineData("D:\\media\\Foo\\Foo s01x01", "Foo", 1, 1)] - [InlineData("D:\\media\\Foo (2019)\\Season 4\\Foo (2019).S04E03", "Foo (2019)", 4, 3)] - [InlineData("/Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 1/seriesname S01E02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Running Man/Running Man S2017E368.mkv", "Running Man", 2017, 368)] - [InlineData("/Season 1/seriesname 01x02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Season 25/The Simpsons.S25E09.Steal this episode.mp4", "The Simpsons", 25, 9)] - [InlineData("/Season 1/seriesname S01x02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 1/seriesname S01xE02 blah.avi", "seriesname", 1, 2)] - [InlineData("/Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 02/Elementary - 02x03-E15 - Ep Name.mp4", "Elementary", 2, 3)] - [InlineData("/Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", "Elementary", 1, 23)] - [InlineData("/The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH/The Wonder Years s04e07 Christmas Party NTSC PDTV.avi", "The Wonder Years", 4, 7)] + [InlineData("/media/Foo/Foo-S01E01", true, "Foo", 1, 1)] + [InlineData("/media/Foo - S04E011", true, "Foo", 4, 11)] + [InlineData("/media/Foo/Foo s01x01", true, "Foo", 1, 1)] + [InlineData("/media/Foo (2019)/Season 4/Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] + [InlineData("D:\\media\\Foo\\Foo-S01E01", true, "Foo", 1, 1)] + [InlineData("D:\\media\\Foo - S04E011", true, "Foo", 4, 11)] + [InlineData("D:\\media\\Foo\\Foo s01x01", true, "Foo", 1, 1)] + [InlineData("D:\\media\\Foo (2019)\\Season 4\\Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] + [InlineData("/Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 1/seriesname S01E02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Running Man/Running Man S2017E368.mkv", false, "Running Man", 2017, 368)] + [InlineData("/Season 1/seriesname 01x02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Season 25/The Simpsons.S25E09.Steal this episode.mp4", false, "The Simpsons", 25, 9)] + [InlineData("/Season 1/seriesname S01x02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 1/seriesname S01xE02 blah.avi", false, "seriesname", 1, 2)] + [InlineData("/Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 02/Elementary - 02x03-E15 - Ep Name.mp4", false, "Elementary", 2, 3)] + [InlineData("/Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", false, "Elementary", 1, 23)] + [InlineData("/The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH/The Wonder Years s04e07 Christmas Party NTSC PDTV.avi", false, "The Wonder Years", 4, 7)] // TODO: [InlineData("/Castle Rock 2x01 Que el rio siga su curso [WEB-DL HULU 1080p h264 Dual DD5.1 Subs].mkv", "Castle Rock", 2, 1)] // TODO: [InlineData("/After Life 1x06 Episodio 6 [WEB-DL NF 1080p h264 Dual DD 5.1 Sub].mkv", "After Life", 1, 6)] // TODO: [InlineData("/Season 4/Uchuu.Senkan.Yamato.2199.E03.avi", "Uchuu Senkan Yamoto 2199", 4, 3)] // TODO: [InlineData("The Daily Show/The Daily Show 25x22 - [WEBDL-720p][AAC 2.0][x264] Noah Baumbach-TBS.mkv", "The Daily Show", 25, 22)] // TODO: [InlineData("Watchmen (2019)/Watchmen 1x03 [WEBDL-720p][EAC3 5.1][h264][-TBS] - She Was Killed by Space Junk.mkv", "Watchmen (2019)", 1, 3)] // TODO: [InlineData("/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv/The.Legend.of.Condor.Heroes.2017.E07.V2.web-dl.1080p.h264.aac-hdctv.mkv", "The Legend of Condor Heroes 2017", 1, 7)] - public void ParseEpisodesCorrectly(string path, string name, int season, int episode) + public void ParseEpisodesCorrectly(string path, bool isDirectory, string name, int season, int episode) { NamingOptions o = new NamingOptions(); EpisodePathParser p = new EpisodePathParser(o); - var res = p.Parse(path, false); + var res = p.Parse(path, isDirectory); Assert.True(res.Success); Assert.Equal(name, res.SeriesName); Assert.Equal(season, res.SeasonNumber); Assert.Equal(episode, res.EpisodeNumber); } + + [Theory] + [InlineData("/test/01-03.avi", true, true)] + public void EpisodePathParserTest_DifferentExpressionsParameters(string path, bool? isNamed, bool? isOptimistic) + { + NamingOptions o = new NamingOptions(); + EpisodePathParser p = new EpisodePathParser(o); + var res = p.Parse(path, false, isNamed, isOptimistic); + + Assert.True(res.Success); + } + + [Fact] + public void EpisodePathParserTest_FalsePositivePixelRate() + { + NamingOptions o = new NamingOptions(); + EpisodePathParser p = new EpisodePathParser(o); + var res = p.Parse("Series Special (1920x1080).mkv", false); + + Assert.False(res.Success); + } + + [Fact] + public void EpisodeResolverTest_WrongExtension() + { + var res = new EpisodeResolver(new NamingOptions()).Resolve("test.mp3", false); + Assert.Null(res); + } + + [Fact] + public void EpisodeResolverTest_WrongExtensionStub() + { + var res = new EpisodeResolver(new NamingOptions()).Resolve("dvd.disc", false); + Assert.NotNull(res); + Assert.True(res!.IsStub); + } + + /* + * EpisodePathParser.cs:130 is currently unreachable, but the piece of code is useful and could be reached with addition of new EpisodeExpressions. + * In order to preserve it but achieve 100% code coverage the test case below with made up expressions and filename is used. + */ + [Fact] + public void EpisodePathParserTest_EmptyDateParsers() + { + NamingOptions o = new NamingOptions() + { + EpisodeExpressions = new[] { new EpisodeExpression("(([0-9]{4})-([0-9]{2})-([0-9]{2}) [0-9]{2}:[0-9]{2}:[0-9]{2})", true) } + }; + o.Compile(); + + EpisodePathParser p = new EpisodePathParser(o); + var res = p.Parse("ABC_2019_10_21 11:00:00", false); + + Assert.True(res.Success); + } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs index 3513050b64..58ea0bec59 100644 --- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs @@ -74,7 +74,7 @@ namespace Jellyfin.Naming.Tests.TV var result = new EpisodePathParser(options) .Parse(filename, false); - Assert.Equal(result.EndingEpsiodeNumber, endingEpisodeNumber); + Assert.Equal(result.EndingEpisodeNumber, endingEpisodeNumber); } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs index 078f940b29..b7b5b54ec8 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs @@ -1,4 +1,4 @@ -using Emby.Naming.TV; +using Emby.Naming.TV; using Xunit; namespace Jellyfin.Naming.Tests.TV @@ -6,26 +6,30 @@ namespace Jellyfin.Naming.Tests.TV public class SeasonFolderTests { [Theory] - [InlineData(@"/Drive/Season 1", 1)] - [InlineData(@"/Drive/Season 2", 2)] - [InlineData(@"/Drive/Season 02", 2)] - [InlineData(@"/Drive/Seinfeld/S02", 2)] - [InlineData(@"/Drive/Seinfeld/2", 2)] - [InlineData(@"/Drive/Season 2009", 2009)] - [InlineData(@"/Drive/Season1", 1)] - [InlineData(@"The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4)] - [InlineData(@"/Drive/Season 7 (2016)", 7)] - [InlineData(@"/Drive/Staffel 7 (2016)", 7)] - [InlineData(@"/Drive/Stagione 7 (2016)", 7)] - [InlineData(@"/Drive/Season (8)", null)] - [InlineData(@"/Drive/3.Staffel", 3)] - [InlineData(@"/Drive/s06e05", null)] - [InlineData(@"/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null)] - public void GetSeasonNumberFromPathTest(string path, int? seasonNumber) + [InlineData(@"/Drive/Season 1", 1, true)] + [InlineData(@"/Drive/Season 2", 2, true)] + [InlineData(@"/Drive/Season 02", 2, true)] + [InlineData(@"/Drive/Seinfeld/S02", 2, true)] + [InlineData(@"/Drive/Seinfeld/2", 2, true)] + [InlineData(@"/Drive/Season 2009", 2009, true)] + [InlineData(@"/Drive/Season1", 1, true)] + [InlineData(@"The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)] + [InlineData(@"/Drive/Season 7 (2016)", 7, false)] + [InlineData(@"/Drive/Staffel 7 (2016)", 7, false)] + [InlineData(@"/Drive/Stagione 7 (2016)", 7, false)] + [InlineData(@"/Drive/Season (8)", null, false)] + [InlineData(@"/Drive/3.Staffel", 3, false)] + [InlineData(@"/Drive/s06e05", null, false)] + [InlineData(@"/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)] + [InlineData(@"/Drive/extras", 0, true)] + [InlineData(@"/Drive/specials", 0, true)] + public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory) { var result = SeasonPathParser.Parse(path, true, true); + Assert.Equal(result.SeasonNumber != null, result.Success); Assert.Equal(result.SeasonNumber, seasonNumber); + Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); } } } diff --git a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs index 40b41b9f3d..89579c0376 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs @@ -1,4 +1,5 @@ -using Emby.Naming.Common; +using System.IO; +using Emby.Naming.Common; using Emby.Naming.TV; using Xunit; @@ -15,7 +16,6 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/server/The Walking Dead 4x01.mp4", "The Walking Dead", 4, 1)] [InlineData("/server/the_simpsons-s02e01_18536.mp4", "the_simpsons", 2, 1)] [InlineData("/server/Temp/S01E02 foo.mp4", "", 1, 2)] - [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12)] [InlineData("Series/4x12 - The Woman.mp4", "", 4, 12)] [InlineData("Series/LA X, Pt. 1_s06e32.mp4", "LA X, Pt. 1", 6, 32)] [InlineData("[Baz-Bar]Foo - [1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] @@ -24,16 +24,37 @@ namespace Jellyfin.Naming.Tests.TV // TODO: [InlineData("[Baz-Bar]Foo - 01 - 12[1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] // TODO: [InlineData("E:\\Anime\\Yahari Ore no Seishun Love Comedy wa Machigatteiru\\Yahari Ore no Seishun Love Comedy wa Machigatteiru. Zoku\\Oregairu Zoku 11 - Hayama Hayato Always Renconds to Everyone's Expectations..mkv", "Yahari Ore no Seishun Love Comedy wa Machigatteiru", null, 11)] // TODO: [InlineData(@"/Library/Series/The Grand Tour (2016)/Season 1/S01E01 The Holy Trinity.mkv", "The Grand Tour", 1, 1)] - public void Test(string path, string seriesName, int? seasonNumber, int? episodeNumber) + public void TestSimple(string path, string seriesName, int? seasonNumber, int? episodeNumber) + { + Test(path, seriesName, seasonNumber, episodeNumber, null); + } + + [Theory] + [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12, 12)] + public void TestWithPossibleEpisodeEnd(string path, string seriesName, int? seasonNumber, int? episodeNumber, int? episodeEndNumber) + { + Test(path, seriesName, seasonNumber, episodeNumber, episodeEndNumber); + } + + private void Test(string path, string seriesName, int? seasonNumber, int? episodeNumber, int? episodeEndNumber) { var options = new NamingOptions(); var result = new EpisodeResolver(options) .Resolve(path, false); + Assert.NotNull(result); Assert.Equal(seasonNumber, result?.SeasonNumber); Assert.Equal(episodeNumber, result?.EpisodeNumber); Assert.Equal(seriesName, result?.SeriesName, true); + Assert.Equal(path, result?.Path); + Assert.Equal(Path.GetExtension(path).Substring(1), result?.Container); + Assert.Null(result?.Format3D); + Assert.False(result?.Is3D); + Assert.False(result?.IsStub); + Assert.Null(result?.StubType); + Assert.Equal(episodeEndNumber, result?.EndingEpisodeNumber); + Assert.False(result?.IsByDate); } } } diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs index 4a01d79883..950899d7e7 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using Emby.Naming.Common; using Emby.Naming.Video; using Xunit; @@ -52,6 +52,8 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("My Movie 2013-12-09", "My Movie 2013-12-09", null)] [InlineData("My Movie 20131209", "My Movie 20131209", null)] [InlineData("My Movie 2013-12-09 2013", "My Movie 2013-12-09", 2013)] + [InlineData(null, null, null)] + [InlineData("", "", null)] public void CleanDateTimeTest(string input, string expectedName, int? expectedYear) { input = Path.GetFileName(input); diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs index 8dfb8f8591..d34f65409f 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -1,7 +1,9 @@ -using Emby.Naming.Common; +using System; +using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.Entities; using Xunit; +using MediaType = Emby.Naming.Common.MediaType; namespace Jellyfin.Naming.Tests.Video { @@ -93,6 +95,23 @@ namespace Jellyfin.Naming.Tests.Video } } + [Fact] + public void TestExtraInfo_InvalidRuleType() + { + var rule = new ExtraRule(ExtraType.Unknown, ExtraRuleType.Regex, @"([eE]x(tra)?\.\w+)", MediaType.Video); + var options = new NamingOptions { VideoExtraRules = new[] { rule } }; + var res = GetExtraTypeParser(options).GetExtraInfo("extra.mp4"); + + Assert.Equal(rule, res.Rule); + } + + [Fact] + public void TestFlagsParser() + { + var flags = new FlagParser(_videoOptions).GetFlags(string.Empty); + Assert.Empty(flags); + } + private ExtraResolver GetExtraTypeParser(NamingOptions videoOptions) { return new ExtraResolver(videoOptions); diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 4198d69ff8..9df6904ef6 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.IO; @@ -11,8 +12,8 @@ namespace Jellyfin.Naming.Tests.Video private readonly NamingOptions _namingOptions = new NamingOptions(); // FIXME - // [Fact] - private void TestMultiEdition1() + [Fact] + public void TestMultiEdition1() { var files = new[] { @@ -35,8 +36,8 @@ namespace Jellyfin.Naming.Tests.Video } // FIXME - // [Fact] - private void TestMultiEdition2() + [Fact] + public void TestMultiEdition2() { var files = new[] { @@ -81,8 +82,8 @@ namespace Jellyfin.Naming.Tests.Video } // FIXME - // [Fact] - private void TestLetterFolders() + [Fact] + public void TestLetterFolders() { var files = new[] { @@ -109,8 +110,8 @@ namespace Jellyfin.Naming.Tests.Video } // FIXME - // [Fact] - private void TestMultiVersionLimit() + [Fact] + public void TestMultiVersionLimit() { var files = new[] { @@ -138,8 +139,8 @@ namespace Jellyfin.Naming.Tests.Video } // FIXME - // [Fact] - private void TestMultiVersionLimit2() + [Fact] + public void TestMultiVersionLimit2() { var files = new[] { @@ -168,8 +169,8 @@ namespace Jellyfin.Naming.Tests.Video } // FIXME - // [Fact] - private void TestMultiVersion3() + [Fact] + public void TestMultiVersion3() { var files = new[] { @@ -194,8 +195,8 @@ namespace Jellyfin.Naming.Tests.Video } // FIXME - // [Fact] - private void TestMultiVersion4() + [Fact] + public void TestMultiVersion4() { // Test for false positive @@ -221,9 +222,8 @@ namespace Jellyfin.Naming.Tests.Video Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion5() + [Fact] + public void TestMultiVersion5() { var files = new[] { @@ -254,8 +254,8 @@ namespace Jellyfin.Naming.Tests.Video } // FIXME - // [Fact] - private void TestMultiVersion6() + [Fact] + public void TestMultiVersion6() { var files = new[] { @@ -285,9 +285,8 @@ namespace Jellyfin.Naming.Tests.Video Assert.True(result[0].AlternateVersions[5].Is3D); } - // FIXME - // [Fact] - private void TestMultiVersion7() + [Fact] + public void TestMultiVersion7() { var files = new[] { @@ -306,12 +305,9 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(2, result.Count); } - // FIXME - // [Fact] - private void TestMultiVersion8() + [Fact] + public void TestMultiVersion8() { - // This is not actually supported yet - var files = new[] { @"/movies/Iron Man/Iron Man.mkv", @@ -339,9 +335,8 @@ namespace Jellyfin.Naming.Tests.Video Assert.True(result[0].AlternateVersions[4].Is3D); } - // FIXME - // [Fact] - private void TestMultiVersion9() + [Fact] + public void TestMultiVersion9() { // Test for false positive @@ -367,9 +362,8 @@ namespace Jellyfin.Naming.Tests.Video Assert.Empty(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion10() + [Fact] + public void TestMultiVersion10() { var files = new[] { @@ -390,12 +384,9 @@ namespace Jellyfin.Naming.Tests.Video Assert.Single(result[0].AlternateVersions); } - // FIXME - // [Fact] - private void TestMultiVersion11() + [Fact] + public void TestMultiVersion11() { - // Currently not supported but we should probably handle this. - var files = new[] { @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [1080p] Blu-ray.x264.DTS.mkv", @@ -415,6 +406,16 @@ namespace Jellyfin.Naming.Tests.Video Assert.Single(result[0].AlternateVersions); } + [Fact] + public void TestEmptyList() + { + var resolver = GetResolver(); + + var result = resolver.Resolve(new List<FileSystemMetadata>()).ToList(); + + Assert.Empty(result); + } + private VideoListResolver GetResolver() { return new VideoListResolver(_namingOptions); diff --git a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs index 30ba941365..6e759c6d6b 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs @@ -1,4 +1,4 @@ -using Emby.Naming.Common; +using Emby.Naming.Common; using Emby.Naming.Video; using Xunit; @@ -23,6 +23,7 @@ namespace Jellyfin.Naming.Tests.Video Test("video.hdtv.disc", true, "tv"); Test("video.pdtv.disc", true, "tv"); Test("video.dsr.disc", true, "tv"); + Test(string.Empty, false, "tv"); } [Fact] diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index 12c4a50fe3..215c7e5405 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.IO; @@ -369,6 +369,26 @@ namespace Jellyfin.Naming.Tests.Video Assert.Single(result); } + [Fact] + public void TestFourRooms() + { + var files = new[] + { + @"Four Rooms - A.avi", + @"Four Rooms - A.mp4" + }; + + var resolver = GetResolver(); + + var result = resolver.Resolve(files.Select(i => new FileSystemMetadata + { + IsDirectory = false, + FullName = i + }).ToList()).ToList(); + + Assert.Equal(2, result.Count); + } + [Fact] public void TestMovieTrailer() { @@ -431,6 +451,13 @@ namespace Jellyfin.Naming.Tests.Video Assert.Single(result); } + [Fact] + public void TestDirectoryStack() + { + var stack = new FileStack(); + Assert.False(stack.ContainsFile("XX", true)); + } + private VideoListResolver GetResolver() { return new VideoListResolver(_namingOptions); diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs index 096ebb836a..7d105b084e 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.Linq; using Emby.Naming.Common; using Emby.Naming.Video; using MediaBrowser.Model.Entities; @@ -14,165 +15,135 @@ namespace Jellyfin.Naming.Tests.Video { yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv", - Container = "mkv", - Name = "7 Psychos" - } + new VideoFileInfo( + path: @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv", + container: "mkv", + name: "7 Psychos") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", - Container = "mkv", - Name = "3 days to kill", - Year = 2005 - } + new VideoFileInfo( + path: @"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", + container: "mkv", + name: "3 days to kill", + year: 2005) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/American Psycho/American.Psycho.mkv", - Container = "mkv", - Name = "American.Psycho", - } + new VideoFileInfo( + path: @"/server/Movies/American Psycho/American.Psycho.mkv", + container: "mkv", + name: "American.Psycho") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", - Container = "mkv", - Name = "brave", - Year = 2006, - Is3D = true, - Format3D = "sbs", - } + new VideoFileInfo( + path: @"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", + container: "mkv", + name: "brave", + year: 2006, + is3D: true, + format3D: "sbs") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", - Container = "mkv", - Name = "300", - Year = 2006 - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", + container: "mkv", + name: "300", + year: 2006) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", - Container = "mkv", - Name = "300", - Year = 2006, - Is3D = true, - Format3D = "sbs", - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", + container: "mkv", + name: "300", + year: 2006, + is3D: true, + format3D: "sbs") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", - Container = "disc", - Name = "brave", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", + container: "disc", + name: "brave", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", - Container = "disc", - Name = "300", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", + container: "disc", + name: "300", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Brave (2007)/Brave (2006).bluray.disc", - Container = "disc", - Name = "Brave", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/Brave (2007)/Brave (2006).bluray.disc", + container: "disc", + name: "Brave", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).bluray.disc", - Container = "disc", - Name = "300", - Year = 2006, - IsStub = true, - StubType = "bluray", - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).bluray.disc", + container: "disc", + name: "300", + year: 2006, + isStub: true, + stubType: "bluray") }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv", - Container = "mkv", - Name = "300", - Year = 2006, - ExtraType = ExtraType.Trailer, - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv", + container: "mkv", + name: "300", + year: 2006, + extraType: ExtraType.Trailer) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", - Container = "mkv", - Name = "Brave", - Year = 2006, - ExtraType = ExtraType.Trailer, - } + new VideoFileInfo( + path: @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", + container: "mkv", + name: "Brave", + year: 2006, + extraType: ExtraType.Trailer) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/300 (2007)/300 (2006).mkv", - Container = "mkv", - Name = "300", - Year = 2006 - } + new VideoFileInfo( + path: @"/server/Movies/300 (2007)/300 (2006).mkv", + container: "mkv", + name: "300", + year: 2006) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", - Container = "mkv", - Name = "Bad Boys", - Year = 1995, - } + new VideoFileInfo( + path: @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", + container: "mkv", + name: "Bad Boys", + year: 1995) }; yield return new object[] { - new VideoFileInfo() - { - Path = @"/server/Movies/Brave (2007)/Brave (2006).mkv", - Container = "mkv", - Name = "Brave", - Year = 2006, - } + new VideoFileInfo( + path: @"/server/Movies/Brave (2007)/Brave (2006).mkv", + container: "mkv", + name: "Brave", + year: 2006) }; yield return new object[] { @@ -204,6 +175,34 @@ namespace Jellyfin.Naming.Tests.Video Assert.Equal(result?.StubType, expectedResult.StubType); Assert.Equal(result?.IsDirectory, expectedResult.IsDirectory); Assert.Equal(result?.FileNameWithoutExtension, expectedResult.FileNameWithoutExtension); + Assert.Equal(result?.ToString(), expectedResult.ToString()); + } + + [Fact] + public void ResolveFile_EmptyPath() + { + var result = new VideoResolver(_namingOptions).ResolveFile(string.Empty); + + Assert.Null(result); + } + + [Fact] + public void ResolveDirectoryTest() + { + var paths = new[] + { + @"/Server/Iron Man", + @"Batman", + string.Empty + }; + + var resolver = new VideoResolver(_namingOptions); + var results = paths.Select(path => resolver.ResolveDirectory(path)).ToList(); + + Assert.Equal(3, results.Count); + Assert.NotNull(results[0]); + Assert.NotNull(results[1]); + Assert.Null(results[2]); } } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index 05323490e2..547f80ed96 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <PropertyGroup> - <TargetFramework>netcoreapp3.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <Nullable>enable</Nullable> @@ -16,8 +16,8 @@ <ItemGroup> <PackageReference Include="AutoFixture" Version="4.14.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> - <PackageReference Include="Moq" Version="4.14.7" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" /> + <PackageReference Include="Moq" Version="4.15.1" /> <PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" /> <PackageReference Include="coverlet.collector" Version="1.3.0" />