diff --git a/.ci/azure-pipelines-abi.yml b/.ci/azure-pipelines-abi.yml
index 4d38a906e6..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
@@ -62,6 +62,7 @@ jobs:
- task: DownloadPipelineArtifact@2
displayName: 'Download Reference Assembly Build Artifact'
+ enabled: false
inputs:
source: "specific"
artifact: "$(NugetPackageName)"
@@ -73,6 +74,7 @@ jobs:
- task: CopyFiles@2
displayName: 'Copy Reference Assembly Build Artifact'
+ enabled: false
inputs:
sourceFolder: $(System.ArtifactsDirectory)/current-artifacts
contents: '**/*.dll'
@@ -83,6 +85,7 @@ jobs:
- task: DotNetCoreCLI@2
displayName: 'Execute ABI Compatibility Check Tool'
+ enabled: false
inputs:
command: custom
custom: compat
diff --git a/.ci/azure-pipelines-api-client.yml b/.ci/azure-pipelines-api-client.yml
index fc89b90d4e..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:
@@ -28,18 +29,15 @@ jobs:
inputs:
script: "wget https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/${{ parameters.GeneratorVersion }}/openapi-generator-cli-${{ parameters.GeneratorVersion }}.jar -O openapi-generator-cli.jar"
-## Generate npm api client
-# Unstable
- - task: CmdLine@2
- displayName: 'Build unstable typescript axios client'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
+## Authenticate with npm registry
+ - task: npmAuthenticate@0
inputs:
- script: "bash ./apiclient/templates/typescript/axios/generate.sh $(System.ArtifactsDirectory) $(Build.BuildNumber)"
+ workingFile: ./.npmrc
+ customEndpoint: 'jellyfin-bot for NPM'
-# Stable
+## Generate npm api client
- 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)"
@@ -51,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 67aac45c9d..d478516b83 100644
--- a/.ci/azure-pipelines-package.yml
+++ b/.ci/azure-pipelines-package.yml
@@ -63,6 +63,7 @@ jobs:
sshEndpoint: repository
sourceFolder: '$(Build.SourcesDirectory)/deployment/dist'
contents: '**'
+ targetFolder: '/srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
- job: OpenAPISpec
dependsOn: Test
@@ -166,7 +167,7 @@ jobs:
inputs:
sshEndpoint: repository
runOptions: 'commands'
- commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
+ commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) unstable &
- task: SSH@0
displayName: 'Update Stable Repository'
@@ -175,7 +176,7 @@ jobs:
inputs:
sshEndpoint: repository
runOptions: 'commands'
- commands: sudo nohup -n /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) &
+ commands: nohup sudo /srv/repository/collect-server.azure.sh /srv/repository/incoming/azure $(Build.BuildNumber) &
- job: PublishNuget
displayName: 'Publish NuGet packages'
@@ -187,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/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000000..5388948189
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,36 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+ schedule:
+ - cron: '24 2 * * 4'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'csharp' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: '5.0.100'
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: ${{ matrix.language }}
+ queries: +security-extended
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v1
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000000..b7a317000b
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,3 @@
+registry=https://registry.npmjs.org/
+@jellyfin:registry=https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/npm/registry/
+always-auth=true
\ No newline at end of file
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 218542dbaa..a63db6ed7c 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -7,6 +7,7 @@
- [anthonylavado](https://github.com/anthonylavado)
- [Artiume](https://github.com/Artiume)
- [AThomsen](https://github.com/AThomsen)
+ - [barongreenback](https://github.com/BaronGreenback)
- [barronpm](https://github.com/barronpm)
- [bilde2910](https://github.com/bilde2910)
- [bfayers](https://github.com/bfayers)
@@ -105,6 +106,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 4b108b89ea..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;
@@ -487,18 +583,32 @@ namespace Emby.Dlna.ContentDirectory
User = user,
Recursive = true,
IsMissing = false,
- ExcludeItemTypes = new[] { typeof(Book).Name },
+ ExcludeItemTypes = new[] { nameof(Book) },
IsFolder = isFolder,
MediaTypes = mediaTypes,
DtoOptions = GetDtoOptions()
});
}
- 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)
@@ -556,7 +666,7 @@ namespace Emby.Dlna.ContentDirectory
Limit = limit,
StartIndex = startIndex,
IsVirtualItem = false,
- ExcludeItemTypes = new[] { typeof(Book).Name },
+ ExcludeItemTypes = new[] { nameof(Book) },
IsPlaceHolder = false,
DtoOptions = GetDtoOptions()
};
@@ -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)
@@ -575,7 +693,7 @@ namespace Emby.Dlna.ContentDirectory
StartIndex = startIndex,
Limit = limit,
};
- query.IncludeItemTypes = new[] { typeof(LiveTvChannel).Name };
+ query.IncludeItemTypes = new[] { nameof(LiveTvChannel) };
SetSorting(query, sort, false);
@@ -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,136 +1068,213 @@ 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;
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(Series).Name };
+ query.IncludeItemTypes = new[] { nameof(Series) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
var result = _libraryManager.GetItemsResult(query);
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;
// query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(BoxSet).Name };
+ query.IncludeItemTypes = new[] { nameof(BoxSet) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name };
+ query.IncludeItemTypes = new[] { nameof(MusicAlbum) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(Audio).Name };
+ query.IncludeItemTypes = new[] { nameof(Audio) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(Audio).Name };
+ query.IncludeItemTypes = new[] { nameof(Audio) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(Series).Name };
+ query.IncludeItemTypes = new[] { nameof(Series) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(Episode).Name };
+ query.IncludeItemTypes = new[] { nameof(Episode) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
var result = _libraryManager.GetItemsResult(query);
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;
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(MusicAlbum).Name };
+ query.IncludeItemTypes = new[] { nameof(MusicAlbum) };
var result = _libraryManager.GetItemsResult(query);
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)>();
@@ -1181,7 +1476,7 @@ namespace Emby.Dlna.ContentDirectory
{
UserId = user.Id,
Limit = 50,
- IncludeItemTypes = new[] { typeof(Episode).Name },
+ IncludeItemTypes = new[] { nameof(Episode) },
ParentId = parent == null ? Guid.Empty : parent.Id,
GroupItems = false
},
@@ -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)
@@ -1215,7 +1527,7 @@ namespace Emby.Dlna.ContentDirectory
Recursive = true,
ParentId = parentId,
ArtistIds = new[] { item.Id },
- IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+ IncludeItemTypes = new[] { nameof(MusicAlbum) },
Limit = limit,
StartIndex = startIndex,
DtoOptions = GetDtoOptions()
@@ -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)
@@ -1259,7 +1591,7 @@ namespace Emby.Dlna.ContentDirectory
Recursive = true,
ParentId = parentId,
GenreIds = new[] { item.Id },
- IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+ IncludeItemTypes = new[] { nameof(MusicAlbum) },
Limit = limit,
StartIndex = startIndex,
DtoOptions = GetDtoOptions()
@@ -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;
@@ -1346,8 +1711,8 @@ namespace Emby.Dlna.ContentDirectory
{
if (id.StartsWith(name + "_", StringComparison.OrdinalIgnoreCase))
{
- stubType = (StubType)Enum.Parse(typeof(StubType), name, true);
- id = id.Split(new[] { '_' }, 2)[1];
+ stubType = Enum.Parse(name, true);
+ id = id.Split('_', 2)[1];
break;
}
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/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs
index 5b8a89d8f3..abaf522bca 100644
--- a/Emby.Dlna/Didl/DidlBuilder.cs
+++ b/Emby.Dlna/Didl/DidlBuilder.cs
@@ -123,7 +123,7 @@ namespace Emby.Dlna.Didl
{
foreach (var att in profile.XmlRootAttributes)
{
- var parts = att.Name.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries);
+ var parts = att.Name.Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2)
{
writer.WriteAttributeString(parts[0], parts[1], null, att.Value);
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 1807ac6a13..fedd20b68a 100644
--- a/Emby.Dlna/DlnaManager.cs
+++ b/Emby.Dlna/DlnaManager.cs
@@ -383,9 +383,9 @@ namespace Emby.Dlna
continue;
}
- var filename = Path.GetFileName(name).Substring(namespaceName.Length);
-
- var path = Path.Combine(systemProfilesPath, filename);
+ var path = Path.Join(
+ systemProfilesPath,
+ Path.GetFileName(name.AsSpan()).Slice(namespaceName.Length));
using (var stream = _assembly.GetManifestResourceStream(name))
{
@@ -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 7d8da86ef9..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))
{
@@ -168,7 +168,7 @@ namespace Emby.Dlna.Eventing
builder.Append("");
- using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl);
+ using var options = new HttpRequestMessage(new HttpMethod("NOTIFY"), subscription.CallbackUrl);
options.Content = new StringContent(builder.ToString(), Encoding.UTF8, MediaTypeNames.Text.Xml);
options.Headers.TryAddWithoutValidation("NT", subscription.NotificationType);
options.Headers.TryAddWithoutValidation("NTS", "upnp:propchange");
diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs
index 40c2cc0e0a..fb4454a343 100644
--- a/Emby.Dlna/Main/DlnaEntryPoint.cs
+++ b/Emby.Dlna/Main/DlnaEntryPoint.cs
@@ -2,12 +2,14 @@
using System;
using System.Globalization;
+using System.Linq;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Emby.Dlna.PlayTo;
using Emby.Dlna.Ssdp;
+using Jellyfin.Networking.Manager;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
@@ -134,20 +136,20 @@ namespace Emby.Dlna.Main
{
await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false);
- await ReloadComponents().ConfigureAwait(false);
+ ReloadComponents();
_config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
}
- private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
+ private void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
{
if (string.Equals(e.Key, "dlna", StringComparison.OrdinalIgnoreCase))
{
- await ReloadComponents().ConfigureAwait(false);
+ ReloadComponents();
}
}
- private async Task ReloadComponents()
+ private void ReloadComponents()
{
var options = _config.GetDlnaConfiguration();
@@ -155,7 +157,7 @@ namespace Emby.Dlna.Main
if (options.EnableServer)
{
- await StartDevicePublisher(options).ConfigureAwait(false);
+ StartDevicePublisher(options);
}
else
{
@@ -225,7 +227,7 @@ namespace Emby.Dlna.Main
}
}
- public async Task StartDevicePublisher(Configuration.DlnaOptions options)
+ public void StartDevicePublisher(Configuration.DlnaOptions options)
{
if (!options.BlastAliveMessages)
{
@@ -245,7 +247,7 @@ namespace Emby.Dlna.Main
SupportPnpRootDevice = false
};
- await RegisterServerEndpoints().ConfigureAwait(false);
+ RegisterServerEndpoints();
_publisher.StartBroadcastingAliveMessages(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds));
}
@@ -255,13 +257,22 @@ namespace Emby.Dlna.Main
}
}
- private async Task RegisterServerEndpoints()
+ private void RegisterServerEndpoints()
{
- var addresses = await _appHost.GetLocalIpAddresses(CancellationToken.None).ConfigureAwait(false);
-
var udn = CreateUuid(_appHost.SystemId);
+ var descriptorUri = "/dlna/" + udn + "/description.xml";
- foreach (var address in addresses)
+ var bindAddresses = NetworkManager.CreateCollection(
+ _networkManager.GetInternalBindAddresses()
+ .Where(i => i.AddressFamily == AddressFamily.InterNetwork || (i.AddressFamily == AddressFamily.InterNetworkV6 && i.Address.ScopeId != 0)));
+
+ if (bindAddresses.Count == 0)
+ {
+ // No interfaces returned, so use loopback.
+ bindAddresses = _networkManager.GetLoopbacks();
+ }
+
+ foreach (IPNetAddress address in bindAddresses)
{
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
@@ -270,7 +281,7 @@ namespace Emby.Dlna.Main
}
// Limit to LAN addresses only
- if (!_networkManager.IsAddressInSubnets(address, true, true))
+ if (!_networkManager.IsInLocalNetwork(address))
{
continue;
}
@@ -279,15 +290,14 @@ namespace Emby.Dlna.Main
_logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
- var descriptorUri = "/dlna/" + udn + "/description.xml";
- var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri);
+ var uri = new Uri(_appHost.GetSmartApiUrl(address.Address) + descriptorUri);
var device = new SsdpRootDevice
{
CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info.
Location = uri, // Must point to the URL that serves your devices UPnP description document.
- Address = address,
- SubnetMask = _networkManager.GetLocalIpSubnetMask(address),
+ Address = address.Address,
+ PrefixLength = address.PrefixLength,
FriendlyName = "Jellyfin",
Manufacturer = "Jellyfin",
ModelName = "Jellyfin Server",
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 a5b8e2b3ce..3907b2a396 100644
--- a/Emby.Dlna/PlayTo/PlayToController.cs
+++ b/Emby.Dlna/PlayTo/PlayToController.cs
@@ -326,7 +326,7 @@ namespace Emby.Dlna.PlayTo
public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken)
{
- _logger.LogDebug("{0} - Received PlayRequest: {1}", this._session.DeviceName, command.PlayCommand);
+ _logger.LogDebug("{0} - Received PlayRequest: {1}", _session.DeviceName, command.PlayCommand);
var user = command.ControllingUserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(command.ControllingUserId);
@@ -339,7 +339,7 @@ namespace Emby.Dlna.PlayTo
var startIndex = command.StartIndex ?? 0;
if (startIndex > 0)
{
- items = items.Skip(startIndex).ToList();
+ items = items.GetRange(startIndex, items.Count - startIndex);
}
var playlist = new List();
@@ -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/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs
index e93aef3043..854b6a1a2b 100644
--- a/Emby.Dlna/PlayTo/PlayToManager.cs
+++ b/Emby.Dlna/PlayTo/PlayToManager.cs
@@ -177,15 +177,7 @@ namespace Emby.Dlna.PlayTo
_sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
- string serverAddress;
- if (info.LocalIpAddress == null || info.LocalIpAddress.Equals(IPAddress.Any) || info.LocalIpAddress.Equals(IPAddress.IPv6Any))
- {
- serverAddress = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
- }
- else
- {
- serverAddress = _appHost.GetLocalApiUrl(info.LocalIpAddress);
- }
+ string serverAddress = _appHost.GetSmartApiUrl(info.LocalIpAddress);
controller = new PlayToController(
sessionInfo,
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/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs
index bca9e81cd0..09525aae4e 100644
--- a/Emby.Dlna/Server/DescriptionXmlBuilder.cs
+++ b/Emby.Dlna/Server/DescriptionXmlBuilder.cs
@@ -40,8 +40,6 @@ namespace Emby.Dlna.Server
_serverId = serverId;
}
- private static bool EnableAbsoluteUrls => false;
-
public string GetXml()
{
var builder = new StringBuilder();
@@ -75,13 +73,6 @@ namespace Emby.Dlna.Server
builder.Append("0");
builder.Append("");
- if (!EnableAbsoluteUrls)
- {
- builder.Append("")
- .Append(SecurityElement.Escape(_serverAddress))
- .Append("");
- }
-
AppendDeviceInfo(builder);
builder.Append("");
@@ -257,14 +248,7 @@ namespace Emby.Dlna.Server
return string.Empty;
}
- url = url.TrimStart('/');
-
- url = "/dlna/" + _serverUdn + "/" + url;
-
- if (EnableAbsoluteUrls)
- {
- url = _serverAddress.TrimEnd('/') + url;
- }
+ url = _serverAddress.TrimEnd('/') + "/dlna/" + _serverUdn + "/" + url.TrimStart('/');
return SecurityElement.Escape(url);
}
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 ed53bd04fa..f6ad3601d7 100644
--- a/Emby.Naming/AudioBook/AudioBookResolver.cs
+++ b/Emby.Naming/AudioBook/AudioBookResolver.cs
@@ -1,5 +1,3 @@
-#pragma warning disable CS1591
-
using System;
using System.IO;
using System.Linq;
@@ -7,35 +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 ParseFile(string path)
+ ///
+ /// Resolve specifics (path, container, partNumber, chapterNumber) about audiobook file.
+ ///
+ /// Path to audiobook file.
+ /// Returns object.
+ public AudioBookFileInfo? Resolve(string path)
{
- return Resolve(path, false);
- }
-
- public AudioBookFileInfo ParseDirectory(string path)
- {
- return Resolve(path, true);
- }
-
- public AudioBookFileInfo Resolve(string path, bool isDirectory = false)
- {
- if (string.IsNullOrEmpty(path))
- {
- throw new ArgumentNullException(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;
}
@@ -51,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 fd4244f64d..035d1b2280 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])(.*?)(\\.[^.]+)$"
+ "(?.*?)(?[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[0-9]+)(?.*?)(?\\.[^.]+)$",
+ "(?.*?)(?[ _.-]*(?:cd|dvd|p(?:ar)?t|dis[ck])[ _.-]*[a-d])(?.*?)(?\\.[^.]+)$",
+ "(?.*?)(?[ ._-]*[a-d])(?.*?)(?\\.[^.]+)$"
};
CleanDateTimes = new[]
@@ -142,7 +137,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|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
@"(\[.*\])"
};
@@ -255,7 +250,7 @@ namespace Emby.Naming.Common
},
//
new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"),
- new EpisodeExpression("([0-9]{4})[\\.-]([0-9]{2})[\\.-]([0-9]{2})", true)
+ new EpisodeExpression("(?[0-9]{4})[\\.-](?[0-9]{2})[\\.-](?[0-9]{2})", true)
{
DateTimeFormats = new[]
{
@@ -264,7 +259,7 @@ namespace Emby.Naming.Common
"yyyy_MM_dd"
}
},
- new EpisodeExpression("([0-9]{2})[\\.-]([0-9]{2})[\\.-]([0-9]{4})", true)
+ new EpisodeExpression(@"(?[0-9]{2})[.-](?[0-9]{2})[.-](?[0-9]{4})", true)
{
DateTimeFormats = new[]
{
@@ -286,7 +281,12 @@ namespace Emby.Naming.Common
{
SupportsAbsoluteEpisodeNumbers = true
},
- new EpisodeExpression(@"[\\\\/\\._ -](?(?![0-9]+[0-9][0-9])([^\\\/])*)[\\\\/\\._ -](?[0-9]+)(?[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(@"[\\/._ -](?(?![0-9]+[0-9][0-9])([^\\\/_])*)[\\\/._ -](?[0-9]+)(?[0-9][0-9](?:(?:[a-i]|\.[1-9])(?![0-9]))?)([._ -][^\\\/]*)$")
{
IsOptimistic = true,
IsNamed = true,
@@ -381,247 +381,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
@@ -631,13 +577,20 @@ namespace Emby.Naming.Common
// Chapter is often beginning of filename
"^(?[0-9]+)",
// Part if often ending of filename
- "(?[0-9]+)$",
+ @"(?[0-9]+)$",
// Sometimes named as 0001_005 (chapter_part)
"(?[0-9]+)_(?[0-9]+)",
// Some audiobooks are ripped from cd's, and will be named by disk number.
@"dis(?:c|k)[\s_-]?(?[0-9]+)"
};
+ AudioBookNamesExpressions = new[]
+ {
+ // Detect year usually in brackets after name Batman (2020)
+ @"^(?.+?)\s*\(\s*(?\d{4})\s*\)\s*$",
+ @"^\s*(?[^ ].*?)\s*$"
+ };
+
var extensions = VideoFileExtensions.ToList();
extensions.AddRange(new[]
@@ -673,7 +626,7 @@ namespace Emby.Naming.Common
".mxf"
});
- MultipleEpisodeExpressions = new string[]
+ MultipleEpisodeExpressions = new[]
{
@".*(\\|\/)[sS]?(?[0-9]{1,4})[xX](?[0-9]{1,3})((-| - )[0-9]{1,4}[eExX](?[0-9]{1,3}))+[^\\\/]*$",
@".*(\\|\/)[sS]?(?[0-9]{1,4})[xX](?[0-9]{1,3})((-| - )[0-9]{1,4}[xX][eE](?[0-9]{1,3}))+[^\\\/]*$",
@@ -697,56 +650,139 @@ namespace Emby.Naming.Common
Compile();
}
+ ///
+ /// Gets or sets list of audio file extensions.
+ ///
public string[] AudioFileExtensions { get; set; }
+ ///
+ /// Gets or sets list of album stacking prefixes.
+ ///
public string[] AlbumStackingPrefixes { get; set; }
+ ///
+ /// Gets or sets list of subtitle file extensions.
+ ///
public string[] SubtitleFileExtensions { get; set; }
+ ///
+ /// Gets or sets list of subtitles flag delimiters.
+ ///
public char[] SubtitleFlagDelimiters { get; set; }
+ ///
+ /// Gets or sets list of subtitle forced flags.
+ ///
public string[] SubtitleForcedFlags { get; set; }
+ ///
+ /// Gets or sets list of subtitle default flags.
+ ///
public string[] SubtitleDefaultFlags { get; set; }
+ ///
+ /// Gets or sets list of episode regular expressions.
+ ///
public EpisodeExpression[] EpisodeExpressions { get; set; }
+ ///
+ /// Gets or sets list of raw episode without season regular expressions strings.
+ ///
public string[] EpisodeWithoutSeasonExpressions { get; set; }
+ ///
+ /// Gets or sets list of raw multi-part episodes regular expressions strings.
+ ///
public string[] EpisodeMultiPartExpressions { get; set; }
+ ///
+ /// Gets or sets list of video file extensions.
+ ///
public string[] VideoFileExtensions { get; set; }
+ ///
+ /// Gets or sets list of video stub file extensions.
+ ///
public string[] StubFileExtensions { get; set; }
+ ///
+ /// Gets or sets list of raw audiobook parts regular expressions strings.
+ ///
public string[] AudioBookPartsExpressions { get; set; }
+ ///
+ /// Gets or sets list of raw audiobook names regular expressions strings.
+ ///
+ public string[] AudioBookNamesExpressions { get; set; }
+
+ ///
+ /// Gets or sets list of stub type rules.
+ ///
public StubTypeRule[] StubTypes { get; set; }
+ ///
+ /// Gets or sets list of video flag delimiters.
+ ///
public char[] VideoFlagDelimiters { get; set; }
+ ///
+ /// Gets or sets list of 3D Format rules.
+ ///
public Format3DRule[] Format3DRules { get; set; }
+ ///
+ /// Gets or sets list of raw video file-stacking expressions strings.
+ ///
public string[] VideoFileStackingExpressions { get; set; }
+ ///
+ /// Gets or sets list of raw clean DateTimes regular expressions strings.
+ ///
public string[] CleanDateTimes { get; set; }
+ ///
+ /// Gets or sets list of raw clean strings regular expressions strings.
+ ///
public string[] CleanStrings { get; set; }
+ ///
+ /// Gets or sets list of multi-episode regular expressions.
+ ///
public EpisodeExpression[] MultipleEpisodeExpressions { get; set; }
+ ///
+ /// Gets or sets list of extra rules for videos.
+ ///
public ExtraRule[] VideoExtraRules { get; set; }
- public Regex[] VideoFileStackingRegexes { get; private set; }
+ ///
+ /// Gets list of video file-stack regular expressions.
+ ///
+ public Regex[] VideoFileStackingRegexes { get; private set; } = Array.Empty();
- public Regex[] CleanDateTimeRegexes { get; private set; }
+ ///
+ /// Gets list of clean datetime regular expressions.
+ ///
+ public Regex[] CleanDateTimeRegexes { get; private set; } = Array.Empty();
- public Regex[] CleanStringRegexes { get; private set; }
+ ///
+ /// Gets list of clean string regular expressions.
+ ///
+ public Regex[] CleanStringRegexes { get; private set; } = Array.Empty();
- public Regex[] EpisodeWithoutSeasonRegexes { get; private set; }
+ ///
+ /// Gets list of episode without season regular expressions.
+ ///
+ public Regex[] EpisodeWithoutSeasonRegexes { get; private set; } = Array.Empty();
- public Regex[] EpisodeMultiPartRegexes { get; private set; }
+ ///
+ /// Gets list of multi-part episode regular expressions.
+ ///
+ public Regex[] EpisodeMultiPartRegexes { get; private set; } = Array.Empty();
+ ///
+ /// Compiles raw regex strings into regexes.
+ ///
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 @@
- netstandard2.1
+ net5.0
false
true
true
@@ -14,6 +14,7 @@
true
true
snupkg
+ enable
@@ -38,7 +39,7 @@
-
+
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
{
+ ///
+ /// Class holding information about subtitle.
+ ///
public class SubtitleInfo
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Path to file.
+ /// Is subtitle default.
+ /// Is subtitle forced.
+ public SubtitleInfo(string path, bool isDefault, bool isForced)
+ {
+ Path = path;
+ IsDefault = isDefault;
+ IsForced = isForced;
+ }
+
///
/// Gets or sets the path.
///
@@ -14,7 +28,7 @@ namespace Emby.Naming.Subtitles
/// Gets or sets the language.
///
/// The language.
- public string Language { get; set; }
+ public string? Language { get; set; }
///
/// 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
{
+ ///
+ /// Subtitle Parser class.
+ ///
public class SubtitleParser
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing SubtitleFileExtensions, SubtitleDefaultFlags, SubtitleForcedFlags and SubtitleFlagDelimiters.
public SubtitleParser(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Parse file to determine if is subtitle and .
+ ///
+ /// Path to file.
+ /// Returns null or object if parsing is successful.
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
{
+ ///
+ /// Holder object for Episode information.
+ ///
public class EpisodeInfo
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Path to the file.
+ public EpisodeInfo(string path)
+ {
+ Path = path;
+ }
+
///
/// Gets or sets the path.
///
@@ -14,19 +24,19 @@ namespace Emby.Naming.TV
/// Gets or sets the container.
///
/// The container.
- public string Container { get; set; }
+ public string? Container { get; set; }
///
/// Gets or sets the name of the series.
///
/// The name of the series.
- public string SeriesName { get; set; }
+ public string? SeriesName { get; set; }
///
/// Gets or sets the format3 d.
///
/// The format3 d.
- public string Format3D { get; set; }
+ public string? Format3D { get; set; }
///
/// 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.
///
/// The type of the stub.
- public string StubType { get; set; }
+ public string? StubType { get; set; }
+ ///
+ /// Gets or sets optional season number.
+ ///
public int? SeasonNumber { get; set; }
+ ///
+ /// Gets or sets optional episode number.
+ ///
public int? EpisodeNumber { get; set; }
- public int? EndingEpsiodeNumber { get; set; }
+ ///
+ /// Gets or sets optional ending episode number. For multi-episode files 1-13.
+ ///
+ public int? EndingEpisodeNumber { get; set; }
+ ///
+ /// Gets or sets optional year of release.
+ ///
public int? Year { get; set; }
+ ///
+ /// Gets or sets optional year of release.
+ ///
public int? Month { get; set; }
+ ///
+ /// Gets or sets optional day of release.
+ ///
public int? Day { get; set; }
+ ///
+ /// Gets or sets a value indicating whether by date expression was used.
+ ///
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
{
+ ///
+ /// Used to parse information about episode from path.
+ ///
public class EpisodePathParser
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing EpisodeExpressions and MultipleEpisodeExpressions.
public EpisodePathParser(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Parses information about episode from path.
+ ///
+ /// Path.
+ /// Is path for a directory or file.
+ /// Do we want to use IsNamed expressions.
+ /// Do we want to use Optimistic expressions.
+ /// Do we want to use expressions supporting absolute episode numbers.
+ /// Should we attempt to retrieve extended information.
+ /// Returns object.
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
{
+ ///
+ /// Holder object for result.
+ ///
public class EpisodePathParserResult
{
+ ///
+ /// Gets or sets optional season number.
+ ///
public int? SeasonNumber { get; set; }
+ ///
+ /// Gets or sets optional episode number.
+ ///
public int? EpisodeNumber { get; set; }
- public int? EndingEpsiodeNumber { get; set; }
+ ///
+ /// Gets or sets optional ending episode number. For multi-episode files 1-13.
+ ///
+ public int? EndingEpisodeNumber { get; set; }
- public string SeriesName { get; set; }
+ ///
+ /// Gets or sets the name of the series.
+ ///
+ /// The name of the series.
+ public string? SeriesName { get; set; }
+ ///
+ /// Gets or sets a value indicating whether parsing was successful.
+ ///
public bool Success { get; set; }
+ ///
+ /// Gets or sets a value indicating whether by date expression was used.
+ ///
public bool IsByDate { get; set; }
+ ///
+ /// Gets or sets optional year of release.
+ ///
public int? Year { get; set; }
+ ///
+ /// Gets or sets optional year of release.
+ ///
public int? Month { get; set; }
+ ///
+ /// Gets or sets optional day of release.
+ ///
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
{
+ ///
+ /// Used to resolve information about episode from path.
+ ///
public class EpisodeResolver
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing VideoFileExtensions and passed to , , and .
public EpisodeResolver(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Resolve information about episode from path.
+ ///
+ /// Path.
+ /// Is path for a directory or file.
+ /// Do we want to use IsNamed expressions.
+ /// Do we want to use Optimistic expressions.
+ /// Do we want to use expressions supporting absolute episode numbers.
+ /// Should we attempt to retrieve extended information.
+ /// Returns null or object if successful.
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
{
+ ///
+ /// Class to parse season paths.
+ ///
public static class SeasonPathParser
{
///
@@ -23,6 +24,13 @@ namespace Emby.Naming.TV
"stagione"
};
+ ///
+ /// Attempts to parse season number from path.
+ ///
+ /// Path to season.
+ /// Support special aliases when parsing.
+ /// Support numeric season folders when parsing.
+ /// Returns object.
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
{
+ ///
+ /// Data object to pass result of .
+ ///
public class SeasonPathParserResult
{
///
@@ -16,6 +17,10 @@ namespace Emby.Naming.TV
/// true if success; otherwise, false.
public bool Success { get; set; }
+ ///
+ /// Gets or sets a value indicating whether "Is season folder".
+ /// Seems redundant and barely used.
+ ///
public bool IsSeasonFolder { get; set; }
}
}
diff --git a/Emby.Naming/Video/CleanDateTimeParser.cs b/Emby.Naming/Video/CleanDateTimeParser.cs
index 579c9e91e1..0ee633dcc6 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
///
public static class CleanDateTimeParser
{
+ ///
+ /// Attempts to clean the name.
+ ///
+ /// Name of video.
+ /// Optional list of regexes to clean the name.
+ /// Returns object.
public static CleanDateTimeResult Clean(string name, IReadOnlyList 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
{
+ ///
+ /// Holder structure for name and year.
+ ///
public readonly struct CleanDateTimeResult
{
- public CleanDateTimeResult(string name, int? year)
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// Name of video.
+ /// Year of release.
+ public CleanDateTimeResult(string name, int? year = null)
{
Name = name;
Year = year;
}
- public CleanDateTimeResult(string name)
- {
- Name = name;
- Year = null;
- }
-
///
/// Gets the name.
///
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
///
public static class CleanStringParser
{
+ ///
+ /// Attempts to extract clean name with regular expressions.
+ ///
+ /// Name of file.
+ /// List of regex to parse name and year from.
+ /// Parsing result string.
+ /// True if parsing was successful.
public static bool TryClean(string name, IReadOnlyList expressions, out ReadOnlySpan 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
{
+ ///
+ /// Resolve if file is extra for video.
+ ///
public class ExtraResolver
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing VideoExtraRules and passed to and .
public ExtraResolver(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Attempts to resolve if file is extra.
+ ///
+ /// Path to file.
+ /// Returns object.
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
{
+ ///
+ /// Holder object for passing results from ExtraResolver.
+ ///
public class ExtraResult
{
///
@@ -16,6 +17,6 @@ namespace Emby.Naming.Video
/// Gets or sets the rule.
///
/// The rule.
- 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
///
public class ExtraRule
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Type of extra.
+ /// Type of rule.
+ /// Token.
+ /// Media type.
+ public ExtraRule(ExtraType extraType, ExtraRuleType ruleType, string token, MediaType mediaType)
+ {
+ Token = token;
+ ExtraType = extraType;
+ RuleType = ruleType;
+ MediaType = mediaType;
+ }
+
///
/// Gets or sets the token to use for matching against the file path.
///
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
{
+ ///
+ /// Extra rules type to determine against what should be matched.
+ ///
public enum ExtraRuleType
{
///
@@ -22,6 +23,6 @@ namespace Emby.Naming.Video
///
/// Match against the name of the directory containing the file.
///
- 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
{
+ ///
+ /// Object holding list of files paths with additional information.
+ ///
public class FileStack
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
public FileStack()
{
Files = new List();
}
- public string Name { get; set; }
+ ///
+ /// Gets or sets name of file stack.
+ ///
+ public string Name { get; set; } = string.Empty;
+ ///
+ /// Gets or sets list of paths in stack.
+ ///
public List Files { get; set; }
+ ///
+ /// Gets or sets a value indicating whether stack is directory stack.
+ ///
public bool IsDirectoryStack { get; set; }
+ ///
+ /// Helper function to determine if path is in the stack.
+ ///
+ /// Path of desired file.
+ /// Requested type of stack.
+ /// True if file is in the stack.
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
{
+ ///
+ /// Parses list of flags from filename based on delimiters.
+ ///
public class FlagParser
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing VideoFlagDelimiters.
public FlagParser(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Calls GetFlags function with _options.VideoFlagDelimiters parameter.
+ ///
+ /// Path to file.
+ /// List of found flags.
public string[] GetFlags(string path)
{
return GetFlags(path, _options.VideoFlagDelimiters);
}
- public string[] GetFlags(string path, char[] delimeters)
+ ///
+ /// Parses flags from filename based on delimiters.
+ ///
+ /// Path to file.
+ /// Delimiters used to extract flags.
+ /// List of found flags.
+ public string[] GetFlags(string path, char[] delimiters)
{
if (string.IsNullOrEmpty(path))
{
- throw new ArgumentNullException(nameof(path));
+ return Array.Empty();
}
// 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
{
+ ///
+ /// Parste 3D format related flags.
+ ///
public class Format3DParser
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing VideoFlagDelimiters and passes options to .
public Format3DParser(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Parse 3D format related flags.
+ ///
+ /// Path to file.
+ /// Returns object.
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
{
+ ///
+ /// Helper object to return data from .
+ ///
public class Format3DResult
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
public Format3DResult()
{
Tokens = new List();
@@ -21,7 +25,7 @@ namespace Emby.Naming.Video
/// Gets or sets the format3 d.
///
/// The format3 d.
- public string Format3D { get; set; }
+ public string? Format3D { get; set; }
///
/// 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
{
+ ///
+ /// Data holder class for 3D format rule.
+ ///
public class Format3DRule
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Token.
+ /// Token present before current token.
+ public Format3DRule(string token, string? precedingToken = null)
+ {
+ Token = token;
+ PrecedingToken = precedingToken;
+ }
+
///
/// Gets or sets the token.
///
@@ -11,9 +23,9 @@ namespace Emby.Naming.Video
public string Token { get; set; }
///
- /// Gets or sets the preceeding token.
+ /// Gets or sets the preceding token.
///
- /// The preceeding token.
- public string PreceedingToken { get; set; }
+ /// The preceding token.
+ 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
{
+ ///
+ /// Resolve from list of paths.
+ ///
public class StackResolver
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing VideoFileStackingRegexes and passes options to .
public StackResolver(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Resolves only directories from paths.
+ ///
+ /// List of paths.
+ /// Enumerable of directories.
public IEnumerable ResolveDirectories(IEnumerable files)
{
return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = true }));
}
+ ///
+ /// Resolves only files from paths.
+ ///
+ /// List of paths.
+ /// Enumerable of files.
public IEnumerable ResolveFiles(IEnumerable files)
{
return Resolve(files.Select(i => new FileSystemMetadata { FullName = i, IsDirectory = false }));
}
- public IEnumerable ResolveAudioBooks(IEnumerable files)
+ ///
+ /// Resolves audiobooks from paths.
+ ///
+ /// List of paths.
+ /// Enumerable of directories.
+ public IEnumerable ResolveAudioBooks(IEnumerable 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;
}
}
+ ///
+ /// Resolves videos from paths.
+ ///
+ /// List of paths.
+ /// Enumerable of videos.
public IEnumerable Resolve(IEnumerable 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
{
+ ///
+ /// Resolve if file is stub (.disc).
+ ///
public static class StubResolver
{
+ ///
+ /// Tries to resolve if file is stub (.disc).
+ ///
+ /// Path to file.
+ /// NamingOptions containing StubFileExtensions and StubTypes.
+ /// Stub type.
+ /// True if file is a stub.
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
- {
- ///
- /// Gets or sets a value indicating whether this instance is stub.
- ///
- /// true if this instance is stub; otherwise, false.
- public bool IsStub { get; set; }
-
- ///
- /// Gets or sets the type of the stub.
- ///
- /// The type of the stub.
- 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
{
+ ///
+ /// Data class holding information about Stub type rule.
+ ///
public class StubTypeRule
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Token.
+ /// Stub type.
+ public StubTypeRule(string token, string stubType)
+ {
+ Token = token;
+ StubType = stubType;
+ }
+
///
/// Gets or sets the token.
///
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
///
public class VideoFileInfo
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Name of file.
+ /// Path to the file.
+ /// Container type.
+ /// Year of release.
+ /// Extra type.
+ /// Extra rule.
+ /// Format 3D.
+ /// Is 3D.
+ /// Is Stub.
+ /// Stub type.
+ /// Is directory.
+ 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;
+ }
+
///
/// Gets or sets the path.
///
@@ -17,7 +46,7 @@ namespace Emby.Naming.Video
/// Gets or sets the container.
///
/// The container.
- public string Container { get; set; }
+ public string? Container { get; set; }
///
/// Gets or sets the name.
@@ -41,13 +70,13 @@ namespace Emby.Naming.Video
/// Gets or sets the extra rule.
///
/// The extra rule.
- public ExtraRule ExtraRule { get; set; }
+ public ExtraRule? ExtraRule { get; set; }
///
/// Gets or sets the format3 d.
///
/// The format3 d.
- public string Format3D { get; set; }
+ public string? Format3D { get; set; }
///
/// 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.
///
/// The type of the stub.
- public string StubType { get; set; }
+ public string? StubType { get; set; }
///
/// Gets or sets a value indicating whether this instance is a directory.
@@ -84,8 +113,7 @@ namespace Emby.Naming.Video
///
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 class.
///
/// The name.
- public VideoInfo(string name)
+ public VideoInfo(string? name)
{
Name = name;
@@ -25,7 +25,7 @@ namespace Emby.Naming.Video
/// Gets or sets the name.
///
/// The name.
- public string Name { get; set; }
+ public string? Name { get; set; }
///
/// Gets or sets the year.
diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs
index 948fe037b5..fd16774739 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
{
+ ///
+ /// Resolves alternative versions and extras from list of video files.
+ ///
public class VideoListResolver
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing CleanStringRegexes and VideoFlagDelimiters and passes options to and .
public VideoListResolver(NamingOptions options)
{
_options = options;
}
+ ///
+ /// Resolves alternative versions and extras from list of video files.
+ ///
+ /// List of related video files.
+ /// Indication we should consider multi-versions of content.
+ /// Returns enumerable of which groups files together when related.
public IEnumerable Resolve(List 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()
.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();
@@ -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()
+ .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,25 @@ 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))
{
- testFilename = testFilename.Substring(folderName.Length).Trim();
+ if (CleanStringParser.TryClean(testFilename, _options.CleanStringRegexes, out var cleanName))
+ {
+ testFilename = cleanName.ToString();
+ }
+
+ if (folderName.Length <= testFilename.Length)
+ {
+ 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
{
+ ///
+ /// Resolves from file path.
+ ///
public class VideoResolver
{
private readonly NamingOptions _options;
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// object containing VideoFileExtensions, StubFileExtensions, CleanStringRegexes and CleanDateTimeRegexes
+ /// and passes options in , , and .
public VideoResolver(NamingOptions options)
{
_options = options;
@@ -22,7 +27,7 @@ namespace Emby.Naming.Video
///
/// The path.
/// VideoFileInfo.
- public VideoFileInfo? ResolveDirectory(string path)
+ public VideoFileInfo? ResolveDirectory(string? path)
{
return Resolve(path, true);
}
@@ -32,7 +37,7 @@ namespace Emby.Naming.Video
///
/// The path.
/// VideoFileInfo.
- public VideoFileInfo? ResolveFile(string path)
+ public VideoFileInfo? ResolveFile(string? path)
{
return Resolve(path, false);
}
@@ -45,11 +50,11 @@ namespace Emby.Naming.Video
/// Whether or not the name should be parsed for info.
/// VideoFileInfo.
/// path is null.
- 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);
}
+ ///
+ /// Determines if path is video file based on extension.
+ ///
+ /// Path to file.
+ /// True if is video file.
public bool IsVideoFile(string path)
{
var extension = Path.GetExtension(path) ?? string.Empty;
return _options.VideoFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
+ ///
+ /// Determines if path is video file stub based on extension.
+ ///
+ /// Path to file.
+ /// True if is video file stub.
public bool IsStubFile(string path)
{
var extension = Path.GetExtension(path) ?? string.Empty;
return _options.StubFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
+ ///
+ /// Tries to clean name of clutter.
+ ///
+ /// Raw name.
+ /// Clean name.
+ /// True if cleaning of name was successful.
public bool TryCleanString(string name, out ReadOnlySpan newName)
{
return CleanStringParser.TryClean(name, _options.CleanStringRegexes, out newName);
}
+ ///
+ /// Tries to get name and year from raw name.
+ ///
+ /// Raw name.
+ /// Returns with name and optional year.
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 @@
- netstandard2.1
+ net5.0
false
true
true
diff --git a/Emby.Notifications/NotificationEntryPoint.cs b/Emby.Notifications/NotificationEntryPoint.cs
index ded22d26cc..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 e)
+ private async void OnActivityManagerEntryCreated(object? sender, GenericEventArgs e)
{
var entry = e.Argument;
@@ -132,7 +132,7 @@ namespace Emby.Notifications
return _config.GetConfiguration("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 items;
@@ -209,7 +209,10 @@ namespace Emby.Notifications
_libraryUpdateTimer = null;
}
- items = items.Take(10).ToList();
+ if (items.Count > 10)
+ {
+ items = items.GetRange(0, 10);
+ }
foreach (var item in 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 @@
- netstandard2.1
+ net5.0
false
true
true
diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
index 2adc1d6c34..660bbb2deb 100644
--- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
+++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
@@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.AppBase
}
///
- public string VirtualDataPath { get; } = "%AppDataPath%";
+ public string VirtualDataPath => "%AppDataPath%";
///
/// Gets the image cache path.
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
}
}
+ ///
+ /// Manually pre-loads a factory so that it is available pre system initialisation.
+ ///
+ /// Class to register.
+ public virtual void RegisterConfiguration()
+ where T : IConfigurationFactory
+ {
+ IConfigurationFactory factory = Activator.CreateInstance();
+
+ 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();
+ }
+
///
/// Adds parts.
///
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 b69eccd819..30ccaf8de4 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;
@@ -16,6 +15,7 @@ using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using System.Xml.Serialization;
using Emby.Dlna;
using Emby.Dlna.Main;
using Emby.Dlna.Ssdp;
@@ -30,7 +30,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;
@@ -48,6 +47,8 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers;
+using Jellyfin.Networking.Configuration;
+using Jellyfin.Networking.Manager;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Events;
@@ -96,10 +97,11 @@ 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;
+using Microsoft.AspNetCore.DataProtection.Repositories;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -120,7 +122,6 @@ namespace Emby.Server.Implementations
private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
private readonly IFileSystem _fileSystemManager;
- private readonly INetworkManager _networkManager;
private readonly IXmlSerializer _xmlSerializer;
private readonly IJsonSerializer _jsonSerializer;
private readonly IStartupOptions _startupOptions;
@@ -128,8 +129,6 @@ namespace Emby.Server.Implementations
private IMediaEncoder _mediaEncoder;
private ISessionManager _sessionManager;
private IHttpClientFactory _httpClientFactory;
- private IWebSocketManager _webSocketManager;
-
private string[] _urlPrefixes;
///
@@ -163,6 +162,11 @@ namespace Emby.Server.Implementations
}
}
+ ///
+ /// Gets the singleton instance.
+ ///
+ public INetworkManager NetManager { get; internal set; }
+
///
/// Occurs when [has pending restart changed].
///
@@ -215,7 +219,7 @@ namespace Emby.Server.Implementations
private readonly List _disposableParts = new List();
///
- /// Gets the configuration manager.
+ /// Gets or sets the configuration manager.
///
/// The configuration manager.
protected IConfigurationManager ConfigurationManager { get; set; }
@@ -248,29 +252,30 @@ namespace Emby.Server.Implementations
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
- /// Instance of the interface.
/// Instance of the interface.
public ApplicationHost(
IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
IStartupOptions options,
IFileSystem fileSystem,
- INetworkManager networkManager,
IServiceCollection serviceCollection)
{
_xmlSerializer = new MyXmlSerializer();
- _jsonSerializer = new JsonSerializer();
-
- ServiceCollection = serviceCollection;
+ _jsonSerializer = new JsonSerializer();
- _networkManager = networkManager;
- networkManager.LocalSubnetsFn = GetConfiguredLocalSubnets;
+ ServiceCollection = serviceCollection;
ApplicationPaths = applicationPaths;
LoggerFactory = loggerFactory;
_fileSystemManager = fileSystem;
ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager);
+ // Have to migrate settings here as migration subsystem not yet initialised.
+ MigrateNetworkConfiguration();
+
+ // Have to pre-register the NetworkConfigurationFactory, as the configuration sub-system is not yet initialised.
+ ConfigurationManager.RegisterConfiguration();
+ NetManager = new NetworkManager((IServerConfigurationManager)ConfigurationManager, LoggerFactory.CreateLogger());
Logger = LoggerFactory.CreateLogger();
@@ -284,8 +289,6 @@ namespace Emby.Server.Implementations
fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem));
- _networkManager.NetworkChanged += OnNetworkChanged;
-
CertificateInfo = new CertificateInfo
{
Path = ServerConfigurationManager.Configuration.CertificatePath,
@@ -298,6 +301,22 @@ namespace Emby.Server.Implementations
ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
}
+ ///
+ /// Temporary function to migration network settings out of system.xml and into network.xml.
+ /// TODO: remove at the point when a fixed migration path has been decided upon.
+ ///
+ private void MigrateNetworkConfiguration()
+ {
+ string path = Path.Combine(ConfigurationManager.CommonApplicationPaths.ConfigurationDirectoryPath, "network.xml");
+ if (!File.Exists(path))
+ {
+ var networkSettings = new NetworkConfiguration();
+ ClassMigrationHelper.CopyProperties(ServerConfigurationManager.Configuration, networkSettings);
+ _xmlSerializer.SerializeToFile(networkSettings, path);
+ Logger?.LogDebug("Successfully migrated network settings.");
+ }
+ }
+
public string ExpandVirtualPath(string path)
{
var appPaths = ApplicationPaths;
@@ -314,16 +333,6 @@ namespace Emby.Server.Implementations
.Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase);
}
- private string[] GetConfiguredLocalSubnets()
- {
- return ServerConfigurationManager.Configuration.LocalNetworkSubnets;
- }
-
- private void OnNetworkChanged(object sender, EventArgs e)
- {
- _validAddressResults.Clear();
- }
-
///
public Version ApplicationVersion { get; }
@@ -340,7 +349,7 @@ namespace Emby.Server.Implementations
/// Gets the email address for use within a comment section of a user agent field.
/// Presently used to provide contact information to MusicBrainz service.
///
- public string ApplicationUserAgentAddress { get; } = "team@jellyfin.org";
+ public string ApplicationUserAgentAddress => "team@jellyfin.org";
///
/// Gets the current application name.
@@ -404,7 +413,7 @@ namespace Emby.Server.Implementations
///
/// Resolves this instance.
///
- /// The type
+ /// The type.
/// ``0.
public T Resolve() => ServiceProvider.GetService();
@@ -490,34 +499,22 @@ namespace Emby.Server.Implementations
///
public void Init()
{
- HttpPort = ServerConfigurationManager.Configuration.HttpServerPortNumber;
- HttpsPort = ServerConfigurationManager.Configuration.HttpsPortNumber;
+ var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
+ HttpPort = networkConfiguration.HttpServerPortNumber;
+ HttpsPort = networkConfiguration.HttpsPortNumber;
// Safeguard against invalid configuration
if (HttpPort == HttpsPort)
{
- HttpPort = ServerConfiguration.DefaultHttpPort;
- 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());
+ HttpPort = NetworkConfiguration.DefaultHttpPort;
+ HttpsPort = NetworkConfiguration.DefaultHttpsPort;
}
DiscoverTypes();
RegisterServices();
+
+ RegisterPluginServices();
}
///
@@ -537,10 +534,9 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton();
ServiceCollection.AddSingleton(_fileSystemManager);
- ServiceCollection.AddSingleton();
ServiceCollection.AddSingleton();
- ServiceCollection.AddSingleton(_networkManager);
+ ServiceCollection.AddSingleton(NetManager);
ServiceCollection.AddSingleton();
@@ -644,7 +640,6 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton();
- ServiceCollection.AddSingleton();
ServiceCollection.AddSingleton();
ServiceCollection.AddSingleton();
@@ -667,7 +662,6 @@ namespace Emby.Server.Implementations
_mediaEncoder = Resolve();
_sessionManager = Resolve();
_httpClientFactory = Resolve();
- _webSocketManager = Resolve();
((AuthenticationRepository)Resolve()).Initialize();
@@ -783,12 +777,25 @@ namespace Emby.Server.Implementations
ConfigurationManager.AddParts(GetExports());
_plugins = GetExports()
- .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();
- _webSocketManager.Init(GetExports());
Resolve().AddParts(
GetExports(),
@@ -817,21 +824,6 @@ namespace Emby.Server.Implementations
Resolve().AddParts(GetExports());
}
- 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;
- }
-
///
/// Discovers the types.
///
@@ -842,6 +834,22 @@ namespace Emby.Server.Implementations
_allConcreteTypes = GetTypes(GetComposablePartAssemblies()).ToArray();
}
+ private void RegisterPluginServices()
+ {
+ foreach (var pluginServiceRegistrator in GetExportTypes())
+ {
+ 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 GetTypes(IEnumerable assemblies)
{
foreach (var ass in assemblies)
@@ -908,9 +916,10 @@ namespace Emby.Server.Implementations
// Don't do anything if these haven't been set yet
if (HttpPort != 0 && HttpsPort != 0)
{
+ var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
// Need to restart if ports have changed
- if (ServerConfigurationManager.Configuration.HttpServerPortNumber != HttpPort ||
- ServerConfigurationManager.Configuration.HttpsPortNumber != HttpsPort)
+ if (networkConfiguration.HttpServerPortNumber != HttpPort ||
+ networkConfiguration.HttpsPortNumber != HttpsPort)
{
if (ServerConfigurationManager.Configuration.IsPortAuthorized)
{
@@ -996,80 +1005,60 @@ namespace Emby.Server.Implementations
protected abstract void RestartInternal();
- ///
- /// Comparison function used in .
- ///
- /// Item to compare.
- /// Item to compare with.
- /// Boolean result of the operation.
- private static int VersionCompare(
- (Version PluginVersion, string Name, string Path) a,
- (Version PluginVersion, string Name, string Path) b)
+ ///
+ public IEnumerable 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();
+ if (!Directory.Exists(path))
{
- return a.PluginVersion.CompareTo(b.PluginVersion);
+ // Plugin path doesn't exist, don't try to enumerate subfolders.
+ return Enumerable.Empty();
}
- return compare;
- }
-
- ///
- /// Returns a list of plugins to install.
- ///
- /// Path to check.
- /// True if an attempt should be made to delete old plugs.
- /// Enumerable list of dlls to load.
- private IEnumerable GetPlugins(string path, bool cleanup = true)
- {
- var dllList = new List();
- 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(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));
- }
+ // Un-versioned folder - Add it under the path name and version 0.0.0.1.
+ versions.Add(new LocalPlugin(Guid.Empty, metafile, minimumVersion, dir));
+ }
}
}
catch
@@ -1079,14 +1068,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;
}
@@ -1103,10 +1092,12 @@ namespace Emby.Server.Implementations
{
Logger.LogWarning(e, "Unable to delete {Path}", versions[x].Path);
}
+
+ versions.RemoveAt(x);
}
}
- return dllList;
+ return versions;
}
///
@@ -1117,21 +1108,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;
+ }
}
}
@@ -1168,6 +1162,9 @@ namespace Emby.Server.Implementations
// Xbmc
yield return typeof(ArtistNfoProvider).Assembly;
+ // Network
+ yield return typeof(NetworkManager).Assembly;
+
foreach (var i in GetAssembliesWithPartsInternal())
{
yield return i;
@@ -1179,13 +1176,10 @@ namespace Emby.Server.Implementations
///
/// Gets the system status.
///
- /// The cancellation token.
+ /// Where this request originated.
/// SystemInfo.
- public async Task GetSystemInfo(CancellationToken cancellationToken)
+ public SystemInfo GetSystemInfo(IPAddress source)
{
- var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
- var transcodingTempPath = ConfigurationManager.GetTranscodePath();
-
return new SystemInfo
{
HasPendingRestart = HasPendingRestart,
@@ -1205,9 +1199,9 @@ namespace Emby.Server.Implementations
CanSelfRestart = CanSelfRestart,
CanLaunchWebBrowser = CanLaunchWebBrowser,
HasUpdateAvailable = HasUpdateAvailable,
- TranscodingTempPath = transcodingTempPath,
+ TranscodingTempPath = ConfigurationManager.GetTranscodePath(),
ServerName = FriendlyName,
- LocalAddress = localAddress,
+ LocalAddress = GetSmartApiUrl(source),
SupportsLibraryMonitor = true,
EncoderLocation = _mediaEncoder.EncoderLocation,
SystemArchitecture = RuntimeInformation.OSArchitecture,
@@ -1216,14 +1210,12 @@ namespace Emby.Server.Implementations
}
public IEnumerable GetWakeOnLanInfo()
- => _networkManager.GetMacAddresses()
+ => NetManager.GetMacAddresses()
.Select(i => new WakeOnLanInfo(i))
.ToList();
- public async Task GetPublicSystemInfo(CancellationToken cancellationToken)
+ public PublicSystemInfo GetPublicSystemInfo(IPAddress source)
{
- var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
-
return new PublicSystemInfo
{
Version = ApplicationVersionString,
@@ -1231,195 +1223,100 @@ namespace Emby.Server.Implementations
Id = SystemId,
OperatingSystem = OperatingSystem.Id.ToString(),
ServerName = FriendlyName,
- LocalAddress = localAddress,
+ LocalAddress = GetSmartApiUrl(source),
StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted
};
}
///
- public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.Configuration.EnableHttps;
+ public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.GetNetworkConfiguration().EnableHttps;
///
- public async Task GetLocalApiUrl(CancellationToken cancellationToken)
+ public string GetSmartApiUrl(IPAddress ipAddress, int? port = null)
{
- try
+ // Published server ends with a /
+ if (_startupOptions.PublishedServerUrl != null)
{
- // Return the first matched address, if found, or the first known local address
- var addresses = await GetLocalIpAddressesInternal(false, 1, cancellationToken).ConfigureAwait(false);
- if (addresses.Count == 0)
- {
- return null;
- }
-
- return GetLocalApiUrl(addresses[0]);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error getting local Ip address information");
+ // Published server ends with a '/', so we need to remove it.
+ return _startupOptions.PublishedServerUrl.ToString().Trim('/');
}
- return null;
+ string smart = NetManager.GetBindInterface(ipAddress, out port);
+ // If the smartAPI doesn't start with http then treat it as a host or ip.
+ if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ return smart.Trim('/');
+ }
+
+ return GetLocalApiUrl(smart.Trim('/'), null, port);
}
- ///
- /// Removes the scope id from IPv6 addresses.
- ///
- /// The IPv6 address.
- /// The IPv6 address without the scope id.
- private ReadOnlySpan RemoveScopeId(ReadOnlySpan address)
+ ///
+ public string GetSmartApiUrl(HttpRequest request, int? port = null)
{
- var index = address.IndexOf('%');
- if (index == -1)
+ // Published server ends with a /
+ if (_startupOptions.PublishedServerUrl != null)
{
- return address;
+ // Published server ends with a '/', so we need to remove it.
+ return _startupOptions.PublishedServerUrl.ToString().Trim('/');
}
- return address.Slice(0, index);
+ string smart = NetManager.GetBindInterface(request, out port);
+ // If the smartAPI doesn't start with http then treat it as a host or ip.
+ if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ return smart.Trim('/');
+ }
+
+ return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port);
}
- ///
- public string GetLocalApiUrl(IPAddress ipAddress)
+ ///
+ public string GetSmartApiUrl(string hostname, int? port = null)
{
- if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
+ // Published server ends with a /
+ if (_startupOptions.PublishedServerUrl != null)
{
- var str = RemoveScopeId(ipAddress.ToString());
- Span span = new char[str.Length + 2];
- span[0] = '[';
- str.CopyTo(span.Slice(1));
- span[^1] = ']';
-
- return GetLocalApiUrl(span);
+ // Published server ends with a '/', so we need to remove it.
+ return _startupOptions.PublishedServerUrl.ToString().Trim('/');
}
- return GetLocalApiUrl(ipAddress.ToString());
+ string smart = NetManager.GetBindInterface(hostname, out port);
+
+ // If the smartAPI doesn't start with http then treat it as a host or ip.
+ if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ return smart.Trim('/');
+ }
+
+ return GetLocalApiUrl(smart.Trim('/'), null, port);
}
///
public string GetLoopbackHttpApiUrl()
{
+ if (NetManager.IsIP6Enabled)
+ {
+ return GetLocalApiUrl("::1", Uri.UriSchemeHttp, HttpPort);
+ }
+
return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort);
}
///
- public string GetLocalApiUrl(ReadOnlySpan host, string scheme = null, int? port = null)
+ public string GetLocalApiUrl(string host, string scheme = null, int? port = null)
{
// NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does
// not. For consistency, always trim the trailing slash.
return new UriBuilder
{
Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp),
- Host = host.ToString(),
+ Host = host,
Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort),
- Path = ServerConfigurationManager.Configuration.BaseUrl
+ Path = ServerConfigurationManager.GetNetworkConfiguration().BaseUrl
}.ToString().TrimEnd('/');
}
- public Task> GetLocalIpAddresses(CancellationToken cancellationToken)
- {
- return GetLocalIpAddressesInternal(true, 0, cancellationToken);
- }
-
- private async Task> GetLocalIpAddressesInternal(bool allowLoopback, int limit, CancellationToken cancellationToken)
- {
- var addresses = ServerConfigurationManager
- .Configuration
- .LocalNetworkAddresses
- .Select(x => NormalizeConfiguredLocalAddress(x))
- .Where(i => i != null)
- .ToList();
-
- if (addresses.Count == 0)
- {
- addresses.AddRange(_networkManager.GetLocalIpAddresses());
- }
-
- var resultList = new List();
-
- foreach (var address in addresses)
- {
- if (!allowLoopback)
- {
- if (address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback))
- {
- continue;
- }
- }
-
- if (await IsLocalIpAddressValidAsync(address, cancellationToken).ConfigureAwait(false))
- {
- resultList.Add(address);
-
- if (limit > 0 && resultList.Count >= limit)
- {
- return resultList;
- }
- }
- }
-
- return resultList;
- }
-
- public IPAddress NormalizeConfiguredLocalAddress(ReadOnlySpan address)
- {
- var index = address.Trim('/').IndexOf('/');
- if (index != -1)
- {
- address = address.Slice(index + 1);
- }
-
- if (IPAddress.TryParse(address.Trim('/'), out IPAddress result))
- {
- return result;
- }
-
- return null;
- }
-
- private readonly ConcurrentDictionary _validAddressResults = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
-
- private async Task IsLocalIpAddressValidAsync(IPAddress address, CancellationToken cancellationToken)
- {
- if (address.Equals(IPAddress.Loopback)
- || address.Equals(IPAddress.IPv6Loopback))
- {
- return true;
- }
-
- var apiUrl = GetLocalApiUrl(address) + "/system/ping";
-
- if (_validAddressResults.TryGetValue(apiUrl, out var cachedResult))
- {
- return cachedResult;
- }
-
- try
- {
- using var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
- 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);
- var result = await System.Text.Json.JsonSerializer.DeserializeAsync(stream, JsonDefaults.GetOptions(), cancellationToken).ConfigureAwait(false);
- var valid = string.Equals(Name, result, StringComparison.OrdinalIgnoreCase);
-
- _validAddressResults.AddOrUpdate(apiUrl, valid, (k, v) => valid);
- Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, valid);
- return valid;
- }
- catch (OperationCanceledException)
- {
- Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, "Cancelled");
- throw;
- }
- catch (Exception ex)
- {
- Logger.LogDebug(ex, "Ping test result to {0}. Success: {1}", apiUrl, false);
-
- _validAddressResults.AddOrUpdate(apiUrl, false, (k, v) => false);
- return false;
- }
- }
-
public string FriendlyName =>
string.IsNullOrEmpty(ServerConfigurationManager.Configuration.ServerName)
? Environment.MachineName
diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs
index fb1bb65a09..3d97a6ca8d 100644
--- a/Emby.Server.Implementations/Channels/ChannelManager.cs
+++ b/Emby.Server.Implementations/Channels/ChannelManager.cs
@@ -250,21 +250,16 @@ namespace Emby.Server.Implementations.Channels
var all = channels;
var totalCount = all.Count;
- if (query.StartIndex.HasValue)
+ if (query.StartIndex.HasValue || query.Limit.HasValue)
{
- all = all.Skip(query.StartIndex.Value).ToList();
+ int startIndex = query.StartIndex ?? 0;
+ int count = query.Limit == null ? totalCount - startIndex : Math.Min(query.Limit.Value, totalCount - startIndex);
+ all = all.GetRange(startIndex, count);
}
- if (query.Limit.HasValue)
- {
- all = all.Take(query.Limit.Value).ToList();
- }
-
- var returnItems = all.ToArray();
-
if (query.RefreshLatestChannelItems)
{
- foreach (var item in returnItems)
+ foreach (var item in all)
{
RefreshLatestChannelItems(GetChannelProvider(item), CancellationToken.None).GetAwaiter().GetResult();
}
@@ -272,7 +267,7 @@ namespace Emby.Server.Implementations.Channels
return new QueryResult
{
- Items = returnItems,
+ Items = all,
TotalRecordCount = totalCount
};
}
@@ -543,7 +538,7 @@ namespace Emby.Server.Implementations.Channels
return _libraryManager.GetItemIds(
new InternalItemsQuery
{
- IncludeItemTypes = new[] { typeof(Channel).Name },
+ IncludeItemTypes = new[] { nameof(Channel) },
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
}).Select(i => GetChannelFeatures(i.ToString("N", CultureInfo.InvariantCulture))).ToArray();
}
@@ -639,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/Channels/ChannelPostScanTask.cs b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs
index eeb49b8fef..2391eed428 100644
--- a/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs
+++ b/Emby.Server.Implementations/Channels/ChannelPostScanTask.cs
@@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.Channels
var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery
{
- IncludeItemTypes = new[] { typeof(Channel).Name },
+ IncludeItemTypes = new[] { nameof(Channel) },
ExcludeItemIds = installedChannelIds.ToArray()
});
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 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 1c2aeda709..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();
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 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 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})");
@@ -3914,7 +3932,7 @@ namespace Emby.Server.Implementations.Data
if (query.IsPlayed.HasValue)
{
// We should probably figure this out for all folders, but for right now, this is the only place where we need it
- if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(Series).Name, StringComparison.OrdinalIgnoreCase))
+ if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(Series), StringComparison.OrdinalIgnoreCase))
{
if (query.IsPlayed.Value)
{
@@ -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();
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();
var index = 0;
@@ -4755,29 +4773,29 @@ namespace Emby.Server.Implementations.Data
{
var list = new List();
- if (IsTypeInQuery(typeof(Person).Name, query))
+ if (IsTypeInQuery(nameof(Person), query))
{
- list.Add(typeof(Person).Name);
+ list.Add(nameof(Person));
}
- if (IsTypeInQuery(typeof(Genre).Name, query))
+ if (IsTypeInQuery(nameof(Genre), query))
{
- list.Add(typeof(Genre).Name);
+ list.Add(nameof(Genre));
}
- if (IsTypeInQuery(typeof(MusicGenre).Name, query))
+ if (IsTypeInQuery(nameof(MusicGenre), query))
{
- list.Add(typeof(MusicGenre).Name);
+ list.Add(nameof(MusicGenre));
}
- if (IsTypeInQuery(typeof(MusicArtist).Name, query))
+ if (IsTypeInQuery(nameof(MusicArtist), query))
{
- list.Add(typeof(MusicArtist).Name);
+ list.Add(nameof(MusicArtist));
}
- if (IsTypeInQuery(typeof(Studio).Name, query))
+ if (IsTypeInQuery(nameof(Studio), query))
{
- list.Add(typeof(Studio).Name);
+ list.Add(nameof(Studio));
}
return list;
@@ -4832,12 +4850,12 @@ namespace Emby.Server.Implementations.Data
var types = new[]
{
- typeof(Episode).Name,
- typeof(Video).Name,
- typeof(Movie).Name,
- typeof(MusicVideo).Name,
- typeof(Series).Name,
- typeof(Season).Name
+ nameof(Episode),
+ nameof(Video),
+ nameof(Movie),
+ nameof(MusicVideo),
+ nameof(Series),
+ nameof(Season)
};
if (types.Any(i => query.IncludeItemTypes.Contains(i, StringComparer.OrdinalIgnoreCase)))
@@ -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();
- 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 _capabilitiesMap = new ();
- public event EventHandler>> 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>> 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(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 edb8753fd4..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;
@@ -465,7 +465,7 @@ namespace Emby.Server.Implementations.Dto
{
var parentAlbumIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
- IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+ IncludeItemTypes = new[] { nameof(MusicAlbum) },
Name = item.Album,
Limit = 1
});
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
index c762aa0b84..dab4ec5a69 100644
--- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj
+++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
@@ -22,7 +22,6 @@
-
@@ -32,13 +31,13 @@
-
-
-
-
-
+
+
+
+
+
-
+
@@ -49,10 +48,12 @@
- netstandard2.1
+ net5.0
false
true
true
+
+ AD0001
diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
index 2e8cc76d23..14201ead29 100644
--- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
+++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
@@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Plugins;
@@ -56,7 +57,7 @@ namespace Emby.Server.Implementations.EntryPoints
private string GetConfigIdentifier()
{
const char Separator = '|';
- var config = _config.Configuration;
+ var config = _config.GetNetworkConfiguration();
return new StringBuilder(32)
.Append(config.EnableUPnP).Append(Separator)
@@ -93,7 +94,8 @@ namespace Emby.Server.Implementations.EntryPoints
private void Start()
{
- if (!_config.Configuration.EnableUPnP || !_config.Configuration.EnableRemoteAccess)
+ var config = _config.GetNetworkConfiguration();
+ if (!config.EnableUPnP || !config.EnableRemoteAccess)
{
return;
}
@@ -156,11 +158,12 @@ namespace Emby.Server.Implementations.EntryPoints
private IEnumerable CreatePortMaps(INatDevice device)
{
- yield return CreatePortMap(device, _appHost.HttpPort, _config.Configuration.PublicPort);
+ var config = _config.GetNetworkConfiguration();
+ yield return CreatePortMap(device, _appHost.HttpPort, config.PublicPort);
if (_appHost.ListenWithHttps)
{
- yield return CreatePortMap(device, _appHost.HttpsPort, _config.Configuration.PublicHttpsPort);
+ yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort);
}
}
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 4b407dd9d2..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 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;
}
///
@@ -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,12 +269,12 @@ 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[] { '"' }));
- result.Add(param[0], value);
+ var value = NormalizeValue(param[1].Trim('"'));
+ result[param[0]] = value;
}
}
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
index 89c1b7ea08..71ece80a75 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Net.WebSockets;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
@@ -14,16 +13,18 @@ namespace Emby.Server.Implementations.HttpServer
{
public class WebSocketManager : IWebSocketManager
{
+ private readonly Lazy> _webSocketListeners;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
- private IWebSocketListener[] _webSocketListeners = Array.Empty();
private bool _disposed = false;
public WebSocketManager(
+ Lazy> webSocketListeners,
ILogger logger,
ILoggerFactory loggerFactory)
{
+ _webSocketListeners = webSocketListeners;
_logger = logger;
_loggerFactory = loggerFactory;
}
@@ -68,15 +69,6 @@ namespace Emby.Server.Implementations.HttpServer
}
}
- ///
- /// Adds the rest handlers.
- ///
- /// The web socket listeners.
- public void Init(IEnumerable listeners)
- {
- _webSocketListeners = listeners.ToArray();
- }
-
///
/// Processes the web socket message received.
///
@@ -90,7 +82,8 @@ namespace Emby.Server.Implementations.HttpServer
IEnumerable GetTasks()
{
- foreach (var x in _webSocketListeners)
+ var listeners = _webSocketListeners.Value;
+ foreach (var x in listeners)
{
yield return x.ProcessMessageAsync(result);
}
diff --git a/Emby.Server.Implementations/Images/ArtistImageProvider.cs b/Emby.Server.Implementations/Images/ArtistImageProvider.cs
index bf57382ed4..afa4ec7b1b 100644
--- a/Emby.Server.Implementations/Images/ArtistImageProvider.cs
+++ b/Emby.Server.Implementations/Images/ArtistImageProvider.cs
@@ -42,7 +42,7 @@ namespace Emby.Server.Implementations.Images
// return _libraryManager.GetItemList(new InternalItemsQuery
// {
// ArtistIds = new[] { item.Id },
- // IncludeItemTypes = new[] { typeof(MusicAlbum).Name },
+ // IncludeItemTypes = new[] { nameof(MusicAlbum) },
// OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) },
// Limit = 4,
// Recursive = true,
diff --git a/Emby.Server.Implementations/Images/GenreImageProvider.cs b/Emby.Server.Implementations/Images/GenreImageProvider.cs
index 1cd4cd66bd..3817882312 100644
--- a/Emby.Server.Implementations/Images/GenreImageProvider.cs
+++ b/Emby.Server.Implementations/Images/GenreImageProvider.cs
@@ -42,7 +42,12 @@ namespace Emby.Server.Implementations.Images
return _libraryManager.GetItemList(new InternalItemsQuery
{
Genres = new[] { item.Name },
- IncludeItemTypes = new[] { typeof(MusicAlbum).Name, typeof(MusicVideo).Name, typeof(Audio).Name },
+ IncludeItemTypes = new[]
+ {
+ nameof(MusicAlbum),
+ nameof(MusicVideo),
+ nameof(Audio)
+ },
OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) },
Limit = 4,
Recursive = true,
@@ -77,7 +82,7 @@ namespace Emby.Server.Implementations.Images
return _libraryManager.GetItemList(new InternalItemsQuery
{
Genres = new[] { item.Name },
- IncludeItemTypes = new[] { typeof(Series).Name, typeof(Movie).Name },
+ IncludeItemTypes = new[] { nameof(Series), nameof(Movie) },
OrderBy = new[] { (ItemSortBy.Random, SortOrder.Ascending) },
Limit = 4,
Recursive = true,
diff --git a/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs b/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs
new file mode 100644
index 0000000000..d4e790c9a6
--- /dev/null
+++ b/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Library
+{
+ ///
+ /// A library post scan/refresh task for pre-fetching remote images.
+ ///
+ public class ImageFetcherPostScanTask : ILibraryPostScanTask
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IProviderManager _providerManager;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _imageFetcherLock;
+
+ private ConcurrentDictionary _queuedItems;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// An instance of .
+ /// An instance of .
+ /// An instance of .
+ public ImageFetcherPostScanTask(
+ ILibraryManager libraryManager,
+ IProviderManager providerManager,
+ ILogger logger)
+ {
+ _libraryManager = libraryManager;
+ _providerManager = providerManager;
+ _logger = logger;
+ _queuedItems = new ConcurrentDictionary();
+ _imageFetcherLock = new SemaphoreSlim(1, 1);
+ _libraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated;
+ _libraryManager.ItemUpdated += OnLibraryManagerItemAddedOrUpdated;
+ _providerManager.RefreshCompleted += OnProviderManagerRefreshCompleted;
+ }
+
+ ///
+ public async Task Run(IProgress progress, CancellationToken cancellationToken)
+ {
+ // Sometimes a library scan will cause this to run twice if there's an item refresh going on.
+ await _imageFetcherLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ var now = DateTime.UtcNow;
+ var itemGuids = _queuedItems.Keys.ToList();
+
+ for (var i = 0; i < itemGuids.Count; i++)
+ {
+ if (!_queuedItems.TryGetValue(itemGuids[i], out var queuedItem))
+ {
+ continue;
+ }
+
+ var itemId = queuedItem.item.Id.ToString("N", CultureInfo.InvariantCulture);
+ var itemType = queuedItem.item.GetType();
+ _logger.LogDebug(
+ "Updating remote images for item {ItemId} with media type {ItemMediaType}",
+ itemId,
+ itemType);
+ try
+ {
+ await _libraryManager.UpdateImagesAsync(queuedItem.item, queuedItem.updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to fetch images for {Type} item with id {ItemId}", itemType, itemId);
+ }
+
+ _queuedItems.TryRemove(queuedItem.item.Id, out _);
+ }
+
+ if (itemGuids.Count > 0)
+ {
+ _logger.LogInformation(
+ "Finished updating/pre-fetching {NumberOfImages} images. Elapsed time: {TimeElapsed}s.",
+ itemGuids.Count.ToString(CultureInfo.InvariantCulture),
+ (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ _logger.LogDebug("No images were updated.");
+ }
+ }
+ finally
+ {
+ _imageFetcherLock.Release();
+ }
+ }
+
+ private void OnLibraryManagerItemAddedOrUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs)
+ {
+ if (!_queuedItems.ContainsKey(itemChangeEventArgs.Item.Id) && itemChangeEventArgs.Item.ImageInfos.Length > 0)
+ {
+ _queuedItems.AddOrUpdate(
+ itemChangeEventArgs.Item.Id,
+ (itemChangeEventArgs.Item, itemChangeEventArgs.UpdateReason),
+ (key, existingValue) => existingValue);
+ }
+ }
+
+ private void OnProviderManagerRefreshCompleted(object sender, GenericEventArgs e)
+ {
+ if (!_queuedItems.ContainsKey(e.Argument.Id) && e.Argument.ImageInfos.Length > 0)
+ {
+ _queuedItems.AddOrUpdate(
+ e.Argument.Id,
+ (e.Argument, ItemUpdateType.None),
+ (key, existingValue) => existingValue);
+ }
+
+ // The RefreshCompleted event is a bit awkward in that it seems to _only_ be fired on
+ // the item that was refreshed regardless of children refreshes. So we take it as a signal
+ // that the refresh is entirely completed.
+ Run(null, CancellationToken.None).GetAwaiter().GetResult();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 00282b71a5..013781258e 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;
@@ -857,7 +858,21 @@ namespace Emby.Server.Implementations.Library
/// Task{Person}.
public Person GetPerson(string name)
{
- return CreateItemByName(Person.GetPath, name, new DtoOptions(true));
+ var path = Person.GetPath(name);
+ var id = GetItemByNameId(path);
+ if (!(GetItemById(id) is Person item))
+ {
+ item = new Person
+ {
+ Name = name,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ Path = path
+ };
+ }
+
+ return item;
}
///
@@ -1502,7 +1517,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) &&
@@ -1940,19 +1955,9 @@ namespace Emby.Server.Implementations.Library
}
///
- public async Task UpdateItemsAsync(IReadOnlyList items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
+ public Task UpdateItemsAsync(IReadOnlyList items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
{
- foreach (var item in items)
- {
- if (item.IsFileProtocol)
- {
- ProviderManager.SaveMetadata(item, updateReason);
- }
-
- item.DateLastSaved = DateTime.UtcNow;
-
- await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
- }
+ RunMetadataSavers(items, updateReason);
_itemRepository.SaveItems(items, cancellationToken);
@@ -1983,12 +1988,27 @@ namespace Emby.Server.Implementations.Library
}
}
}
+
+ return Task.CompletedTask;
}
///
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken);
+ public void RunMetadataSavers(IReadOnlyList items, ItemUpdateType updateReason)
+ {
+ foreach (var item in items)
+ {
+ if (item.IsFileProtocol)
+ {
+ ProviderManager.SaveMetadata(item, updateReason);
+ }
+
+ item.DateLastSaved = DateTime.UtcNow;
+ }
+ }
+
///
/// Reports the item removed.
///
@@ -2440,6 +2460,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;
+ }
+
///
public bool IsVideoFile(string path)
{
@@ -2470,9 +2505,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 +2597,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 +2726,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 +2928,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 GetSortedStreams(IEnumerable 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/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs
index 877fdec86e..658c53f288 100644
--- a/Emby.Server.Implementations/Library/MusicManager.cs
+++ b/Emby.Server.Implementations/Library/MusicManager.cs
@@ -49,7 +49,7 @@ namespace Emby.Server.Implementations.Library
var genres = item
.GetRecursiveChildren(user, new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { typeof(Audio).Name },
+ IncludeItemTypes = new[] { nameof(Audio) },
DtoOptions = dtoOptions
})
.Cast
private readonly ILogger _logger;
private readonly IApplicationPaths _appPaths;
+ private readonly IEventManager _eventManager;
private readonly IHttpClientFactory _httpClientFactory;
- private readonly IJsonSerializer _jsonSerializer;
private readonly IServerConfigurationManager _config;
private readonly IFileSystem _fileSystem;
+ private readonly JsonSerializerOptions _jsonSerializerOptions;
///
/// Gets the application host.
///
/// The application host.
- private readonly IApplicationHost _applicationHost;
+ private readonly IServerApplicationHost _applicationHost;
private readonly IZipClient _zipClient;
@@ -63,85 +66,65 @@ namespace Emby.Server.Implementations.Updates
public InstallationManager(
ILogger logger,
- IApplicationHost appHost,
+ IServerApplicationHost appHost,
IApplicationPaths appPaths,
+ IEventManager eventManager,
IHttpClientFactory httpClientFactory,
- IJsonSerializer jsonSerializer,
IServerConfigurationManager config,
IFileSystem fileSystem,
IZipClient zipClient)
{
- if (logger == null)
- {
- throw new ArgumentNullException(nameof(logger));
- }
-
_currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
_completedInstallationsInternal = new ConcurrentBag();
_logger = logger;
_applicationHost = appHost;
_appPaths = appPaths;
+ _eventManager = eventManager;
_httpClientFactory = httpClientFactory;
- _jsonSerializer = jsonSerializer;
_config = config;
_fileSystem = fileSystem;
_zipClient = zipClient;
+ _jsonSerializerOptions = JsonDefaults.GetOptions();
}
- ///
- public event EventHandler PackageInstalling;
-
- ///
- public event EventHandler PackageInstallationCompleted;
-
- ///
- public event EventHandler PackageInstallationFailed;
-
- ///
- public event EventHandler PackageInstallationCancelled;
-
- ///
- public event EventHandler PluginUninstalled;
-
- ///
- public event EventHandler PluginUpdated;
-
- ///
- public event EventHandler PluginInstalled;
-
///
public IEnumerable CompletedInstallations => _completedInstallationsInternal;
///
- public async Task> GetPackages(string manifest, CancellationToken cancellationToken = default)
+ public async Task> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default)
{
try
{
- using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
- .GetAsync(manifest, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
-
- try
+ var packages = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetFromJsonAsync>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
+ if (packages == null)
{
- return await _jsonSerializer.DeserializeFromStreamAsync>(stream).ConfigureAwait(false);
- }
- catch (SerializationException ex)
- {
- _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
return Array.Empty();
}
+
+ // Store the repository and repository url with each version, as they may be spread apart.
+ foreach (var entry in packages)
+ {
+ foreach (var ver in entry.versions)
+ {
+ ver.repositoryName = manifestName;
+ ver.repositoryUrl = manifest;
+ }
+ }
+
+ return packages;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
+ return Array.Empty();
}
catch (UriFormatException ex)
{
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
return Array.Empty();
}
- catch (HttpException ex)
- {
- _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
- return Array.Empty();
- }
catch (HttpRequestException ex)
{
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
@@ -149,17 +132,75 @@ namespace Emby.Server.Implementations.Updates
}
}
+ private static void MergeSort(IList source, IList dest)
+ {
+ int sLength = source.Count - 1;
+ int dLength = dest.Count;
+ int s = 0, d = 0;
+ var sourceVersion = source[0].VersionNumber;
+ var destVersion = dest[0].VersionNumber;
+
+ while (d < dLength)
+ {
+ if (sourceVersion.CompareTo(destVersion) >= 0)
+ {
+ if (s < sLength)
+ {
+ sourceVersion = source[++s].VersionNumber;
+ }
+ else
+ {
+ // Append all of destination to the end of source.
+ while (d < dLength)
+ {
+ source.Add(dest[d++]);
+ }
+
+ break;
+ }
+ }
+ else
+ {
+ source.Insert(s++, dest[d++]);
+ if (d >= dLength)
+ {
+ break;
+ }
+
+ sLength++;
+ destVersion = dest[d].VersionNumber;
+ }
+ }
+ }
+
///
public async Task> GetAvailablePackages(CancellationToken cancellationToken = default)
{
var result = new List();
foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
{
- foreach (var package in await GetPackages(repository.Url, cancellationToken).ConfigureAwait(true))
+ if (repository.Enabled)
{
- package.repositoryName = repository.Name;
- package.repositoryUrl = repository.Url;
- result.Add(package);
+ // Where repositories have the same content, the details of the first is taken.
+ foreach (var package in await GetPackages(repository.Name, repository.Url, cancellationToken).ConfigureAwait(true))
+ {
+ if (!Guid.TryParse(package.guid, out var packageGuid))
+ {
+ // Package doesn't have a valid GUID, skip.
+ continue;
+ }
+
+ var existing = FilterPackages(result, package.name, packageGuid).FirstOrDefault();
+ if (existing != null)
+ {
+ // Assumption is both lists are ordered, so slot these into the correct place.
+ MergeSort(existing.versions, package.versions);
+ }
+ else
+ {
+ result.Add(package);
+ }
+ }
}
}
@@ -170,7 +211,8 @@ namespace Emby.Server.Implementations.Updates
public IEnumerable FilterPackages(
IEnumerable availablePackages,
string name = null,
- Guid guid = default)
+ Guid guid = default,
+ Version specificVersion = null)
{
if (name != null)
{
@@ -182,6 +224,11 @@ namespace Emby.Server.Implementations.Updates
availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
}
+ if (specificVersion != null)
+ {
+ availablePackages = availablePackages.Where(x => x.versions.Where(y => y.VersionNumber.Equals(specificVersion)).Any());
+ }
+
return availablePackages;
}
@@ -193,7 +240,7 @@ namespace Emby.Server.Implementations.Updates
Version minVersion = null,
Version specificVersion = null)
{
- var package = FilterPackages(availablePackages, name, guid).FirstOrDefault();
+ var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
// Package not found in repository
if (package == null)
@@ -207,21 +254,21 @@ namespace Emby.Server.Implementations.Updates
if (specificVersion != null)
{
- availableVersions = availableVersions.Where(x => new Version(x.version) == specificVersion);
+ availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
}
else if (minVersion != null)
{
- availableVersions = availableVersions.Where(x => new Version(x.version) >= minVersion);
+ availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
}
- foreach (var v in availableVersions.OrderByDescending(x => x.version))
+ foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
{
yield return new InstallationInfo
{
Changelog = v.changelog,
Guid = new Guid(package.guid),
Name = package.name,
- Version = new Version(v.version),
+ Version = v.VersionNumber,
SourceUrl = v.sourceUrl,
Checksum = v.checksum
};
@@ -237,7 +284,8 @@ namespace Emby.Server.Implementations.Updates
private IEnumerable GetAvailablePluginUpdates(IReadOnlyList 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);
@@ -266,13 +314,14 @@ 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;
- PackageInstalling?.Invoke(this, package);
+ await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false);
try
{
- await InstallPackageInternal(package, linkedToken).ConfigureAwait(false);
+ var isUpdate = await InstallPackageInternal(package, linkedToken).ConfigureAwait(false);
lock (_currentInstallationsLock)
{
@@ -280,8 +329,11 @@ namespace Emby.Server.Implementations.Updates
}
_completedInstallationsInternal.Add(package);
+ await _eventManager.PublishAsync(isUpdate
+ ? (GenericEventArgs)new PluginUpdatedEventArgs(package)
+ : new PluginInstalledEventArgs(package)).ConfigureAwait(false);
- PackageInstallationCompleted?.Invoke(this, package);
+ _applicationHost.NotifyPendingRestart();
}
catch (OperationCanceledException)
{
@@ -292,7 +344,7 @@ namespace Emby.Server.Implementations.Updates
_logger.LogInformation("Package installation cancelled: {0} {1}", package.Name, package.Version);
- PackageInstallationCancelled?.Invoke(this, package);
+ await _eventManager.PublishAsync(new PluginInstallationCancelledEventArgs(package)).ConfigureAwait(false);
throw;
}
@@ -305,11 +357,11 @@ namespace Emby.Server.Implementations.Updates
_currentInstallations.Remove(tuple);
}
- PackageInstallationFailed?.Invoke(this, new InstallationFailedEventArgs
+ await _eventManager.PublishAsync(new InstallationFailedEventArgs
{
InstallationInfo = package,
Exception = ex
- });
+ }).ConfigureAwait(false);
throw;
}
@@ -326,7 +378,7 @@ namespace Emby.Server.Implementations.Updates
/// The package.
/// The cancellation token.
/// .
- private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
+ private async Task InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
{
// Set last update time if we were installed before
IPlugin plugin = _applicationHost.Plugins.FirstOrDefault(p => p.Id == package.Guid)
@@ -336,20 +388,9 @@ namespace Emby.Server.Implementations.Updates
await PerformPackageInstallation(package, cancellationToken).ConfigureAwait(false);
// Do plugin-specific processing
- if (plugin == null)
- {
- _logger.LogInformation("New plugin installed: {0} {1}", package.Name, package.Version);
+ _logger.LogInformation(plugin == null ? "New plugin installed: {0} {1}" : "Plugin updated: {0} {1}", package.Name, package.Version);
- PluginInstalled?.Invoke(this, package);
- }
- else
- {
- _logger.LogInformation("Plugin updated: {0} {1}", package.Name, package.Version);
-
- PluginUpdated?.Invoke(this, package);
- }
-
- _applicationHost.NotifyPendingRestart();
+ return plugin != null;
}
private async Task PerformPackageInstallation(InstallationInfo package, CancellationToken cancellationToken)
@@ -365,15 +406,15 @@ namespace Emby.Server.Implementations.Updates
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
- .GetAsync(package.SourceUrl, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
// CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
using var md5 = MD5.Create();
cancellationToken.ThrowIfCancellationRequested();
- var hash = Hex.Encode(md5.ComputeHash(stream));
+ var hash = Convert.ToHexString(md5.ComputeHash(stream));
if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
@@ -467,7 +508,7 @@ namespace Emby.Server.Implementations.Updates
_config.SaveConfiguration();
}
- PluginUninstalled?.Invoke(this, plugin);
+ _eventManager.Publish(new PluginUninstalledEventArgs(plugin));
_applicationHost.NotifyPendingRestart();
}
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.
///
public const string Token = "Jellyfin-Token";
+
+ ///
+ /// Is Api Key.
+ ///
+ 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
-{
- ///
- /// The albums controller.
- ///
- [Route("")]
- public class AlbumsController : BaseJellyfinApiController
- {
- private readonly IUserManager _userManager;
- private readonly ILibraryManager _libraryManager;
- private readonly IDtoService _dtoService;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// Instance of the interface.
- /// Instance of the interface.
- /// Instance of the interface.
- public AlbumsController(
- IUserManager userManager,
- ILibraryManager libraryManager,
- IDtoService dtoService)
- {
- _userManager = userManager;
- _libraryManager = libraryManager;
- _dtoService = dtoService;
- }
-
- ///
- /// Finds albums similar to a given album.
- ///
- /// The album id.
- /// Optional. Filter by user id, and attach user data.
- /// Optional. Ids of artists to exclude.
- /// Optional. The maximum number of records to return.
- /// Similar albums returned.
- /// A with similar albums.
- [HttpGet("Albums/{albumId}/Similar")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult> 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);
- }
-
- ///
- /// Finds artists similar to a given artist.
- ///
- /// The artist id.
- /// Optional. Filter by user id, and attach user data.
- /// Optional. Ids of artists to exclude.
- /// Optional. The maximum number of records to return.
- /// Similar artists returned.
- /// A with similar artists.
- [HttpGet("Artists/{artistId}/Similar")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult> 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);
- }
-
- ///
- /// Gets a similairty score of two albums.
- ///
- /// The first item.
- /// The item1 people.
- /// All people.
- /// The second item.
- /// System.Int32.
- private int GetAlbumSimilarityScore(BaseItem item1, List item1People, List 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(
- album2.GetAllArtists().DistinctNames(),
- StringComparer.OrdinalIgnoreCase);
-
- return points + artists1.Where(artists2.Contains).Sum(i => 5);
- }
- }
-}
diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs
index e8d6ccdf27..8c43d786a7 100644
--- a/Jellyfin.Api/Controllers/ApiKeyController.cs
+++ b/Jellyfin.Api/Controllers/ApiKeyController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jellyfin.Api.Constants;
diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs
index 784ecaeb32..c65dc86201 100644
--- a/Jellyfin.Api/Controllers/ArtistsController.cs
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -1,14 +1,16 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
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;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -51,7 +53,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The maximum number of records to return.
/// Optional. Search term.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.
/// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.
/// Optional. Specify additional filters to apply.
@@ -86,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] ItemFilter[] filters,
+ [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] string? 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,
@@ -112,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);
@@ -130,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,
@@ -173,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
{
@@ -229,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;
@@ -260,7 +257,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The maximum number of records to return.
/// Optional. Search term.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.
/// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.
/// Optional. Specify additional filters to apply.
@@ -295,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] ItemFilter[] filters,
+ [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] string? 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,
@@ -321,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);
@@ -339,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,
@@ -382,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
{
@@ -438,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..c22979495d 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;
@@ -31,6 +31,171 @@ namespace Jellyfin.Api.Controllers
_audioHelper = audioHelper;
}
+ ///
+ /// Gets an audio stream.
+ ///
+ /// The item id.
+ /// The audio container.
+ /// Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.
+ /// The streaming parameters.
+ /// The tag.
+ /// Optional. The dlna device profile id to utilize.
+ /// The play session id.
+ /// The segment container.
+ /// The segment length.
+ /// The minimum number of segments.
+ /// The media version id, if playing an alternate version.
+ /// The device id of the client requesting. Used to stop encoding processes when needed.
+ /// Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.
+ /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.
+ /// Whether or not to allow copying of the video stream url.
+ /// Whether or not to allow copying of the audio stream url.
+ /// Optional. Whether to break on non key frames.
+ /// Optional. Specify a specific audio sample rate, e.g. 44100.
+ /// Optional. The maximum audio bit depth.
+ /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.
+ /// Optional. Specify a specific number of audio channels to encode to, e.g. 2.
+ /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2.
+ /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.
+ /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.
+ /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.
+ /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.
+ /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false.
+ /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.
+ /// Optional. The fixed horizontal resolution of the encoded video.
+ /// Optional. The fixed vertical resolution of the encoded video.
+ /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.
+ /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.
+ /// Optional. Specify the subtitle delivery method.
+ /// Optional.
+ /// Optional. The maximum video bit depth.
+ /// Optional. Whether to require avc.
+ /// Optional. Whether to deinterlace the video.
+ /// Optional. Whether to require a non anamorphic stream.
+ /// Optional. The maximum number of audio channels to transcode.
+ /// Optional. The limit of how many cpu cores to use.
+ /// The live stream id.
+ /// Optional. Whether to enable the MpegtsM2Ts mode.
+ /// Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.
+ /// Optional. Specify a subtitle codec to encode to.
+ /// Optional. The transcoding reason.
+ /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used.
+ /// Optional. The index of the video stream to use. If omitted the first video stream will be used.
+ /// Optional. The .
+ /// Optional. The streaming options.
+ /// Audio stream returned.
+ /// A containing the audio file.
+ [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
+ [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesAudioFile]
+ public async Task GetAudioStream(
+ [FromRoute, Required] Guid itemId,
+ [FromQuery] string? container,
+ [FromQuery] bool? @static,
+ [FromQuery] string? @params,
+ [FromQuery] string? tag,
+ [FromQuery] string? deviceProfileId,
+ [FromQuery] string? playSessionId,
+ [FromQuery] string? segmentContainer,
+ [FromQuery] int? segmentLength,
+ [FromQuery] int? minSegments,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] string? deviceId,
+ [FromQuery] string? audioCodec,
+ [FromQuery] bool? enableAutoStreamCopy,
+ [FromQuery] bool? allowVideoStreamCopy,
+ [FromQuery] bool? allowAudioStreamCopy,
+ [FromQuery] bool? breakOnNonKeyFrames,
+ [FromQuery] int? audioSampleRate,
+ [FromQuery] int? maxAudioBitDepth,
+ [FromQuery] int? audioBitRate,
+ [FromQuery] int? audioChannels,
+ [FromQuery] int? maxAudioChannels,
+ [FromQuery] string? profile,
+ [FromQuery] string? level,
+ [FromQuery] float? framerate,
+ [FromQuery] float? maxFramerate,
+ [FromQuery] bool? copyTimestamps,
+ [FromQuery] long? startTimeTicks,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? videoBitRate,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] SubtitleDeliveryMethod subtitleMethod,
+ [FromQuery] int? maxRefFrames,
+ [FromQuery] int? maxVideoBitDepth,
+ [FromQuery] bool? requireAvc,
+ [FromQuery] bool? deInterlace,
+ [FromQuery] bool? requireNonAnamorphic,
+ [FromQuery] int? transcodingMaxAudioChannels,
+ [FromQuery] int? cpuCoreLimit,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] bool? enableMpegtsM2TsMode,
+ [FromQuery] string? videoCodec,
+ [FromQuery] string? subtitleCodec,
+ [FromQuery] string? transcodingReasons,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? videoStreamIndex,
+ [FromQuery] EncodingContext? context,
+ [FromQuery] Dictionary? streamOptions)
+ {
+ StreamingRequestDto streamingRequest = new StreamingRequestDto
+ {
+ Id = itemId,
+ Container = container,
+ Static = @static ?? true,
+ Params = @params,
+ Tag = tag,
+ DeviceProfileId = deviceProfileId,
+ PlaySessionId = playSessionId,
+ SegmentContainer = segmentContainer,
+ SegmentLength = segmentLength,
+ MinSegments = minSegments,
+ MediaSourceId = mediaSourceId,
+ DeviceId = deviceId,
+ AudioCodec = audioCodec,
+ EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+ AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+ AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+ BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+ AudioSampleRate = audioSampleRate,
+ MaxAudioChannels = maxAudioChannels,
+ AudioBitRate = audioBitRate,
+ MaxAudioBitDepth = maxAudioBitDepth,
+ AudioChannels = audioChannels,
+ Profile = profile,
+ Level = level,
+ Framerate = framerate,
+ MaxFramerate = maxFramerate,
+ CopyTimestamps = copyTimestamps ?? true,
+ StartTimeTicks = startTimeTicks,
+ Width = width,
+ Height = height,
+ VideoBitRate = videoBitRate,
+ SubtitleStreamIndex = subtitleStreamIndex,
+ SubtitleMethod = subtitleMethod,
+ MaxRefFrames = maxRefFrames,
+ MaxVideoBitDepth = maxVideoBitDepth,
+ RequireAvc = requireAvc ?? true,
+ DeInterlace = deInterlace ?? true,
+ RequireNonAnamorphic = requireNonAnamorphic ?? true,
+ TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+ CpuCoreLimit = cpuCoreLimit,
+ LiveStreamId = liveStreamId,
+ EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+ VideoCodec = videoCodec,
+ SubtitleCodec = subtitleCodec,
+ TranscodeReasons = transcodingReasons,
+ AudioStreamIndex = audioStreamIndex,
+ VideoStreamIndex = videoStreamIndex,
+ Context = context ?? EncodingContext.Static,
+ StreamOptions = streamOptions
+ };
+
+ return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
+ }
+
///
/// Gets an audio stream.
///
@@ -85,15 +250,13 @@ namespace Jellyfin.Api.Controllers
/// Optional. The streaming options.
/// Audio stream returned.
/// A containing the audio file.
- [HttpGet("{itemId}/stream.{container:required}", Name = "GetAudioStreamByContainer")]
- [HttpGet("{itemId}/stream", Name = "GetAudioStream")]
- [HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")]
- [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
+ [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")]
+ [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesAudioFile]
- public async Task GetAudioStream(
+ public async Task GetAudioStreamByContainer(
[FromRoute, Required] Guid itemId,
- [FromRoute] string? container,
+ [FromRoute, Required] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs
index 1d4836f278..d3ea412015 100644
--- a/Jellyfin.Api/Controllers/BrandingController.cs
+++ b/Jellyfin.Api/Controllers/BrandingController.cs
@@ -1,4 +1,4 @@
-using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Branding;
using Microsoft.AspNetCore.Http;
diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs
index fda00b8d46..c4dc44cc36 100644
--- a/Jellyfin.Api/Controllers/ChannelsController.cs
+++ b/Jellyfin.Api/Controllers/ChannelsController.cs
@@ -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.Controller.Channels;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -107,7 +108,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Sort Order - Ascending,Descending.
/// Optional. Specify additional filters to apply.
/// 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.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Channel items returned.
///
/// A representing the request to get the channel items.
@@ -121,9 +122,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? sortOrder,
- [FromQuery] ItemFilter[] filters,
+ [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)
@@ -136,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)
@@ -184,7 +184,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
/// Optional. Specify additional filters to apply.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Specify one or more channel id's, comma delimited.
/// Latest channel items returned.
///
@@ -196,9 +196,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
- [FromQuery] ItemFilter[] filters,
- [FromQuery] string? fields,
- [FromQuery] string? channelIds)
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
@@ -208,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 2fc697a6aa..2a7b2b5c62 100644
--- a/Jellyfin.Api/Controllers/CollectionController.cs
+++ b/Jellyfin.Api/Controllers/CollectionController.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
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> 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);
@@ -83,14 +84,16 @@ namespace Jellyfin.Api.Controllers
/// Adds items to a collection.
///
/// The collection id.
- /// Item ids, comma delimited.
+ /// Item ids, comma delimited.
/// Items added to collection.
/// A indicating success.
[HttpPost("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds)
+ public async Task AddToCollection(
+ [FromRoute, Required] Guid collectionId,
+ [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(true);
+ await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
return NoContent();
}
@@ -98,14 +101,16 @@ namespace Jellyfin.Api.Controllers
/// Removes items from a collection.
///
/// The collection id.
- /// Item ids, comma delimited.
+ /// Item ids, comma delimited.
/// Items removed from collection.
/// A indicating success.
[HttpDelete("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string itemIds)
+ public async Task RemoveFromCollection(
+ [FromRoute, Required] Guid collectionId,
+ [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(itemIds)).ConfigureAwait(false);
+ await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
return NoContent();
}
}
diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs
index a859ac114c..ccc81dfc55 100644
--- a/Jellyfin.Api/Controllers/DashboardController.cs
+++ b/Jellyfin.Api/Controllers/DashboardController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs
index 74380c2eff..b3e3490c2a 100644
--- a/Jellyfin.Api/Controllers/DevicesController.cs
+++ b/Jellyfin.Api/Controllers/DevicesController.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Api.Controllers
///
/// Devices Controller.
///
- [Authorize(Policy = Policies.DefaultAuthorization)]
+ [Authorize(Policy = Policies.RequiresElevation)]
public class DevicesController : BaseJellyfinApiController
{
private readonly IDeviceManager _deviceManager;
@@ -46,7 +46,6 @@ namespace Jellyfin.Api.Controllers
/// Devices retrieved.
/// An containing the list of devices.
[HttpGet]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
{
@@ -62,7 +61,6 @@ namespace Jellyfin.Api.Controllers
/// Device not found.
/// An containing the device info on success, or a if the device could not be found.
[HttpGet("Info")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult GetDeviceInfo([FromQuery, Required] string id)
@@ -84,7 +82,6 @@ namespace Jellyfin.Api.Controllers
/// Device not found.
/// An containing the device info on success, or a if the device could not be found.
[HttpGet("Options")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult GetDeviceOptions([FromQuery, Required] string id)
@@ -107,7 +104,6 @@ namespace Jellyfin.Api.Controllers
/// Device not found.
/// A on success, or a if the device could not be found.
[HttpPost("Options")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateDeviceOptions(
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..4fd9c2fbf6 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.
///
/// Server UUID.
+ /// Dlna media receiver registrar xml returned.
/// Dlna media receiver registrar xml.
[HttpGet("{serverId}/MediaReceiverRegistrar")]
[HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
@@ -94,6 +95,7 @@ namespace Jellyfin.Api.Controllers
/// Gets Dlna media receiver registrar xml.
///
/// Server UUID.
+ /// Dlna media receiver registrar xml returned.
/// Dlna media receiver registrar xml.
[HttpGet("{serverId}/ConnectionManager")]
[HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
@@ -111,8 +113,12 @@ namespace Jellyfin.Api.Controllers
/// Process a content directory control request.
///
/// Server UUID.
+ /// Request processed.
/// Control response.
[HttpPost("{serverId}/ContentDirectory/Control")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Produces(MediaTypeNames.Text.Xml)]
+ [ProducesFile(MediaTypeNames.Text.Xml)]
public async Task> 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.
///
/// Server UUID.
+ /// Request processed.
/// Control response.
[HttpPost("{serverId}/ConnectionManager/Control")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Produces(MediaTypeNames.Text.Xml)]
+ [ProducesFile(MediaTypeNames.Text.Xml)]
public async Task> 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.
///
/// Server UUID.
+ /// Request processed.
/// Control response.
[HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Produces(MediaTypeNames.Text.Xml)]
+ [ProducesFile(MediaTypeNames.Text.Xml)]
public async Task> 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.
///
/// Server UUID.
+ /// Request processed.
/// Event subscription response.
[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 ProcessMediaReceiverRegistrarEventRequest(string serverId)
{
return ProcessEventRequest(_mediaReceiverRegistrar);
@@ -158,11 +176,15 @@ namespace Jellyfin.Api.Controllers
/// Processes an event subscription request.
///
/// Server UUID.
+ /// Request processed.
/// Event subscription response.
[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 ProcessContentDirectoryEventRequest(string serverId)
{
return ProcessEventRequest(_contentDirectory);
@@ -172,11 +194,15 @@ namespace Jellyfin.Api.Controllers
/// Processes an event subscription request.
///
/// Server UUID.
+ /// Request processed.
/// Event subscription response.
[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 ProcessConnectionManagerEventRequest(string serverId)
{
return ProcessEventRequest(_connectionManager);
@@ -226,7 +252,7 @@ namespace Jellyfin.Api.Controllers
private string GetAbsoluteUri()
{
- return $"{Request.Scheme}://{Request.Host}{Request.Path}";
+ return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
}
private Task ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index 1153a601e7..eff5bd54ad 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 _logger;
private readonly EncodingHelper _encodingHelper;
private readonly DynamicHlsHelper _dynamicHlsHelper;
-
- private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
+ private readonly EncodingOptions _encodingOptions;
///
/// Initializes a new instance of the class.
@@ -91,6 +95,8 @@ namespace Jellyfin.Api.Controllers
ILogger 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();
}
///
@@ -119,7 +124,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The dlna device profile id to utilize.
/// The play session id.
/// The segment container.
- /// The segment lenght.
+ /// The segment length.
/// The minimum number of segments.
/// The media version id, if playing an alternate version.
/// The device id of the client requesting. Used to stop encoding processes when needed.
@@ -148,7 +153,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The maximum video bit depth.
/// Optional. Whether to require avc.
/// Optional. Whether to deinterlace the video.
- /// Optional. Whether to require a non anamporphic stream.
+ /// Optional. Whether to require a non anamorphic stream.
/// Optional. The maximum number of audio channels to transcode.
/// Optional. The limit of how many cpu cores to use.
/// The live stream id.
@@ -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);
}
///
@@ -284,7 +289,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The dlna device profile id to utilize.
/// The play session id.
/// The segment container.
- /// The segment lenght.
+ /// The segment length.
/// The minimum number of segments.
/// The media version id, if playing an alternate version.
/// The device id of the client requesting. Used to stop encoding processes when needed.
@@ -295,6 +300,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Whether to break on non key frames.
/// Optional. Specify a specific audio sample rate, e.g. 44100.
/// Optional. The maximum audio bit depth.
+ /// Optional. The maximum streaming bitrate.
/// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.
/// Optional. Specify a specific number of audio channels to encode to, e.g. 2.
/// Optional. Specify a maximum number of audio channels to encode to, e.g. 2.
@@ -313,7 +319,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The maximum video bit depth.
/// Optional. Whether to require avc.
/// Optional. Whether to deinterlace the video.
- /// Optional. Whether to require a non anamporphic stream.
+ /// Optional. Whether to require a non anamorphic stream.
/// Optional. The maximum number of audio channels to transcode.
/// Optional. The limit of how many cpu cores to use.
/// The live stream id.
@@ -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);
}
///
@@ -449,7 +456,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The dlna device profile id to utilize.
/// The play session id.
/// The segment container.
- /// The segment lenght.
+ /// The segment length.
/// The minimum number of segments.
/// The media version id, if playing an alternate version.
/// The device id of the client requesting. Used to stop encoding processes when needed.
@@ -478,7 +485,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The maximum video bit depth.
/// Optional. Whether to require avc.
/// Optional. Whether to deinterlace the video.
- /// Optional. Whether to require a non anamporphic stream.
+ /// Optional. Whether to require a non anamorphic stream.
/// Optional. The maximum number of audio channels to transcode.
/// Optional. The limit of how many cpu cores to use.
/// The live stream id.
@@ -612,7 +619,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The dlna device profile id to utilize.
/// The play session id.
/// The segment container.
- /// The segment lenght.
+ /// The segment length.
/// The minimum number of segments.
/// The media version id, if playing an alternate version.
/// The device id of the client requesting. Used to stop encoding processes when needed.
@@ -623,6 +630,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Whether to break on non key frames.
/// Optional. Specify a specific audio sample rate, e.g. 44100.
/// Optional. The maximum audio bit depth.
+ /// Optional. The maximum streaming bitrate.
/// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.
/// Optional. Specify a specific number of audio channels to encode to, e.g. 2.
/// Optional. Specify a maximum number of audio channels to encode to, e.g. 2.
@@ -641,7 +649,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The maximum video bit depth.
/// Optional. Whether to require avc.
/// Optional. Whether to deinterlace the video.
- /// Optional. Whether to require a non anamporphic stream.
+ /// Optional. Whether to require a non anamorphic stream.
/// Optional. The maximum number of audio channels to transcode.
/// Optional. The limit of how many cpu cores to use.
/// The live stream id.
@@ -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
/// Optional. The maximum video bit depth.
/// Optional. Whether to require avc.
/// Optional. Whether to deinterlace the video.
- /// Optional. Whether to require a non anamporphic stream.
+ /// Optional. Whether to require a non anamorphic stream.
/// Optional. The maximum number of audio channels to transcode.
/// Optional. The limit of how many cpu cores to use.
/// The live stream id.
@@ -829,7 +838,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
- [FromRoute] string container,
+ [FromRoute, Required] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -948,7 +957,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The dlna device profile id to utilize.
/// The play session id.
/// The segment container.
- /// The segment lenght.
+ /// The segment length.
/// The minimum number of segments.
/// The media version id, if playing an alternate version.
/// The device id of the client requesting. Used to stop encoding processes when needed.
@@ -959,6 +968,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Whether to break on non key frames.
/// Optional. Specify a specific audio sample rate, e.g. 44100.
/// Optional. The maximum audio bit depth.
+ /// Optional. The maximum streaming bitrate.
/// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.
/// Optional. Specify a specific number of audio channels to encode to, e.g. 2.
/// Optional. Specify a maximum number of audio channels to encode to, e.g. 2.
@@ -977,7 +987,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The maximum video bit depth.
/// Optional. Whether to require avc.
/// Optional. Whether to deinterlace the video.
- /// Optional. Whether to require a non anamporphic stream.
+ /// Optional. Whether to require a non anamorphic stream.
/// Optional. The maximum number of audio channels to transcode.
/// Optional. The limit of how many cpu cores to use.
/// The live stream id.
@@ -999,7 +1009,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
- [FromRoute] string container,
+ [FromRoute, Required] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -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)
+ ///
+ /// Gets the audio arguments for transcoding.
+ ///
+ /// The .
+ /// The command line arguments for audio transcoding.
+ 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();
+ 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)
+ ///
+ /// Gets the video arguments for transcoding.
+ ///
+ /// The .
+ /// The first number in the hls sequence.
+ /// The command line arguments for video transcoding.
+ 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
///
/// Environment Controller.
///
- [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..31cb9e2736 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -1,6 +1,7 @@
-using System;
+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 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 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(),
@@ -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 f4b69b312e..d2b41e0a8d 100644
--- a/Jellyfin.Api/Controllers/GenresController.cs
+++ b/Jellyfin.Api/Controllers/GenresController.cs
@@ -1,15 +1,16 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
-using System.Globalization;
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;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -47,30 +48,16 @@ namespace Jellyfin.Api.Controllers
///
/// Gets all genres from a given item, folder, or the entire library.
///
- /// Optional filter by minimum community rating.
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
/// The search term.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.
/// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.
- /// Optional. Specify additional filters to apply.
/// Optional filter by items that are marked as favorite, or not.
- /// Optional filter by MediaType. Allows multiple, comma delimited.
- /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.
- /// Optional, include user data.
/// Optional, the max number of images to return, per image type.
/// Optional. The image types to include in the output.
- /// Optional. If specified, results will be filtered to include only those containing the specified person.
- /// Optional. If specified, results will be filtered to include only those containing the specified person id.
- /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.
- /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.
/// User id.
/// Optional filter by items whose name is sorted equally or greater than a given input string.
/// Optional filter by items whose name is sorted equally than a given input string.
@@ -82,30 +69,16 @@ namespace Jellyfin.Api.Controllers
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> 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] 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] string? 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,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
@@ -169,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
- {
- Items = dtos.ToArray(),
- TotalRecordCount = result.TotalRecordCount
- };
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
///
@@ -291,7 +176,7 @@ namespace Jellyfin.Api.Controllers
return _dtoService.GetBaseItemDto(item, dtoOptions);
}
- private T GetItemFromSlugName(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+ private T? GetItemFromSlugName(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..f519877326 100644
--- a/Jellyfin.Api/Controllers/HlsSegmentController.cs
+++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.IO;
@@ -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
/// The segment id.
/// The segment container.
/// Hls video segment returned.
+ /// Hls segment not found.
/// A containing the video segment.
// 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
/// Theme to search.
/// File name to search for.
/// A containing the image contents on success, or a if the image could not be found.
- 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 05efe23553..65de81d7a6 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -1,10 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
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;
@@ -85,7 +86,6 @@ namespace Jellyfin.Api.Controllers
/// User does not have permission to delete the image.
/// A .
[HttpPost("Users/{userId}/Images/{imageType}")]
- [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -94,7 +94,7 @@ namespace Jellyfin.Api.Controllers
public async Task PostUserImage(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? index = null)
+ [FromQuery] int? index = null)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{
@@ -109,7 +109,53 @@ namespace Jellyfin.Api.Controllers
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage != null)
{
- _userManager.ClearProfileImage(user);
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+ }
+
+ user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
+
+ await _providerManager
+ .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+ .ConfigureAwait(false);
+ await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ ///
+ /// Sets the user image.
+ ///
+ /// User Id.
+ /// (Unused) Image type.
+ /// (Unused) Image index.
+ /// Image updated.
+ /// User does not have permission to delete the image.
+ /// A .
+ [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+ public async Task PostUserImageByIndex(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int index)
+ {
+ if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+ {
+ return Forbid("User is not allowed to update the image.");
+ }
+
+ var user = _userManager.GetUserById(userId);
+ await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+ var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+ if (user.ProfileImage != null)
+ {
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
@@ -131,17 +177,16 @@ namespace Jellyfin.Api.Controllers
/// Image deleted.
/// User does not have permission to delete the image.
/// A .
- [HttpDelete("Users/{userId}/Images/{itemType}")]
- [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")]
+ [HttpDelete("Users/{userId}/Images/{imageType}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
- public ActionResult DeleteUserImage(
+ public async Task DeleteUserImage(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? index = null)
+ [FromQuery] int? index = null)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{
@@ -158,7 +203,46 @@ namespace Jellyfin.Api.Controllers
_logger.LogError(e, "Error deleting user profile image:");
}
- _userManager.ClearProfileImage(user);
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ ///
+ /// Delete the user's image.
+ ///
+ /// User Id.
+ /// (Unused) Image type.
+ /// (Unused) Image index.
+ /// Image deleted.
+ /// User does not have permission to delete the image.
+ /// A .
+ [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task DeleteUserImageByIndex(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int index)
+ {
+ if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+ {
+ return Forbid("User is not allowed to delete the image.");
+ }
+
+ var user = _userManager.GetUserById(userId);
+ try
+ {
+ System.IO.File.Delete(user.ProfileImage.Path);
+ }
+ catch (IOException e)
+ {
+ _logger.LogError(e, "Error deleting user profile image:");
+ }
+
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
return NoContent();
}
@@ -172,14 +256,13 @@ namespace Jellyfin.Api.Controllers
/// Item not found.
/// A on success, or a if item not found.
[HttpDelete("Items/{itemId}/Images/{imageType}")]
- [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task DeleteItemImage(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -191,6 +274,65 @@ namespace Jellyfin.Api.Controllers
return NoContent();
}
+ ///
+ /// Delete an item's image.
+ ///
+ /// Item id.
+ /// Image type.
+ /// The image index.
+ /// Image deleted.
+ /// Item not found.
+ /// A on success, or a if item not found.
+ [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task DeleteItemImageByIndex(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int imageIndex)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ ///
+ /// Set item image.
+ ///
+ /// Item id.
+ /// Image type.
+ /// Image saved.
+ /// Item not found.
+ /// A on success, or a if item not found.
+ [HttpPost("Items/{itemId}/Images/{imageType}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+ public async Task SetItemImage(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] ImageType imageType)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+ await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+ await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
///
/// Set item image.
///
@@ -200,16 +342,15 @@ namespace Jellyfin.Api.Controllers
/// Image saved.
/// Item not found.
/// A on success, or a if item not found.
- [HttpPost("Items/{itemId}/Images/{imageType}")]
- [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")]
+ [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
- public async Task SetItemImage(
+ public async Task SetItemImageByIndex(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? imageIndex = null)
+ [FromRoute] int imageIndex)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -349,8 +490,6 @@ namespace Jellyfin.Api.Controllers
///
[HttpGet("Items/{itemId}/Images/{imageType}")]
[HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
- [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")]
- [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -371,7 +510,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ itemId,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// Gets the item's image.
+ ///
+ /// Item id.
+ /// Image type.
+ /// Image index.
+ /// The maximum image width to return.
+ /// The maximum image height to return.
+ /// The fixed image width to return.
+ /// The fixed image height to return.
+ /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.
+ /// Optional. Supply the cache tag from the item object to receive strong caching headers.
+ /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.
+ /// Optional. The of the returned image.
+ /// Optional. Add a played indicator.
+ /// Optional. Percent to render for the percent played overlay.
+ /// Optional. Unplayed count overlay to render.
+ /// Optional. Blur image.
+ /// Optional. Apply a background color for transparent images.
+ /// Optional. Apply a foreground layer on top of the image.
+ /// Image stream returned.
+ /// Item not found.
+ ///
+ /// A containing the file stream on success,
+ /// or a if item not found.
+ ///
+ [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task GetItemImageByIndex(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int imageIndex,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] string? tag,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -507,8 +725,8 @@ namespace Jellyfin.Api.Controllers
/// A containing the file stream on success,
/// or a if item not found.
///
- [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")]
+ [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -586,8 +804,8 @@ namespace Jellyfin.Api.Controllers
/// A containing the file stream on success,
/// or a if item not found.
///
- [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")]
+ [HttpGet("Genres/{name}/Images/{imageType}")]
+ [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -608,7 +826,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetGenre(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// Get genre image by name.
+ ///
+ /// Genre name.
+ /// Image type.
+ /// Image index.
+ /// Optional. Supply the cache tag from the item object to receive strong caching headers.
+ /// Determines the output format of the image - original,gif,jpg,png.
+ /// The maximum image width to return.
+ /// The maximum image height to return.
+ /// Optional. Percent to render for the percent played overlay.
+ /// Optional. Unplayed count overlay to render.
+ /// The fixed image width to return.
+ /// The fixed image height to return.
+ /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.
+ /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.
+ /// Optional. Add a played indicator.
+ /// Optional. Blur image.
+ /// Optional. Apply a background color for transparent images.
+ /// Optional. Apply a foreground layer on top of the image.
+ /// Image stream returned.
+ /// Item not found.
+ ///
+ /// A containing the file stream on success,
+ /// or a if item not found.
+ ///
+ [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task GetGenreImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetGenre(name);
if (item == null)
@@ -665,8 +962,8 @@ namespace Jellyfin.Api.Controllers
/// A containing the file stream on success,
/// or a if item not found.
///
- [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")]
+ [HttpGet("MusicGenres/{name}/Images/{imageType}")]
+ [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -687,7 +984,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetMusicGenre(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// Get music genre image by name.
+ ///
+ /// Music genre name.
+ /// Image type.
+ /// Image index.
+ /// Optional. Supply the cache tag from the item object to receive strong caching headers.
+ /// Determines the output format of the image - original,gif,jpg,png.
+ /// The maximum image width to return.
+ /// The maximum image height to return.
+ /// Optional. Percent to render for the percent played overlay.
+ /// Optional. Unplayed count overlay to render.
+ /// The fixed image width to return.
+ /// The fixed image height to return.
+ /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.
+ /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.
+ /// Optional. Add a played indicator.
+ /// Optional. Blur image.
+ /// Optional. Apply a background color for transparent images.
+ /// Optional. Apply a foreground layer on top of the image.
+ /// Image stream returned.
+ /// Item not found.
+ ///
+ /// A containing the file stream on success,
+ /// or a if item not found.
+ ///
+ [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task GetMusicGenreImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetMusicGenre(name);
if (item == null)
@@ -744,8 +1120,8 @@ namespace Jellyfin.Api.Controllers
/// A containing the file stream on success,
/// or a if item not found.
///
- [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")]
+ [HttpGet("Persons/{name}/Images/{imageType}")]
+ [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -766,7 +1142,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetPerson(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// Get person image by name.
+ ///
+ /// Person name.
+ /// Image type.
+ /// Image index.
+ /// Optional. Supply the cache tag from the item object to receive strong caching headers.
+ /// Determines the output format of the image - original,gif,jpg,png.
+ /// The maximum image width to return.
+ /// The maximum image height to return.
+ /// Optional. Percent to render for the percent played overlay.
+ /// Optional. Unplayed count overlay to render.
+ /// The fixed image width to return.
+ /// The fixed image height to return.
+ /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.
+ /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.
+ /// Optional. Add a played indicator.
+ /// Optional. Blur image.
+ /// Optional. Apply a background color for transparent images.
+ /// Optional. Apply a foreground layer on top of the image.
+ /// Image stream returned.
+ /// Item not found.
+ ///
+ /// A containing the file stream on success,
+ /// or a if item not found.
+ ///
+ [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task GetPersonImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetPerson(name);
if (item == null)
@@ -823,16 +1278,16 @@ namespace Jellyfin.Api.Controllers
/// A containing the file stream on success,
/// or a if item not found.
///
- [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")]
+ [HttpGet("Studios/{name}/Images/{imageType}")]
+ [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task GetStudioImage(
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
- [FromRoute, Required] string tag,
- [FromRoute, Required] ImageFormat format,
+ [FromQuery] string? tag,
+ [FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
@@ -845,7 +1300,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetStudio(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// Get studio image by name.
+ ///
+ /// Studio name.
+ /// Image type.
+ /// Image index.
+ /// Optional. Supply the cache tag from the item object to receive strong caching headers.
+ /// Determines the output format of the image - original,gif,jpg,png.
+ /// The maximum image width to return.
+ /// The maximum image height to return.
+ /// Optional. Percent to render for the percent played overlay.
+ /// Optional. Unplayed count overlay to render.
+ /// The fixed image width to return.
+ /// The fixed image height to return.
+ /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.
+ /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.
+ /// Optional. Add a played indicator.
+ /// Optional. Blur image.
+ /// Optional. Apply a background color for transparent images.
+ /// Optional. Apply a foreground layer on top of the image.
+ /// Image stream returned.
+ /// Item not found.
+ ///
+ /// A containing the file stream on success,
+ /// or a if item not found.
+ ///
+ [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task GetStudioImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string? tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetStudio(name);
if (item == null)
@@ -902,8 +1436,8 @@ namespace Jellyfin.Api.Controllers
/// A containing the file stream on success,
/// or a if item not found.
///
- [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")]
+ [HttpGet("Users/{userId}/Images/{imageType}")]
+ [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -924,7 +1458,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
{
var user = _userManager.GetUserById(userId);
if (user == null)
@@ -973,6 +1507,103 @@ namespace Jellyfin.Api.Controllers
.ConfigureAwait(false);
}
+ ///
+ /// Get user profile image.
+ ///
+ /// User id.
+ /// Image type.
+ /// Image index.
+ /// Optional. Supply the cache tag from the item object to receive strong caching headers.
+ /// Determines the output format of the image - original,gif,jpg,png.
+ /// The maximum image width to return.
+ /// The maximum image height to return.
+ /// Optional. Percent to render for the percent played overlay.
+ /// Optional. Unplayed count overlay to render.
+ /// The fixed image width to return.
+ /// The fixed image height to return.
+ /// Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.
+ /// Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.
+ /// Optional. Add a played indicator.
+ /// Optional. Blur image.
+ /// Optional. Apply a background color for transparent images.
+ /// Optional. Apply a foreground layer on top of the image.
+ /// Image stream returned.
+ /// Item not found.
+ ///
+ /// A containing the file stream on success,
+ /// or a if item not found.
+ ///
+ [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task GetUserImageByIndex(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string? tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
+ {
+ var user = _userManager.GetUserById(userId);
+ if (user?.ProfileImage == null)
+ {
+ return NotFound();
+ }
+
+ var info = new ItemImageInfo
+ {
+ Path = user.ProfileImage.Path,
+ Type = ImageType.Profile,
+ DateModified = user.ProfileImage.LastModified
+ };
+
+ if (width.HasValue)
+ {
+ info.Width = width.Value;
+ }
+
+ if (height.HasValue)
+ {
+ info.Height = height.Value;
+ }
+
+ return await GetImageInternal(
+ user.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ null,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase),
+ info)
+ .ConfigureAwait(false);
+ }
+
private static async Task GetMemoryStream(Stream inputStream)
{
using var reader = new StreamReader(inputStream);
@@ -1268,7 +1899,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 07fed97642..2446257528 100644
--- a/Jellyfin.Api/Controllers/InstantMixController.cs
+++ b/Jellyfin.Api/Controllers/InstantMixController.cs
@@ -1,15 +1,17 @@
-using System;
+using System;
using System.Collections.Generic;
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;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -54,7 +56,7 @@ namespace Jellyfin.Api.Controllers
/// The item id.
/// Optional. Filter by user id, and attach user data.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
@@ -67,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] string? 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);
@@ -91,7 +92,7 @@ namespace Jellyfin.Api.Controllers
/// The item id.
/// Optional. Filter by user id, and attach user data.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
@@ -104,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] string? 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);
@@ -128,7 +128,7 @@ namespace Jellyfin.Api.Controllers
/// The item id.
/// Optional. Filter by user id, and attach user data.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
@@ -141,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] string? 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);
@@ -165,7 +164,7 @@ namespace Jellyfin.Api.Controllers
/// The genre name.
/// Optional. Filter by user id, and attach user data.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
@@ -178,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] string? 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);
@@ -201,31 +199,30 @@ namespace Jellyfin.Api.Controllers
/// The item id.
/// Optional. Filter by user id, and attach user data.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
/// Optional. The image types to include in the output.
/// Instant playlist returned.
/// A with the playlist items.
- [HttpGet("Artists/InstantMix")]
+ [HttpGet("Artists/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> GetInstantMixFromArtists(
[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] string? 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);
@@ -238,31 +235,30 @@ namespace Jellyfin.Api.Controllers
/// The item id.
/// Optional. Filter by user id, and attach user data.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
/// Optional. The image types to include in the output.
/// Instant playlist returned.
/// A with the playlist items.
- [HttpGet("MusicGenres/InstantMix")]
+ [HttpGet("MusicGenres/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> GetInstantMixFromMusicGenres(
[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] string? 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);
@@ -275,7 +271,7 @@ namespace Jellyfin.Api.Controllers
/// The item id.
/// Optional. Filter by user id, and attach user data.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
@@ -288,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] string? 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);
@@ -315,9 +310,9 @@ namespace Jellyfin.Api.Controllers
TotalRecordCount = list.Count
};
- if (limit.HasValue)
+ if (limit.HasValue && limit < list.Count)
{
- list = list.Take(limit.Value).ToList();
+ list = list.GetRange(0, limit.Value);
}
var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs
index ab73aa4286..6c38f77ce1 100644
--- a/Jellyfin.Api/Controllers/ItemLookupController.cs
+++ b/Jellyfin.Api/Controllers/ItemLookupController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
@@ -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/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 0a6ed31ae3..9e1a398538 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index b50b1e836b..b0979fbcf7 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -1,10 +1,11 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
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;
@@ -59,7 +60,445 @@ namespace Jellyfin.Api.Controllers
///
/// Gets items based on a query.
///
- /// The user id supplied in the /Users/{uid}/Items.
+ /// The user id supplied as query parameter.
+ /// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).
+ /// Optional filter by items with theme songs.
+ /// Optional filter by items with theme videos.
+ /// Optional filter by items with subtitles.
+ /// Optional filter by items with special features.
+ /// Optional filter by items with trailers.
+ /// Optional. Return items that are siblings of a supplied item.
+ /// Optional filter by parent index number.
+ /// Optional filter by items that have or do not have a parental rating.
+ /// Optional filter by items that are HD or not.
+ /// Optional filter by items that are 4K or not.
+ /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.
+ /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.
+ /// Optional filter by items that are missing episodes or not.
+ /// Optional filter by items that are unaired episodes or not.
+ /// Optional filter by minimum community rating.
+ /// Optional filter by minimum critic rating.
+ /// Optional. The minimum premiere date. Format = ISO.
+ /// Optional. The minimum last saved date. Format = ISO.
+ /// Optional. The minimum last saved date for the current user. Format = ISO.
+ /// Optional. The maximum premiere date. Format = ISO.
+ /// Optional filter by items that have an overview or not.
+ /// Optional filter by items that have an imdb id or not.
+ /// Optional filter by items that have a tmdb id or not.
+ /// Optional filter by items that have a tvdb id or not.
+ /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.
+ /// Optional. The record index to start at. All items with a lower index will be dropped from the results.
+ /// Optional. The maximum number of records to return.
+ /// When searching within folders, this determines whether or not the search will be recursive. true/false.
+ /// Optional. Filter based on a search term.
+ /// Sort Order - Ascending,Descending.
+ /// Specify this to localize the search to a specific item or folder. Omit to use the root.
+ /// 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.
+ /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.
+ /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.
+ /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.
+ /// Optional filter by items that are marked as favorite, or not.
+ /// Optional filter by MediaType. Allows multiple, comma delimited.
+ /// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.
+ /// 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.
+ /// Optional filter by items that are played, or not.
+ /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.
+ /// Optional, include user data.
+ /// Optional, the max number of images to return, per image type.
+ /// Optional. The image types to include in the output.
+ /// Optional. If specified, results will be filtered to include only those containing the specified person.
+ /// Optional. If specified, results will be filtered to include only those containing the specified person id.
+ /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.
+ /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered to include only those containing the specified artist id.
+ /// Optional. If specified, results will be filtered to include only those containing the specified album artist id.
+ /// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.
+ /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.
+ /// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.
+ /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.
+ /// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).
+ /// Optional filter by items that are locked.
+ /// Optional filter by items that are placeholders.
+ /// Optional filter by items that have official ratings.
+ /// Whether or not to hide items behind their boxsets.
+ /// Optional. Filter by the minimum width of the item.
+ /// Optional. Filter by the minimum height of the item.
+ /// Optional. Filter by the maximum width of the item.
+ /// Optional. Filter by the maximum height of the item.
+ /// Optional filter by items that are 3D, or not.
+ /// Optional filter by Series Status. Allows multiple, comma delimited.
+ /// Optional filter by items whose name is sorted equally or greater than a given input string.
+ /// Optional filter by items whose name is sorted equally than a given input string.
+ /// Optional filter by items whose name is equally or lesser than a given input string.
+ /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.
+ /// Optional. Enable the total record count.
+ /// Optional, include image information in output.
+ /// A with the items.
+ [HttpGet("Items")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult> GetItems(
+ [FromQuery] Guid? userId,
+ [FromQuery] string? maxOfficialRating,
+ [FromQuery] bool? hasThemeSong,
+ [FromQuery] bool? hasThemeVideo,
+ [FromQuery] bool? hasSubtitles,
+ [FromQuery] bool? hasSpecialFeature,
+ [FromQuery] bool? hasTrailer,
+ [FromQuery] string? adjacentTo,
+ [FromQuery] int? parentIndexNumber,
+ [FromQuery] bool? hasParentalRating,
+ [FromQuery] bool? isHd,
+ [FromQuery] bool? is4K,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+ [FromQuery] bool? isMissing,
+ [FromQuery] bool? isUnaired,
+ [FromQuery] double? minCommunityRating,
+ [FromQuery] double? minCriticRating,
+ [FromQuery] DateTime? minPremiereDate,
+ [FromQuery] DateTime? minDateLastSaved,
+ [FromQuery] DateTime? minDateLastSavedForUser,
+ [FromQuery] DateTime? maxPremiereDate,
+ [FromQuery] bool? hasOverview,
+ [FromQuery] bool? hasImdbId,
+ [FromQuery] bool? hasTmdbId,
+ [FromQuery] bool? hasTvdbId,
+ [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, 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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
+ [FromQuery] string? sortBy,
+ [FromQuery] bool? isPlayed,
+ [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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+ [FromQuery] string? person,
+ [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,
+ [FromQuery] bool? hasOfficialRating,
+ [FromQuery] bool? collapseBoxSetItems,
+ [FromQuery] int? minWidth,
+ [FromQuery] int? minHeight,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] bool? is3D,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+ [FromQuery] bool enableTotalRecordCount = true,
+ [FromQuery] bool? enableImages = true)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions { Fields = fields }
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+ if (includeItemTypes.Length == 1
+ && (includeItemTypes[0].Equals("Playlist", StringComparison.OrdinalIgnoreCase)
+ || includeItemTypes[0].Equals("BoxSet", StringComparison.OrdinalIgnoreCase)))
+ {
+ parentId = null;
+ }
+
+ BaseItem? item = null;
+ QueryResult result;
+ if (!string.IsNullOrEmpty(parentId))
+ {
+ item = _libraryManager.GetItemById(parentId);
+ }
+
+ item ??= _libraryManager.GetUserRootFolder();
+
+ if (!(item is Folder folder))
+ {
+ folder = _libraryManager.GetUserRootFolder();
+ }
+
+ if (folder is IHasCollectionType hasCollectionType
+ && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
+ {
+ recursive = true;
+ includeItemTypes = new[] { "Playlist" };
+ }
+
+ bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
+ // Assume all folders inside an EnabledChannel are enabled
+ || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id)
+ // Assume all items inside an EnabledChannel are enabled
+ || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.ChannelId);
+
+ var collectionFolders = _libraryManager.GetCollectionFolders(item);
+ foreach (var collectionFolder in collectionFolders)
+ {
+ if (user.GetPreference(PreferenceKind.EnabledFolders).Contains(
+ collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
+ StringComparer.OrdinalIgnoreCase))
+ {
+ isInEnabledFolder = true;
+ }
+ }
+
+ if (!(item is UserRootFolder)
+ && !isInEnabledFolder
+ && !user.HasPermission(PermissionKind.EnableAllFolders)
+ && !user.HasPermission(PermissionKind.EnableAllChannels))
+ {
+ _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}.", user.Username, item.Name);
+ return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
+ }
+
+ if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder))
+ {
+ var query = new InternalItemsQuery(user!)
+ {
+ IsPlayed = isPlayed,
+ MediaTypes = mediaTypes,
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
+ Recursive = recursive ?? false,
+ OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+ IsFavorite = isFavorite,
+ Limit = limit,
+ StartIndex = startIndex,
+ IsMissing = isMissing,
+ IsUnaired = isUnaired,
+ CollapseBoxSetItems = collapseBoxSetItems,
+ NameLessThan = nameLessThan,
+ NameStartsWith = nameStartsWith,
+ NameStartsWithOrGreater = nameStartsWithOrGreater,
+ HasImdbId = hasImdbId,
+ IsPlaceHolder = isPlaceHolder,
+ IsLocked = isLocked,
+ MinWidth = minWidth,
+ MinHeight = minHeight,
+ MaxWidth = maxWidth,
+ MaxHeight = maxHeight,
+ Is3D = is3D,
+ HasTvdbId = hasTvdbId,
+ HasTmdbId = hasTmdbId,
+ HasOverview = hasOverview,
+ HasOfficialRating = hasOfficialRating,
+ HasParentalRating = hasParentalRating,
+ HasSpecialFeature = hasSpecialFeature,
+ HasSubtitles = hasSubtitles,
+ HasThemeSong = hasThemeSong,
+ HasThemeVideo = hasThemeVideo,
+ HasTrailer = hasTrailer,
+ IsHD = isHd,
+ Is4K = is4K,
+ Tags = tags,
+ OfficialRatings = officialRatings,
+ Genres = genres,
+ ArtistIds = artistIds,
+ AlbumArtistIds = albumArtistIds,
+ ContributingArtistIds = contributingArtistIds,
+ GenreIds = genreIds,
+ StudioIds = studioIds,
+ Person = person,
+ PersonIds = personIds,
+ PersonTypes = personTypes,
+ Years = years,
+ ImageTypes = imageTypes,
+ VideoTypes = videoTypes,
+ AdjacentTo = adjacentTo,
+ ItemIds = ids,
+ MinCommunityRating = minCommunityRating,
+ MinCriticRating = minCriticRating,
+ ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
+ ParentIndexNumber = parentIndexNumber,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ ExcludeItemIds = excludeItemIds,
+ DtoOptions = dtoOptions,
+ SearchTerm = searchTerm,
+ MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
+ MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
+ MinPremiereDate = minPremiereDate?.ToUniversalTime(),
+ MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
+ };
+
+ if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
+ {
+ query.CollapseBoxSetItems = false;
+ }
+
+ 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;
+ }
+ }
+
+ // Filter by Series Status
+ if (seriesStatus.Length != 0)
+ {
+ query.SeriesStatuses = seriesStatus;
+ }
+
+ // ExcludeLocationTypes
+ if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
+ {
+ query.IsVirtualItem = false;
+ }
+
+ if (locationTypes.Length > 0 && locationTypes.Length < 4)
+ {
+ query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
+ }
+
+ // Min official rating
+ if (!string.IsNullOrWhiteSpace(minOfficialRating))
+ {
+ query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating);
+ }
+
+ // Max official rating
+ if (!string.IsNullOrWhiteSpace(maxOfficialRating))
+ {
+ query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating);
+ }
+
+ // Artists
+ if (artists.Length != 0)
+ {
+ query.ArtistIds = artists.Select(i =>
+ {
+ try
+ {
+ return _libraryManager.GetArtist(i, new DtoOptions(false));
+ }
+ catch
+ {
+ return null;
+ }
+ }).Where(i => i != null).Select(i => i!.Id).ToArray();
+ }
+
+ // ExcludeArtistIds
+ if (excludeArtistIds.Length != 0)
+ {
+ query.ExcludeArtistIds = excludeArtistIds;
+ }
+
+ if (albumIds.Length != 0)
+ {
+ query.AlbumIds = albumIds;
+ }
+
+ // Albums
+ if (albums.Length != 0)
+ {
+ query.AlbumIds = albums.SelectMany(i =>
+ {
+ return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
+ }).ToArray();
+ }
+
+ // Studios
+ if (studios.Length != 0)
+ {
+ query.StudioIds = studios.Select(i =>
+ {
+ try
+ {
+ return _libraryManager.GetStudio(i);
+ }
+ catch
+ {
+ return null;
+ }
+ }).Where(i => i != null).Select(i => i!.Id).ToArray();
+ }
+
+ // Apply default sorting if none requested
+ if (query.OrderBy.Count == 0)
+ {
+ // Albums by artist
+ if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], "MusicAlbum", StringComparison.OrdinalIgnoreCase))
+ {
+ query.OrderBy = new[] { new ValueTuple(ItemSortBy.ProductionYear, SortOrder.Descending), new ValueTuple(ItemSortBy.SortName, SortOrder.Ascending) };
+ }
+ }
+
+ result = folder.GetItems(query);
+ }
+ else
+ {
+ var itemsArray = folder.GetChildren(user, true);
+ result = new QueryResult { Items = itemsArray, TotalRecordCount = itemsArray.Count, StartIndex = 0 };
+ }
+
+ return new QueryResult { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
+ }
+
+ ///
+ /// Gets items based on a query.
+ ///
/// The user id supplied as query parameter.
/// Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).
/// Optional filter by items with theme songs.
@@ -141,12 +580,10 @@ namespace Jellyfin.Api.Controllers
/// Optional. Enable the total record count.
/// Optional, include image information in output.
/// A with the items.
- [HttpGet("Items")]
- [HttpGet("Users/{uId}/Items", Name = "GetItems_2")]
+ [HttpGet("Users/{userId}/Items")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult> GetItems(
- [FromRoute] Guid? uId,
- [FromQuery] Guid? userId,
+ public ActionResult> GetItemsByUserId(
+ [FromRoute] Guid userId,
[FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong,
[FromQuery] bool? hasThemeVideo,
@@ -158,8 +595,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
- [FromQuery] string? locationTypes,
- [FromQuery] LocationType[] excludeLocationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
[FromQuery] double? minCommunityRating,
@@ -172,42 +609,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] ItemFilter[] filters,
+ [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? 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] string? 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,
@@ -218,291 +655,96 @@ 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)
{
- // use user id route parameter over query parameter
- userId = uId ?? userId;
-
- var user = userId.HasValue && !userId.Equals(Guid.Empty)
- ? _userManager.GetUserById(userId.Value)
- : null;
- var dtoOptions = new DtoOptions()
- .AddItemFields(fields)
- .AddClientFields(Request)
- .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
-
- if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase))
- {
- parentId = null;
- }
-
- BaseItem? item = null;
- QueryResult result;
- if (!string.IsNullOrEmpty(parentId))
- {
- item = _libraryManager.GetItemById(parentId);
- }
-
- item ??= _libraryManager.GetUserRootFolder();
-
- if (!(item is Folder folder))
- {
- folder = _libraryManager.GetUserRootFolder();
- }
-
- if (folder is IHasCollectionType hasCollectionType
- && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
- {
- recursive = true;
- includeItemTypes = "Playlist";
- }
-
- bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
- // Assume all folders inside an EnabledChannel are enabled
- || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id)
- // Assume all items inside an EnabledChannel are enabled
- || user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.ChannelId);
-
- var collectionFolders = _libraryManager.GetCollectionFolders(item);
- foreach (var collectionFolder in collectionFolders)
- {
- if (user.GetPreference(PreferenceKind.EnabledFolders).Contains(
- collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
- StringComparer.OrdinalIgnoreCase))
- {
- isInEnabledFolder = true;
- }
- }
-
- if (!(item is UserRootFolder)
- && !isInEnabledFolder
- && !user.HasPermission(PermissionKind.EnableAllFolders)
- && !user.HasPermission(PermissionKind.EnableAllChannels))
- {
- _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}.", user.Username, item.Name);
- return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
- }
-
- if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(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),
- Recursive = recursive ?? false,
- OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
- IsFavorite = isFavorite,
- Limit = limit,
- StartIndex = startIndex,
- IsMissing = isMissing,
- IsUnaired = isUnaired,
- CollapseBoxSetItems = collapseBoxSetItems,
- NameLessThan = nameLessThan,
- NameStartsWith = nameStartsWith,
- NameStartsWithOrGreater = nameStartsWithOrGreater,
- HasImdbId = hasImdbId,
- IsPlaceHolder = isPlaceHolder,
- IsLocked = isLocked,
- MinWidth = minWidth,
- MinHeight = minHeight,
- MaxWidth = maxWidth,
- MaxHeight = maxHeight,
- Is3D = is3D,
- HasTvdbId = hasTvdbId,
- HasTmdbId = hasTmdbId,
- HasOverview = hasOverview,
- HasOfficialRating = hasOfficialRating,
- HasParentalRating = hasParentalRating,
- HasSpecialFeature = hasSpecialFeature,
- HasSubtitles = hasSubtitles,
- HasThemeSong = hasThemeSong,
- HasThemeVideo = hasThemeVideo,
- 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),
- Person = person,
- PersonIds = RequestHelpers.GetGuids(personIds),
- PersonTypes = RequestHelpers.Split(personTypes, ',', true),
- Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
- ImageTypes = RequestHelpers.Split(imageTypes, ',', true).Select(v => Enum.Parse(v, true)).ToArray(),
- VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse(v, true)).ToArray(),
- AdjacentTo = adjacentTo,
- ItemIds = RequestHelpers.GetGuids(ids),
- MinCommunityRating = minCommunityRating,
- MinCriticRating = minCriticRating,
- ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
- ParentIndexNumber = parentIndexNumber,
- EnableTotalRecordCount = enableTotalRecordCount,
- ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds),
- DtoOptions = dtoOptions,
- SearchTerm = searchTerm,
- MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
- MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
- MinPremiereDate = minPremiereDate?.ToUniversalTime(),
- MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
- };
-
- if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm))
- {
- query.CollapseBoxSetItems = false;
- }
-
- 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;
- }
- }
-
- // Filter by Series Status
- if (!string.IsNullOrEmpty(seriesStatus))
- {
- query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray();
- }
-
- // ExcludeLocationTypes
- if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
- {
- query.IsVirtualItem = false;
- }
-
- if (!string.IsNullOrEmpty(locationTypes))
- {
- var requestedLocationTypes = locationTypes.Split(',');
- if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4)
- {
- query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString());
- }
- }
-
- // Min official rating
- if (!string.IsNullOrWhiteSpace(minOfficialRating))
- {
- query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating);
- }
-
- // Max official rating
- if (!string.IsNullOrWhiteSpace(maxOfficialRating))
- {
- query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating);
- }
-
- // Artists
- if (!string.IsNullOrEmpty(artists))
- {
- query.ArtistIds = artists.Split('|').Select(i =>
- {
- try
- {
- return _libraryManager.GetArtist(i, new DtoOptions(false));
- }
- catch
- {
- return null;
- }
- }).Where(i => i != null).Select(i => i!.Id).ToArray();
- }
-
- // ExcludeArtistIds
- if (!string.IsNullOrWhiteSpace(excludeArtistIds))
- {
- query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
- }
-
- if (!string.IsNullOrWhiteSpace(albumIds))
- {
- query.AlbumIds = RequestHelpers.GetGuids(albumIds);
- }
-
- // Albums
- if (!string.IsNullOrEmpty(albums))
- {
- query.AlbumIds = albums.Split('|').SelectMany(i =>
- {
- return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
- }).ToArray();
- }
-
- // 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();
- }
-
- // Apply default sorting if none requested
- if (query.OrderBy.Count == 0)
- {
- // Albums by artist
- if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], "MusicAlbum", StringComparison.OrdinalIgnoreCase))
- {
- query.OrderBy = new[] { new ValueTuple(ItemSortBy.ProductionYear, SortOrder.Descending), new ValueTuple(ItemSortBy.SortName, SortOrder.Ascending) };
- }
- }
-
- result = folder.GetItems(query);
- }
- else
- {
- var itemsArray = folder.GetChildren(user, true);
- result = new QueryResult { Items = itemsArray, TotalRecordCount = itemsArray.Count, StartIndex = 0 };
- }
-
- return new QueryResult { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
+ return GetItems(
+ userId,
+ maxOfficialRating,
+ hasThemeSong,
+ hasThemeVideo,
+ hasSubtitles,
+ hasSpecialFeature,
+ hasTrailer,
+ adjacentTo,
+ parentIndexNumber,
+ hasParentalRating,
+ isHd,
+ is4K,
+ locationTypes,
+ excludeLocationTypes,
+ isMissing,
+ isUnaired,
+ minCommunityRating,
+ minCriticRating,
+ minPremiereDate,
+ minDateLastSaved,
+ minDateLastSavedForUser,
+ maxPremiereDate,
+ hasOverview,
+ hasImdbId,
+ hasTmdbId,
+ hasTvdbId,
+ excludeItemIds,
+ startIndex,
+ limit,
+ recursive,
+ searchTerm,
+ sortOrder,
+ parentId,
+ fields,
+ excludeItemTypes,
+ includeItemTypes,
+ filters,
+ isFavorite,
+ mediaTypes,
+ imageTypes,
+ sortBy,
+ isPlayed,
+ genres,
+ officialRatings,
+ tags,
+ years,
+ enableUserData,
+ imageTypeLimit,
+ enableImageTypes,
+ person,
+ personIds,
+ personTypes,
+ studios,
+ artists,
+ excludeArtistIds,
+ artistIds,
+ albumArtistIds,
+ contributingArtistIds,
+ albums,
+ albumIds,
+ ids,
+ videoTypes,
+ minOfficialRating,
+ isLocked,
+ isPlaceHolder,
+ hasOfficialRating,
+ collapseBoxSetItems,
+ minWidth,
+ minHeight,
+ maxWidth,
+ maxHeight,
+ is3D,
+ seriesStatus,
+ nameStartsWithOrGreater,
+ nameStartsWith,
+ nameLessThan,
+ studioIds,
+ genreIds,
+ enableTotalRecordCount,
+ enableImages);
}
///
@@ -513,13 +755,13 @@ namespace Jellyfin.Api.Controllers
/// The item limit.
/// The search term.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
+ /// 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.
/// Optional. Filter by MediaType. Allows multiple, comma delimited.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
/// Optional. The image types to include in the output.
- /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.
- /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.
+ /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.
+ /// Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.
/// Optional. Enable the total record count.
/// Optional. Include image information in output.
/// Items returned.
@@ -532,20 +774,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] string? 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);
@@ -570,13 +811,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
/// 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.
/// Similar items returned.
/// A containing the similar items.
- [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> 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();
}
- 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();
+ 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 itemsResult = _libraryManager.GetItemList(query);
+
+ var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user);
+
+ return new QueryResult
+ {
+ Items = returnList,
+ TotalRecordCount = itemsResult.Count
+ };
}
///
@@ -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 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 itemsResult;
-
- if (isMovie)
- {
- var itemTypes = new List { 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();
-
- 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
- {
- Items = returnList,
- TotalRecordCount = itemsResult.Count
- };
-
- return result;
- }
-
private static string[] GetRepresentativeItemTypes(string? contentType)
{
return contentType switch
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index d290e3c5bd..94995650cb 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -7,6 +7,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.LibraryStructureDto;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller;
@@ -75,7 +76,7 @@ namespace Jellyfin.Api.Controllers
public async Task AddVirtualFolder(
[FromQuery] string? name,
[FromQuery] string? collectionType,
- [FromQuery] string[] paths,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths,
[FromBody] AddVirtualFolderDto? libraryOptionsDto,
[FromQuery] bool refreshLibrary = false)
{
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index 3557e63047..56d4b3933e 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -14,9 +14,9 @@ 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;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Dto;
@@ -26,6 +26,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Querying;
@@ -117,7 +118,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Include image information in output.
/// Optional. The max number of images to return, per image type.
/// "Optional. The image types to include in the output.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include user data.
/// Optional. Key to sort by.
/// Optional. Sort order.
@@ -145,16 +146,15 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? isDisliked,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
- [FromQuery] string? 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);
@@ -174,7 +174,7 @@ namespace Jellyfin.Api.Controllers
IsNews = isNews,
IsKids = isKids,
IsSports = isSports,
- SortBy = RequestHelpers.Split(sortBy, ',', true),
+ SortBy = sortBy,
SortOrder = sortOrder ?? SortOrder.Ascending,
AddCurrentProgram = addCurrentProgram
},
@@ -238,7 +238,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Include image information in output.
/// Optional. The max number of images to return, per image type.
/// Optional. The image types to include in the output.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include user data.
/// Optional. Filter for movies.
/// Optional. Filter for series.
@@ -262,8 +262,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? seriesTimerId,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
- [FromQuery] string? 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,
@@ -273,8 +273,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);
@@ -295,7 +294,7 @@ namespace Jellyfin.Api.Controllers
IsKids = isKids,
IsSports = isSports,
IsLibraryItem = isLibraryItem,
- Fields = RequestHelpers.GetItemFields(fields),
+ Fields = fields,
ImageTypeLimit = imageTypeLimit,
EnableImages = enableImages
}, dtoOptions);
@@ -315,7 +314,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Include image information in output.
/// Optional. The max number of images to return, per image type.
/// Optional. The image types to include in the output.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include user data.
/// Optional. Return total record count.
/// Live tv recordings returned.
@@ -349,8 +348,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? seriesTimerId,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
- [FromQuery] string? enableImageTypes,
- [FromQuery] string? fields,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] bool? enableUserData,
[FromQuery] bool enableTotalRecordCount = true)
{
@@ -529,7 +528,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. Include user data.
/// Optional. Filter by series timer id.
/// Optional. Filter by library series id.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Retrieve total record count.
/// Live tv epgs returned.
///
@@ -539,7 +538,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.DefaultAuthorization)]
public async Task>> GetLiveTvPrograms(
- [FromQuery] string? channelIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
[FromQuery] Guid? userId,
[FromQuery] DateTime? minStartDate,
[FromQuery] bool? hasAired,
@@ -556,15 +555,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] string? 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)
@@ -573,8 +572,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,
@@ -591,8 +589,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))
@@ -605,8 +603,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);
@@ -629,8 +626,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,
@@ -647,8 +643,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))
@@ -661,8 +657,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);
@@ -684,7 +679,7 @@ namespace Jellyfin.Api.Controllers
/// Optional. The max number of images to return, per image type.
/// Optional. The image types to include in the output.
/// The genres to return guide information for.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. include user data.
/// Retrieve total record count.
/// Recommended epgs returned.
@@ -704,9 +699,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? isSports,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
- [FromQuery] string? 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)
{
@@ -725,11 +720,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);
@@ -1020,7 +1014,9 @@ namespace Jellyfin.Api.Controllers
if (!string.IsNullOrEmpty(pw))
{
using var sha = SHA1.Create();
- listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw)));
+ // TODO: remove ToLower when Convert.ToHexString supports lowercase
+ // Schedules Direct requires the hex to be lowercase
+ listingsProviderInfo.Password = Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
}
return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
@@ -1076,7 +1072,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);
@@ -1158,7 +1154,8 @@ namespace Jellyfin.Api.Controllers
/// Only discover new tuners.
/// Tuners returned.
/// An containing the tuners.
- [HttpGet("Tuners/Discvover")]
+ [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
+ [HttpGet("Tuners/Discover")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task>> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
@@ -1219,11 +1216,8 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
- await using var memoryStream = new MemoryStream();
- await new ProgressiveFileCopier(liveStreamInfo, null, _transcodingJobHelper, CancellationToken.None)
- .WriteToAsync(memoryStream, CancellationToken.None)
- .ConfigureAwait(false);
- return File(memoryStream, MimeTypes.GetMimeType("file." + container));
+ var liveStream = new ProgressiveFileStream(liveStreamInfo.GetFilePath(), null, _transcodingJobHelper);
+ return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container));
}
private void AssertUserCanManageLiveTv()
diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs
index ef2e7e8b15..3d8b9e0cac 100644
--- a/Jellyfin.Api/Controllers/LocalizationController.cs
+++ b/Jellyfin.Api/Controllers/LocalizationController.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using Jellyfin.Api.Constants;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index 4c21999b1b..a76dc057a0 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Buffers;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@@ -8,7 +8,6 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.MediaInfoDtos;
-using Jellyfin.Api.Models.VideoDtos;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
@@ -81,6 +80,9 @@ namespace Jellyfin.Api.Controllers
///
/// Gets live playback media info for an item.
///
+ ///
+ /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
+ ///
/// The item id.
/// The user id.
/// The maximum streaming bitrate.
@@ -90,13 +92,13 @@ namespace Jellyfin.Api.Controllers
/// The maximum number of audio channels.
/// The media source id.
/// The livestream id.
- /// The device profile.
/// Whether to auto open the livestream.
/// Whether to enable direct play. Default: true.
/// Whether to enable direct stream. Default: true.
/// Whether to enable transcoding. Default: true.
/// Whether to allow to copy the video stream. Default: true.
/// Whether to allow to copy the audio stream. Default: true.
+ /// The playback info.
/// Playback info returned.
/// A containing a with the playback info.
[HttpPost("Items/{itemId}/PlaybackInfo")]
@@ -104,25 +106,24 @@ namespace Jellyfin.Api.Controllers
public async Task> GetPostedPlaybackInfo(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
- [FromQuery] long? maxStreamingBitrate,
+ [FromQuery] int? maxStreamingBitrate,
[FromQuery] long? startTimeTicks,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? subtitleStreamIndex,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? mediaSourceId,
[FromQuery] string? liveStreamId,
- [FromBody] DeviceProfileDto? deviceProfile,
- [FromQuery] bool autoOpenLiveStream = false,
- [FromQuery] bool enableDirectPlay = true,
- [FromQuery] bool enableDirectStream = true,
- [FromQuery] bool enableTranscoding = true,
- [FromQuery] bool allowVideoStreamCopy = true,
- [FromQuery] bool allowAudioStreamCopy = true)
+ [FromQuery] bool? autoOpenLiveStream,
+ [FromQuery] bool? enableDirectPlay,
+ [FromQuery] bool? enableDirectStream,
+ [FromQuery] bool? enableTranscoding,
+ [FromQuery] bool? allowVideoStreamCopy,
+ [FromQuery] bool? allowAudioStreamCopy,
+ [FromBody] PlaybackInfoDto? playbackInfoDto)
{
var authInfo = _authContext.GetAuthorizationInfo(Request);
- var profile = deviceProfile?.DeviceProfile;
-
+ var profile = playbackInfoDto?.DeviceProfile;
_logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
if (profile == null)
@@ -134,6 +135,23 @@ namespace Jellyfin.Api.Controllers
}
}
+ // Copy params from posted body
+ // TODO clean up when breaking API compatibility.
+ userId ??= playbackInfoDto?.UserId;
+ maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate;
+ startTimeTicks ??= playbackInfoDto?.StartTimeTicks;
+ audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex;
+ subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex;
+ maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels;
+ mediaSourceId ??= playbackInfoDto?.MediaSourceId;
+ liveStreamId ??= playbackInfoDto?.LiveStreamId;
+ autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false;
+ enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true;
+ enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true;
+ enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true;
+ allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true;
+ allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true;
+
var info = await _mediaInfoHelper.GetPlaybackInfo(
itemId,
userId,
@@ -161,18 +179,18 @@ namespace Jellyfin.Api.Controllers
maxAudioChannels,
info!.PlaySessionId!,
userId ?? Guid.Empty,
- enableDirectPlay,
- enableDirectStream,
- enableTranscoding,
- allowVideoStreamCopy,
- allowAudioStreamCopy,
+ enableDirectPlay.Value,
+ enableDirectStream.Value,
+ enableTranscoding.Value,
+ allowVideoStreamCopy.Value,
+ allowAudioStreamCopy.Value,
Request.HttpContext.GetNormalizedRemoteIp());
}
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
}
- if (autoOpenLiveStream)
+ if (autoOpenLiveStream.Value)
{
var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
@@ -183,9 +201,9 @@ namespace Jellyfin.Api.Controllers
new LiveStreamRequest
{
AudioStreamIndex = audioStreamIndex,
- DeviceProfile = deviceProfile?.DeviceProfile,
- EnableDirectPlay = enableDirectPlay,
- EnableDirectStream = enableDirectStream,
+ DeviceProfile = playbackInfoDto?.DeviceProfile,
+ EnableDirectPlay = enableDirectPlay.Value,
+ EnableDirectStream = enableDirectStream.Value,
ItemId = itemId,
MaxAudioChannels = maxAudioChannels,
MaxStreamingBitrate = maxStreamingBitrate,
@@ -234,7 +252,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 7fcfc749de..75dfd4e68b 100644
--- a/Jellyfin.Api/Controllers/MoviesController.cs
+++ b/Jellyfin.Api/Controllers/MoviesController.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.Collections.Generic;
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> 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();
@@ -85,8 +85,8 @@ namespace Jellyfin.Api.Controllers
IncludeItemTypes = new[]
{
nameof(Movie),
- // typeof(Trailer).Name,
- // typeof(LiveTvProgram).Name
+ // nameof(Trailer),
+ // nameof(LiveTvProgram)
},
// IsMovie = true
OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple(i, SortOrder.Descending)).ToArray(),
diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs
index d216b7f728..e7d0a61c58 100644
--- a/Jellyfin.Api/Controllers/MusicGenresController.cs
+++ b/Jellyfin.Api/Controllers/MusicGenresController.cs
@@ -1,16 +1,17 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
-using System.Globalization;
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;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -47,30 +48,16 @@ namespace Jellyfin.Api.Controllers
///
/// Gets all music genres from a given item, folder, or the entire library.
///
- /// Optional filter by minimum community rating.
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
/// The search term.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.
/// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.
- /// Optional. Specify additional filters to apply.
/// Optional filter by items that are marked as favorite, or not.
- /// Optional filter by MediaType. Allows multiple, comma delimited.
- /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.
- /// Optional, include user data.
/// Optional, the max number of images to return, per image type.
/// Optional. The image types to include in the output.
- /// Optional. If specified, results will be filtered to include only those containing the specified person.
- /// Optional. If specified, results will be filtered to include only those containing the specified person id.
- /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.
- /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.
/// User id.
/// Optional filter by items whose name is sorted equally or greater than a given input string.
/// Optional filter by items whose name is sorted equally than a given input string.
@@ -80,31 +67,18 @@ namespace Jellyfin.Api.Controllers
/// Music genres returned.
/// An containing the queryresult of music genres.
[HttpGet]
+ [Obsolete("Use GetGenres instead")]
public ActionResult> 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] 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] string? 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,
@@ -112,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
@@ -168,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
- {
- Items = dtos.ToArray(),
- TotalRecordCount = result.TotalRecordCount
- };
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
///
@@ -263,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)
{
@@ -284,7 +160,7 @@ namespace Jellyfin.Api.Controllers
return _dtoService.GetBaseItemDto(item, dtoOptions);
}
- private T GetItemFromSlugName(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+ private T? GetItemFromSlugName(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..83b3597665 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;
}
@@ -94,7 +99,7 @@ namespace Jellyfin.Api.Controllers
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl))
{
- packages = packages.Where(p => p.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))
+ packages = packages.Where(p => p.versions.Where(q => q.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)).Any())
.ToList();
}
@@ -149,12 +154,13 @@ namespace Jellyfin.Api.Controllers
/// The list of package repositories.
/// Package repositories saved.
/// A .
- [HttpOptions("Repositories")]
+ [HttpPost("Repositories")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetRepositories([FromBody] List repositoryInfos)
{
_serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
+ _serverConfigurationManager.SaveConfiguration();
return NoContent();
}
}
diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs
index bbef1ff9a1..aaad36551c 100644
--- a/Jellyfin.Api/Controllers/PersonsController.cs
+++ b/Jellyfin.Api/Controllers/PersonsController.cs
@@ -1,15 +1,16 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
-using System.Globalization;
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;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -26,6 +27,7 @@ namespace Jellyfin.Api.Controllers
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataManager;
///
/// Initializes a new instance of the class.
@@ -33,221 +35,81 @@ namespace Jellyfin.Api.Controllers
/// Instance of the interface.
/// Instance of the interface.
/// Instance of the interface.
+ /// Instance of the interface.
public PersonsController(
ILibraryManager libraryManager,
IDtoService dtoService,
- IUserManager userManager)
+ IUserManager userManager,
+ IUserDataManager userDataManager)
{
_libraryManager = libraryManager;
_dtoService = dtoService;
_userManager = userManager;
+ _userDataManager = userDataManager;
}
///
- /// Gets all persons from a given item, folder, or the entire library.
+ /// Gets all persons.
///
- /// Optional filter by minimum community rating.
- /// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
/// The search term.
- /// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
- /// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.
- /// Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.
- /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.
- /// Optional filter by items that are marked as favorite, or not.
- /// Optional filter by MediaType. Allows multiple, comma delimited.
- /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.
+ /// Optional. Specify additional fields of information to return in the output.
+ /// Optional. Specify additional filters to apply.
+ /// Optional filter by items that are marked as favorite, or not. userId is required.
/// Optional, include user data.
/// Optional, the max number of images to return, per image type.
/// Optional. The image types to include in the output.
- /// Optional. If specified, results will be filtered to include only those containing the specified person.
- /// Optional. If specified, results will be filtered to include only those containing the specified person id.
- /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.
- /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.
+ /// Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.
+ /// Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.
+ /// Optional. If specified, person results will be filtered on items related to said persons.
/// User id.
- /// Optional filter by items whose name is sorted equally or greater than a given input string.
- /// Optional filter by items whose name is sorted equally than a given input string.
- /// Optional filter by items whose name is equally or lesser than a given input string.
/// Optional, include image information in output.
- /// Optional. Include total record count.
/// Persons returned.
/// An containing the queryresult of persons.
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> 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] ItemFilter[] filters,
+ [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] string? 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
{
- 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 1e95bd2b38..3e55434c06 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -1,15 +1,17 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
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;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Playlists;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
@@ -61,11 +63,10 @@ namespace Jellyfin.Api.Controllers
public async Task> 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);
@@ -85,10 +86,10 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task 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();
}
@@ -120,9 +121,11 @@ namespace Jellyfin.Api.Controllers
/// An on success.
[HttpDelete("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds)
+ public async Task 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();
}
@@ -133,7 +136,7 @@ namespace Jellyfin.Api.Controllers
/// User id.
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Include image information in output.
/// Optional. Include user data.
/// Optional. The max number of images to return, per image type.
@@ -147,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] string? enableImageTypes)
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
if (playlist == null)
@@ -175,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/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
index 5c15e9a0d7..ec7b84ff60 100644
--- a/Jellyfin.Api/Controllers/PlaystateController.cs
+++ b/Jellyfin.Api/Controllers/PlaystateController.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
@@ -74,7 +75,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult MarkPlayedItem(
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
- [FromQuery] DateTime? datePlayed)
+ [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
{
var user = _userManager.GetUserById(userId);
var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index 0f8ceba291..98f1bc2d23 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
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 GetRemoteImage([FromQuery, Required] string imageUrl)
+ public async Task 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
/// The URL hash.
/// The pointer cache path.
/// Task.
- 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/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs
index ab7920895b..68e4f0586f 100644
--- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs
+++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs
@@ -36,7 +36,7 @@ namespace Jellyfin.Api.Controllers
/// The list of scheduled tasks.
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
- public IEnumerable GetTasks(
+ public IEnumerable GetTasks(
[FromQuery] bool? isHidden,
[FromQuery] bool? isEnabled)
{
@@ -57,7 +57,7 @@ namespace Jellyfin.Api.Controllers
}
}
- yield return task;
+ yield return ScheduledTaskHelpers.GetTaskInfo(task);
}
}
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(BaseItem item, ImageType type)
+ private T? GetParentWithImage(BaseItem item, ImageType type)
where T : BaseItem
{
return item.GetParents().OfType().FirstOrDefault(i => i.HasImage(type));
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index 5656709629..e2269a2ce2 100644
--- a/Jellyfin.Api/Controllers/SessionController.cs
+++ b/Jellyfin.Api/Controllers/SessionController.cs
@@ -6,6 +6,7 @@ using System.Threading;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
+using Jellyfin.Api.Models.SessionDtos;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
@@ -160,12 +161,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,8 +379,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostCapabilities(
[FromQuery] string? id,
- [FromQuery] string? playableMediaTypes,
- [FromQuery] GeneralCommandType[] supportedCommands,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
[FromQuery] bool supportsMediaControl = false,
[FromQuery] bool supportsSync = false,
[FromQuery] bool supportsPersistentIdentifier = true)
@@ -391,7 +392,7 @@ namespace Jellyfin.Api.Controllers
_sessionManager.ReportCapabilities(id, new ClientCapabilities
{
- PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true),
+ PlayableMediaTypes = playableMediaTypes,
SupportedCommands = supportedCommands,
SupportsMediaControl = supportsMediaControl,
SupportsSync = supportsSync,
@@ -412,14 +413,14 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostFullCapabilities(
[FromQuery] string? id,
- [FromBody, Required] ClientCapabilities capabilities)
+ [FromBody, Required] ClientCapabilitiesDto capabilities)
{
if (string.IsNullOrWhiteSpace(id))
{
id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
}
- _sessionManager.ReportCapabilities(id, capabilities);
+ _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index 9c259cc198..d9cb34557b 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.StartupDtos;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
@@ -72,9 +73,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
{
- _config.Configuration.UICulture = startupConfiguration.UICulture;
- _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode;
- _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage;
+ _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty;
+ _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty;
+ _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty;
_config.SaveConfiguration();
return NoContent();
}
@@ -89,9 +90,10 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
{
- _config.Configuration.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
- _config.Configuration.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
- _config.SaveConfiguration();
+ NetworkConfiguration settings = _config.GetNetworkConfiguration();
+ settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
+ settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
+ _config.SaveConfiguration("network", settings);
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs
index 3a8ac1b243..5090bf1dee 100644
--- a/Jellyfin.Api/Controllers/StudiosController.cs
+++ b/Jellyfin.Api/Controllers/StudiosController.cs
@@ -1,14 +1,15 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
-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;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -45,30 +46,17 @@ namespace Jellyfin.Api.Controllers
///
/// Gets all studios from a given item, folder, or the entire library.
///
- /// Optional filter by minimum community rating.
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
/// Optional. Search term.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.
/// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.
- /// Optional. Specify additional filters to apply.
/// Optional filter by items that are marked as favorite, or not.
- /// Optional filter by MediaType. Allows multiple, comma delimited.
- /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.
/// Optional, include user data.
/// Optional, the max number of images to return, per image type.
/// Optional. The image types to include in the output.
- /// Optional. If specified, results will be filtered to include only those containing the specified person.
- /// Optional. If specified, results will be filtered to include only those containing the specified person ids.
- /// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.
- /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.
- /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.
/// User id.
/// Optional filter by items whose name is sorted equally or greater than a given input string.
/// Optional filter by items whose name is sorted equally than a given input string.
@@ -80,30 +68,17 @@ namespace Jellyfin.Api.Controllers
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> 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] 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] string? 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,
@@ -111,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
@@ -171,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
- {
- Items = dtos.ToArray(),
- TotalRecordCount = result.TotalRecordCount
- };
+ var result = _libraryManager.GetStudios(query);
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
+ return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
///
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index cc682ed542..dcb8e803b8 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
///
/// Initializes a new instance of the class.
///
+ /// Instance of interface.
/// Instance of interface.
/// Instance of interface.
/// Instance of interface.
@@ -55,6 +61,7 @@ namespace Jellyfin.Api.Controllers
/// Instance of interface.
/// Instance of interface.
public SubtitleController(
+ IServerConfigurationManager serverConfigurationManager,
ILibraryManager libraryManager,
ISubtitleManager subtitleManager,
ISubtitleEncoder subtitleEncoder,
@@ -64,6 +71,7 @@ namespace Jellyfin.Api.Controllers
IAuthorizationContext authContext,
ILogger logger)
{
+ _serverConfigurationManager = serverConfigurationManager;
_libraryManager = libraryManager;
_subtitleManager = subtitleManager;
_subtitleEncoder = subtitleEncoder;
@@ -185,7 +193,6 @@ namespace Jellyfin.Api.Controllers
/// File returned.
/// A with the subtitle file.
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
- [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile("text/*")]
public async Task GetSubtitle(
@@ -196,7 +203,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] long? endPositionTicks,
[FromQuery] bool copyTimestamps = false,
[FromQuery] bool addVttTimeMap = false,
- [FromRoute] long startPositionTicks = 0)
+ [FromQuery] long startPositionTicks = 0)
{
if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
{
@@ -241,6 +248,43 @@ namespace Jellyfin.Api.Controllers
MimeTypes.GetMimeType("file." + format));
}
+ ///
+ /// Gets subtitles in a specified format.
+ ///
+ /// The item id.
+ /// The media source id.
+ /// The subtitle stream index.
+ /// Optional. The start position of the subtitle in ticks.
+ /// The format of the returned subtitle.
+ /// Optional. The end position of the subtitle in ticks.
+ /// Optional. Whether to copy the timestamps.
+ /// Optional. Whether to add a VTT time map.
+ /// File returned.
+ /// A with the subtitle file.
+ [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesFile("text/*")]
+ public Task GetSubtitleWithTicks(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] string mediaSourceId,
+ [FromRoute, Required] int index,
+ [FromRoute, Required] long startPositionTicks,
+ [FromRoute, Required] string format,
+ [FromQuery] long? endPositionTicks,
+ [FromQuery] bool copyTimestamps = false,
+ [FromQuery] bool addVttTimeMap = false)
+ {
+ return GetSubtitle(
+ itemId,
+ mediaSourceId,
+ index,
+ format,
+ endPositionTicks,
+ copyTimestamps,
+ addVttTimeMap,
+ startPositionTicks);
+ }
+
///
/// Gets an HLS subtitle playlist.
///
@@ -319,6 +363,34 @@ namespace Jellyfin.Api.Controllers
return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
}
+ ///
+ /// Upload an external subtitle file.
+ ///
+ /// The item the subtitle belongs to.
+ /// The request body.
+ /// Subtitle uploaded.
+ /// A .
+ [HttpPost("Videos/{itemId}/Subtitles")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task 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();
+ }
+
///
/// Encodes a subtitle in the specified format.
///
@@ -351,5 +423,96 @@ namespace Jellyfin.Api.Controllers
copyTimestamps,
CancellationToken.None);
}
+
+ ///
+ /// Gets a list of available fallback font files.
+ ///
+ /// Information retrieved.
+ /// An array of with the available font files.
+ [HttpGet("FallbackFont/Fonts")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public IEnumerable 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;
+ }
+ }
+
+ ///
+ /// Gets a fallback font file.
+ ///
+ /// The name of the fallback font file to get.
+ /// Fallback font file retrieved.
+ /// The fallback font file.
+ [HttpGet("FallbackFont/Fonts/{name}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesFile("font/*")]
+ 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 d7c81a3ab6..9f1dec712a 100644
--- a/Jellyfin.Api/Controllers/SuggestionsController.cs
+++ b/Jellyfin.Api/Controllers/SuggestionsController.cs
@@ -1,14 +1,17 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
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;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -18,6 +21,7 @@ namespace Jellyfin.Api.Controllers
/// The suggestions controller.
///
[Route("")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class SuggestionsController : BaseJellyfinApiController
{
private readonly IDtoService _dtoService;
@@ -55,8 +59,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> 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)
@@ -67,8 +71,8 @@ namespace Jellyfin.Api.Controllers
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{
OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple(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/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs
index 8e9314b4aa..530cce1ea7 100644
--- a/Jellyfin.Api/Controllers/SyncPlayController.cs
+++ b/Jellyfin.Api/Controllers/SyncPlayController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading;
diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs
index 4cb1984a2f..7784e8a119 100644
--- a/Jellyfin.Api/Controllers/SystemController.cs
+++ b/Jellyfin.Api/Controllers/SystemController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
@@ -64,9 +64,9 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Info")]
[Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> GetSystemInfo()
+ public ActionResult GetSystemInfo()
{
- return await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false);
+ return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress);
}
///
@@ -76,9 +76,9 @@ namespace Jellyfin.Api.Controllers
/// A with public info about the system.
[HttpGet("Info/Public")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> GetPublicSystemInfo()
+ public ActionResult GetPublicSystemInfo()
{
- return await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false);
+ return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress);
}
///
diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs
index 5de5604173..c730ac12b3 100644
--- a/Jellyfin.Api/Controllers/TimeSyncController.cs
+++ b/Jellyfin.Api/Controllers/TimeSyncController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Globalization;
using MediaBrowser.Model.SyncPlay;
using Microsoft.AspNetCore.Http;
diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs
index dec4498575..5b71fed5a0 100644
--- a/Jellyfin.Api/Controllers/TrailersController.cs
+++ b/Jellyfin.Api/Controllers/TrailersController.cs
@@ -1,5 +1,6 @@
-using System;
+using System;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.ModelBinders;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
@@ -41,8 +42,8 @@ namespace Jellyfin.Api.Controllers
/// Optional filter by items that have or do not have a parental rating.
/// Optional filter by items that are HD or not.
/// Optional filter by items that are 4K or not.
- /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.
- /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.
+ /// Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.
+ /// Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.
/// Optional filter by items that are missing episodes or not.
/// Optional filter by items that are unaired episodes or not.
/// Optional filter by minimum community rating.
@@ -55,41 +56,41 @@ namespace Jellyfin.Api.Controllers
/// Optional filter by items that have an imdb id or not.
/// Optional filter by items that have a tmdb id or not.
/// Optional filter by items that have a tvdb id or not.
- /// Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.
+ /// Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
/// When searching within folders, this determines whether or not the search will be recursive. true/false.
/// Optional. Filter based on a search term.
/// Sort Order - Ascending,Descending.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
- /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.
- /// Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.
+ /// 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.
+ /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.
+ /// Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.
/// Optional filter by items that are marked as favorite, or not.
/// Optional filter by MediaType. Allows multiple, comma delimited.
/// Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.
- /// 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.
+ /// 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.
/// Optional filter by items that are played, or not.
- /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.
- /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.
- /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.
- /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.
+ /// Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.
/// Optional, include user data.
/// Optional, the max number of images to return, per image type.
/// Optional. The image types to include in the output.
/// Optional. If specified, results will be filtered to include only those containing the specified person.
/// Optional. If specified, results will be filtered to include only those containing the specified person id.
/// Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.
- /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.
- /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.
- /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.
+ /// Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.
/// Optional. If specified, results will be filtered to include only those containing the specified artist id.
/// Optional. If specified, results will be filtered to include only those containing the specified album artist id.
/// Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.
- /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.
- /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.
+ /// Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.
/// Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.
- /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.
+ /// Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.
/// Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).
/// Optional filter by items that are locked.
/// Optional filter by items that are placeholders.
@@ -100,12 +101,12 @@ namespace Jellyfin.Api.Controllers
/// Optional. Filter by the maximum width of the item.
/// Optional. Filter by the maximum height of the item.
/// Optional filter by items that are 3D, or not.
- /// Optional filter by Series Status. Allows multiple, comma delimeted.
+ /// Optional filter by Series Status. Allows multiple, comma delimited.
/// Optional filter by items whose name is sorted equally or greater than a given input string.
/// Optional filter by items whose name is sorted equally than a given input string.
/// Optional filter by items whose name is equally or lesser than a given input string.
- /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.
- /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.
+ /// Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.
+ /// Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.
/// Optional. Enable the total record count.
/// Optional, include image information in output.
/// A with the trailers.
@@ -124,8 +125,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
- [FromQuery] string? locationTypes,
- [FromQuery] LocationType[] excludeLocationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
[FromQuery] double? minCommunityRating,
@@ -138,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] ItemFilter[] filters,
+ [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] string? 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] string? 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,
@@ -183,20 +184,19 @@ 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(
- userId,
userId,
maxOfficialRating,
hasThemeSong,
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index d158f6c342..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;
@@ -13,6 +14,7 @@ using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.TV;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -57,7 +59,7 @@ namespace Jellyfin.Api.Controllers
/// The user id of the user to get the next up episodes for.
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Filter by series id.
/// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.
/// Optional. Include image information in output.
@@ -72,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] string? 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!);
@@ -117,7 +118,7 @@ namespace Jellyfin.Api.Controllers
/// The user id of the user to get the upcoming episodes for.
/// Optional. The record index to start at. All items with a lower index will be dropped from the results.
/// Optional. The maximum number of records to return.
- /// 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.
+ /// Optional. Specify additional fields of information to return in the output.
/// Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.
/// Optional. Include image information in output.
/// Optional. The max number of images to return, per image type.
@@ -130,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] string? enableImageTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -145,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!);
@@ -176,7 +176,7 @@ namespace Jellyfin.Api.Controllers
///
/// The series id.
/// The user id.
- /// 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.
+ /// 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.
/// Optional filter by season number.
/// Optional. Filter by season id.
/// Optional. Filter by items that are missing episodes or not.
@@ -188,7 +188,7 @@ namespace Jellyfin.Api.Controllers
/// Optional, the max number of images to return, per image type.
/// Optional. The image types to include in the output.
/// Optional. Include user data.
- /// 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.
+ /// 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.
/// A with the episodes on success or a if the series was not found.
[HttpGet("{seriesId}/Episodes")]
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -196,7 +196,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult> 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,
@@ -206,7 +206,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
- [FromQuery] string? enableImageTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData,
[FromQuery] string? sortBy)
{
@@ -216,8 +216,7 @@ namespace Jellyfin.Api.Controllers
List episodes;
- var dtoOptions = new DtoOptions()
- .AddItemFields(fields!)
+ var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
@@ -304,7 +303,7 @@ namespace Jellyfin.Api.Controllers
///
/// The series id.
/// The user id.
- /// 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.
+ /// 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.
/// Optional. Filter by special season.
/// Optional. Filter by items that are missing episodes or not.
/// Optional. Return items that are siblings of a supplied item.
@@ -319,13 +318,13 @@ namespace Jellyfin.Api.Controllers
public ActionResult> 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] string? enableImageTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -344,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 df20a92b3d..34c9f32fa4 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
@@ -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
/// Optional. The maximum number of audio channels.
/// Optional. The number of how many audio channels to transcode to.
/// Optional. The maximum streaming bitrate.
+ /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.
/// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.
/// Optional. The container to transcode to.
/// Optional. The transcoding protocol.
@@ -88,23 +90,22 @@ namespace Jellyfin.Api.Controllers
/// Redirected to remote audio stream.
/// A containing the audio file.
[HttpGet("Audio/{itemId}/universal")]
- [HttpGet("Audio/{itemId}/universal.{container}", Name = "GetUniversalAudioStream_2")]
[HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
- [HttpHead("Audio/{itemId}/universal.{container}", Name = "HeadUniversalAudioStream_2")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesAudioFile]
public async Task GetUniversalAudioStream(
[FromRoute, Required] Guid itemId,
- [FromRoute] 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,
@@ -191,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
{
@@ -201,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,
@@ -212,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,
@@ -244,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,
@@ -258,7 +262,7 @@ namespace Jellyfin.Api.Controllers
}
private DeviceProfile GetDeviceProfile(
- string? container,
+ string[] containers,
string? transcodingContainer,
string? audioCodec,
string? transcodingProtocol,
@@ -270,20 +274,23 @@ namespace Jellyfin.Api.Controllers
{
var deviceProfile = new DeviceProfile();
- var directPlayProfiles = new List();
-
- var containers = RequestHelpers.Split(container, ',', true);
-
- foreach (var cont in containers)
+ int len = containers.Length;
+ var directPlayProfiles = new DirectPlayProfile[len];
+ for (int i = 0; i < len; i++)
{
- var parts = RequestHelpers.Split(cont, ',', true);
+ var parts = RequestHelpers.Split(containers[i], '|', true);
- var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray());
+ var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1));
- directPlayProfiles.Add(new DirectPlayProfile { Type = DlnaProfileType.Audio, Container = parts[0], AudioCodec = audioCodecs });
+ directPlayProfiles[i] = new DirectPlayProfile
+ {
+ Type = DlnaProfileType.Audio,
+ Container = parts[0],
+ AudioCodec = audioCodecs
+ };
}
- deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray();
+ deviceProfile.DirectPlayProfiles = directPlayProfiles;
deviceProfile.TranscodingProfiles = new[]
{
@@ -323,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/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index 50bb8bb2aa..9805b84b1a 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@@ -381,17 +381,13 @@ namespace Jellyfin.Api.Controllers
var user = _userManager.GetUserById(userId);
- if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
- {
- await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
- _userManager.UpdateConfiguration(user.Id, updateUser.Configuration);
- }
- else
+ if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
{
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
- _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration);
}
+ await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false);
+
return NoContent();
}
@@ -409,7 +405,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
- public ActionResult UpdateUserPolicy(
+ public async Task UpdateUserPolicy(
[FromRoute, Required] Guid userId,
[FromBody] UserPolicy newPolicy)
{
@@ -447,7 +443,7 @@ namespace Jellyfin.Api.Controllers
_sessionManager.RevokeUserTokens(user.Id, currentToken);
}
- _userManager.UpdatePolicy(userId, newPolicy);
+ await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false);
return NoContent();
}
@@ -464,7 +460,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
- public ActionResult UpdateUserConfiguration(
+ public async Task UpdateUserConfiguration(
[FromRoute, Required] Guid userId,
[FromBody] UserConfiguration userConfig)
{
@@ -473,7 +469,7 @@ namespace Jellyfin.Api.Controllers
return Forbid("User configuration update not allowed");
}
- _userManager.UpdateConfiguration(userId, userConfig);
+ await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
return NoContent();
}
@@ -534,6 +530,33 @@ namespace Jellyfin.Api.Controllers
return result;
}
+ ///
+ /// Gets the user based on auth token.
+ ///
+ /// User returned.
+ /// Token is not owned by a user.
+ /// A for the authenticated user.
+ [HttpGet("Me")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public ActionResult GetCurrentUser()
+ {
+ var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
+ if (userId == null)
+ {
+ return BadRequest();
+ }
+
+ var user = _userManager.GetUserById(userId.Value);
+ if (user == null)
+ {
+ return BadRequest();
+ }
+
+ return _userManager.GetUserDto(user);
+ }
+
private IEnumerable Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
{
var users = _userManager.Users;
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 48262f0620..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
///
/// User id.
/// Specify this to localize the search to a specific item or folder. Omit to use the root.
- /// 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.
- /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.
+ /// Optional. Specify additional fields of information to return in the output.
+ /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.
/// Filter by items that are played, or not.
/// Optional. include image information in output.
/// Optional. the max number of images to return, per image type.
@@ -267,12 +268,12 @@ namespace Jellyfin.Api.Controllers
public ActionResult> 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] string? 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..e1483ce9d5 100644
--- a/Jellyfin.Api/Controllers/UserViewsController.cs
+++ b/Jellyfin.Api/Controllers/UserViewsController.cs
@@ -1,10 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
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> 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..c2bb0dfffe 100644
--- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
+++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
@@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@@ -43,10 +44,10 @@ namespace Jellyfin.Api.Controllers
/// Video or attachment not found.
/// An containing the attachment stream on success, or a if the attachment could not be found.
[HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
- [Produces(MediaTypeNames.Application.Octet)]
+ [ProducesFile(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetAttachment(
+ public async Task 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 2afa878f41..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
/// Optional. The maximum video bit depth.
/// Optional. Whether to require avc.
/// Optional. Whether to deinterlace the video.
- /// Optional. Whether to require a non anamporphic stream.
+ /// Optional. Whether to require a non anamorphic stream.
/// Optional. The maximum number of audio channels to transcode.
/// Optional. The limit of how many cpu cores to use.
/// The live stream id.
@@ -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,38 +360,67 @@ 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,
- "\"hls{0}\"",
+ "\"hls/{0}/\"",
Path.GetFileNameWithoutExtension(outputPath));
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();
}
///
@@ -399,14 +430,53 @@ namespace Jellyfin.Api.Controllers
/// The command line arguments for audio transcoding.
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
/// The command line arguments for video transcoding.
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..8e17b843a6 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 MergeVersions([FromQuery, Required] string itemIds)
+ public async Task MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds)
{
- var items = RequestHelpers.Split(itemIds, ',', true)
+ var items = itemIds
.Select(i => _libraryManager.GetItemById(i))
.OfType
public static class DtoExtensions
{
- ///
- /// Add Dto Item fields.
- ///
- ///
- /// Converted from IHasItemFields.
- /// Legacy order: 1.
- ///
- /// DtoOptions object.
- /// Comma delimited string of fields.
- /// Modified DtoOptions object.
- internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string? fields)
- {
- if (string.IsNullOrEmpty(fields))
- {
- dtoOptions.Fields = Array.Empty();
- }
- 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;
- }
-
///
/// Add additional fields depending on client.
///
@@ -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,
- string? enableImageTypes)
+ IReadOnlyList enableImageTypes)
{
dtoOptions.EnableImages = enableImages ?? true;
@@ -140,11 +106,9 @@ namespace Jellyfin.Api.Extensions
dtoOptions.EnableUserData = enableUserData.Value;
}
- if (!string.IsNullOrWhiteSpace(enableImageTypes))
+ if (enableImageTypes.Count != 0)
{
- dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
- .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true))
- .ToArray();
+ dtoOptions.ImageTypes = enableImageTypes;
}
return dtoOptions;
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);
+ ///
+ /// Gets a flag specifying whether the request is using an api key.
+ ///
+ /// Current claims principal.
+ /// The flag specifying whether the request is using an api key.
+ 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/ClassMigrationHelper.cs b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs
new file mode 100644
index 0000000000..a911a33241
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Reflection;
+
+namespace Jellyfin.Api.Helpers
+{
+ ///
+ /// A static class for copying matching properties from one object to another.
+ /// TODO: remove at the point when a fixed migration path has been decided upon.
+ ///
+ public static class ClassMigrationHelper
+ {
+ ///
+ /// Extension for 'Object' that copies the properties to a destination object.
+ ///
+ /// The source.
+ /// The destination.
+ public static void CopyProperties(this object source, object destination)
+ {
+ // If any this null throw an exception.
+ if (source == null || destination == null)
+ {
+ throw new Exception("Source or/and Destination Objects are null");
+ }
+
+ // Getting the Types of the objects.
+ Type typeDest = destination.GetType();
+ Type typeSrc = source.GetType();
+
+ // Iterate the Properties of the source instance and populate them from their destination counterparts.
+ PropertyInfo[] srcProps = typeSrc.GetProperties();
+ foreach (PropertyInfo srcProp in srcProps)
+ {
+ if (!srcProp.CanRead)
+ {
+ continue;
+ }
+
+ var targetProperty = typeDest.GetProperty(srcProp.Name);
+ if (targetProperty == null)
+ {
+ continue;
+ }
+
+ if (!targetProperty.CanWrite)
+ {
+ continue;
+ }
+
+ var obj = targetProperty.GetSetMethod(true);
+ if (obj != null && obj.IsPrivate)
+ {
+ continue;
+ }
+
+ var target = targetProperty.GetSetMethod();
+ if (target != null && (target.Attributes & MethodAttributes.Static) != 0)
+ {
+ continue;
+ }
+
+ if (!targetProperty.PropertyType.IsAssignableFrom(srcProp.PropertyType))
+ {
+ continue;
+ }
+
+ // Passed all tests, lets set the value.
+ targetProperty.SetValue(destination, srcProp.GetValue(source, null), null);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index af0519ffa8..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,
@@ -155,7 +160,7 @@ namespace Jellyfin.Api.Helpers
return new FileContentResult(Array.Empty(), MimeTypes.GetMimeType("playlist.m3u8"));
}
- var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0;
+ var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0);
var builder = new StringBuilder();
@@ -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;
+ }
+
+ ///
+ /// Appends a VIDEO-RANGE field containing the range of the output video stream.
+ ///
+ ///
+ /// StringBuilder to append the field to.
+ /// StreamState of the current stream.
+ 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");
+ }
+ }
}
///
@@ -414,15 +510,27 @@ namespace Jellyfin.Api.Helpers
/// H.26X level of the output video stream.
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;
}
+ ///
+ /// Get the H.26X profile of the output video stream.
+ ///
+ /// StreamState of the current stream.
+ /// Video codec.
+ /// H.26X profile of the output video stream.
+ 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;
+ }
+
///
/// Gets a formatted string of the output audio codec, for use in the CODECS field.
///
@@ -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 6b516977e8..cfa2c1229a 100644
--- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
+++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
@@ -24,12 +24,14 @@ namespace Jellyfin.Api.Helpers
/// Whether the current request is a HTTP HEAD request so only the headers get returned.
/// The making the remote request.
/// The current http context.
+ /// A cancellation token that can be used to cancel the operation.
/// A containing the API response.
public static async Task 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(), contentType);
}
- return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType);
+ return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
}
///
@@ -123,9 +125,8 @@ namespace Jellyfin.Api.Helpers
state.Dispose();
}
- await new ProgressiveFileCopier(outputPath, job, transcodingJobHelper, CancellationToken.None)
- .WriteToAsync(httpContext.Response.Body, CancellationToken.None).ConfigureAwait(false);
- return new FileStreamResult(httpContext.Response.Body, contentType);
+ var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper);
+ return new FileStreamResult(stream, contentType);
}
finally
{
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
///
public static class HlsCodecStringHelpers
{
+ ///
+ /// Codec name for MP3.
+ ///
+ public const string MP3 = "mp4a.40.34";
+
+ ///
+ /// Codec name for AC-3.
+ ///
+ public const string AC3 = "mp4a.a5";
+
+ ///
+ /// Codec name for E-AC-3.
+ ///
+ public const string EAC3 = "mp4a.a6";
+
+ ///
+ /// Codec name for FLAC.
+ ///
+ public const string FLAC = "fLaC";
+
+ ///
+ /// Codec name for ALAC.
+ ///
+ public const string ALAC = "alac";
+
///
/// Gets a MP3 codec string.
///
/// MP3 codec string.
public static string GetMP3String()
{
- return "mp4a.40.34";
+ return MP3;
}
///
@@ -23,7 +48,7 @@ namespace Jellyfin.Api.Helpers
///
/// AAC profile.
/// AAC codec string.
- 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();
}
+ ///
+ /// Gets an AC-3 codec string.
+ ///
+ /// AC-3 codec string.
+ public static string GetAC3String()
+ {
+ return AC3;
+ }
+
+ ///
+ /// Gets an E-AC-3 codec string.
+ ///
+ /// E-AC-3 codec string.
+ public static string GetEAC3String()
+ {
+ return EAC3;
+ }
+
+ ///
+ /// Gets an FLAC codec string.
+ ///
+ /// FLAC codec string.
+ public static string GetFLACString()
+ {
+ return FLAC;
+ }
+
+ ///
+ /// Gets an ALAC codec string.
+ ///
+ /// ALAC codec string.
+ public static string GetALACString()
+ {
+ return ALAC;
+ }
+
///
/// Gets a H.264 codec string.
///
/// H.264 profile.
/// H.264 level.
/// H.264 string.
- 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
/// H.265 profile.
/// H.265 level.
/// H.265 string.
- 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();
}
-
- ///
- /// Gets an AC-3 codec string.
- ///
- /// AC-3 codec string.
- public static string GetAC3String()
- {
- return "mp4a.a5";
- }
-
- ///
- /// Gets an E-AC-3 codec string.
- ///
- /// E-AC-3 codec string.
- 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
}
}
+ ///
+ /// Gets the #EXT-X-MAP string.
+ ///
+ /// The output path of the file.
+ /// The .
+ /// Get a normal string or depends on OS.
+ /// The string text of #EXT-X-MAP.
+ 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;
+ }
+
///
/// Gets the hls playlist text.
///
/// The path to the playlist file.
- /// The segment length.
+ /// The .
/// The playlist text as a string.
- 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/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs
new file mode 100644
index 0000000000..824870c7ef
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs
@@ -0,0 +1,166 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Model.IO;
+
+namespace Jellyfin.Api.Helpers
+{
+ ///
+ /// A progressive file stream for transferring transcoded files as they are written to.
+ ///
+ public class ProgressiveFileStream : Stream
+ {
+ private readonly FileStream _fileStream;
+ private readonly TranscodingJobDto? _job;
+ private readonly TranscodingJobHelper _transcodingJobHelper;
+ private readonly bool _allowAsyncFileRead;
+ private int _bytesWritten;
+ private bool _disposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The path to the transcoded file.
+ /// The transcoding job information.
+ /// The transcoding job helper.
+ public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper)
+ {
+ _job = job;
+ _transcodingJobHelper = transcodingJobHelper;
+ _bytesWritten = 0;
+
+ var fileOptions = FileOptions.SequentialScan;
+ _allowAsyncFileRead = false;
+
+ // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ fileOptions |= FileOptions.Asynchronous;
+ _allowAsyncFileRead = true;
+ }
+
+ _fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, fileOptions);
+ }
+
+ ///
+ public override bool CanRead => _fileStream.CanRead;
+
+ ///
+ public override bool CanSeek => false;
+
+ ///
+ public override bool CanWrite => false;
+
+ ///
+ public override long Length => throw new NotSupportedException();
+
+ ///
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ ///
+ public override void Flush()
+ {
+ _fileStream.Flush();
+ }
+
+ ///
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ return _fileStream.Read(buffer, offset, count);
+ }
+
+ ///
+ public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ int totalBytesRead = 0;
+ int remainingBytesToRead = count;
+
+ int newOffset = offset;
+ while (remainingBytesToRead > 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ int bytesRead;
+ if (_allowAsyncFileRead)
+ {
+ bytesRead = await _fileStream.ReadAsync(buffer, newOffset, remainingBytesToRead, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ bytesRead = _fileStream.Read(buffer, newOffset, remainingBytesToRead);
+ }
+
+ remainingBytesToRead -= bytesRead;
+ newOffset += bytesRead;
+
+ if (bytesRead > 0)
+ {
+ _bytesWritten += bytesRead;
+ totalBytesRead += bytesRead;
+
+ if (_job != null)
+ {
+ _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten);
+ }
+ }
+ else
+ {
+ // If the job is null it's a live stream and will require user action to close
+ if (_job?.HasExited ?? false)
+ {
+ break;
+ }
+
+ await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return totalBytesRead;
+ }
+
+ ///
+ public override long Seek(long offset, SeekOrigin origin)
+ => throw new NotSupportedException();
+
+ ///
+ public override void SetLength(long value)
+ => throw new NotSupportedException();
+
+ ///
+ public override void Write(byte[] buffer, int offset, int count)
+ => throw new NotSupportedException();
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ if (disposing)
+ {
+ _fileStream.Dispose();
+
+ if (_job != null)
+ {
+ _transcodingJobHelper.OnTranscodeEndRequest(_job);
+ }
+ }
+ }
+ finally
+ {
+ _disposed = true;
+ base.Dispose(disposing);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index fb5c283f59..efce11f8a7 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -1,11 +1,14 @@
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;
@@ -71,7 +74,7 @@ namespace Jellyfin.Api.Helpers
}
return removeEmpty
- ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
+ ? value.Split(separator, StringSplitOptions.RemoveEmptyEntries)
: value.Split(separator);
}
@@ -119,47 +122,39 @@ namespace Jellyfin.Api.Helpers
return session;
}
- ///
- /// Get Guid array from string.
- ///
- /// String value.
- /// Guid array.
- internal static Guid[] GetGuids(string? value)
+ internal static QueryResult 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();
- }
+ var (baseItem, counts) = i;
+ var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
- return Split(value, ',', true)
- .Select(i => new Guid(i))
- .ToArray();
- }
-
- ///
- /// Gets the item fields.
- ///
- /// The fields string.
- /// IEnumerable{ItemFields}.
- internal static ItemFields[] GetItemFields(string? fields)
- {
- if (string.IsNullOrEmpty(fields))
- {
- return Array.Empty();
- }
-
- 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;
+ });
+
+ return new QueryResult
+ {
+ 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 b922e76cfd..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
-{
- ///
- /// The similar items helper class.
- ///
- public static class SimilarItemsHelper
- {
- internal static QueryResult GetSimilarItemsResult(
- DtoOptions dtoOptions,
- IUserManager userManager,
- ILibraryManager libraryManager,
- IDtoService dtoService,
- Guid? userId,
- string id,
- string? excludeArtistIds,
- int? limit,
- Type[] includeTypes,
- Func, List, 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)
- {
- returnItems = returnItems.Take(limit.Value).ToList();
- }
-
- var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
-
- return new QueryResult
- {
- Items = dtos,
- TotalRecordCount = items.Count
- };
- }
-
- ///
- /// Gets the similaritems.
- ///
- /// The item.
- /// The library manager.
- /// The input items.
- /// The get similarity score.
- /// IEnumerable{BaseItem}.
- private static IEnumerable GetSimilaritems(
- BaseItem item,
- ILibraryManager libraryManager,
- IEnumerable inputItems,
- Func, List, 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(i, getSimilarityScore(item, itemPeople, allPeople, i)))
- .Where(i => i.Item2 > 2)
- .OrderByDescending(i => i.Item2)
- .Select(i => i.Item1);
- }
-
- private static IEnumerable GetTags(BaseItem item)
- {
- return item.Tags;
- }
-
- ///
- /// Gets the similiarity score.
- ///
- /// The item1.
- /// The item1 people.
- /// All people.
- /// The item2.
- /// System.Int32.
- internal static int GetSimiliarityScore(BaseItem item1, List item1People, List 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
///
/// Playback session id.
/// The transcoding job.
- public TranscodingJobDto GetTranscodingJob(string playSessionId)
+ public TranscodingJobDto? GetTranscodingJob(string playSessionId)
{
lock (_activeTranscodingJobs)
{
@@ -116,7 +117,7 @@ namespace Jellyfin.Api.Helpers
/// Path to the transcoding file.
/// The .
/// The transcoding job.
- 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].
///
/// The state.
- 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
}
///
- /// Starts the FFMPEG.
+ /// Starts FFmpeg.
///
/// The state.
/// The output path.
- /// The command line arguments for ffmpeg.
+ /// The command line arguments for FFmpeg.
/// The .
/// The .
/// The cancellation token source.
@@ -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 @@
- netstandard2.1
+ net5.0
true
true
enable
+
+ AD0001
-
+
-
+
diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs
index 208566dc8e..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,25 +13,28 @@ namespace Jellyfin.Api.ModelBinders
///
public class CommaDelimitedArrayModelBinder : IModelBinder
{
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ public CommaDelimitedArrayModelBinder(ILogger logger)
+ {
+ _logger = logger;
+ }
+
///
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
- var elementType = bindingContext.ModelType.GetElementType();
+ var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
var converter = TypeDescriptor.GetConverter(elementType);
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 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/CommaDelimitedArrayModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs
deleted file mode 100644
index b9785a73b8..0000000000
--- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinderProvider.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using Microsoft.AspNetCore.Mvc.ModelBinding;
-
-namespace Jellyfin.Api.ModelBinders
-{
- ///
- /// Comma delimited array model binder provider.
- ///
- public class CommaDelimitedArrayModelBinderProvider : IModelBinderProvider
- {
- private readonly IModelBinder _binder;
-
- ///
- /// Initializes a new instance of the class.
- ///
- public CommaDelimitedArrayModelBinderProvider()
- {
- _binder = new CommaDelimitedArrayModelBinder();
- }
-
- ///
- public IModelBinder? GetBinder(ModelBinderProviderContext context)
- {
- return context.Metadata.ModelType.IsArray ? _binder : null;
- }
- }
-}
diff --git a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs
new file mode 100644
index 0000000000..e1cb725f3e
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ ///
+ /// DateTime model binder.
+ ///
+ public class LegacyDateTimeModelBinder : IModelBinder
+ {
+ // Borrowed from the DateTimeModelBinderProvider
+ private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
+ private readonly DateTimeModelBinder _defaultModelBinder;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory)
+ {
+ _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory);
+ }
+
+ ///
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+ if (valueProviderResult.Values.Count == 1)
+ {
+ var dateTimeString = valueProviderResult.FirstValue;
+ // Mark Played Item.
+ if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
+ {
+ bindingContext.Result = ModelBindingResult.Success(dateTime);
+ }
+ else
+ {
+ return _defaultModelBinder.BindModelAsync(bindingContext);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs
new file mode 100644
index 0000000000..5d296227e6
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs
@@ -0,0 +1,47 @@
+using System;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ ///
+ /// Nullable enum model binder.
+ ///
+ public class NullableEnumModelBinder : IModelBinder
+ {
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ public NullableEnumModelBinder(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ 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 != 0)
+ {
+ try
+ {
+ var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue);
+ bindingContext.Result = ModelBindingResult.Success(convertedValue);
+ }
+ catch (FormatException e)
+ {
+ _logger.LogWarning(e, "Error converting value.");
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs
new file mode 100644
index 0000000000..bc12ad05da
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs
@@ -0,0 +1,27 @@
+using System;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ ///
+ /// Nullable enum model binder provider.
+ ///
+ public class NullableEnumModelBinderProvider : IModelBinderProvider
+ {
+ ///
+ public IModelBinder? GetBinder(ModelBinderProviderContext context)
+ {
+ var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType);
+ if (nullableType == null || !nullableType.IsEnum)
+ {
+ // Type isn't nullable or isn't an enum.
+ return null;
+ }
+
+ var logger = context.Services.GetRequiredService>();
+ return new NullableEnumModelBinder(logger);
+ }
+ }
+}
\ No newline at end of file
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
+{
+ ///
+ /// Comma delimited array model binder.
+ /// Returns an empty array of specified type if there is no query parameter.
+ ///
+ public class PipeDelimitedArrayModelBinder : IModelBinder
+ {
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ public PipeDelimitedArrayModelBinder(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ 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 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
///
/// Gets or sets the metadata savers.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataSavers", Justification = "Imported from ServiceStack")]
- public LibraryOptionInfoDto[] MetadataSavers { get; set; } = null!;
+ public IReadOnlyList MetadataSavers { get; set; } = Array.Empty();
///
/// Gets or sets the metadata readers.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataReaders", Justification = "Imported from ServiceStack")]
- public LibraryOptionInfoDto[] MetadataReaders { get; set; } = null!;
+ public IReadOnlyList MetadataReaders { get; set; } = Array.Empty();
///
/// Gets or sets the subtitle fetchers.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SubtitleFetchers", Justification = "Imported from ServiceStack")]
- public LibraryOptionInfoDto[] SubtitleFetchers { get; set; } = null!;
+ public IReadOnlyList SubtitleFetchers { get; set; } = Array.Empty();
///
/// Gets or sets the type options.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "TypeOptions", Justification = "Imported from ServiceStack")]
- public LibraryTypeOptionsDto[] TypeOptions { get; set; } = null!;
+ public IReadOnlyList TypeOptions { get; set; } = Array.Empty();
}
}
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
///
/// Gets or sets the metadata fetchers.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataFetchers", Justification = "Imported from ServiceStack")]
- public LibraryOptionInfoDto[] MetadataFetchers { get; set; } = null!;
+ public IReadOnlyList MetadataFetchers { get; set; } = Array.Empty();
///
/// Gets or sets the image fetchers.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "ImageFetchers", Justification = "Imported from ServiceStack")]
- public LibraryOptionInfoDto[] ImageFetchers { get; set; } = null!;
+ public IReadOnlyList ImageFetchers { get; set; } = Array.Empty();
///
/// Gets or sets the supported image types.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SupportedImageTypes", Justification = "Imported from ServiceStack")]
- public ImageType[] SupportedImageTypes { get; set; } = null!;
+ public IReadOnlyList SupportedImageTypes { get; set; } = Array.Empty();
///
/// Gets or sets the default image options.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "DefaultImageOptions", Justification = "Imported from ServiceStack")]
- public ImageOption[] DefaultImageOptions { get; set; } = null!;
+ public IReadOnlyList DefaultImageOptions { get; set; } = Array.Empty();
}
}
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
///
/// Gets or sets list of mappings.
///
- [SuppressMessage("Microsoft.Performance", "CA1819:DontReturnArrays", MessageId = "Mappings", Justification = "Imported from ServiceStack")]
- public NameValuePair[] Mappings { get; set; } = null!;
+ public IReadOnlyList Mappings { get; set; } = Array.Empty();
///
/// Gets or sets provider name.
diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
index d7eaab30de..a47ae926c1 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
+++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
@@ -1,4 +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
{
@@ -10,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos
///
/// Gets or sets the channels to return guide information for.
///
- public string? ChannelIds { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList ChannelIds { get; set; } = Array.Empty();
///
/// Gets or sets optional. Filter by user id.
@@ -109,12 +116,14 @@ namespace Jellyfin.Api.Models.LiveTvDtos
///
/// Gets or sets the genres to return guide information for.
///
- public string? Genres { get; set; }
+ [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))]
+ public IReadOnlyList Genres { get; set; } = Array.Empty();
///
/// Gets or sets the genre ids to return guide information for.
///
- public string? GenreIds { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList GenreIds { get; set; } = Array.Empty();
///
/// Gets or sets include image information in output.
@@ -137,7 +146,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// Gets or sets the image types to include in the output.
/// Optional.
///
- public string? EnableImageTypes { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList EnableImageTypes { get; set; } = Array.Empty();
///
/// Gets or sets include user data.
@@ -161,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.
///
- public string? Fields { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList Fields { get; set; } = Array.Empty();
}
}
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
///
/// Gets or sets the device play protocols.
///
- [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 DirectPlayProtocols { get; set; } = Array.Empty();
}
}
diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs
new file mode 100644
index 0000000000..2cfdba507e
--- /dev/null
+++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs
@@ -0,0 +1,86 @@
+using System;
+using MediaBrowser.Model.Dlna;
+
+namespace Jellyfin.Api.Models.MediaInfoDtos
+{
+ ///
+ /// Plabyback info dto.
+ ///
+ public class PlaybackInfoDto
+ {
+ ///
+ /// Gets or sets the playback userId.
+ ///
+ public Guid? UserId { get; set; }
+
+ ///
+ /// Gets or sets the max streaming bitrate.
+ ///
+ public int? MaxStreamingBitrate { get; set; }
+
+ ///
+ /// Gets or sets the start time in ticks.
+ ///
+ public long? StartTimeTicks { get; set; }
+
+ ///
+ /// Gets or sets the audio stream index.
+ ///
+ public int? AudioStreamIndex { get; set; }
+
+ ///
+ /// Gets or sets the subtitle stream index.
+ ///
+ public int? SubtitleStreamIndex { get; set; }
+
+ ///
+ /// Gets or sets the max audio channels.
+ ///
+ public int? MaxAudioChannels { get; set; }
+
+ ///
+ /// Gets or sets the media source id.
+ ///
+ public string? MediaSourceId { get; set; }
+
+ ///
+ /// Gets or sets the live stream id.
+ ///
+ public string? LiveStreamId { get; set; }
+
+ ///
+ /// Gets or sets the device profile.
+ ///
+ public DeviceProfile? DeviceProfile { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to enable direct play.
+ ///
+ public bool? EnableDirectPlay { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to enable direct stream.
+ ///
+ public bool? EnableDirectStream { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to enable transcoding.
+ ///
+ public bool? EnableTranscoding { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to enable video stream copy.
+ ///
+ public bool? AllowVideoStreamCopy { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to allow audio stream copy.
+ ///
+ public bool? AllowAudioStreamCopy { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to auto open the live stream.
+ ///
+ public bool? AutoOpenLiveStream { get; set; }
+ }
+}
\ No newline at end of file
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.
///
/// Callback action.
- public void StartKillTimer(Action
/// Callback action.
/// Callback interval.
- public void StartKillTimer(Action callback, int intervalMs)
+ public void StartKillTimer(Action 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("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
///
/// Gets or sets item ids to add to the playlist.
///
- public string? Ids { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList Ids { get; set; } = Array.Empty();
///
/// Gets or sets the user id.
diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
new file mode 100644
index 0000000000..e58095536a
--- /dev/null
+++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Api.Models.SessionDtos
+{
+ ///
+ /// Client capabilities dto.
+ ///
+ public class ClientCapabilitiesDto
+ {
+ ///
+ /// Gets or sets the list of playable media types.
+ ///
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList PlayableMediaTypes { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the list of supported commands.
+ ///
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList SupportedCommands { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets a value indicating whether session supports media control.
+ ///
+ public bool SupportsMediaControl { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether session supports content uploading.
+ ///
+ public bool SupportsContentUploading { get; set; }
+
+ ///
+ /// Gets or sets the message callback url.
+ ///
+ public string? MessageCallbackUrl { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether session supports a persistent identifier.
+ ///
+ public bool SupportsPersistentIdentifier { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether session supports sync.
+ ///
+ public bool SupportsSync { get; set; }
+
+ ///
+ /// Gets or sets the device profile.
+ ///
+ public DeviceProfile? DeviceProfile { get; set; }
+
+ ///
+ /// Gets or sets the app store url.
+ ///
+ public string? AppStoreUrl { get; set; }
+
+ ///
+ /// Gets or sets the icon url.
+ ///
+ public string? IconUrl { get; set; }
+
+ ///
+ /// Convert the dto to the full model.
+ ///
+ /// The converted model.
+ public ClientCapabilities ToClientCapabilities()
+ {
+ return new ClientCapabilities
+ {
+ PlayableMediaTypes = PlayableMediaTypes,
+ SupportedCommands = SupportedCommands,
+ SupportsMediaControl = SupportsMediaControl,
+ SupportsContentUploading = SupportsContentUploading,
+ MessageCallbackUrl = MessageCallbackUrl,
+ SupportsPersistentIdentifier = SupportsPersistentIdentifier,
+ SupportsSync = SupportsSync,
+ DeviceProfile = DeviceProfile,
+ AppStoreUrl = AppStoreUrl,
+ IconUrl = IconUrl
+ };
+ }
+ }
+}
\ No newline at end of file
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
+{
+ ///
+ /// Upload subtitles dto.
+ ///
+ public class UploadSubtitleDto
+ {
+ ///
+ /// Gets or sets the subtitle language.
+ ///
+ [Required]
+ public string Language { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the subtitle format.
+ ///
+ [Required]
+ public string Format { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether the subtitle is forced.
+ ///
+ [Required]
+ public bool IsForced { get; set; }
+
+ ///
+ /// Gets or sets the subtitle data.
+ ///
+ [Required]
+ public string Data { get; set; } = string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs b/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs
deleted file mode 100644
index db55dc34b5..0000000000
--- a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using MediaBrowser.Model.Dlna;
-
-namespace Jellyfin.Api.Models.VideoDtos
-{
- ///
- /// Device profile dto.
- ///
- public class DeviceProfileDto
- {
- ///
- /// Gets or sets device profile.
- ///
- public DeviceProfile? DeviceProfile { get; set; }
- }
-}
diff --git a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs b/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
deleted file mode 100644
index 315b473293..0000000000
--- a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.ComponentModel;
-using System.Globalization;
-
-namespace Jellyfin.Api.TypeConverters
-{
- ///
- /// Custom datetime parser.
- ///
- public class DateTimeTypeConverter : TypeConverter
- {
- ///
- public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
- {
- if (sourceType == typeof(string))
- {
- return true;
- }
-
- return base.CanConvertFrom(context, sourceType);
- }
-
- ///
- public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
- {
- if (value is string dateString)
- {
- // Mark Played Item.
- if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
- {
- return dateTime;
- }
-
- // Get Activity Logs.
- if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime))
- {
- return dateTime;
- }
- }
-
- return base.ConvertFrom(context, culture, value);
- }
- }
-}
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 e)
+ private void OnEntryCreated(object? sender, GenericEventArgs 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 e)
+ private void OnTaskExecuting(object? sender, GenericEventArgs e)
{
SendData(true);
e.Argument.TaskProgress += OnTaskProgress;
}
- private void OnTaskProgress(object sender, GenericEventArgs e)
+ private void OnTaskProgress(object? sender, GenericEventArgs 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.
///
///
- /// 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.
///
public virtual CollectionItem Next { get; set; }
@@ -81,7 +81,7 @@ namespace Jellyfin.Data.Entities.Libraries
/// Gets or sets the previous item in the collection.
///
///
- /// 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.
///
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 PersonRoles { get; protected set; }
///
- /// Gets or sets a collection containing the generes for this item.
+ /// Gets or sets a collection containing the genres for this item.
///
public virtual ICollection 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 @@
- netstandard2.0;netstandard2.1
+ net5.0
false
true
true
@@ -41,8 +41,8 @@
-
-
+
+
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 @@
- netstandard2.1
+ net5.0
false
true
true
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.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs
new file mode 100644
index 0000000000..df420f48a2
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs
@@ -0,0 +1,221 @@
+#pragma warning disable CA1819 // Properties should not return arrays
+
+using System;
+using MediaBrowser.Model.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ ///
+ /// Defines the .
+ ///
+ public class NetworkConfiguration
+ {
+ ///
+ /// The default value for .
+ ///
+ public const int DefaultHttpPort = 8096;
+
+ ///
+ /// The default value for and .
+ ///
+ public const int DefaultHttpsPort = 8920;
+
+ private string _baseUrl = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether the server should force connections over HTTPS.
+ ///
+ public bool RequireHttps { get; set; }
+
+ ///
+ /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
+ ///
+ public string BaseUrl
+ {
+ get => _baseUrl;
+ set
+ {
+ // Normalize the start of the string
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ // If baseUrl is empty, set an empty prefix string
+ _baseUrl = string.Empty;
+ return;
+ }
+
+ if (value[0] != '/')
+ {
+ // If baseUrl was not configured with a leading slash, append one for consistency
+ value = "/" + value;
+ }
+
+ // Normalize the end of the string
+ if (value[^1] == '/')
+ {
+ // If baseUrl was configured with a trailing slash, remove it for consistency
+ value = value.Remove(value.Length - 1);
+ }
+
+ _baseUrl = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the public HTTPS port.
+ ///
+ /// The public HTTPS port.
+ public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
+
+ ///
+ /// Gets or sets the HTTP server port number.
+ ///
+ /// The HTTP server port number.
+ public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
+
+ ///
+ /// Gets or sets the HTTPS server port number.
+ ///
+ /// The HTTPS server port number.
+ public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
+
+ ///
+ /// Gets or sets a value indicating whether to use HTTPS.
+ ///
+ ///
+ /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
+ /// provided for and .
+ ///
+ public bool EnableHttps { get; set; }
+
+ ///
+ /// Gets or sets the public mapped port.
+ ///
+ /// The public mapped port.
+ public int PublicPort { get; set; } = DefaultHttpPort;
+
+ ///
+ /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
+ ///
+ public bool UPnPCreateHttpPortMap { get; set; }
+
+ ///
+ /// Gets or sets the UDPPortRange.
+ ///
+ public string UDPPortRange { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether gets or sets IPV6 capability.
+ ///
+ public bool EnableIPV6 { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether gets or sets IPV4 capability.
+ ///
+ public bool EnableIPV4 { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether detailed SSDP logs are sent to the console/log.
+ /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to have any effect.
+ ///
+ public bool EnableSSDPTracing { get; set; }
+
+ ///
+ /// Gets or sets the SSDPTracingFilter
+ /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log.
+ /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
+ ///
+ public string SSDPTracingFilter { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the number of times SSDP UDP messages are sent.
+ ///
+ public int UDPSendCount { get; set; } = 2;
+
+ ///
+ /// Gets or sets the delay between each groups of SSDP messages (in ms).
+ ///
+ public int UDPSendDelay { get; set; } = 100;
+
+ ///
+ /// Gets or sets a value indicating whether address names that match should be Ignore for the purposes of binding.
+ ///
+ public bool IgnoreVirtualInterfaces { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. .
+ ///
+ public string VirtualInterfaceNames { get; set; } = "vEthernet*";
+
+ ///
+ /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
+ ///
+ public int GatewayMonitorPeriod { get; set; } = 60;
+
+ ///
+ /// Gets a value indicating whether multi-socket binding is available.
+ ///
+ public bool EnableMultiSocketBinding { get; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network.
+ /// Depending on the address range implemented ULA ranges might not be used.
+ ///
+ public bool TrustAllIP6Interfaces { get; set; }
+
+ ///
+ /// Gets or sets the ports that HDHomerun uses.
+ ///
+ public string HDHomerunPortRange { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the PublishedServerUriBySubnet
+ /// Gets or sets PublishedServerUri to advertise for specific subnets.
+ ///
+ public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
+ ///
+ public bool AutoDiscoveryTracing { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether Autodiscovery is enabled.
+ ///
+ public bool AutoDiscovery { get; set; } = true;
+
+ ///
+ /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with .
+ ///
+ public string[] RemoteIPFilter { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets a value indicating whether contains a blacklist or a whitelist. Default is a whitelist.
+ ///
+ public bool IsRemoteIPFilterBlacklist { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to enable automatic port forwarding.
+ ///
+ public bool EnableUPnP { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether access outside of the LAN is permitted.
+ ///
+ public bool EnableRemoteAccess { get; set; } = true;
+
+ ///
+ /// Gets or sets the subnets that are deemed to make up the LAN.
+ ///
+ public string[] LocalNetworkSubnets { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
+ ///
+ public string[] LocalNetworkAddresses { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the known proxies.
+ ///
+ public string[] KnownProxies { get; set; } = Array.Empty();
+ }
+}
diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs
new file mode 100644
index 0000000000..e77b17ba92
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs
@@ -0,0 +1,21 @@
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ ///
+ /// Defines the .
+ ///
+ public static class NetworkConfigurationExtensions
+ {
+ ///
+ /// Retrieves the network configuration.
+ ///
+ /// The .
+ /// The .
+ public static NetworkConfiguration GetNetworkConfiguration(this IConfigurationManager config)
+ {
+ return config.GetConfiguration("network");
+ }
+ }
+}
diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs
new file mode 100644
index 0000000000..ac0485d871
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ ///
+ /// Defines the .
+ ///
+ public class NetworkConfigurationFactory : IConfigurationFactory
+ {
+ ///
+ /// The GetConfigurations.
+ ///
+ /// The .
+ public IEnumerable GetConfigurations()
+ {
+ return new[]
+ {
+ new ConfigurationStore
+ {
+ Key = "network",
+ ConfigurationType = typeof(NetworkConfiguration)
+ }
+ };
+ }
+ }
+}
diff --git a/Jellyfin.Networking/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj
new file mode 100644
index 0000000000..cbda74361f
--- /dev/null
+++ b/Jellyfin.Networking/Jellyfin.Networking.csproj
@@ -0,0 +1,30 @@
+
+
+ net5.0
+ false
+ true
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ../jellyfin.ruleset
+
+
+
+
+
+
+
diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs
new file mode 100644
index 0000000000..1a5614b7b0
--- /dev/null
+++ b/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -0,0 +1,1324 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Networking.Manager
+{
+ ///
+ /// Class to take care of network interface management.
+ /// Note: The normal collection methods and properties will not work with Collection{IPObject}. .
+ ///
+ public class NetworkManager : INetworkManager, IDisposable
+ {
+ ///
+ /// Contains the description of the interface along with its index.
+ ///
+ private readonly Dictionary _interfaceNames;
+
+ ///
+ /// Threading lock for network properties.
+ ///
+ private readonly object _intLock = new object();
+
+ ///
+ /// List of all interface addresses and masks.
+ ///
+ private readonly Collection _interfaceAddresses;
+
+ ///
+ /// List of all interface MAC addresses.
+ ///
+ private readonly List _macAddresses;
+
+ private readonly ILogger _logger;
+
+ private readonly IConfigurationManager _configurationManager;
+
+ private readonly object _eventFireLock;
+
+ ///
+ /// Holds the bind address overrides.
+ ///
+ private readonly Dictionary _publishedServerUrls;
+
+ ///
+ /// Used to stop "event-racing conditions".
+ ///
+ private bool _eventfire;
+
+ ///
+ /// Unfiltered user defined LAN subnets. ()
+ /// or internal interface network subnets if undefined by user.
+ ///
+ private Collection _lanSubnets;
+
+ ///
+ /// User defined list of subnets to excluded from the LAN.
+ ///
+ private Collection _excludedSubnets;
+
+ ///
+ /// List of interface addresses to bind the WS.
+ ///
+ private Collection _bindAddresses;
+
+ ///
+ /// List of interface addresses to exclude from bind.
+ ///
+ private Collection _bindExclusions;
+
+ ///
+ /// Caches list of all internal filtered interface addresses and masks.
+ ///
+ private Collection _internalInterfaces;
+
+ ///
+ /// Flag set when no custom LAN has been defined in the configuration.
+ ///
+ private bool _usingPrivateAddresses;
+
+ ///
+ /// True if this object is disposed.
+ ///
+ private bool _disposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// IServerConfigurationManager instance.
+ /// Logger to use for messages.
+#pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this.
+ public NetworkManager(IConfigurationManager configurationManager, ILogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager));
+
+ _interfaceAddresses = new Collection();
+ _macAddresses = new List();
+ _interfaceNames = new Dictionary();
+ _publishedServerUrls = new Dictionary();
+ _eventFireLock = new object();
+
+ UpdateSettings(_configurationManager.GetNetworkConfiguration());
+
+ NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
+ NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
+
+ _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated;
+ }
+#pragma warning restore CS8618 // Non-nullable field is uninitialized.
+
+ ///
+ /// Event triggered on network changes.
+ ///
+ public event EventHandler? NetworkChanged;
+
+ ///
+ /// Gets or sets a value indicating whether testing is taking place.
+ ///
+ public static string MockNetworkSettings { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether IP6 is enabled.
+ ///
+ public bool IsIP6Enabled { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether IP4 is enabled.
+ ///
+ public bool IsIP4Enabled { get; set; }
+
+ ///
+ public Collection RemoteAddressFilter { get; private set; }
+
+ ///
+ /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
+ ///
+ public bool TrustAllIP6Interfaces { get; internal set; }
+
+ ///
+ /// Gets the Published server override list.
+ ///
+ public Dictionary PublishedServerUrls => _publishedServerUrls;
+
+ ///
+ /// Creates a new network collection.
+ ///
+ /// Items to assign the collection, or null.
+ /// The collection created.
+ public static Collection CreateCollection(IEnumerable? source = null)
+ {
+ var result = new Collection();
+ if (source != null)
+ {
+ foreach (var item in source)
+ {
+ result.AddItem(item);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ public IReadOnlyCollection GetMacAddresses()
+ {
+ // Populated in construction - so always has values.
+ return _macAddresses;
+ }
+
+ ///
+ public bool IsGatewayInterface(IPObject? addressObj)
+ {
+ var address = addressObj?.Address ?? IPAddress.None;
+ return _internalInterfaces.Any(i => i.Address.Equals(address) && i.Tag < 0);
+ }
+
+ ///
+ public bool IsGatewayInterface(IPAddress? addressObj)
+ {
+ return _internalInterfaces.Any(i => i.Address.Equals(addressObj ?? IPAddress.None) && i.Tag < 0);
+ }
+
+ ///
+ public Collection GetLoopbacks()
+ {
+ Collection nc = new Collection();
+ if (IsIP4Enabled)
+ {
+ nc.AddItem(IPAddress.Loopback);
+ }
+
+ if (IsIP6Enabled)
+ {
+ nc.AddItem(IPAddress.IPv6Loopback);
+ }
+
+ return nc;
+ }
+
+ ///
+ public bool IsExcluded(IPAddress ip)
+ {
+ return _excludedSubnets.ContainsAddress(ip);
+ }
+
+ ///
+ public bool IsExcluded(EndPoint ip)
+ {
+ return ip != null && IsExcluded(((IPEndPoint)ip).Address);
+ }
+
+ ///
+ public Collection CreateIPCollection(string[] values, bool negated = false)
+ {
+ Collection col = new Collection();
+ if (values == null)
+ {
+ return col;
+ }
+
+ for (int a = 0; a < values.Length; a++)
+ {
+ string v = values[a].Trim();
+
+ try
+ {
+ if (v.StartsWith('!'))
+ {
+ if (negated)
+ {
+ AddToCollection(col, v[1..]);
+ }
+ }
+ else if (!negated)
+ {
+ AddToCollection(col, v);
+ }
+ }
+ catch (ArgumentException e)
+ {
+ _logger.LogWarning(e, "Ignoring LAN value {value}.", v);
+ }
+ }
+
+ return col;
+ }
+
+ ///
+ public Collection GetAllBindInterfaces(bool individualInterfaces = false)
+ {
+ int count = _bindAddresses.Count;
+
+ if (count == 0)
+ {
+ if (_bindExclusions.Count > 0)
+ {
+ // Return all the interfaces except the ones specifically excluded.
+ return _interfaceAddresses.Exclude(_bindExclusions);
+ }
+
+ if (individualInterfaces)
+ {
+ return new Collection(_interfaceAddresses);
+ }
+
+ // No bind address and no exclusions, so listen on all interfaces.
+ Collection result = new Collection();
+
+ if (IsIP4Enabled)
+ {
+ result.AddItem(IPAddress.Any);
+ }
+
+ if (IsIP6Enabled)
+ {
+ result.AddItem(IPAddress.IPv6Any);
+ }
+
+ return result;
+ }
+
+ // Remove any excluded bind interfaces.
+ return _bindAddresses.Exclude(_bindExclusions);
+ }
+
+ ///
+ public string GetBindInterface(string source, out int? port)
+ {
+ if (!string.IsNullOrEmpty(source) && IPHost.TryParse(source, out IPHost host))
+ {
+ return GetBindInterface(host, out port);
+ }
+
+ return GetBindInterface(IPHost.None, out port);
+ }
+
+ ///
+ public string GetBindInterface(IPAddress source, out int? port)
+ {
+ return GetBindInterface(new IPNetAddress(source), out port);
+ }
+
+ ///
+ public string GetBindInterface(HttpRequest source, out int? port)
+ {
+ string result;
+
+ if (source != null && IPHost.TryParse(source.Host.Host, out IPHost host))
+ {
+ result = GetBindInterface(host, out port);
+ port ??= source.Host.Port;
+ }
+ else
+ {
+ result = GetBindInterface(IPNetAddress.None, out port);
+ port ??= source?.Host.Port;
+ }
+
+ return result;
+ }
+
+ ///
+ public string GetBindInterface(IPObject source, out int? port)
+ {
+ port = null;
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ // Do we have a source?
+ bool haveSource = !source.Address.Equals(IPAddress.None);
+ bool isExternal = false;
+
+ if (haveSource)
+ {
+ if (!IsIP6Enabled && source.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ _logger.LogWarning("IPv6 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected.");
+ }
+
+ if (!IsIP4Enabled && source.AddressFamily == AddressFamily.InterNetwork)
+ {
+ _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected.");
+ }
+
+ isExternal = !IsInLocalNetwork(source);
+
+ if (MatchesPublishedServerUrl(source, isExternal, out string res, out port))
+ {
+ _logger.LogInformation("{Source}: Using BindAddress {Address}:{Port}", source, res, port);
+ return res;
+ }
+ }
+
+ _logger.LogDebug("GetBindInterface: Source: {HaveSource}, External: {IsExternal}:", haveSource, isExternal);
+
+ // No preference given, so move on to bind addresses.
+ if (MatchesBindInterface(source, isExternal, out string result))
+ {
+ return result;
+ }
+
+ if (isExternal && MatchesExternalInterface(source, out result))
+ {
+ return result;
+ }
+
+ // Get the first LAN interface address that isn't a loopback.
+ var interfaces = CreateCollection(_interfaceAddresses
+ .Exclude(_bindExclusions)
+ .Where(p => IsInLocalNetwork(p))
+ .OrderBy(p => p.Tag));
+
+ if (interfaces.Count > 0)
+ {
+ if (haveSource)
+ {
+ // Does the request originate in one of the interface subnets?
+ // (For systems with multiple internal network cards, and multiple subnets)
+ foreach (var intf in interfaces)
+ {
+ if (intf.Contains(source))
+ {
+ result = FormatIP6String(intf.Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Has source, matched best internal interface on range. {Result}", source, result);
+ return result;
+ }
+ }
+ }
+
+ result = FormatIP6String(interfaces.First().Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Matched first internal interface. {Result}", source, result);
+ return result;
+ }
+
+ // There isn't any others, so we'll use the loopback.
+ result = IsIP6Enabled ? "::" : "127.0.0.1";
+ _logger.LogWarning("{Source}: GetBindInterface: Loopback {Result} returned.", source, result);
+ return result;
+ }
+
+ ///
+ public Collection GetInternalBindAddresses()
+ {
+ int count = _bindAddresses.Count;
+
+ if (count == 0)
+ {
+ if (_bindExclusions.Count > 0)
+ {
+ // Return all the internal interfaces except the ones excluded.
+ return CreateCollection(_internalInterfaces.Where(p => !_bindExclusions.ContainsAddress(p)));
+ }
+
+ // No bind address, so return all internal interfaces.
+ return CreateCollection(_internalInterfaces.Where(p => !p.IsLoopback()));
+ }
+
+ return new Collection(_bindAddresses);
+ }
+
+ ///
+ public bool IsInLocalNetwork(IPObject address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.Equals(IPAddress.None))
+ {
+ return false;
+ }
+
+ // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+ if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return true;
+ }
+
+ // As private addresses can be redefined by Configuration.LocalNetworkAddresses
+ return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address);
+ }
+
+ ///
+ public bool IsInLocalNetwork(string address)
+ {
+ if (IPHost.TryParse(address, out IPHost ep))
+ {
+ return _lanSubnets.ContainsAddress(ep) && !_excludedSubnets.ContainsAddress(ep);
+ }
+
+ return false;
+ }
+
+ ///
+ public bool IsInLocalNetwork(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+ if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return true;
+ }
+
+ // As private addresses can be redefined by Configuration.LocalNetworkAddresses
+ return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address);
+ }
+
+ ///
+ public bool IsPrivateAddressRange(IPObject address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+ if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return true;
+ }
+ else
+ {
+ return address.IsPrivateAddressRange();
+ }
+ }
+
+ ///
+ public bool IsExcludedInterface(IPAddress address)
+ {
+ return _bindExclusions.ContainsAddress(address);
+ }
+
+ ///
+ public Collection GetFilteredLANSubnets(Collection? filter = null)
+ {
+ if (filter == null)
+ {
+ return _lanSubnets.Exclude(_excludedSubnets).AsNetworks();
+ }
+
+ return _lanSubnets.Exclude(filter);
+ }
+
+ ///
+ public bool IsValidInterfaceAddress(IPAddress address)
+ {
+ return _interfaceAddresses.ContainsAddress(address);
+ }
+
+ ///
+ public bool TryParseInterface(string token, out Collection? result)
+ {
+ result = null;
+ if (string.IsNullOrEmpty(token))
+ {
+ return false;
+ }
+
+ if (_interfaceNames != null && _interfaceNames.TryGetValue(token.ToLower(CultureInfo.InvariantCulture), out int index))
+ {
+ result = new Collection();
+
+ _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token);
+
+ // Replace interface tags with the interface IP's.
+ foreach (IPNetAddress iface in _interfaceAddresses)
+ {
+ if (Math.Abs(iface.Tag) == index
+ && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork)
+ || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6)))
+ {
+ result.AddItem(iface);
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Reloads all settings and re-initialises the instance.
+ ///
+ /// The to use.
+ public void UpdateSettings(object configuration)
+ {
+ NetworkConfiguration config = (NetworkConfiguration)configuration ?? throw new ArgumentNullException(nameof(configuration));
+
+ IsIP4Enabled = Socket.OSSupportsIPv4 && config.EnableIPV4;
+ IsIP6Enabled = Socket.OSSupportsIPv6 && config.EnableIPV6;
+
+ if (!IsIP6Enabled && !IsIP4Enabled)
+ {
+ _logger.LogError("IPv4 and IPv6 cannot both be disabled.");
+ IsIP4Enabled = true;
+ }
+
+ TrustAllIP6Interfaces = config.TrustAllIP6Interfaces;
+ // UdpHelper.EnableMultiSocketBinding = config.EnableMultiSocketBinding;
+
+ if (string.IsNullOrEmpty(MockNetworkSettings))
+ {
+ InitialiseInterfaces();
+ }
+ else // Used in testing only.
+ {
+ // Format is ,,: . Set index to -ve to simulate a gateway.
+ var interfaceList = MockNetworkSettings.Split(':');
+ foreach (var details in interfaceList)
+ {
+ var parts = details.Split(',');
+ var address = IPNetAddress.Parse(parts[0]);
+ var index = int.Parse(parts[1], CultureInfo.InvariantCulture);
+ address.Tag = index;
+ _interfaceAddresses.AddItem(address);
+ _interfaceNames.Add(parts[2], Math.Abs(index));
+ }
+ }
+
+ InitialiseLAN(config);
+ InitialiseBind(config);
+ InitialiseRemote(config);
+ InitialiseOverrides(config);
+ }
+
+ ///
+ /// Protected implementation of Dispose pattern.
+ ///
+ /// True to dispose the managed state.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ _configurationManager.NamedConfigurationUpdated -= ConfigurationUpdated;
+ NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
+ NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged;
+ }
+
+ _disposed = true;
+ }
+ }
+
+ ///
+ /// Tries to identify the string and return an object of that class.
+ ///
+ /// String to parse.
+ /// IPObject to return.
+ /// true if the value parsed successfully, false otherwise.
+ private static bool TryParse(string addr, out IPObject result)
+ {
+ if (!string.IsNullOrEmpty(addr))
+ {
+ // Is it an IP address
+ if (IPNetAddress.TryParse(addr, out IPNetAddress nw))
+ {
+ result = nw;
+ return true;
+ }
+
+ if (IPHost.TryParse(addr, out IPHost h))
+ {
+ result = h;
+ return true;
+ }
+ }
+
+ result = IPNetAddress.None;
+ return false;
+ }
+
+ ///
+ /// Converts an IPAddress into a string.
+ /// Ipv6 addresses are returned in [ ], with their scope removed.
+ ///
+ /// Address to convert.
+ /// URI safe conversion of the address.
+ private static string FormatIP6String(IPAddress address)
+ {
+ var str = address.ToString();
+ if (address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ int i = str.IndexOf("%", StringComparison.OrdinalIgnoreCase);
+
+ if (i != -1)
+ {
+ str = str.Substring(0, i);
+ }
+
+ return $"[{str}]";
+ }
+
+ return str;
+ }
+
+ private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt)
+ {
+ if (evt.Key.Equals("network", StringComparison.Ordinal))
+ {
+ UpdateSettings((NetworkConfiguration)evt.NewConfiguration);
+ }
+ }
+
+ ///
+ /// Checks the string to see if it matches any interface names.
+ ///
+ /// String to check.
+ /// Interface index number.
+ /// true if an interface name matches the token, False otherwise.
+ private bool IsInterface(string token, out int index)
+ {
+ index = -1;
+
+ // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1.
+ // Null check required here for automated testing.
+ if (_interfaceNames != null && token.Length > 1)
+ {
+ bool partial = token[^1] == '*';
+ if (partial)
+ {
+ token = token[0..^1];
+ }
+
+ foreach ((string interfc, int interfcIndex) in _interfaceNames)
+ {
+ if ((!partial && string.Equals(interfc, token, StringComparison.OrdinalIgnoreCase))
+ || (partial && interfc.StartsWith(token, true, CultureInfo.InvariantCulture)))
+ {
+ index = interfcIndex;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Parses a string and adds it into the collection, replacing any interface references.
+ ///
+ /// Collection.
+ /// String value to parse.
+ private void AddToCollection(Collection col, string token)
+ {
+ // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1.
+ // Null check required here for automated testing.
+ if (IsInterface(token, out int index))
+ {
+ _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token);
+
+ // Replace interface tags with the interface IP's.
+ foreach (IPNetAddress iface in _interfaceAddresses)
+ {
+ if (Math.Abs(iface.Tag) == index
+ && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork)
+ || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6)))
+ {
+ col.AddItem(iface);
+ }
+ }
+ }
+ else if (TryParse(token, out IPObject obj))
+ {
+ // Expand if the ip address is "any".
+ if ((obj.Address.Equals(IPAddress.Any) && IsIP4Enabled)
+ || (obj.Address.Equals(IPAddress.IPv6Any) && IsIP6Enabled))
+ {
+ foreach (IPNetAddress iface in _interfaceAddresses)
+ {
+ if (obj.AddressFamily == iface.AddressFamily)
+ {
+ col.AddItem(iface);
+ }
+ }
+ }
+ else if (!IsIP6Enabled)
+ {
+ // Remove IP6 addresses from multi-homed IPHosts.
+ obj.Remove(AddressFamily.InterNetworkV6);
+ if (!obj.IsIP6())
+ {
+ col.AddItem(obj);
+ }
+ }
+ else if (!IsIP4Enabled)
+ {
+ // Remove IP4 addresses from multi-homed IPHosts.
+ obj.Remove(AddressFamily.InterNetwork);
+ if (obj.IsIP6())
+ {
+ col.AddItem(obj);
+ }
+ }
+ else
+ {
+ col.AddItem(obj);
+ }
+ }
+ else
+ {
+ _logger.LogDebug("Invalid or unknown network {Token}.", token);
+ }
+ }
+
+ ///
+ /// Handler for network change events.
+ ///
+ /// Sender.
+ /// A containing network availability information.
+ private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
+ {
+ _logger.LogDebug("Network availability changed.");
+ OnNetworkChanged();
+ }
+
+ ///
+ /// Handler for network change events.
+ ///
+ /// Sender.
+ /// An .
+ private void OnNetworkAddressChanged(object? sender, EventArgs e)
+ {
+ _logger.LogDebug("Network address change detected.");
+ OnNetworkChanged();
+ }
+
+ ///
+ /// Async task that waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession.
+ ///
+ /// A representing the asynchronous operation.
+ private async Task OnNetworkChangeAsync()
+ {
+ try
+ {
+ await Task.Delay(2000).ConfigureAwait(false);
+ InitialiseInterfaces();
+ // Recalculate LAN caches.
+ InitialiseLAN(_configurationManager.GetNetworkConfiguration());
+
+ NetworkChanged?.Invoke(this, EventArgs.Empty);
+ }
+ finally
+ {
+ _eventfire = false;
+ }
+ }
+
+ ///
+ /// Triggers our event, and re-loads interface information.
+ ///
+ private void OnNetworkChanged()
+ {
+ lock (_eventFireLock)
+ {
+ if (!_eventfire)
+ {
+ _logger.LogDebug("Network Address Change Event.");
+ // As network events tend to fire one after the other only fire once every second.
+ _eventfire = true;
+ OnNetworkChangeAsync().GetAwaiter().GetResult();
+ }
+ }
+ }
+
+ ///
+ /// Parses the user defined overrides into the dictionary object.
+ /// Overrides are the equivalent of localised publishedServerUrl, enabling
+ /// different addresses to be advertised over different subnets.
+ /// format is subnet=ipaddress|host|uri
+ /// when subnet = 0.0.0.0, any external address matches.
+ ///
+ private void InitialiseOverrides(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ _publishedServerUrls.Clear();
+ string[] overrides = config.PublishedServerUriBySubnet;
+ if (overrides == null)
+ {
+ return;
+ }
+
+ foreach (var entry in overrides)
+ {
+ var parts = entry.Split('=');
+ if (parts.Length != 2)
+ {
+ _logger.LogError("Unable to parse bind override: {Entry}", entry);
+ }
+ else
+ {
+ var replacement = parts[1].Trim();
+ if (string.Equals(parts[0], "all", StringComparison.OrdinalIgnoreCase))
+ {
+ _publishedServerUrls[new IPNetAddress(IPAddress.Broadcast)] = replacement;
+ }
+ else if (string.Equals(parts[0], "external", StringComparison.OrdinalIgnoreCase))
+ {
+ _publishedServerUrls[new IPNetAddress(IPAddress.Any)] = replacement;
+ }
+ else if (TryParseInterface(parts[0], out Collection? addresses) && addresses != null)
+ {
+ foreach (IPNetAddress na in addresses)
+ {
+ _publishedServerUrls[na] = replacement;
+ }
+ }
+ else if (IPNetAddress.TryParse(parts[0], out IPNetAddress result))
+ {
+ _publishedServerUrls[result] = replacement;
+ }
+ else
+ {
+ _logger.LogError("Unable to parse bind ip address. {Parts}", parts[1]);
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Initialises the network bind addresses.
+ ///
+ private void InitialiseBind(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ string[] lanAddresses = config.LocalNetworkAddresses;
+
+ // TODO: remove when bug fixed: https://github.com/jellyfin/jellyfin-web/issues/1334
+
+ if (lanAddresses.Length == 1 && lanAddresses[0].IndexOf(',', StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ lanAddresses = lanAddresses[0].Split(',');
+ }
+
+ // TODO: end fix: https://github.com/jellyfin/jellyfin-web/issues/1334
+
+ // Add virtual machine interface names to the list of bind exclusions, so that they are auto-excluded.
+ if (config.IgnoreVirtualInterfaces)
+ {
+ var virtualInterfaceNames = config.VirtualInterfaceNames.Split(',');
+ var newList = new string[lanAddresses.Length + virtualInterfaceNames.Length];
+ Array.Copy(lanAddresses, newList, lanAddresses.Length);
+ Array.Copy(virtualInterfaceNames, 0, newList, lanAddresses.Length, virtualInterfaceNames.Length);
+ lanAddresses = newList;
+ }
+
+ // Read and parse bind addresses and exclusions, removing ones that don't exist.
+ _bindAddresses = CreateIPCollection(lanAddresses).Union(_interfaceAddresses);
+ _bindExclusions = CreateIPCollection(lanAddresses, true).Union(_interfaceAddresses);
+ _logger.LogInformation("Using bind addresses: {0}", _bindAddresses.AsString());
+ _logger.LogInformation("Using bind exclusions: {0}", _bindExclusions.AsString());
+ }
+ }
+
+ ///
+ /// Initialises the remote address values.
+ ///
+ private void InitialiseRemote(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ RemoteAddressFilter = CreateIPCollection(config.RemoteIPFilter);
+ }
+ }
+
+ ///
+ /// Initialises internal LAN cache settings.
+ ///
+ private void InitialiseLAN(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ _logger.LogDebug("Refreshing LAN information.");
+
+ // Get configuration options.
+ string[] subnets = config.LocalNetworkSubnets;
+
+ // Create lists from user settings.
+
+ _lanSubnets = CreateIPCollection(subnets);
+ _excludedSubnets = CreateIPCollection(subnets, true).AsNetworks();
+
+ // If no LAN addresses are specified - all private subnets are deemed to be the LAN
+ _usingPrivateAddresses = _lanSubnets.Count == 0;
+
+ // NOTE: The order of the commands generating the collection in this statement matters.
+ // Altering the order will cause the collections to be created incorrectly.
+ if (_usingPrivateAddresses)
+ {
+ _logger.LogDebug("Using LAN interface addresses as user provided no LAN details.");
+ // Internal interfaces must be private and not excluded.
+ _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsPrivateAddressRange(i) && !_excludedSubnets.ContainsAddress(i)));
+
+ // Subnets are the same as the calculated internal interface.
+ _lanSubnets = new Collection();
+
+ // We must listen on loopback for LiveTV to function regardless of the settings.
+ if (IsIP6Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP6Loopback);
+ _lanSubnets.AddItem(IPNetAddress.Parse("fc00::/7")); // ULA
+ _lanSubnets.AddItem(IPNetAddress.Parse("fe80::/10")); // Site local
+ }
+
+ if (IsIP4Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP4Loopback);
+ _lanSubnets.AddItem(IPNetAddress.Parse("10.0.0.0/8"));
+ _lanSubnets.AddItem(IPNetAddress.Parse("172.16.0.0/12"));
+ _lanSubnets.AddItem(IPNetAddress.Parse("192.168.0.0/16"));
+ }
+ }
+ else
+ {
+ // We must listen on loopback for LiveTV to function regardless of the settings.
+ if (IsIP6Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP6Loopback);
+ }
+
+ if (IsIP4Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP4Loopback);
+ }
+
+ // Internal interfaces must be private, not excluded and part of the LocalNetworkSubnet.
+ _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsInLocalNetwork(i)));
+ }
+
+ _logger.LogInformation("Defined LAN addresses : {0}", _lanSubnets.AsString());
+ _logger.LogInformation("Defined LAN exclusions : {0}", _excludedSubnets.AsString());
+ _logger.LogInformation("Using LAN addresses: {0}", _lanSubnets.Exclude(_excludedSubnets).AsNetworks().AsString());
+ }
+ }
+
+ ///
+ /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state.
+ /// Generate a list of all active mac addresses that aren't loopback addresses.
+ ///
+ private void InitialiseInterfaces()
+ {
+ lock (_intLock)
+ {
+ _logger.LogDebug("Refreshing interfaces.");
+
+ _interfaceNames.Clear();
+ _interfaceAddresses.Clear();
+ _macAddresses.Clear();
+
+ try
+ {
+ IEnumerable nics = NetworkInterface.GetAllNetworkInterfaces()
+ .Where(i => i.SupportsMulticast && i.OperationalStatus == OperationalStatus.Up);
+
+ foreach (NetworkInterface adapter in nics)
+ {
+ try
+ {
+ IPInterfaceProperties ipProperties = adapter.GetIPProperties();
+ PhysicalAddress mac = adapter.GetPhysicalAddress();
+
+ // populate mac list
+ if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && mac != null && mac != PhysicalAddress.None)
+ {
+ _macAddresses.Add(mac);
+ }
+
+ // populate interface address list
+ foreach (UnicastIPAddressInformation info in ipProperties.UnicastAddresses)
+ {
+ if (IsIP4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ IPNetAddress nw = new IPNetAddress(info.Address, IPObject.MaskToCidr(info.IPv4Mask))
+ {
+ // Keep the number of gateways on this interface, along with its index.
+ Tag = ipProperties.GetIPv4Properties().Index
+ };
+
+ int tag = nw.Tag;
+ if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback())
+ {
+ // -ve Tags signify the interface has a gateway.
+ nw.Tag *= -1;
+ }
+
+ _interfaceAddresses.AddItem(nw);
+
+ // Store interface name so we can use the name in Collections.
+ _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag;
+ _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag;
+ }
+ else if (IsIP6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ IPNetAddress nw = new IPNetAddress(info.Address, (byte)info.PrefixLength)
+ {
+ // Keep the number of gateways on this interface, along with its index.
+ Tag = ipProperties.GetIPv6Properties().Index
+ };
+
+ int tag = nw.Tag;
+ if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback())
+ {
+ // -ve Tags signify the interface has a gateway.
+ nw.Tag *= -1;
+ }
+
+ _interfaceAddresses.AddItem(nw);
+
+ // Store interface name so we can use the name in Collections.
+ _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag;
+ _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag;
+ }
+ }
+ }
+#pragma warning disable CA1031 // Do not catch general exception types
+ catch (Exception ex)
+ {
+ // Ignore error, and attempt to continue.
+ _logger.LogError(ex, "Error encountered parsing interfaces.");
+ }
+#pragma warning restore CA1031 // Do not catch general exception types
+ }
+
+ _logger.LogDebug("Discovered {0} interfaces.", _interfaceAddresses.Count);
+ _logger.LogDebug("Interfaces addresses : {0}", _interfaceAddresses.AsString());
+
+ // If for some reason we don't have an interface info, resolve our DNS name.
+ if (_interfaceAddresses.Count == 0)
+ {
+ _logger.LogError("No interfaces information available. Resolving DNS name.");
+ IPHost host = new IPHost(Dns.GetHostName());
+ foreach (var a in host.GetAddresses())
+ {
+ _interfaceAddresses.AddItem(a);
+ }
+
+ if (_interfaceAddresses.Count == 0)
+ {
+ _logger.LogWarning("No interfaces information available. Using loopback.");
+ // Last ditch attempt - use loopback address.
+ _interfaceAddresses.AddItem(IPNetAddress.IP4Loopback);
+ if (IsIP6Enabled)
+ {
+ _interfaceAddresses.AddItem(IPNetAddress.IP6Loopback);
+ }
+ }
+ }
+ }
+ catch (NetworkInformationException ex)
+ {
+ _logger.LogError(ex, "Error in InitialiseInterfaces.");
+ }
+ }
+ }
+
+ ///
+ /// Attempts to match the source against a user defined bind interface.
+ ///
+ /// IP source address to use.
+ /// True if the source is in the external subnet.
+ /// The published server url that matches the source address.
+ /// The resultant port, if one exists.
+ /// true if a match is found, false otherwise.
+ private bool MatchesPublishedServerUrl(IPObject source, bool isInExternalSubnet, out string bindPreference, out int? port)
+ {
+ bindPreference = string.Empty;
+ port = null;
+
+ // Check for user override.
+ foreach (var addr in _publishedServerUrls)
+ {
+ // Remaining. Match anything.
+ if (addr.Key.Address.Equals(IPAddress.Broadcast))
+ {
+ bindPreference = addr.Value;
+ break;
+ }
+ else if ((addr.Key.Address.Equals(IPAddress.Any) || addr.Key.Address.Equals(IPAddress.IPv6Any)) && isInExternalSubnet)
+ {
+ // External.
+ bindPreference = addr.Value;
+ break;
+ }
+ else if (addr.Key.Contains(source))
+ {
+ // Match ip address.
+ bindPreference = addr.Value;
+ break;
+ }
+ }
+
+ if (string.IsNullOrEmpty(bindPreference))
+ {
+ return false;
+ }
+
+ // Has it got a port defined?
+ var parts = bindPreference.Split(':');
+ if (parts.Length > 1)
+ {
+ if (int.TryParse(parts[1], out int p))
+ {
+ bindPreference = parts[0];
+ port = p;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Attempts to match the source against a user defined bind interface.
+ ///
+ /// IP source address to use.
+ /// True if the source is in the external subnet.
+ /// The result, if a match is found.
+ /// true if a match is found, false otherwise.
+ private bool MatchesBindInterface(IPObject source, bool isInExternalSubnet, out string result)
+ {
+ result = string.Empty;
+ var addresses = _bindAddresses.Exclude(_bindExclusions);
+
+ int count = addresses.Count;
+ if (count == 1 && (_bindAddresses[0].Equals(IPAddress.Any) || _bindAddresses[0].Equals(IPAddress.IPv6Any)))
+ {
+ // Ignore IPAny addresses.
+ count = 0;
+ }
+
+ if (count != 0)
+ {
+ // Check to see if any of the bind interfaces are in the same subnet.
+
+ IPAddress? defaultGateway = null;
+ IPAddress? bindAddress = null;
+
+ if (isInExternalSubnet)
+ {
+ // Find all external bind addresses. Store the default gateway, but check to see if there is a better match first.
+ foreach (var addr in addresses.OrderBy(p => p.Tag))
+ {
+ if (defaultGateway == null && !IsInLocalNetwork(addr))
+ {
+ defaultGateway = addr.Address;
+ }
+
+ if (bindAddress == null && addr.Contains(source))
+ {
+ bindAddress = addr.Address;
+ }
+
+ if (defaultGateway != null && bindAddress != null)
+ {
+ break;
+ }
+ }
+ }
+ else
+ {
+ // Look for the best internal address.
+ bindAddress = addresses
+ .Where(p => IsInLocalNetwork(p) && (p.Contains(source) || p.Equals(IPAddress.None)))
+ .OrderBy(p => p.Tag)
+ .FirstOrDefault()?.Address;
+ }
+
+ if (bindAddress != null)
+ {
+ result = FormatIP6String(bindAddress);
+ _logger.LogDebug("{Source}: GetBindInterface: Has source, found a match bind interface subnets. {Result}", source, result);
+ return true;
+ }
+
+ if (isInExternalSubnet && defaultGateway != null)
+ {
+ result = FormatIP6String(defaultGateway);
+ _logger.LogDebug("{Source}: GetBindInterface: Using first user defined external interface. {Result}", source, result);
+ return true;
+ }
+
+ result = FormatIP6String(addresses[0].Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Selected first user defined interface. {Result}", source, result);
+
+ if (isInExternalSubnet)
+ {
+ _logger.LogWarning("{Source}: External request received, however, only an internal interface bind found.", source);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Attempts to match the source against an external interface.
+ ///
+ /// IP source address to use.
+ /// The result, if a match is found.
+ /// true if a match is found, false otherwise.
+ private bool MatchesExternalInterface(IPObject source, out string result)
+ {
+ result = string.Empty;
+ // Get the first WAN interface address that isn't a loopback.
+ var extResult = _interfaceAddresses
+ .Exclude(_bindExclusions)
+ .Where(p => !IsInLocalNetwork(p))
+ .OrderBy(p => p.Tag);
+
+ if (extResult.Any())
+ {
+ // Does the request originate in one of the interface subnets?
+ // (For systems with multiple internal network cards, and multiple subnets)
+ foreach (var intf in extResult)
+ {
+ if (!IsInLocalNetwork(intf) && intf.Contains(source))
+ {
+ result = FormatIP6String(intf.Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Selected best external on interface on range. {Result}", source, result);
+ return true;
+ }
+ }
+
+ result = FormatIP6String(extResult.First().Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Selected first external interface. {Result}", source, result);
+ return true;
+ }
+
+ // Have to return something, so return an internal address
+
+ _logger.LogWarning("{Source}: External request received, however, no WAN interface found.", source);
+ return false;
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
index 5926abfe0d..7bde4f35be 100644
--- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
+++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
@@ -72,6 +72,18 @@ namespace Jellyfin.Server.Implementations.Activity
};
}
+ ///
+ public async Task CleanAsync(DateTime startDate)
+ {
+ await using var dbContext = _provider.CreateContext();
+ var entries = dbContext.ActivityLogs
+ .AsQueryable()
+ .Where(entry => entry.DateCreated <= startDate);
+
+ dbContext.RemoveRange(entries);
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
+
private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
{
return new ActivityLogEntry
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
index 51a882c143..a0bad29e9b 100644
--- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
@@ -59,6 +59,12 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
var user = eventArgs.Users[0];
+ var notificationType = GetPlaybackStoppedNotificationType(item.MediaType);
+ if (notificationType == null)
+ {
+ return;
+ }
+
await _activityManager.CreateAsync(new ActivityLog(
string.Format(
CultureInfo.InvariantCulture,
@@ -66,7 +72,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
user.Username,
GetItemName(item),
eventArgs.DeviceName),
- GetPlaybackStoppedNotificationType(item.MediaType),
+ notificationType,
user.Id))
.ConfigureAwait(false);
}
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 @@
- netcoreapp3.1
+ net5.0
false
true
true
@@ -24,12 +24,12 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
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(str).ConfigureAwait(false);
+ spr = await JsonSerializer.DeserializeAsync(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 437833aa38..f684d151d6 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -2,6 +2,7 @@
#pragma warning disable CA1307
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -48,6 +49,8 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider;
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
+ private readonly IDictionary _users;
+
///
/// Initializes a new instance of the class.
///
@@ -81,38 +84,28 @@ namespace Jellyfin.Server.Implementations.Users
_invalidAuthProvider = _authenticationProviders.OfType().First();
_defaultAuthenticationProvider = _authenticationProviders.OfType().First();
_defaultPasswordResetProvider = _passwordResetProviders.OfType().First();
+
+ _users = new ConcurrentDictionary();
+ using var dbContext = _dbProvider.CreateContext();
+ foreach (var user in dbContext.Users
+ .Include(user => user.Permissions)
+ .Include(user => user.Preferences)
+ .Include(user => user.AccessSchedules)
+ .Include(user => user.ProfileImage)
+ .AsEnumerable())
+ {
+ _users.Add(user.Id, user);
+ }
}
///
public event EventHandler>? OnUserUpdated;
///
- public IEnumerable Users
- {
- get
- {
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .Include(user => user.Permissions)
- .Include(user => user.Preferences)
- .Include(user => user.AccessSchedules)
- .Include(user => user.ProfileImage)
- .ToList();
- }
- }
+ public IEnumerable Users => _users.Values;
///
- public IEnumerable UsersIds
- {
- get
- {
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .AsQueryable()
- .Select(user => user.Id)
- .ToList();
- }
- }
+ public IEnumerable UsersIds => _users.Keys;
///
public User? GetUserById(Guid id)
@@ -122,13 +115,8 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Guid can't be empty", nameof(id));
}
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .Include(user => user.Permissions)
- .Include(user => user.Preferences)
- .Include(user => user.AccessSchedules)
- .Include(user => user.ProfileImage)
- .FirstOrDefault(user => user.Id == id);
+ _users.TryGetValue(id, out var user);
+ return user;
}
///
@@ -139,14 +127,7 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Invalid username", nameof(name));
}
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .Include(user => user.Permissions)
- .Include(user => user.Preferences)
- .Include(user => user.AccessSchedules)
- .Include(user => user.ProfileImage)
- .AsEnumerable()
- .FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
+ return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
}
///
@@ -177,7 +158,6 @@ namespace Jellyfin.Server.Implementations.Users
user.Username = newName;
await UpdateUserAsync(user).ConfigureAwait(false);
-
OnUserUpdated?.Invoke(this, new GenericEventArgs(user));
}
@@ -186,6 +166,7 @@ namespace Jellyfin.Server.Implementations.Users
{
using var dbContext = _dbProvider.CreateContext();
dbContext.Users.Update(user);
+ _users[user.Id] = user;
dbContext.SaveChanges();
}
@@ -194,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);
}
@@ -205,13 +186,17 @@ namespace Jellyfin.Server.Implementations.Users
? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false)
: 0;
- return new User(
+ var user = new User(
name,
_defaultAuthenticationProvider.GetType().FullName,
_defaultPasswordResetProvider.GetType().FullName)
{
InternalId = max + 1
};
+
+ _users.Add(user.Id, user);
+
+ return user;
}
///
@@ -237,28 +222,12 @@ namespace Jellyfin.Server.Implementations.Users
///
public void DeleteUser(Guid userId)
{
- using var dbContext = _dbProvider.CreateContext();
- var user = dbContext.Users
- .Include(u => u.Permissions)
- .Include(u => u.Preferences)
- .Include(u => u.AccessSchedules)
- .Include(u => u.ProfileImage)
- .FirstOrDefault(u => u.Id == userId);
- if (user == null)
+ if (!_users.TryGetValue(userId, out var user))
{
throw new ResourceNotFoundException(nameof(userId));
}
- if (dbContext.Users.Find(user.Id) == null)
- {
- throw new ArgumentException(string.Format(
- CultureInfo.InvariantCulture,
- "The user cannot be deleted because there is no user with the Name {0} and Id {1}.",
- user.Username,
- user.Id));
- }
-
- if (dbContext.Users.Count() == 1)
+ if (_users.Count == 1)
{
throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture,
@@ -277,6 +246,8 @@ namespace Jellyfin.Server.Implementations.Users
nameof(userId));
}
+ using var dbContext = _dbProvider.CreateContext();
+
// Clear all entities related to the user from the database.
if (user.ProfileImage != null)
{
@@ -288,6 +259,7 @@ namespace Jellyfin.Server.Implementations.Users
dbContext.RemoveRange(user.AccessSchedules);
dbContext.Users.Remove(user);
dbContext.SaveChanges();
+ _users.Remove(userId);
_eventManager.Publish(new UserDeletedEventArgs(user));
}
@@ -460,11 +432,9 @@ namespace Jellyfin.Server.Implementations.Users
// the authentication provider might have created it
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
- if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy)
+ if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user != null)
{
- UpdatePolicy(user.Id, hasNewUserPolicy.GetNewUserPolicy());
-
- await UpdateUserAsync(user).ConfigureAwait(false);
+ await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
}
}
}
@@ -589,9 +559,7 @@ namespace Jellyfin.Server.Implementations.Users
public async Task InitializeAsync()
{
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
- await using var dbContext = _dbProvider.CreateContext();
-
- if (await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false))
+ if (_users.Any())
{
return;
}
@@ -604,6 +572,7 @@ namespace Jellyfin.Server.Implementations.Users
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
+ await using var dbContext = _dbProvider.CreateContext();
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
newUser.SetPermission(PermissionKind.IsAdministrator, true);
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
@@ -644,9 +613,9 @@ namespace Jellyfin.Server.Implementations.Users
}
///
- public void UpdateConfiguration(Guid userId, UserConfiguration config)
+ public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
{
- using var dbContext = _dbProvider.CreateContext();
+ await using var dbContext = _dbProvider.CreateContext();
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
@@ -673,13 +642,14 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user);
- dbContext.SaveChanges();
+ _users[user.Id] = user;
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
///
- public void UpdatePolicy(Guid userId, UserPolicy policy)
+ public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
- using var dbContext = _dbProvider.CreateContext();
+ await using var dbContext = _dbProvider.CreateContext();
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
@@ -744,15 +714,18 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
- dbContext.SaveChanges();
+ _users[user.Id] = user;
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
///
- public void ClearProfileImage(User user)
+ public async Task ClearProfileImageAsync(User user)
{
- using var dbContext = _dbProvider.CreateContext();
+ await using var dbContext = _dbProvider.CreateContext();
dbContext.Remove(user.ProfileImage);
- dbContext.SaveChanges();
+ 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 8d569a779a..78f596a5c9 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -4,6 +4,8 @@ using System.IO;
using System.Reflection;
using Emby.Drawing;
using Emby.Server.Implementations;
+using Emby.Server.Implementations.Session;
+using Jellyfin.Api.WebSocketListeners;
using Jellyfin.Drawing.Skia;
using Jellyfin.Server.Implementations;
using Jellyfin.Server.Implementations.Activity;
@@ -11,9 +13,11 @@ 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;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Activity;
using MediaBrowser.Model.IO;
using Microsoft.EntityFrameworkCore;
@@ -34,21 +38,18 @@ namespace Jellyfin.Server
/// The to be used by the .
/// The to be used by the .
/// The to be used by the .
- /// The to be used by the .
/// The to be used by the .
public CoreAppHost(
IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
IStartupOptions options,
IFileSystem fileSystem,
- INetworkManager networkManager,
IServiceCollection collection)
: base(
applicationPaths,
loggerFactory,
options,
fileSystem,
- networkManager,
collection)
{
}
@@ -73,6 +74,7 @@ namespace Jellyfin.Server
options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"));
ServiceCollection.AddEventServices();
+ ServiceCollection.AddSingleton();
ServiceCollection.AddSingleton();
ServiceCollection.AddSingleton();
@@ -80,6 +82,14 @@ namespace Jellyfin.Server
ServiceCollection.AddSingleton();
ServiceCollection.AddSingleton();
+ ServiceCollection.AddScoped();
+ ServiceCollection.AddScoped();
+ ServiceCollection.AddScoped();
+ ServiceCollection.AddScoped();
+
+ // TODO fix circular dependency on IWebSocketManager
+ ServiceCollection.AddScoped(serviceProvider => new Lazy>(serviceProvider.GetRequiredService>));
+
base.RegisterServices();
}
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index c7fbfa4d02..6bf6f383fc 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Jellyfin.Networking.Configuration;
using Jellyfin.Server.Middleware;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
@@ -24,8 +25,8 @@ namespace Jellyfin.Server.Extensions
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
// specifying the Swagger JSON endpoint.
- var baseUrl = serverConfigurationManager.Configuration.BaseUrl.Trim('/');
- var apiDocBaseUrl = serverConfigurationManager.Configuration.BaseUrl;
+ var baseUrl = serverConfigurationManager.GetNetworkConfiguration().BaseUrl.Trim('/');
+ var apiDocBaseUrl = serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
if (!string.IsNullOrEmpty(baseUrl))
{
baseUrl += '/';
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index d7b9da5c2f..6cb88c9f73 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;
@@ -28,6 +29,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;
@@ -168,7 +171,7 @@ namespace Jellyfin.Server.Extensions
opts.OutputFormatters.Add(new CssOutputFormatter());
opts.OutputFormatters.Add(new XmlOutputFormatter());
- opts.ModelBinderProviders.Insert(0, new CommaDelimitedArrayModelBinderProvider());
+ opts.ModelBinderProviders.Insert(0, new NullableEnumModelBinderProvider());
})
// Clear app parts to avoid other assemblies being picked up
@@ -212,7 +215,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
+ {
+ {
+ "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
/// Write stream task.
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
///
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 7de34f4160..03d06fdff3 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -8,7 +8,7 @@
jellyfin
Exe
- netcoreapp3.1
+ net5.0
false
true
true
@@ -38,19 +38,19 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
index 9316737bdf..c23da2fd63 100644
--- a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
+++ b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
@@ -42,7 +43,7 @@ namespace Jellyfin.Server.Middleware
public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager)
{
var localPath = httpContext.Request.Path.ToString();
- var baseUrlPrefix = serverConfigurationManager.Configuration.BaseUrl;
+ var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
if (string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase)
|| string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
diff --git a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
index 4bda8f2737..525cd9ffe2 100644
--- a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
+++ b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
@@ -1,5 +1,6 @@
-using System.Linq;
+using System.Net;
using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
@@ -34,40 +35,40 @@ namespace Jellyfin.Server.Middleware
{
if (httpContext.IsLocal())
{
+ // Running locally.
await _next(httpContext).ConfigureAwait(false);
return;
}
- var remoteIp = httpContext.GetNormalizedRemoteIp();
+ var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
- if (serverConfigurationManager.Configuration.EnableRemoteAccess)
+ if (serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess)
{
- var addressFilter = serverConfigurationManager.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
+ // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
+ // If left blank, all remote addresses will be allowed.
+ var remoteAddressFilter = networkManager.RemoteAddressFilter;
- if (addressFilter.Length > 0 && !networkManager.IsInLocalNetwork(remoteIp))
+ if (remoteAddressFilter.Count > 0 && !networkManager.IsInLocalNetwork(remoteIp))
{
- if (serverConfigurationManager.Configuration.IsRemoteIPFilterBlacklist)
+ // remoteAddressFilter is a whitelist or blacklist.
+ bool isListed = remoteAddressFilter.ContainsAddress(remoteIp);
+ if (!serverConfigurationManager.GetNetworkConfiguration().IsRemoteIPFilterBlacklist)
{
- if (networkManager.IsAddressInSubnets(remoteIp, addressFilter))
- {
- return;
- }
+ // Black list, so flip over.
+ isListed = !isListed;
}
- else
+
+ if (!isListed)
{
- if (!networkManager.IsAddressInSubnets(remoteIp, addressFilter))
- {
- return;
- }
+ // If your name isn't on the list, you arn't coming in.
+ return;
}
}
}
- else
+ else if (!networkManager.IsInLocalNetwork(remoteIp))
{
- if (!networkManager.IsInLocalNetwork(remoteIp))
- {
- return;
- }
+ // Remote not enabled. So everyone should be LAN.
+ return;
}
await _next(httpContext).ConfigureAwait(false);
diff --git a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
index 9d795145aa..8065054a1e 100644
--- a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
+++ b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
@@ -1,6 +1,9 @@
using System;
using System.Linq;
+using System.Net;
using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Http;
@@ -32,45 +35,14 @@ namespace Jellyfin.Server.Middleware
/// The async task.
public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
{
- var currentHost = httpContext.Request.Host.ToString();
- var hosts = serverConfigurationManager
- .Configuration
- .LocalNetworkAddresses
- .Select(NormalizeConfiguredLocalAddress)
- .ToList();
+ var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
- if (hosts.Count == 0)
+ if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess)
{
- await _next(httpContext).ConfigureAwait(false);
return;
}
- currentHost ??= string.Empty;
-
- if (networkManager.IsInPrivateAddressSpace(currentHost))
- {
- hosts.Add("localhost");
- hosts.Add("127.0.0.1");
-
- if (hosts.All(i => currentHost.IndexOf(i, StringComparison.OrdinalIgnoreCase) == -1))
- {
- return;
- }
- }
-
await _next(httpContext).ConfigureAwait(false);
}
-
- private static string NormalizeConfiguredLocalAddress(string address)
- {
- var add = address.AsSpan().Trim('/');
- int index = add.IndexOf('/');
- if (index != -1)
- {
- add = add.Slice(index + 1);
- }
-
- return add.TrimStart('/').ToString();
- }
}
}
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)
};
///
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;
///
/// Initializes a new instance of the class.
@@ -33,11 +35,17 @@ namespace Jellyfin.Server.Migrations.Routines
/// The logger.
/// The server application paths.
/// The database provider.
- public MigrateDisplayPreferencesDb(ILogger logger, IServerApplicationPaths paths, JellyfinDbProvider provider)
+ /// The user manager.
+ public MigrateDisplayPreferencesDb(
+ ILogger 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(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null,
ShowBackdrop = dto.ShowBackdrop,
diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
index b281b5cc09..394f14d63c 100644
--- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
@@ -46,4 +46,4 @@ namespace Jellyfin.Server.Migrations.Routines
}
}
}
-}
\ No newline at end of file
+}
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
+{
+ ///
+ /// Removes the old 'RemoveDownloadImagesInAdvance' from library options.
+ ///
+ internal class RemoveDownloadImagesInAdvance : IMigrationRoutine
+ {
+ private readonly ILogger _logger;
+ private readonly ILibraryManager _libraryManager;
+
+ public RemoveDownloadImagesInAdvance(ILogger logger, ILibraryManager libraryManager)
+ {
+ _logger = logger;
+ _libraryManager = libraryManager;
+ }
+
+ ///
+ public Guid Id => Guid.Parse("{A81F75E0-8F43-416F-A5E8-516CCAB4D8CC}");
+
+ ///
+ public string Name => "RemoveDownloadImagesInAdvance";
+
+ ///
+ public bool PerformOnNewInstall => false;
+
+ ///
+ 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/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index c933d679f4..a1a7a30534 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -12,9 +12,9 @@ using System.Threading.Tasks;
using CommandLine;
using Emby.Server.Implementations;
using Emby.Server.Implementations.IO;
-using Emby.Server.Implementations.Networking;
using Jellyfin.Api.Controllers;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Extensions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
@@ -106,6 +106,10 @@ namespace Jellyfin.Server
// $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager
Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath);
+ // Enable cl-va P010 interop for tonemapping on Intel VAAPI
+ Environment.SetEnvironmentVariable("NEOReadDebugKeys", "1");
+ Environment.SetEnvironmentVariable("EnableExtendedVaFormats", "1");
+
await InitLoggingConfigFile(appPaths).ConfigureAwait(false);
// Create an instance of the application configuration to use for application startup
@@ -161,7 +165,6 @@ namespace Jellyfin.Server
_loggerFactory,
options,
new ManagedFileSystem(_loggerFactory.CreateLogger(), appPaths),
- new NetworkManager(_loggerFactory.CreateLogger()),
serviceCollection);
try
@@ -272,75 +275,36 @@ namespace Jellyfin.Server
return builder
.UseKestrel((builderContext, options) =>
{
- var addresses = appHost.ServerConfigurationManager
- .Configuration
- .LocalNetworkAddresses
- .Select(x => appHost.NormalizeConfiguredLocalAddress(x))
- .Where(i => i != null)
- .ToHashSet();
- if (addresses.Count > 0 && !addresses.Contains(IPAddress.Any))
- {
- if (!addresses.Contains(IPAddress.Loopback))
- {
- // we must listen on loopback for LiveTV to function regardless of the settings
- addresses.Add(IPAddress.Loopback);
- }
+ var addresses = appHost.NetManager.GetAllBindInterfaces();
- foreach (var address in addresses)
- {
- _logger.LogInformation("Kestrel listening on {IpAddress}", address);
- options.Listen(address, appHost.HttpPort);
- if (appHost.ListenWithHttps)
- {
- options.Listen(address, appHost.HttpsPort, listenOptions =>
- {
- listenOptions.UseHttps(appHost.Certificate);
- listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
- });
- }
- else if (builderContext.HostingEnvironment.IsDevelopment())
- {
- try
- {
- options.Listen(address, appHost.HttpsPort, listenOptions =>
- {
- listenOptions.UseHttps();
- listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
- });
- }
- catch (InvalidOperationException ex)
- {
- _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
- }
- }
- }
- }
- else
+ bool flagged = false;
+ foreach (IPObject netAdd in addresses)
{
- _logger.LogInformation("Kestrel listening on all interfaces");
- options.ListenAnyIP(appHost.HttpPort);
-
+ _logger.LogInformation("Kestrel listening on {0}", netAdd);
+ options.Listen(netAdd.Address, appHost.HttpPort);
if (appHost.ListenWithHttps)
{
- options.ListenAnyIP(appHost.HttpsPort, listenOptions =>
- {
- listenOptions.UseHttps(appHost.Certificate);
- listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
- });
+ options.Listen(
+ netAdd.Address,
+ appHost.HttpsPort,
+ listenOptions => listenOptions.UseHttps(appHost.Certificate));
}
else if (builderContext.HostingEnvironment.IsDevelopment())
{
try
{
- options.ListenAnyIP(appHost.HttpsPort, listenOptions =>
- {
- listenOptions.UseHttps();
- listenOptions.Protocols = HttpProtocols.Http1AndHttp2;
- });
+ options.Listen(
+ netAdd.Address,
+ appHost.HttpsPort,
+ listenOptions => listenOptions.UseHttps());
}
- catch (InvalidOperationException ex)
+ catch (InvalidOperationException)
{
- _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
+ if (!flagged)
+ {
+ _logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
+ flagged = true;
+ }
}
}
}
@@ -378,7 +342,7 @@ namespace Jellyfin.Server
.ConfigureServices(services =>
{
// Merge the external ServiceCollection into ASP.NET DI
- services.TryAdd(serviceCollection);
+ services.Add(serviceCollection);
})
.UseStartup();
}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 62ffe174cd..4ffcb940ff 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,8 +1,6 @@
-using System;
-using System.ComponentModel;
using System.Net.Http.Headers;
using System.Net.Mime;
-using Jellyfin.Api.TypeConverters;
+using Jellyfin.Networking.Configuration;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Implementations;
using Jellyfin.Server.Middleware;
@@ -54,7 +52,7 @@ namespace Jellyfin.Server
{
options.HttpsPort = _serverApplicationHost.HttpsPort;
});
- services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.Configuration.KnownProxies);
+ services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration().KnownProxies);
services.AddJellyfinApiSwagger();
@@ -66,10 +64,16 @@ namespace Jellyfin.Server
var productHeader = new ProductInfoHeaderValue(
_serverApplicationHost.Name.Replace(' ', '-'),
_serverApplicationHost.ApplicationVersionString);
+ var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
+ var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
+ var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
services
.AddHttpClient(NamedClient.Default, c =>
{
c.DefaultRequestHeaders.UserAgent.Add(productHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
})
.ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
@@ -77,6 +81,8 @@ namespace Jellyfin.Server
{
c.DefaultRequestHeaders.UserAgent.Add(productHeader);
c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})"));
+ c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
})
.ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
@@ -98,7 +104,7 @@ namespace Jellyfin.Server
app.UseBaseUrlRedirection();
// Wrap rest of configuration so everything only listens on BaseUrl.
- app.Map(_serverConfigurationManager.Configuration.BaseUrl, mainApp =>
+ app.Map(_serverConfigurationManager.GetNetworkConfiguration().BaseUrl, mainApp =>
{
if (env.IsDevelopment())
{
@@ -116,7 +122,7 @@ namespace Jellyfin.Server
mainApp.UseCors();
- if (_serverConfigurationManager.Configuration.RequireHttps
+ if (_serverConfigurationManager.GetNetworkConfiguration().RequireHttps
&& _serverApplicationHost.ListenWithHttps)
{
mainApp.UseHttpsRedirection();
@@ -164,9 +170,6 @@ namespace Jellyfin.Server
endpoints.MapHealthChecks("/health");
});
});
-
- // Add type descriptor for legacy datetime parsing.
- TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter)));
}
}
}
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
/// The new configuration.
void ReplaceConfiguration(BaseApplicationConfiguration newConfiguration);
+ ///
+ /// Manually pre-loads a factory so that it is available pre system initialisation.
+ ///
+ /// Class to register.
+ void RegisterConfiguration()
+ where T : IConfigurationFactory;
+
///
/// Gets the configuration.
///
diff --git a/MediaBrowser.Common/Cryptography/PasswordHash.cs b/MediaBrowser.Common/Cryptography/PasswordHash.cs
index 3e12536ec7..3e2eae1c8e 100644
--- a/MediaBrowser.Common/Cryptography/PasswordHash.cs
+++ b/MediaBrowser.Common/Cryptography/PasswordHash.cs
@@ -101,13 +101,13 @@ namespace MediaBrowser.Common.Cryptography
// Check if the string also contains a salt
if (splitted.Length - index == 2)
{
- salt = Hex.Decode(splitted[index++]);
- hash = Hex.Decode(splitted[index++]);
+ salt = Convert.FromHexString(splitted[index++]);
+ hash = Convert.FromHexString(splitted[index++]);
}
else
{
salt = Array.Empty();
- hash = Hex.Decode(splitted[index++]);
+ hash = Convert.FromHexString(splitted[index++]);
}
return new PasswordHash(id, hash, salt, parameters);
@@ -144,11 +144,11 @@ namespace MediaBrowser.Common.Cryptography
if (_salt.Length != 0)
{
str.Append('$')
- .Append(Hex.Encode(_salt, false));
+ .Append(Convert.ToHexString(_salt));
}
return str.Append('$')
- .Append(Hex.Encode(_hash, false)).ToString();
+ .Append(Convert.ToHexString(_hash)).ToString();
}
}
}
diff --git a/MediaBrowser.Common/Hex.cs b/MediaBrowser.Common/Hex.cs
deleted file mode 100644
index 559109f74f..0000000000
--- a/MediaBrowser.Common/Hex.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using System;
-using System.Diagnostics.CodeAnalysis;
-
-namespace MediaBrowser.Common
-{
- ///
- /// Encoding and decoding hex strings.
- ///
- public static class Hex
- {
- internal const string HexCharsLower = "0123456789abcdef";
- internal const string HexCharsUpper = "0123456789ABCDEF";
-
- internal const int LastHexSymbol = 0x66; // 102: f
-
- ///
- /// Gets a map from an ASCII char to its hex value shifted,
- /// e.g. b -> 11. 0xFF means it's not a hex symbol.
- ///
- internal static ReadOnlySpan HexLookup => new byte[]
- {
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
- };
-
- ///
- /// Encodes each element of the specified bytes as its hexadecimal string representation.
- ///
- /// An array of bytes.
- /// true to use lowercase hexadecimal characters; otherwise false.
- /// bytes as a hex string.
- public static string Encode(ReadOnlySpan bytes, bool lowercase = true)
- {
- var hexChars = lowercase ? HexCharsLower : HexCharsUpper;
-
- // TODO: use string.Create when it's supports spans
- // Ref: https://github.com/dotnet/corefx/issues/29120
- char[] s = new char[bytes.Length * 2];
- int j = 0;
- for (int i = 0; i < bytes.Length; i++)
- {
- s[j++] = hexChars[bytes[i] >> 4];
- s[j++] = hexChars[bytes[i] & 0x0f];
- }
-
- return new string(s);
- }
-
- ///
- /// Decodes a hex string into bytes.
- ///
- /// The .
- /// The decoded bytes.
- public static byte[] Decode(ReadOnlySpan str)
- {
- if (str.Length == 0)
- {
- return Array.Empty();
- }
-
- var unHex = HexLookup;
-
- int byteLen = str.Length / 2;
- byte[] bytes = new byte[byteLen];
- int i = 0;
- for (int j = 0; j < byteLen; j++)
- {
- byte a;
- byte b;
- if (str[i] > LastHexSymbol
- || (a = unHex[str[i++]]) == 0xFF
- || str[i] > LastHexSymbol
- || (b = unHex[str[i++]]) == 0xFF)
- {
- ThrowArgumentException(nameof(str));
- break; // Unreachable
- }
-
- bytes[j] = (byte)((a * 16) | b);
- }
-
- return bytes;
- }
-
- [DoesNotReturn]
- private static void ThrowArgumentException(string paramName)
- => throw new ArgumentException("Character is not a hex symbol.", paramName);
- }
-}
diff --git a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
new file mode 100644
index 0000000000..a259cb7bcb
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
@@ -0,0 +1,75 @@
+using System;
+using System.ComponentModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ ///
+ /// Convert comma delimited string to array of type.
+ ///
+ /// Type to convert to.
+ public class JsonCommaDelimitedArrayConverter : JsonConverter
+ {
+ private readonly TypeConverter _typeConverter;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public JsonCommaDelimitedArrayConverter()
+ {
+ _typeConverter = TypeDescriptor.GetConverter(typeof(T));
+ }
+
+ ///
+ 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();
+ }
+
+ 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(ref reader, options);
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value, options);
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs
new file mode 100644
index 0000000000..24ed3ea19e
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverterFactory.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ ///
+ /// Json comma delimited array converter factory.
+ ///
+ ///
+ /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
+ ///
+ public class JsonCommaDelimitedArrayConverterFactory : JsonConverterFactory
+ {
+ ///
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return true;
+ }
+
+ ///
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
+ return (JsonConverter)Activator.CreateInstance(typeof(JsonCommaDelimitedArrayConverter<>).MakeGenericType(structType));
+ }
+ }
+}
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
+{
+ ///
+ /// Returns an ISO8601 formatted datetime.
+ ///
+ ///
+ /// Used for legacy compatibility.
+ ///
+ public class JsonDateTimeIso8601Converter : JsonConverter
+ {
+ ///
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => reader.GetDateTime();
+
+ ///
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture));
+ }
+}
diff --git a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs
index d35a761f35..54325a02b0 100644
--- a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs
+++ b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs
@@ -15,6 +15,15 @@ namespace MediaBrowser.Common.Json.Converters
///
public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options)
- => writer.WriteStringValue(value);
+ {
+ if (value == Guid.Empty)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value);
+ }
+ }
}
}
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
+{
+ ///
+ /// Convert Pipe delimited string to array of type.
+ ///
+ /// Type to convert to.
+ public class JsonPipeDelimitedArrayConverter : JsonConverter
+ {
+ private readonly TypeConverter _typeConverter;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public JsonPipeDelimitedArrayConverter()
+ {
+ _typeConverter = TypeDescriptor.GetConverter(typeof(T));
+ }
+
+ ///
+ 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();
+ }
+
+ 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(ref reader, options);
+ }
+
+ ///
+ 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
+{
+ ///
+ /// Json Pipe delimited array converter factory.
+ ///
+ ///
+ /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
+ ///
+ public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory
+ {
+ ///
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return true;
+ }
+
+ ///
+ 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/Converters/JsonVersionConverter.cs b/MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs
new file mode 100644
index 0000000000..37e6f64e34
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs
@@ -0,0 +1,20 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ ///
+ /// Converts a Version object or value to/from JSON.
+ ///
+ public class JsonVersionConverter : JsonConverter
+ {
+ ///
+ public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => new Version(reader.GetString());
+
+ ///
+ public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs
index 6605ae9624..1aaf4a43bb 100644
--- a/MediaBrowser.Common/Json/JsonDefaults.cs
+++ b/MediaBrowser.Common/Json/JsonDefaults.cs
@@ -40,8 +40,10 @@ namespace MediaBrowser.Common.Json
};
options.Converters.Add(new JsonGuidConverter());
+ options.Converters.Add(new JsonVersionConverter());
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 @@
-
+
@@ -18,9 +18,9 @@
-
-
-
+
+
+
@@ -29,7 +29,7 @@
- netstandard2.1
+ net5.0
false
true
true
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
///
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..b6c390d239 100644
--- a/MediaBrowser.Common/Net/INetworkManager.cs
+++ b/MediaBrowser.Common/Net/INetworkManager.cs
@@ -1,97 +1,233 @@
-#pragma warning disable CS1591
-
+#nullable enable
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Net;
using System.Net.NetworkInformation;
+using MediaBrowser.Common.Net;
+using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Common.Net
{
+ ///
+ /// Interface for the NetworkManager class.
+ ///
public interface INetworkManager
{
+ ///
+ /// Event triggered on network changes.
+ ///
event EventHandler NetworkChanged;
///
- /// Gets or sets a function to return the list of user defined LAN addresses.
+ /// Gets the published server urls list.
///
- Func LocalSubnetsFn { get; set; }
+ Dictionary PublishedServerUrls { get; }
///
- /// Gets a random port TCP number that is currently available.
+ /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
///
- /// System.Int32.
- int GetRandomUnusedTcpPort();
+ bool TrustAllIP6Interfaces { get; }
///
- /// Gets a random port UDP number that is currently available.
+ /// Gets the remote address filter.
///
- /// System.Int32.
- int GetRandomUnusedUdpPort();
+ Collection RemoteAddressFilter { get; }
///
- /// Returns the MAC Address from first Network Card in Computer.
+ /// Gets or sets a value indicating whether iP6 is enabled.
///
- /// The MAC Address.
- List GetMacAddresses();
+ bool IsIP6Enabled { get; set; }
///
- /// Determines whether [is in private address space] [the specified endpoint].
+ /// Gets or sets a value indicating whether iP4 is enabled.
///
- /// The endpoint.
- /// true if [is in private address space] [the specified endpoint]; otherwise, false.
- bool IsInPrivateAddressSpace(string endpoint);
+ bool IsIP4Enabled { get; set; }
///
- /// Determines whether [is in private address space 10.x.x.x] [the specified endpoint] and exists in the subnets returned by GetSubnets().
+ /// Calculates the list of interfaces to use for Kestrel.
///
- /// The endpoint.
- /// true if [is in private address space 10.x.x.x] [the specified endpoint]; otherwise, false.
- bool IsInPrivateAddressSpaceAndLocalSubnet(string endpoint);
+ /// A Collection{IPObject} object containing all the interfaces to bind.
+ /// If all the interfaces are specified, and none are excluded, it returns zero items
+ /// to represent any address.
+ /// When false, return or for all interfaces.
+ Collection GetAllBindInterfaces(bool individualInterfaces = false);
///
- /// Determines whether [is in local network] [the specified endpoint].
+ /// Returns a collection containing the loopback interfaces.
///
- /// The endpoint.
- /// true if [is in local network] [the specified endpoint]; otherwise, false.
- bool IsInLocalNetwork(string endpoint);
+ /// Collection{IPObject}.
+ Collection GetLoopbacks();
///
- /// Investigates an caches a list of interface addresses, excluding local link and LAN excluded addresses.
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// The priority of selection is as follows:-
+ ///
+ /// The value contained in the startup parameter --published-server-url.
+ ///
+ /// If the user specified custom subnet overrides, the correct subnet for the source address.
+ ///
+ /// If the user specified bind interfaces to use:-
+ /// The bind interface that contains the source subnet.
+ /// The first bind interface specified that suits best first the source's endpoint. eg. external or internal.
+ ///
+ /// If the source is from a public subnet address range and the user hasn't specified any bind addresses:-
+ /// The first public interface that isn't a loopback and contains the source subnet.
+ /// The first public interface that isn't a loopback. Priority is given to interfaces with gateways.
+ /// An internal interface if there are no public ip addresses.
+ ///
+ /// If the source is from a private subnet address range and the user hasn't specified any bind addresses:-
+ /// The first private interface that contains the source subnet.
+ /// The first private interface that isn't a loopback. Priority is given to interfaces with gateways.
+ ///
+ /// If no interfaces meet any of these criteria, then a loopback address is returned.
+ ///
+ /// Interface that have been specifically excluded from binding are not used in any of the calculations.
///
- /// The list of ipaddresses.
- IPAddress[] GetLocalIpAddresses();
+ /// Source of the request.
+ /// Optional port returned, if it's part of an override.
+ /// IP Address to use, or loopback address if all else fails.
+ string GetBindInterface(IPObject source, out int? port);
///
- /// Checks if the given address falls within the ranges given in [subnets]. The addresses in subnets can be hosts or subnets in the CIDR format.
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// (See .
///
- /// The address to check.
- /// If true, check against addresses in the LAN settings surrounded by brackets ([]).
- /// trueif the address is in at least one of the given subnets, false otherwise.
- bool IsAddressInSubnets(string addressString, string[] subnets);
+ /// Source of the request.
+ /// Optional port returned, if it's part of an override.
+ /// IP Address to use, or loopback address if all else fails.
+ string GetBindInterface(HttpRequest source, out int? port);
///
- /// Returns true if address is in the LAN list in the config file.
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// (See .
///
- /// The address to check.
- /// If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.
- /// If true, returns false if address is in the 127.x.x.x or 169.128.x.x range.
- /// falseif the address isn't in the LAN list, true if the address has been defined as a LAN address.
- bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC);
+ /// IP address of the request.
+ /// Optional port returned, if it's part of an override.
+ /// IP Address to use, or loopback address if all else fails.
+ string GetBindInterface(IPAddress source, out int? port);
///
- /// Checks if address is in the LAN list in the config file.
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// (See .
///
- /// Source address to check.
- /// Destination address to check against.
- /// Destination subnet to check against.
- /// true/falsedepending on whether address1 is in the same subnet as IPAddress2 with subnetMask.
- bool IsInSameSubnet(IPAddress address1, IPAddress address2, IPAddress subnetMask);
+ /// Source of the request.
+ /// Optional port returned, if it's part of an override.
+ /// IP Address to use, or loopback address if all else fails.
+ string GetBindInterface(string source, out int? port);
///
- /// Returns the subnet mask of an interface with the given address.
+ /// Checks to see if the ip address is specifically excluded in LocalNetworkAddresses.
///
- /// The address to check.
- /// Returns the subnet mask of an interface with the given address, or null if an interface match cannot be found.
- IPAddress GetLocalIpSubnetMask(IPAddress address);
+ /// IP address to check.
+ /// True if it is.
+ bool IsExcludedInterface(IPAddress address);
+
+ ///
+ /// Get a list of all the MAC addresses associated with active interfaces.
+ ///
+ /// List of MAC addresses.
+ IReadOnlyCollection GetMacAddresses();
+
+ ///
+ /// Checks to see if the IP Address provided matches an interface that has a gateway.
+ ///
+ /// IP to check. Can be an IPAddress or an IPObject.
+ /// Result of the check.
+ bool IsGatewayInterface(IPObject? addressObj);
+
+ ///
+ /// Checks to see if the IP Address provided matches an interface that has a gateway.
+ ///
+ /// IP to check. Can be an IPAddress or an IPObject.
+ /// Result of the check.
+ bool IsGatewayInterface(IPAddress? addressObj);
+
+ ///
+ /// Returns true if the address is a private address.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
+ ///
+ /// Address to check.
+ /// True or False.
+ bool IsPrivateAddressRange(IPObject address);
+
+ ///
+ /// Returns true if the address is part of the user defined LAN.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
+ ///
+ /// IP to check.
+ /// True if endpoint is within the LAN range.
+ bool IsInLocalNetwork(string address);
+
+ ///
+ /// Returns true if the address is part of the user defined LAN.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
+ ///
+ /// IP to check.
+ /// True if endpoint is within the LAN range.
+ bool IsInLocalNetwork(IPObject address);
+
+ ///
+ /// Returns true if the address is part of the user defined LAN.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
+ ///
+ /// IP to check.
+ /// True if endpoint is within the LAN range.
+ bool IsInLocalNetwork(IPAddress address);
+
+ ///
+ /// Attempts to convert the token to an IP address, permitting for interface descriptions and indexes.
+ /// eg. "eth1", or "TP-LINK Wireless USB Adapter".
+ ///
+ /// Token to parse.
+ /// Resultant object's ip addresses, if successful.
+ /// Success of the operation.
+ bool TryParseInterface(string token, out Collection? result);
+
+ ///
+ /// Parses an array of strings into a Collection{IPObject}.
+ ///
+ /// Values to parse.
+ /// When true, only include values beginning with !. When false, ignore ! values.
+ /// IPCollection object containing the value strings.
+ Collection CreateIPCollection(string[] values, bool negated = false);
+
+ ///
+ /// Returns all the internal Bind interface addresses.
+ ///
+ /// An internal list of interfaces addresses.
+ Collection GetInternalBindAddresses();
+
+ ///
+ /// Checks to see if an IP address is still a valid interface address.
+ ///
+ /// IP address to check.
+ /// True if it is.
+ bool IsValidInterfaceAddress(IPAddress address);
+
+ ///
+ /// Returns true if the IP address is in the excluded list.
+ ///
+ /// IP to check.
+ /// True if excluded.
+ bool IsExcluded(IPAddress ip);
+
+ ///
+ /// Returns true if the IP address is in the excluded list.
+ ///
+ /// IP to check.
+ /// True if excluded.
+ bool IsExcluded(EndPoint ip);
+
+ ///
+ /// Gets the filtered LAN ip addresses.
+ ///
+ /// Optional filter for the list.
+ /// Returns a filtered list of LAN addresses.
+ Collection GetFilteredLANSubnets(Collection? filter = null);
}
}
diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs
new file mode 100644
index 0000000000..4cede9ab16
--- /dev/null
+++ b/MediaBrowser.Common/Net/IPHost.cs
@@ -0,0 +1,445 @@
+#nullable enable
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+
+namespace MediaBrowser.Common.Net
+{
+ ///
+ /// Object that holds a host name.
+ ///
+ public class IPHost : IPObject
+ {
+ ///
+ /// Gets or sets timeout value before resolve required, in minutes.
+ ///
+ public const int Timeout = 30;
+
+ ///
+ /// Represents an IPHost that has no value.
+ ///
+ public static readonly IPHost None = new IPHost(string.Empty, IPAddress.None);
+
+ ///
+ /// Time when last resolved in ticks.
+ ///
+ private DateTime? _lastResolved = null;
+
+ ///
+ /// Gets the IP Addresses, attempting to resolve the name, if there are none.
+ ///
+ private IPAddress[] _addresses;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Host name to assign.
+ public IPHost(string name)
+ {
+ HostName = name ?? throw new ArgumentNullException(nameof(name));
+ _addresses = Array.Empty();
+ Resolved = false;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Host name to assign.
+ /// Address to assign.
+ private IPHost(string name, IPAddress address)
+ {
+ HostName = name ?? throw new ArgumentNullException(nameof(name));
+ _addresses = new IPAddress[] { address ?? throw new ArgumentNullException(nameof(address)) };
+ Resolved = !address.Equals(IPAddress.None);
+ }
+
+ ///
+ /// Gets or sets the object's first IP address.
+ ///
+ public override IPAddress Address
+ {
+ get
+ {
+ return ResolveHost() ? this[0] : IPAddress.None;
+ }
+
+ set
+ {
+ // Not implemented, as a host's address is determined by DNS.
+ throw new NotImplementedException("The address of a host is determined by DNS.");
+ }
+ }
+
+ ///
+ /// Gets or sets the object's first IP's subnet prefix.
+ /// The setter does nothing, but shouldn't raise an exception.
+ ///
+ public override byte PrefixLength
+ {
+ get
+ {
+ return (byte)(ResolveHost() ? 128 : 32);
+ }
+
+ set
+ {
+ // Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length,
+ // which is automatically determined by it's IP type. Anything else is meaningless.
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the address has a value.
+ ///
+ public bool HasAddress => _addresses.Length != 0;
+
+ ///
+ /// Gets the host name of this object.
+ ///
+ public string HostName { get; }
+
+ ///
+ /// Gets a value indicating whether this host has attempted to be resolved.
+ ///
+ public bool Resolved { get; private set; }
+
+ ///
+ /// Gets or sets the IP Addresses associated with this object.
+ ///
+ /// Index of address.
+ public IPAddress this[int index]
+ {
+ get
+ {
+ ResolveHost();
+ return index >= 0 && index < _addresses.Length ? _addresses[index] : IPAddress.None;
+ }
+ }
+
+ ///
+ /// Attempts to parse the host string.
+ ///
+ /// Host name to parse.
+ /// Object representing the string, if it has successfully been parsed.
+ /// true if the parsing is successful, false if not.
+ public static bool TryParse(string host, out IPHost hostObj)
+ {
+ if (!string.IsNullOrEmpty(host))
+ {
+ // See if it's an IPv6 with port address e.g. [::1]:120.
+ int i = host.IndexOf("]:", StringComparison.OrdinalIgnoreCase);
+ if (i != -1)
+ {
+ return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
+ }
+ else
+ {
+ // See if it's an IPv6 in [] with no port.
+ i = host.IndexOf(']', StringComparison.OrdinalIgnoreCase);
+ if (i != -1)
+ {
+ return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
+ }
+
+ // Is it a host or IPv4 with port?
+ string[] hosts = host.Split(':');
+
+ if (hosts.Length > 2)
+ {
+ hostObj = new IPHost(string.Empty, IPAddress.None);
+ return false;
+ }
+
+ // Remove port from IPv4 if it exists.
+ host = hosts[0];
+
+ if (string.Equals("localhost", host, StringComparison.OrdinalIgnoreCase))
+ {
+ hostObj = new IPHost(host, new IPAddress(Ipv4Loopback));
+ return true;
+ }
+
+ if (IPNetAddress.TryParse(host, out IPNetAddress netIP))
+ {
+ // Host name is an ip address, so fake resolve.
+ hostObj = new IPHost(host, netIP.Address);
+ return true;
+ }
+ }
+
+ // Only thing left is to see if it's a host string.
+ if (!string.IsNullOrEmpty(host))
+ {
+ // Use regular expression as CheckHostName isn't RFC5892 compliant.
+ // Modified from gSkinner's expression at https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation
+ Regex re = new Regex(@"^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){0,127}(?![0-9]*$)[a-z0-9-]+\.?)$", RegexOptions.IgnoreCase | RegexOptions.Multiline);
+ if (re.Match(host).Success)
+ {
+ hostObj = new IPHost(host);
+ return true;
+ }
+ }
+ }
+
+ hostObj = IPHost.None;
+ return false;
+ }
+
+ ///
+ /// Attempts to parse the host string.
+ ///
+ /// Host name to parse.
+ /// Object representing the string, if it has successfully been parsed.
+ public static IPHost Parse(string host)
+ {
+ if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
+ {
+ return res;
+ }
+
+ throw new InvalidCastException("Host does not contain a valid value. {host}");
+ }
+
+ ///
+ /// Attempts to parse the host string, ensuring that it resolves only to a specific IP type.
+ ///
+ /// Host name to parse.
+ /// Addressfamily filter.
+ /// Object representing the string, if it has successfully been parsed.
+ public static IPHost Parse(string host, AddressFamily family)
+ {
+ if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
+ {
+ if (family == AddressFamily.InterNetwork)
+ {
+ res.Remove(AddressFamily.InterNetworkV6);
+ }
+ else
+ {
+ res.Remove(AddressFamily.InterNetwork);
+ }
+
+ return res;
+ }
+
+ throw new InvalidCastException("Host does not contain a valid value. {host}");
+ }
+
+ ///
+ /// Returns the Addresses that this item resolved to.
+ ///
+ /// IPAddress Array.
+ public IPAddress[] GetAddresses()
+ {
+ ResolveHost();
+ return _addresses;
+ }
+
+ ///
+ public override bool Contains(IPAddress address)
+ {
+ if (address != null && !Address.Equals(IPAddress.None))
+ {
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ foreach (var addr in GetAddresses())
+ {
+ if (address.Equals(addr))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ public override bool Equals(IPObject? other)
+ {
+ if (other is IPHost otherObj)
+ {
+ // Do we have the name Hostname?
+ if (string.Equals(otherObj.HostName, HostName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (!ResolveHost() || !otherObj.ResolveHost())
+ {
+ return false;
+ }
+
+ // Do any of our IP addresses match?
+ foreach (IPAddress addr in _addresses)
+ {
+ foreach (IPAddress otherAddress in otherObj._addresses)
+ {
+ if (addr.Equals(otherAddress))
+ {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ public override bool IsIP6()
+ {
+ // Returns true if interfaces are only IP6.
+ if (ResolveHost())
+ {
+ foreach (IPAddress i in _addresses)
+ {
+ if (i.AddressFamily != AddressFamily.InterNetworkV6)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ public override string ToString()
+ {
+ // StringBuilder not optimum here.
+ string output = string.Empty;
+ if (_addresses.Length > 0)
+ {
+ bool moreThanOne = _addresses.Length > 1;
+ if (moreThanOne)
+ {
+ output = "[";
+ }
+
+ foreach (var i in _addresses)
+ {
+ if (Address.Equals(IPAddress.None) && Address.AddressFamily == AddressFamily.Unspecified)
+ {
+ output += HostName + ",";
+ }
+ else if (i.Equals(IPAddress.Any))
+ {
+ output += "Any IP4 Address,";
+ }
+ else if (Address.Equals(IPAddress.IPv6Any))
+ {
+ output += "Any IP6 Address,";
+ }
+ else if (i.Equals(IPAddress.Broadcast))
+ {
+ output += "Any Address,";
+ }
+ else
+ {
+ output += $"{i}/32,";
+ }
+ }
+
+ output = output[0..^1];
+
+ if (moreThanOne)
+ {
+ output += "]";
+ }
+ }
+ else
+ {
+ output = HostName;
+ }
+
+ return output;
+ }
+
+ ///
+ public override void Remove(AddressFamily family)
+ {
+ if (ResolveHost())
+ {
+ _addresses = _addresses.Where(p => p.AddressFamily != family).ToArray();
+ }
+ }
+
+ ///
+ public override bool Contains(IPObject address)
+ {
+ // An IPHost cannot contain another IPObject, it can only be equal.
+ return Equals(address);
+ }
+
+ ///
+ protected override IPObject CalculateNetworkAddress()
+ {
+ var netAddr = NetworkAddressOf(this[0], PrefixLength);
+ return new IPNetAddress(netAddr.Address, netAddr.PrefixLength);
+ }
+
+ ///
+ /// Attempt to resolve the ip address of a host.
+ ///
+ /// true if any addresses have been resolved, otherwise false.
+ private bool ResolveHost()
+ {
+ // When was the last time we resolved?
+ if (_lastResolved == null)
+ {
+ _lastResolved = DateTime.UtcNow;
+ }
+
+ // If we haven't resolved before, or our timer has run out...
+ if ((_addresses.Length == 0 && !Resolved) || (DateTime.UtcNow > _lastResolved?.AddMinutes(Timeout)))
+ {
+ _lastResolved = DateTime.UtcNow;
+ ResolveHostInternal().GetAwaiter().GetResult();
+ Resolved = true;
+ }
+
+ return _addresses.Length > 0;
+ }
+
+ ///
+ /// Task that looks up a Host name and returns its IP addresses.
+ ///
+ /// A representing the asynchronous operation.
+ private async Task ResolveHostInternal()
+ {
+ if (!string.IsNullOrEmpty(HostName))
+ {
+ // Resolves the host name - so save a DNS lookup.
+ if (string.Equals(HostName, "localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ _addresses = new IPAddress[] { new IPAddress(Ipv4Loopback), new IPAddress(Ipv6Loopback) };
+ return;
+ }
+
+ if (Uri.CheckHostName(HostName).Equals(UriHostNameType.Dns))
+ {
+ try
+ {
+ IPHostEntry ip = await Dns.GetHostEntryAsync(HostName).ConfigureAwait(false);
+ _addresses = ip.AddressList;
+ }
+ catch (SocketException ex)
+ {
+ // Log and then ignore socket errors, as the result value will just be an empty array.
+ Debug.WriteLine("GetHostEntryAsync failed with {Message}.", ex.Message);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Net/IPNetAddress.cs b/MediaBrowser.Common/Net/IPNetAddress.cs
new file mode 100644
index 0000000000..a6f5fe4b37
--- /dev/null
+++ b/MediaBrowser.Common/Net/IPNetAddress.cs
@@ -0,0 +1,277 @@
+#nullable enable
+using System;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MediaBrowser.Common.Net
+{
+ ///
+ /// An object that holds and IP address and subnet mask.
+ ///
+ public class IPNetAddress : IPObject
+ {
+ ///
+ /// Represents an IPNetAddress that has no value.
+ ///
+ public static readonly IPNetAddress None = new IPNetAddress(IPAddress.None);
+
+ ///
+ /// IPv4 multicast address.
+ ///
+ public static readonly IPAddress SSDPMulticastIPv4 = IPAddress.Parse("239.255.255.250");
+
+ ///
+ /// IPv6 local link multicast address.
+ ///
+ public static readonly IPAddress SSDPMulticastIPv6LinkLocal = IPAddress.Parse("ff02::C");
+
+ ///
+ /// IPv6 site local multicast address.
+ ///
+ public static readonly IPAddress SSDPMulticastIPv6SiteLocal = IPAddress.Parse("ff05::C");
+
+ ///
+ /// IP4Loopback address host.
+ ///
+ public static readonly IPNetAddress IP4Loopback = IPNetAddress.Parse("127.0.0.1/32");
+
+ ///
+ /// IP6Loopback address host.
+ ///
+ public static readonly IPNetAddress IP6Loopback = IPNetAddress.Parse("::1");
+
+ ///
+ /// Object's IP address.
+ ///
+ private IPAddress _address;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Address to assign.
+ public IPNetAddress(IPAddress address)
+ {
+ _address = address ?? throw new ArgumentNullException(nameof(address));
+ PrefixLength = (byte)(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// IP Address.
+ /// Mask as a CIDR.
+ public IPNetAddress(IPAddress address, byte prefixLength)
+ {
+ if (address?.IsIPv4MappedToIPv6 ?? throw new ArgumentNullException(nameof(address)))
+ {
+ _address = address.MapToIPv4();
+ }
+ else
+ {
+ _address = address;
+ }
+
+ PrefixLength = prefixLength;
+ }
+
+ ///
+ /// Gets or sets the object's IP address.
+ ///
+ public override IPAddress Address
+ {
+ get
+ {
+ return _address;
+ }
+
+ set
+ {
+ _address = value ?? IPAddress.None;
+ }
+ }
+
+ ///
+ public override byte PrefixLength { get; set; }
+
+ ///
+ /// Try to parse the address and subnet strings into an IPNetAddress object.
+ ///
+ /// IP address to parse. Can be CIDR or X.X.X.X notation.
+ /// Resultant object.
+ /// True if the values parsed successfully. False if not, resulting in the IP being null.
+ public static bool TryParse(string addr, out IPNetAddress ip)
+ {
+ if (!string.IsNullOrEmpty(addr))
+ {
+ addr = addr.Trim();
+
+ // Try to parse it as is.
+ if (IPAddress.TryParse(addr, out IPAddress? res))
+ {
+ ip = new IPNetAddress(res);
+ return true;
+ }
+
+ // Is it a network?
+ string[] tokens = addr.Split("/");
+
+ if (tokens.Length == 2)
+ {
+ tokens[0] = tokens[0].TrimEnd();
+ tokens[1] = tokens[1].TrimStart();
+
+ if (IPAddress.TryParse(tokens[0], out res))
+ {
+ // Is the subnet part a cidr?
+ if (byte.TryParse(tokens[1], out byte cidr))
+ {
+ ip = new IPNetAddress(res, cidr);
+ return true;
+ }
+
+ // Is the subnet in x.y.a.b form?
+ if (IPAddress.TryParse(tokens[1], out IPAddress? mask))
+ {
+ ip = new IPNetAddress(res, MaskToCidr(mask));
+ return true;
+ }
+ }
+ }
+ }
+
+ ip = None;
+ return false;
+ }
+
+ ///
+ /// Parses the string provided, throwing an exception if it is badly formed.
+ ///
+ /// String to parse.
+ /// IPNetAddress object.
+ public static IPNetAddress Parse(string addr)
+ {
+ if (TryParse(addr, out IPNetAddress o))
+ {
+ return o;
+ }
+
+ throw new ArgumentException("Unable to recognise object :" + addr);
+ }
+
+ ///
+ public override bool Contains(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ var altAddress = NetworkAddressOf(address, PrefixLength);
+ return NetworkAddress.Address.Equals(altAddress.Address) && NetworkAddress.PrefixLength >= altAddress.PrefixLength;
+ }
+
+ ///
+ public override bool Contains(IPObject address)
+ {
+ if (address is IPHost addressObj && addressObj.HasAddress)
+ {
+ foreach (IPAddress addr in addressObj.GetAddresses())
+ {
+ if (Contains(addr))
+ {
+ return true;
+ }
+ }
+ }
+ else if (address is IPNetAddress netaddrObj)
+ {
+ // Have the same network address, but different subnets?
+ if (NetworkAddress.Address.Equals(netaddrObj.NetworkAddress.Address))
+ {
+ return NetworkAddress.PrefixLength <= netaddrObj.PrefixLength;
+ }
+
+ var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength);
+ return NetworkAddress.Address.Equals(altAddress.Address);
+ }
+
+ return false;
+ }
+
+ ///
+ public override bool Equals(IPObject? other)
+ {
+ if (other is IPNetAddress otherObj && !Address.Equals(IPAddress.None) && !otherObj.Address.Equals(IPAddress.None))
+ {
+ return Address.Equals(otherObj.Address) &&
+ PrefixLength == otherObj.PrefixLength;
+ }
+
+ return false;
+ }
+
+ ///
+ public override bool Equals(IPAddress address)
+ {
+ if (address != null && !address.Equals(IPAddress.None) && !Address.Equals(IPAddress.None))
+ {
+ return address.Equals(Address);
+ }
+
+ return false;
+ }
+
+ ///
+ public override string ToString()
+ {
+ return ToString(false);
+ }
+
+ ///
+ /// Returns a textual representation of this object.
+ ///
+ /// Set to true, if the subnet is to be excluded as part of the address.
+ /// String representation of this object.
+ public string ToString(bool shortVersion)
+ {
+ if (!Address.Equals(IPAddress.None))
+ {
+ if (Address.Equals(IPAddress.Any))
+ {
+ return "Any IP4 Address";
+ }
+
+ if (Address.Equals(IPAddress.IPv6Any))
+ {
+ return "Any IP6 Address";
+ }
+
+ if (Address.Equals(IPAddress.Broadcast))
+ {
+ return "Any Address";
+ }
+
+ if (shortVersion)
+ {
+ return Address.ToString();
+ }
+
+ return $"{Address}/{PrefixLength}";
+ }
+
+ return string.Empty;
+ }
+
+ ///
+ protected override IPObject CalculateNetworkAddress()
+ {
+ var value = NetworkAddressOf(_address, PrefixLength);
+ return new IPNetAddress(value.Address, value.PrefixLength);
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Net/IPObject.cs b/MediaBrowser.Common/Net/IPObject.cs
new file mode 100644
index 0000000000..69cd57f8ae
--- /dev/null
+++ b/MediaBrowser.Common/Net/IPObject.cs
@@ -0,0 +1,406 @@
+#nullable enable
+using System;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MediaBrowser.Common.Net
+{
+ ///
+ /// Base network object class.
+ ///
+ public abstract class IPObject : IEquatable
+ {
+ ///
+ /// IPv6 Loopback address.
+ ///
+ protected static readonly byte[] Ipv6Loopback = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
+
+ ///
+ /// IPv4 Loopback address.
+ ///
+ protected static readonly byte[] Ipv4Loopback = { 127, 0, 0, 1 };
+
+ ///
+ /// The network address of this object.
+ ///
+ private IPObject? _networkAddress;
+
+ ///
+ /// Gets or sets a user defined value that is associated with this object.
+ ///
+ public int Tag { get; set; }
+
+ ///
+ /// Gets or sets the object's IP address.
+ ///
+ public abstract IPAddress Address { get; set; }
+
+ ///
+ /// Gets the object's network address.
+ ///
+ public IPObject NetworkAddress => _networkAddress ??= CalculateNetworkAddress();
+
+ ///
+ /// Gets or sets the object's IP address.
+ ///
+ public abstract byte PrefixLength { get; set; }
+
+ ///
+ /// Gets the AddressFamily of this object.
+ ///
+ public AddressFamily AddressFamily
+ {
+ get
+ {
+ // Keep terms separate as Address performs other functions in inherited objects.
+ IPAddress address = Address;
+ return address.Equals(IPAddress.None) ? AddressFamily.Unspecified : address.AddressFamily;
+ }
+ }
+
+ ///
+ /// Returns the network address of an object.
+ ///
+ /// IP Address to convert.
+ /// Subnet prefix.
+ /// IPAddress.
+ public static (IPAddress Address, byte PrefixLength) NetworkAddressOf(IPAddress address, byte prefixLength)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (IsLoopback(address))
+ {
+ return (Address: address, PrefixLength: prefixLength);
+ }
+
+ // An ip address is just a list of bytes, each one representing a segment on the network.
+ // This separates the IP address into octets and calculates how many octets will need to be altered or set to zero dependant upon the
+ // prefix length value. eg. /16 on a 4 octet ip4 address (192.168.2.240) will result in the 2 and the 240 being zeroed out.
+ // Where there is not an exact boundary (eg /23), mod is used to calculate how many bits of this value are to be kept.
+
+ // GetAddressBytes
+ Span addressBytes = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+ address.TryWriteBytes(addressBytes, out _);
+
+ int div = prefixLength / 8;
+ int mod = prefixLength % 8;
+ if (mod != 0)
+ {
+ // Prefix length is counted right to left, so subtract 8 so we know how many bits to clear.
+ mod = 8 - mod;
+
+ // Shift out the bits from the octet that we don't want, by moving right then back left.
+ addressBytes[div] = (byte)((int)addressBytes[div] >> mod << mod);
+ // Move on the next byte.
+ div++;
+ }
+
+ // Blank out the remaining octets from mod + 1 to the end of the byte array. (192.168.2.240/16 becomes 192.168.0.0)
+ for (int octet = div; octet < addressBytes.Length; octet++)
+ {
+ addressBytes[octet] = 0;
+ }
+
+ // Return the network address for the prefix.
+ return (Address: new IPAddress(addressBytes), PrefixLength: prefixLength);
+ }
+
+ ///
+ /// Tests to see if the ip address is a Loopback address.
+ ///
+ /// Value to test.
+ /// True if it is.
+ public static bool IsLoopback(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (!address.Equals(IPAddress.None))
+ {
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ return address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Tests to see if the ip address is an IP6 address.
+ ///
+ /// Value to test.
+ /// True if it is.
+ public static bool IsIP6(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ return !address.Equals(IPAddress.None) && (address.AddressFamily == AddressFamily.InterNetworkV6);
+ }
+
+ ///
+ /// Tests to see if the address in the private address range.
+ ///
+ /// Object to test.
+ /// True if it contains a private address.
+ public static bool IsPrivateAddressRange(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (!address.Equals(IPAddress.None))
+ {
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ // GetAddressBytes
+ Span octet = stackalloc byte[4];
+ address.TryWriteBytes(octet, out _);
+
+ return (octet[0] == 10)
+ || (octet[0] == 172 && octet[1] >= 16 && octet[1] <= 31) // RFC1918
+ || (octet[0] == 192 && octet[1] == 168) // RFC1918
+ || (octet[0] == 127); // RFC1122
+ }
+ else
+ {
+ // GetAddressBytes
+ Span octet = stackalloc byte[16];
+ address.TryWriteBytes(octet, out _);
+
+ uint word = (uint)(octet[0] << 8) + octet[1];
+
+ return (word >= 0xfe80 && word <= 0xfebf) // fe80::/10 :Local link.
+ || (word >= 0xfc00 && word <= 0xfdff); // fc00::/7 :Unique local address.
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Returns true if the IPAddress contains an IP6 Local link address.
+ ///
+ /// IPAddress object to check.
+ /// True if it is a local link address.
+ ///
+ /// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress
+ /// it appears that the IPAddress.IsIPv6LinkLocal is out of date.
+ ///
+ public static bool IsIPv6LinkLocal(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (address.AddressFamily != AddressFamily.InterNetworkV6)
+ {
+ return false;
+ }
+
+ // GetAddressBytes
+ Span octet = stackalloc byte[16];
+ address.TryWriteBytes(octet, out _);
+ uint word = (uint)(octet[0] << 8) + octet[1];
+
+ return word >= 0xfe80 && word <= 0xfebf; // fe80::/10 :Local link.
+ }
+
+ ///
+ /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only.
+ ///
+ /// Subnet mask in CIDR notation.
+ /// IPv4 or IPv6 family.
+ /// String value of the subnet mask in dotted decimal notation.
+ public static IPAddress CidrToMask(byte cidr, AddressFamily family)
+ {
+ uint addr = 0xFFFFFFFF << (family == AddressFamily.InterNetwork ? 32 : 128 - cidr);
+ addr = ((addr & 0xff000000) >> 24)
+ | ((addr & 0x00ff0000) >> 8)
+ | ((addr & 0x0000ff00) << 8)
+ | ((addr & 0x000000ff) << 24);
+ return new IPAddress(addr);
+ }
+
+ ///
+ /// Convert a mask to a CIDR. IPv4 only.
+ /// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask.
+ ///
+ /// Subnet mask.
+ /// Byte CIDR representing the mask.
+ public static byte MaskToCidr(IPAddress mask)
+ {
+ if (mask == null)
+ {
+ throw new ArgumentNullException(nameof(mask));
+ }
+
+ byte cidrnet = 0;
+ if (!mask.Equals(IPAddress.Any))
+ {
+ // GetAddressBytes
+ Span bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+ mask.TryWriteBytes(bytes, out _);
+
+ var zeroed = false;
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ for (int v = bytes[i]; (v & 0xFF) != 0; v <<= 1)
+ {
+ if (zeroed)
+ {
+ // Invalid netmask.
+ return (byte)~cidrnet;
+ }
+
+ if ((v & 0x80) == 0)
+ {
+ zeroed = true;
+ }
+ else
+ {
+ cidrnet++;
+ }
+ }
+ }
+ }
+
+ return cidrnet;
+ }
+
+ ///
+ /// Tests to see if this object is a Loopback address.
+ ///
+ /// True if it is.
+ public virtual bool IsLoopback()
+ {
+ return IsLoopback(Address);
+ }
+
+ ///
+ /// Removes all addresses of a specific type from this object.
+ ///
+ /// Type of address to remove.
+ public virtual void Remove(AddressFamily family)
+ {
+ // This method only performs a function in the IPHost implementation of IPObject.
+ }
+
+ ///
+ /// Tests to see if this object is an IPv6 address.
+ ///
+ /// True if it is.
+ public virtual bool IsIP6()
+ {
+ return IsIP6(Address);
+ }
+
+ ///
+ /// Returns true if this IP address is in the RFC private address range.
+ ///
+ /// True this object has a private address.
+ public virtual bool IsPrivateAddressRange()
+ {
+ return IsPrivateAddressRange(Address);
+ }
+
+ ///
+ /// Compares this to the object passed as a parameter.
+ ///
+ /// Object to compare to.
+ /// Equality result.
+ public virtual bool Equals(IPAddress ip)
+ {
+ if (ip != null)
+ {
+ if (ip.IsIPv4MappedToIPv6)
+ {
+ ip = ip.MapToIPv4();
+ }
+
+ return !Address.Equals(IPAddress.None) && Address.Equals(ip);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Compares this to the object passed as a parameter.
+ ///
+ /// Object to compare to.
+ /// Equality result.
+ public virtual bool Equals(IPObject? other)
+ {
+ if (other != null)
+ {
+ return !Address.Equals(IPAddress.None) && Address.Equals(other.Address);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Compares the address in this object and the address in the object passed as a parameter.
+ ///
+ /// Object's IP address to compare to.
+ /// Comparison result.
+ public abstract bool Contains(IPObject address);
+
+ ///
+ /// Compares the address in this object and the address in the object passed as a parameter.
+ ///
+ /// Object's IP address to compare to.
+ /// Comparison result.
+ public abstract bool Contains(IPAddress address);
+
+ ///
+ public override int GetHashCode()
+ {
+ return Address.GetHashCode();
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ {
+ return Equals(obj as IPObject);
+ }
+
+ ///
+ /// Calculates the network address of this object.
+ ///
+ /// Returns the network address of this object.
+ protected abstract IPObject CalculateNetworkAddress();
+ }
+}
diff --git a/MediaBrowser.Common/Net/NetworkExtensions.cs b/MediaBrowser.Common/Net/NetworkExtensions.cs
new file mode 100644
index 0000000000..d07bba249b
--- /dev/null
+++ b/MediaBrowser.Common/Net/NetworkExtensions.cs
@@ -0,0 +1,262 @@
+#pragma warning disable CA1062 // Validate arguments of public methods
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace MediaBrowser.Common.Net
+{
+ ///
+ /// Defines the .
+ ///
+ public static class NetworkExtensions
+ {
+ ///
+ /// Add an address to the collection.
+ ///
+ /// The .
+ /// Item to add.
+ public static void AddItem(this Collection source, IPAddress ip)
+ {
+ if (!source.ContainsAddress(ip))
+ {
+ source.Add(new IPNetAddress(ip, 32));
+ }
+ }
+
+ ///
+ /// Adds a network to the collection.
+ ///
+ /// The .
+ /// Item to add.
+ public static void AddItem(this Collection source, IPObject item)
+ {
+ if (!source.ContainsAddress(item))
+ {
+ source.Add(item);
+ }
+ }
+
+ ///
+ /// Converts this object to a string.
+ ///
+ /// The .
+ /// Returns a string representation of this object.
+ public static string AsString(this Collection source)
+ {
+ return $"[{string.Join(',', source)}]";
+ }
+
+ ///
+ /// Returns true if the collection contains an item with the ip address,
+ /// or the ip address falls within any of the collection's network ranges.
+ ///
+ /// The .
+ /// The item to look for.
+ /// True if the collection contains the item.
+ public static bool ContainsAddress(this Collection source, IPAddress item)
+ {
+ if (source.Count == 0)
+ {
+ return false;
+ }
+
+ if (item == null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ if (item.IsIPv4MappedToIPv6)
+ {
+ item = item.MapToIPv4();
+ }
+
+ foreach (var i in source)
+ {
+ if (i.Contains(item))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Returns true if the collection contains an item with the ip address,
+ /// or the ip address falls within any of the collection's network ranges.
+ ///
+ /// The .
+ /// The item to look for.
+ /// True if the collection contains the item.
+ public static bool ContainsAddress(this Collection source, IPObject item)
+ {
+ if (source.Count == 0)
+ {
+ return false;
+ }
+
+ if (item == null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ foreach (var i in source)
+ {
+ if (i.Contains(item))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Compares two Collection{IPObject} objects. The order is ignored.
+ ///
+ /// The .
+ /// Item to compare to.
+ /// True if both are equal.
+ public static bool Compare(this Collection source, Collection dest)
+ {
+ if (dest == null || source.Count != dest.Count)
+ {
+ return false;
+ }
+
+ foreach (var sourceItem in source)
+ {
+ bool found = false;
+ foreach (var destItem in dest)
+ {
+ if (sourceItem.Equals(destItem))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns a collection containing the subnets of this collection given.
+ ///
+ /// The .
+ /// Collection{IPObject} object containing the subnets.
+ public static Collection AsNetworks(this Collection source)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ Collection res = new Collection();
+
+ foreach (IPObject i in source)
+ {
+ if (i is IPNetAddress nw)
+ {
+ // Add the subnet calculated from the interface address/mask.
+ var na = nw.NetworkAddress;
+ na.Tag = i.Tag;
+ res.AddItem(na);
+ }
+ else if (i is IPHost ipHost)
+ {
+ // Flatten out IPHost and add all its ip addresses.
+ foreach (var addr in ipHost.GetAddresses())
+ {
+ IPNetAddress host = new IPNetAddress(addr)
+ {
+ Tag = i.Tag
+ };
+
+ res.AddItem(host);
+ }
+ }
+ }
+
+ return res;
+ }
+
+ ///
+ /// Excludes all the items from this list that are found in excludeList.
+ ///
+ /// The .
+ /// Items to exclude.
+ /// A new collection, with the items excluded.
+ public static Collection Exclude(this Collection source, Collection excludeList)
+ {
+ if (source.Count == 0 || excludeList == null)
+ {
+ return new Collection(source);
+ }
+
+ Collection results = new Collection();
+
+ bool found;
+ foreach (var outer in source)
+ {
+ found = false;
+
+ foreach (var inner in excludeList)
+ {
+ if (outer.Equals(inner))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ results.AddItem(outer);
+ }
+ }
+
+ return results;
+ }
+
+ ///
+ /// Returns all items that co-exist in this object and target.
+ ///
+ /// The .
+ /// Collection to compare with.
+ /// A collection containing all the matches.
+ public static Collection Union(this Collection source, Collection target)
+ {
+ if (source.Count == 0)
+ {
+ return new Collection();
+ }
+
+ if (target == null)
+ {
+ throw new ArgumentNullException(nameof(target));
+ }
+
+ Collection nc = new Collection();
+
+ foreach (IPObject i in source)
+ {
+ if (target.ContainsAddress(i))
+ {
+ nc.AddItem(i);
+ }
+ }
+
+ return nc;
+ }
+ }
+}
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
{
}
- ///
- public virtual void RegisterServices(IServiceCollection serviceCollection)
- {
- }
-
- ///
- public virtual void UnregisterServices(IServiceCollection serviceCollection)
- {
- }
-
///
public void SetAttributes(string assemblyFilePath, string dataFolderPath, Version assemblyVersion)
{
@@ -185,6 +175,11 @@ namespace MediaBrowser.Common.Plugins
/// The type of the configuration.
public Type ConfigurationType => typeof(TConfigurationType);
+ ///
+ /// Gets or sets the event handler that is triggered when this configuration changes.
+ ///
+ public EventHandler ConfigurationChanged { get; set; }
+
///
/// Gets the name the assembly file.
///
@@ -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;
+ }
+ }
+
+ ///
+ /// Saves the current configuration to the file system.
+ ///
+ /// Configuration to save.
+ public virtual void SaveConfiguration(TConfigurationType config)
+ {
+ lock (_configurationSaveLock)
+ {
+ _directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
+
+ XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
}
}
@@ -261,12 +272,7 @@ namespace MediaBrowser.Common.Plugins
///
public virtual void SaveConfiguration()
{
- lock (_configurationSaveLock)
- {
- _directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
-
- XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath);
- }
+ SaveConfiguration(Configuration);
}
///
@@ -279,7 +285,9 @@ namespace MediaBrowser.Common.Plugins
Configuration = (TConfigurationType)configuration;
- SaveConfiguration();
+ SaveConfiguration(Configuration);
+
+ ConfigurationChanged?.Invoke(this, configuration);
}
///
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.
///
void OnUninstalling();
-
- ///
- /// Registers the plugin's services to the service collection.
- ///
- /// The service collection.
- void RegisterServices(IServiceCollection serviceCollection);
-
- ///
- /// Unregisters the plugin's services from the service collection.
- ///
- /// The service collection.
- 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;
+
+ ///
+ /// Defines the .
+ ///
+ public interface IPluginServiceRegistrator
+ {
+ ///
+ /// Registers the plugin's services with the service collection.
+ ///
+ ///
+ /// This interface is only used for service registration and requires a parameterless constructor.
+ ///
+ /// The service collection.
+ 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
+{
+ ///
+ /// Local plugin struct.
+ ///
+ public class LocalPlugin : IEquatable
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The plugin id.
+ /// The plugin name.
+ /// The plugin version.
+ /// The plugin path.
+ public LocalPlugin(Guid id, string name, Version version, string path)
+ {
+ Id = id;
+ Name = name;
+ Version = version;
+ Path = path;
+ DllFiles = new List();
+ }
+
+ ///
+ /// Gets the plugin id.
+ ///
+ public Guid Id { get; }
+
+ ///
+ /// Gets the plugin name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the plugin version.
+ ///
+ public Version Version { get; }
+
+ ///
+ /// Gets the plugin path.
+ ///
+ public string Path { get; }
+
+ ///
+ /// Gets the list of dll files for this plugin.
+ ///
+ public List DllFiles { get; }
+
+ ///
+ /// == operator.
+ ///
+ /// Left item.
+ /// Right item.
+ /// Comparison result.
+ public static bool operator ==(LocalPlugin left, LocalPlugin right)
+ {
+ return left.Equals(right);
+ }
+
+ ///
+ /// != operator.
+ ///
+ /// Left item.
+ /// Right item.
+ /// Comparison result.
+ public static bool operator !=(LocalPlugin left, LocalPlugin right)
+ {
+ return !left.Equals(right);
+ }
+
+ ///
+ /// Compare two .
+ ///
+ /// The first item.
+ /// The second item.
+ /// Comparison result.
+ 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;
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ return obj is LocalPlugin other && this.Equals(other);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return Name.GetHashCode(StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ public bool Equals(LocalPlugin other)
+ {
+ return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase)
+ && Id.Equals(other.Id);
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Updates/IInstallationManager.cs b/MediaBrowser.Common/Updates/IInstallationManager.cs
index 169aca2ca0..585b1ee19e 100644
--- a/MediaBrowser.Common/Updates/IInstallationManager.cs
+++ b/MediaBrowser.Common/Updates/IInstallationManager.cs
@@ -11,29 +11,6 @@ namespace MediaBrowser.Common.Updates
{
public interface IInstallationManager : IDisposable
{
- event EventHandler PackageInstalling;
-
- event EventHandler PackageInstallationCompleted;
-
- event EventHandler PackageInstallationFailed;
-
- event EventHandler PackageInstallationCancelled;
-
- ///
- /// Occurs when a plugin is uninstalled.
- ///
- event EventHandler PluginUninstalled;
-
- ///
- /// Occurs when a plugin is updated.
- ///
- event EventHandler PluginUpdated;
-
- ///
- /// Occurs when a plugin is installed.
- ///
- event EventHandler PluginInstalled;
-
///
/// Gets the completed installations.
///
@@ -42,10 +19,11 @@ namespace MediaBrowser.Common.Updates
///
/// Parses a plugin manifest at the supplied URL.
///
+ /// Name of the repository.
/// The URL to query.
/// The cancellation token.
/// Task{IReadOnlyList{PackageInfo}}.
- Task> GetPackages(string manifest, CancellationToken cancellationToken = default);
+ Task> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default);
///
/// Gets all available packages.
@@ -60,11 +38,13 @@ namespace MediaBrowser.Common.Updates
/// The available packages.
/// The name of the plugin.
/// The id of the plugin.
+ /// The version of the plugin.
/// All plugins matching the requirements.
IEnumerable FilterPackages(
IEnumerable availablePackages,
string name = null,
- Guid guid = default);
+ Guid guid = default,
+ Version specificVersion = null);
///
/// Returns all compatible versions ordered from newest to oldest.
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
+{
+ ///
+ public class BaseItemManager : IBaseItemManager
+ {
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ public BaseItemManager(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ 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
+{
+ ///
+ /// The BaseItem manager.
+ ///
+ public interface IBaseItemManager
+ {
+ ///
+ /// Is metadata fetcher enabled.
+ ///
+ /// The base item.
+ /// The library options.
+ /// The metadata fetcher name.
+ /// true if metadata fetcher is enabled, else false.
+ bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name);
+
+ ///
+ /// Is image fetcher enabled.
+ ///
+ /// The base item.
+ /// The library options.
+ /// The image fetcher name.
+ /// true if image fetcher is enabled, else false.
+ 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 Fields { get; set; }
- public ImageType[] ImageTypes { get; set; }
+ public IReadOnlyList ImageTypes { get; set; }
public int ImageTypeLimit { get; set; }
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
index 397a68ff7e..c5e50cf45d 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
@@ -56,7 +56,7 @@ namespace MediaBrowser.Controller.Entities.Audio
{
if (query.IncludeItemTypes.Length == 0)
{
- query.IncludeItemTypes = new[] { typeof(Audio).Name, typeof(MusicVideo).Name, typeof(MusicAlbum).Name };
+ query.IncludeItemTypes = new[] { nameof(Audio), nameof(MusicVideo), nameof(MusicAlbum) };
query.ArtistIds = new[] { Id };
}
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs
index 5a117a6b15..f0c076108e 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs
@@ -64,7 +64,7 @@ namespace MediaBrowser.Controller.Entities.Audio
public IList GetTaggedItems(InternalItemsQuery query)
{
query.GenreIds = new[] { Id };
- query.IncludeItemTypes = new[] { typeof(MusicVideo).Name, typeof(Audio).Name, typeof(MusicAlbum).Name, typeof(MusicArtist).Name };
+ query.IncludeItemTypes = new[] { nameof(MusicVideo), nameof(Audio), nameof(MusicAlbum), nameof(MusicArtist) };
return LibraryManager.GetItemList(query);
}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 2fc7d45c90..d8fad3bfbb 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)
@@ -1435,7 +1385,6 @@ namespace MediaBrowser.Controller.Entities
new List();
var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
- await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh
if (ownedItemsChanged)
{
@@ -2607,7 +2556,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 35ddaffadc..675cdbd963 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -212,7 +212,7 @@ namespace MediaBrowser.Controller.Entities
///
/// Loads our children. Validation will occur externally.
- /// We want this sychronous.
+ /// We want this synchronous.
///
protected virtual List LoadChildren()
{
@@ -353,11 +353,6 @@ namespace MediaBrowser.Controller.Entities
{
await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
- else
- {
- // metadata is up-to-date; make sure DB has correct images dimensions and hash
- await LibraryManager.UpdateImagesAsync(currentChild).ConfigureAwait(false);
- }
continue;
}
@@ -723,7 +718,7 @@ namespace MediaBrowser.Controller.Entities
private bool RequiresPostFiltering2(InternalItemsQuery query)
{
- if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], typeof(BoxSet).Name, StringComparison.OrdinalIgnoreCase))
+ if (query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase))
{
Logger.LogDebug("Query requires post-filtering due to BoxSet query");
return true;
@@ -813,7 +808,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsPlayed.HasValue)
{
- if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(typeof(Series).Name))
+ if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(nameof(Series)))
{
Logger.LogDebug("Query requires post-filtering due to IsPlayed");
return true;
@@ -1067,12 +1062,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 +1172,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/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs
index db6c85caf7..74a1702040 100644
--- a/MediaBrowser.Controller/Entities/Genre.cs
+++ b/MediaBrowser.Controller/Entities/Genre.cs
@@ -59,7 +59,13 @@ namespace MediaBrowser.Controller.Entities
public IList GetTaggedItems(InternalItemsQuery query)
{
query.GenreIds = new[] { Id };
- query.ExcludeItemTypes = new[] { typeof(MusicVideo).Name, typeof(Audio.Audio).Name, typeof(MusicAlbum).Name, typeof(MusicArtist).Name };
+ query.ExcludeItemTypes = new[]
+ {
+ nameof(MusicVideo),
+ nameof(Entities.Audio.Audio),
+ nameof(MusicAlbum),
+ nameof(MusicArtist)
+ };
return LibraryManager.GetItemList(query);
}
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 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 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 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();
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index 75a746bfb3..e8afa9a490 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -151,7 +151,7 @@ namespace MediaBrowser.Controller.Entities.TV
if (query.IncludeItemTypes.Length == 0)
{
- query.IncludeItemTypes = new[] { typeof(Episode).Name };
+ query.IncludeItemTypes = new[] { nameof(Episode) };
}
query.IsVirtualItem = false;
@@ -207,7 +207,7 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
- query.IncludeItemTypes = new[] { typeof(Season).Name };
+ query.IncludeItemTypes = new[] { nameof(Season) };
query.OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray();
if (user != null && !user.DisplayMissingEpisodes)
@@ -233,7 +233,7 @@ namespace MediaBrowser.Controller.Entities.TV
if (query.IncludeItemTypes.Length == 0)
{
- query.IncludeItemTypes = new[] { typeof(Episode).Name, typeof(Season).Name };
+ query.IncludeItemTypes = new[] { nameof(Episode), nameof(Season) };
}
query.IsVirtualItem = false;
@@ -253,7 +253,7 @@ namespace MediaBrowser.Controller.Entities.TV
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
- IncludeItemTypes = new[] { typeof(Episode).Name, typeof(Season).Name },
+ IncludeItemTypes = new[] { nameof(Episode), nameof(Season) },
OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(),
DtoOptions = options
};
@@ -364,7 +364,7 @@ namespace MediaBrowser.Controller.Entities.TV
{
AncestorWithPresentationUniqueKey = queryFromSeries ? null : seriesKey,
SeriesPresentationUniqueKey = queryFromSeries ? seriesKey : null,
- IncludeItemTypes = new[] { typeof(Episode).Name },
+ IncludeItemTypes = new[] { nameof(Episode) },
OrderBy = new[] { ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(),
DtoOptions = options
};
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index 7bb311900e..4e33a6bbde 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -142,7 +142,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IncludeItemTypes.Length == 0)
{
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
}
return parent.QueryRecursive(query);
@@ -167,7 +167,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
return _libraryManager.GetItemsResult(query);
}
@@ -178,7 +178,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(Series).Name };
+ query.IncludeItemTypes = new[] { nameof(Series) };
return _libraryManager.GetItemsResult(query);
}
@@ -189,7 +189,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.IsFavorite = true;
- query.IncludeItemTypes = new[] { typeof(Episode).Name };
+ query.IncludeItemTypes = new[] { nameof(Episode) };
return _libraryManager.GetItemsResult(query);
}
@@ -200,7 +200,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
return _libraryManager.GetItemsResult(query);
}
@@ -208,7 +208,7 @@ namespace MediaBrowser.Controller.Entities
private QueryResult GetMovieCollections(Folder parent, User user, InternalItemsQuery query)
{
query.Parent = null;
- query.IncludeItemTypes = new[] { typeof(BoxSet).Name };
+ query.IncludeItemTypes = new[] { nameof(BoxSet) };
query.SetUser(user);
query.Recursive = true;
@@ -223,7 +223,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
return ConvertToResult(_libraryManager.GetItemList(query));
}
@@ -236,7 +236,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
return ConvertToResult(_libraryManager.GetItemList(query));
}
@@ -255,7 +255,7 @@ namespace MediaBrowser.Controller.Entities
{
var genres = parent.QueryRecursive(new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { typeof(Movie).Name },
+ IncludeItemTypes = new[] { nameof(Movie) },
Recursive = true,
EnableTotalRecordCount = false
}).Items
@@ -286,7 +286,7 @@ namespace MediaBrowser.Controller.Entities
query.GenreIds = new[] { displayParent.Id };
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(Movie).Name };
+ query.IncludeItemTypes = new[] { nameof(Movie) };
return _libraryManager.GetItemsResult(query);
}
@@ -333,7 +333,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { typeof(Episode).Name };
+ query.IncludeItemTypes = new[] { nameof(Episode) };
query.IsVirtualItem = false;
return ConvertToResult(_libraryManager.GetItemList(query));
@@ -362,7 +362,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
query.Limit = GetSpecialItemsLimit();
- query.IncludeItemTypes = new[] { typeof(Episode).Name };
+ query.IncludeItemTypes = new[] { nameof(Episode) };
return ConvertToResult(_libraryManager.GetItemList(query));
}
@@ -373,7 +373,7 @@ namespace MediaBrowser.Controller.Entities
query.Parent = parent;
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(Series).Name };
+ query.IncludeItemTypes = new[] { nameof(Series) };
return _libraryManager.GetItemsResult(query);
}
@@ -382,7 +382,7 @@ namespace MediaBrowser.Controller.Entities
{
var genres = parent.QueryRecursive(new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { typeof(Series).Name },
+ IncludeItemTypes = new[] { nameof(Series) },
Recursive = true,
EnableTotalRecordCount = false
}).Items
@@ -413,7 +413,7 @@ namespace MediaBrowser.Controller.Entities
query.GenreIds = new[] { displayParent.Id };
query.SetUser(user);
- query.IncludeItemTypes = new[] { typeof(Series).Name };
+ query.IncludeItemTypes = new[] { nameof(Series) };
return _libraryManager.GetItemsResult(query);
}
@@ -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
///
/// Gets the display preferences for the user and client.
///
+ ///
+ /// This will create the display preferences if it does not exist, but it will not save automatically.
+ ///
/// The user's id.
/// The client string.
/// The associated display preferences.
@@ -20,6 +23,9 @@ namespace MediaBrowser.Controller
///
/// Gets the default item display preferences for the user and client.
///
+ ///
+ /// This will create the item display preferences if it does not exist, but it will not save automatically.
+ ///
/// The user id.
/// The item id.
/// The client string.
diff --git a/MediaBrowser.Controller/IResourceFileManager.cs b/MediaBrowser.Controller/IResourceFileManager.cs
deleted file mode 100644
index 26f0424b7a..0000000000
--- a/MediaBrowser.Controller/IResourceFileManager.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-#pragma warning disable CS1591
-
-namespace MediaBrowser.Controller
-{
- public interface IResourceFileManager
- {
- string GetResourcePath(string basePath, string virtualPath);
- }
-}
diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs
index cfad17fb72..2456da826f 100644
--- a/MediaBrowser.Controller/IServerApplicationHost.cs
+++ b/MediaBrowser.Controller/IServerApplicationHost.cs
@@ -6,6 +6,7 @@ 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;
@@ -56,41 +57,43 @@ namespace MediaBrowser.Controller
///
/// Gets the system info.
///
+ /// The originator of the request.
/// SystemInfo.
- Task GetSystemInfo(CancellationToken cancellationToken);
+ SystemInfo GetSystemInfo(IPAddress source);
- Task GetPublicSystemInfo(CancellationToken cancellationToken);
+ PublicSystemInfo GetPublicSystemInfo(IPAddress address);
///
- /// Gets all the local IP addresses of this API instance. Each address is validated by sending a 'ping' request
- /// to the API that should exist at the address.
+ /// Gets a URL specific for the request.
///
- /// A cancellation token that can be used to cancel the task.
- /// A list containing all the local IP addresses of the server.
- Task> GetLocalIpAddresses(CancellationToken cancellationToken);
+ /// The instance.
+ /// Optional port number.
+ /// An accessible URL.
+ string GetSmartApiUrl(HttpRequest request, int? port = null);
///
- /// Gets a local (LAN) URL that can be used to access the API. The hostname used is the first valid configured
- /// IP address that can be found via . HTTPS will be preferred when available.
+ /// Gets a URL specific for the request.
///
- /// A cancellation token that can be used to cancel the task.
- /// The server URL.
- Task GetLocalApiUrl(CancellationToken cancellationToken);
+ /// The remote of the connection.
+ /// Optional port number.
+ /// An accessible URL.
+ string GetSmartApiUrl(IPAddress remoteAddr, int? port = null);
///
- /// Gets a localhost URL that can be used to access the API using the loop-back IP address (127.0.0.1)
+ /// Gets a URL specific for the request.
+ ///
+ /// The hostname used in the connection.
+ /// Optional port number.
+ /// An accessible URL.
+ string GetSmartApiUrl(string hostname, int? port = null);
+
+ ///
+ /// Gets a localhost URL that can be used to access the API using the loop-back IP address.
/// over HTTP (not HTTPS).
///
/// The API URL.
string GetLoopbackHttpApiUrl();
- ///
- /// Gets a local (LAN) URL that can be used to access the API. HTTPS will be preferred when available.
- ///
- /// The IP address to use as the hostname in the URL.
- /// The API URL.
- string GetLocalApiUrl(IPAddress address);
-
///
/// Gets a local (LAN) URL that can be used to access the API.
/// Note: if passing non-null scheme or port it is up to the caller to ensure they form the correct pair.
@@ -105,7 +108,7 @@ namespace MediaBrowser.Controller
/// preferring the HTTPS port, if available.
///
/// The API URL.
- string GetLocalApiUrl(ReadOnlySpan hostname, string scheme = null, int? port = null);
+ string GetLocalApiUrl(string hostname, string scheme = null, int? port = null);
///
/// Open a URL in an external browser window.
@@ -119,5 +122,13 @@ namespace MediaBrowser.Controller
string ExpandVirtualPath(string path);
string ReverseVirtualPath(string path);
+
+ ///
+ /// Gets the list of local plugins.
+ ///
+ /// Plugin base directory.
+ /// Cleanup old plugins.
+ /// Enumerable of local plugins.
+ IEnumerable GetLocalPlugins(string path, bool cleanup = true);
}
}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 32703c2fd1..601ca35360 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -570,5 +570,9 @@ namespace MediaBrowser.Controller.Library
List streams,
string videoPath,
string[] files);
+
+ void RunMetadataSavers(IReadOnlyList items, ItemUpdateType updateReason);
+
+ BaseItem GetParentItem(string parentId, Guid? userId);
}
}
diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs
index 22bf9488f7..21c6ef2af1 100644
--- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs
+++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs
@@ -115,5 +115,7 @@ namespace MediaBrowser.Controller.Library
public interface IDirectStreamProvider
{
Task CopyToAsync(Stream stream, CancellationToken cancellationToken);
+
+ string GetFilePath();
}
}
diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs
index 6a4f5cf679..8fd3b8c347 100644
--- a/MediaBrowser.Controller/Library/IUserManager.cs
+++ b/MediaBrowser.Controller/Library/IUserManager.cs
@@ -158,7 +158,8 @@ namespace MediaBrowser.Controller.Library
///
/// The user's Id.
/// The request containing the new user configuration.
- void UpdateConfiguration(Guid userId, UserConfiguration config);
+ /// A task representing the update.
+ Task UpdateConfigurationAsync(Guid userId, UserConfiguration config);
///
/// This method updates the user's policy.
@@ -167,12 +168,14 @@ namespace MediaBrowser.Controller.Library
///
/// The user's Id.
/// The request containing the new user policy.
- void UpdatePolicy(Guid userId, UserPolicy policy);
+ /// A task representing the update.
+ Task UpdatePolicyAsync(Guid userId, UserPolicy policy);
///
/// Clears the user's profile image.
///
/// The user.
- void ClearProfileImage(User user);
+ /// A task representing the clearing of the profile image.
+ Task ClearProfileImageAsync(User user);
}
}
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
/// The fields.
/// The user.
/// Task.
- Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, ItemFields[] fields, User user = null);
+ Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem, BaseItemDto)> programs, IReadOnlyList fields, User user = null);
///
/// 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 @@
-
-
+
+
@@ -29,7 +29,7 @@
- netstandard2.1
+ net5.0
false
true
true
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index f913c32e43..3baa59caed 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;
@@ -111,6 +112,16 @@ namespace MediaBrowser.Controller.MediaEncoding
return _mediaEncoder.SupportsHwaccel("vaapi");
}
+ private bool IsTonemappingSupported(EncodingJobInfo state, EncodingOptions options)
+ {
+ var videoStream = state.VideoStream;
+ return IsColorDepth10(state)
+ && _mediaEncoder.SupportsHwaccel("opencl")
+ && options.EnableTonemapping
+ && !string.IsNullOrEmpty(videoStream.VideoRange)
+ && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase);
+ }
+
///
/// Gets the name of the output video codec.
///
@@ -247,7 +258,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 +451,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();
}
@@ -451,14 +468,17 @@ namespace MediaBrowser.Controller.MediaEncoding
var arg = new StringBuilder();
var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, encodingOptions) ?? string.Empty;
var outputVideoCodec = GetVideoEncoder(state, encodingOptions) ?? string.Empty;
+ var isSwDecoder = string.IsNullOrEmpty(videoDecoder);
+ var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiEncoder = outputVideoCodec.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isQsvDecoder = videoDecoder.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1;
var isQsvEncoder = outputVideoCodec.IndexOf("qsv", StringComparison.OrdinalIgnoreCase) != -1;
- var isNvencHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
+ var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
var isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
+ var isTonemappingSupported = IsTonemappingSupported(state, encodingOptions);
if (!IsCopyCodec(outputVideoCodec))
{
@@ -468,10 +488,24 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (isVaapiDecoder)
{
- arg.Append("-hwaccel_output_format vaapi ")
- .Append("-vaapi_device ")
- .Append(encodingOptions.VaapiDevice)
- .Append(' ');
+ if (isTonemappingSupported)
+ {
+ arg.Append("-init_hw_device vaapi=va:")
+ .Append(encodingOptions.VaapiDevice)
+ .Append(' ')
+ .Append("-init_hw_device opencl=ocl@va ")
+ .Append("-hwaccel vaapi ")
+ .Append("-hwaccel_device va ")
+ .Append("-hwaccel_output_format vaapi ")
+ .Append("-filter_hw_device ocl ");
+ }
+ else
+ {
+ arg.Append("-hwaccel_output_format vaapi ")
+ .Append("-vaapi_device ")
+ .Append(encodingOptions.VaapiDevice)
+ .Append(' ');
+ }
}
else if (!isVaapiDecoder && isVaapiEncoder)
{
@@ -517,15 +551,10 @@ namespace MediaBrowser.Controller.MediaEncoding
}
if (state.IsVideoRequest
- && string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase))
+ && (string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecHevcDecoder || isSwDecoder)
+ || (string.Equals(encodingOptions.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && isD3d11vaDecoder || isSwDecoder))
{
- var isColorDepth10 = IsColorDepth10(state);
-
- if (isNvencHevcDecoder && isColorDepth10
- && _mediaEncoder.SupportsHwaccel("opencl")
- && encodingOptions.EnableTonemapping
- && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
- && state.VideoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
+ if (isTonemappingSupported)
{
arg.Append("-init_hw_device opencl=ocl:")
.Append(encodingOptions.OpenclDevice)
@@ -570,7 +599,7 @@ namespace MediaBrowser.Controller.MediaEncoding
///
/// The stream.
/// true if the specified stream is H264; otherwise, false.
- public bool IsH264(MediaStream stream)
+ public static bool IsH264(MediaStream stream)
{
var codec = stream.Codec ?? string.Empty;
@@ -578,7 +607,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;
@@ -586,10 +615,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";
@@ -598,12 +634,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;
@@ -651,16 +719,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;
@@ -763,6 +845,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;
+ }
+
///
/// Gets the video bitrate to specify on the command line.
///
@@ -770,6 +918,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);
@@ -778,11 +967,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;
@@ -806,38 +995,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":
@@ -845,27 +1036,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":
@@ -873,13 +1064,31 @@ 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;
}
+
+ 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))
+ {
+ // 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
{
@@ -901,7 +1110,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,
@@ -909,15 +1118,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);
@@ -929,11 +1138,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)
@@ -941,13 +1164,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;
}
}
@@ -955,55 +1196,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;
}
@@ -1016,42 +1237,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_v4l2m2m", StringComparison.OrdinalIgnoreCase))
- {
- param = "-pix_fmt yuv420p " + param;
- }
-
- if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase))
- {
- var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, encodingOptions) ?? string.Empty;
- var videoStream = state.VideoStream;
- var isColorDepth10 = IsColorDepth10(state);
-
- if (videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1
- && 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;
@@ -1330,7 +1520,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
{
- return .5;
+ return .6;
}
return 1;
@@ -1364,24 +1554,52 @@ namespace MediaBrowser.Controller.MediaEncoding
public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream)
{
- if (request.AudioBitRate.HasValue)
- {
- // Don't encode any higher than this
- return Math.Min(384000, request.AudioBitRate.Value);
- }
-
- return null;
+ return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream);
}
- public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream)
+ public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream)
{
- if (audioBitRate.HasValue)
+ if (audioStream == null)
+ {
+ return null;
+ }
+
+ if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec))
{
- // Don't encode any higher than this
return Math.Min(384000, audioBitRate.Value);
}
- return null;
+ 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
+ return 128000;
}
public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls)
@@ -1415,7 +1633,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (filters.Count > 0)
{
- return "-af \"" + string.Join(",", filters) + "\"";
+ return " -af \"" + string.Join(",", filters) + "\"";
}
return string.Empty;
@@ -1430,6 +1648,11 @@ namespace MediaBrowser.Controller.MediaEncoding
/// System.Nullable{System.Int32}.
public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec)
{
+ if (audioStream == null)
+ {
+ return null;
+ }
+
var request = state.BaseRequest;
var inputChannels = audioStream?.Channels;
@@ -1452,6 +1675,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
@@ -1651,52 +1879,25 @@ namespace MediaBrowser.Controller.MediaEncoding
var outputSizeParam = ReadOnlySpan.Empty;
var request = state.BaseRequest;
- outputSizeParam = GetOutputSizeParam(state, options, outputVideoCodec).TrimEnd('"');
-
- // All possible beginning of video filters
- // Don't break the order
- string[] beginOfOutputSizeParam = new[]
- {
- // for tonemap_opencl
- "hwupload,tonemap_opencl",
-
- // hwupload=extra_hw_frames=64,vpp_qsv (for overlay_qsv on linux)
- "hwupload=extra_hw_frames",
-
- // vpp_qsv
- "vpp",
-
- // hwdownload,format=p010le (hardware decode + software encode for vaapi)
- "hwdownload",
-
- // format=nv12|vaapi,hwupload,scale_vaapi
- "format",
-
- // bwdif,scale=expr
- "bwdif",
-
- // yadif,scale=expr
- "yadif",
-
- // scale=expr
- "scale"
- };
-
- var index = -1;
- foreach (var param in beginOfOutputSizeParam)
- {
- index = outputSizeParam.IndexOf(param, StringComparison.OrdinalIgnoreCase);
- if (index != -1)
- {
- outputSizeParam = outputSizeParam.Slice(index);
- break;
- }
- }
+ outputSizeParam = GetOutputSizeParamInternal(state, options, outputVideoCodec);
var videoSizeParam = string.Empty;
var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, options) ?? string.Empty;
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
+ 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 isTonemappingSupported = IsTonemappingSupported(state, options);
+ var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder);
+
+ // Tonemapping and burn-in graphical subtitles requires overlay_vaapi.
+ // But it's still in ffmpeg mailing list. Disable it for now.
+ if (isTonemappingSupported && isTonemappingSupportedOnVaapi)
+ {
+ return GetOutputSizeParam(state, options, outputVideoCodec);
+ }
+
// Setup subtitle scaling
if (state.VideoStream != null && state.VideoStream.Width.HasValue && state.VideoStream.Height.HasValue)
{
@@ -1716,7 +1917,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";
}
@@ -1737,7 +1939,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
@@ -1749,7 +1952,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
@@ -1758,7 +1962,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.
@@ -1784,7 +1989,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,
@@ -1824,6 +2029,7 @@ namespace MediaBrowser.Controller.MediaEncoding
public List GetScalingFilters(
EncodingJobInfo state,
+ EncodingOptions options,
int? videoWidth,
int? videoHeight,
Video3DFormat? threedFormat,
@@ -1844,7 +2050,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)
{
@@ -1853,12 +2061,26 @@ 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)
|| state.DeInterlace("hevc", true);
+ var isTonemappingSupported = IsTonemappingSupported(state, options);
+ var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && !qsv_or_vaapi;
+
+ var outputPixFmt = string.Empty;
+ if (isTonemappingSupported && isTonemappingSupportedOnVaapi)
+ {
+ outputPixFmt = "format=p010:out_range=limited";
+ }
+ else
+ {
+ outputPixFmt = "format=nv12";
+ }
+
if (!videoWidth.HasValue
|| outputWidth != videoWidth.Value
|| !videoHeight.HasValue
@@ -1869,10 +2091,11 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.Add(
string.Format(
CultureInfo.InvariantCulture,
- "{0}=w={1}:h={2}:format=nv12{3}",
+ "{0}=w={1}:h={2}{3}{4}",
qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi",
outputWidth,
outputHeight,
+ ":" + outputPixFmt,
(qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty));
}
else
@@ -1880,8 +2103,9 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.Add(
string.Format(
CultureInfo.InvariantCulture,
- "{0}=format=nv12{1}",
+ "{0}={1}{2}",
qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi",
+ outputPixFmt,
(qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty));
}
}
@@ -2083,10 +2307,19 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Format(CultureInfo.InvariantCulture, filter, widthParam, heightParam);
}
+ public string GetOutputSizeParam(
+ EncodingJobInfo state,
+ EncodingOptions options,
+ string outputVideoCodec)
+ {
+ string filters = GetOutputSizeParamInternal(state, options, outputVideoCodec);
+ return string.IsNullOrEmpty(filters) ? string.Empty : " -vf \"" + filters + "\"";
+ }
+
///
/// If we're going to put a fixed size on the command line, this will calculate it.
///
- public string GetOutputSizeParam(
+ public string GetOutputSizeParamInternal(
EncodingJobInfo state,
EncodingOptions options,
string outputVideoCodec)
@@ -2102,14 +2335,24 @@ namespace MediaBrowser.Controller.MediaEncoding
var inputHeight = videoStream?.Height;
var threeDFormat = state.MediaSource.Video3DFormat;
+ var isSwDecoder = string.IsNullOrEmpty(videoDecoder);
+ var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+ var isVaapiEncoder = outputVideoCodec.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);
+ var isTonemappingSupported = IsTonemappingSupported(state, options);
+ var isTonemappingSupportedOnNvenc = string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecHevcDecoder || isSwDecoder;
+ var isTonemappingSupportedOnAmf = string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && isD3d11vaDecoder || isSwDecoder;
+ var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder);
var hasTextSubs = state.SubtitleStream != null && state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
@@ -2117,65 +2360,130 @@ namespace MediaBrowser.Controller.MediaEncoding
// If double rate deinterlacing is enabled and the input framerate is 30fps or below, otherwise the output framerate will be too high for many devices
var doubleRateDeinterlace = options.DeinterlaceDoubleRate && (videoStream?.RealFrameRate ?? 60) <= 30;
- // Currently only with the use of NVENC decoder can we get a decent performance.
- // Currently only the HEVC/H265 format is supported.
- // NVIDIA Pascal and Turing or higher are recommended.
- if (isNvdecHevcDecoder && isColorDepth10
- && _mediaEncoder.SupportsHwaccel("opencl")
- && options.EnableTonemapping
- && !string.IsNullOrEmpty(videoStream.VideoRange)
- && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
+ var isScalingInAdvance = false;
+ var isDeinterlaceH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
+ var isDeinterlaceHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
+
+ if (isTonemappingSupportedOnNvenc || isTonemappingSupportedOnAmf || isTonemappingSupportedOnVaapi)
{
- var parameters = "tonemap_opencl=format=nv12:primaries=bt709:transfer=bt709:matrix=bt709:tonemap={0}:desat={1}:threshold={2}:peak={3}";
-
- if (options.TonemappingParam != 0)
+ // Currently only with the use of NVENC decoder can we get a decent performance.
+ // Currently only the HEVC/H265 format is supported with NVDEC decoder.
+ // NVIDIA Pascal and Turing or higher are recommended.
+ // AMD Polaris and Vega or higher are recommended.
+ // Intel Kaby Lake or newer is required.
+ if (isTonemappingSupported)
{
- parameters += ":param={4}";
- }
+ var parameters = "tonemap_opencl=format=nv12:primaries=bt709:transfer=bt709:matrix=bt709:tonemap={0}:desat={1}:threshold={2}:peak={3}";
- if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase))
- {
- parameters += ":range={5}";
- }
+ if (options.TonemappingParam != 0)
+ {
+ parameters += ":param={4}";
+ }
- // Upload the HDR10 or HLG data to the OpenCL device,
- // use tonemap_opencl filter for tone mapping,
- // and then download the SDR data to memory.
- filters.Add("hwupload");
- filters.Add(
- string.Format(
- CultureInfo.InvariantCulture,
- parameters,
- options.TonemappingAlgorithm,
- options.TonemappingDesat,
- options.TonemappingThreshold,
- options.TonemappingPeak,
- options.TonemappingParam,
- options.TonemappingRange));
- filters.Add("hwdownload");
+ if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase))
+ {
+ parameters += ":range={5}";
+ }
- if (hasGraphicalSubs || state.DeInterlace("h265", true) || state.DeInterlace("hevc", true)
- || string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase))
- {
- filters.Add("format=nv12");
+ if (isSwDecoder || isD3d11vaDecoder)
+ {
+ isScalingInAdvance = true;
+ // Add zscale filter before tone mapping filter for performance.
+ var (width, height) = GetFixedOutputSize(inputWidth, inputHeight, request.Width, request.Height, request.MaxWidth, request.MaxHeight);
+ if (width.HasValue && height.HasValue)
+ {
+ filters.Add(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ "zscale=s={0}x{1}",
+ width.Value,
+ height.Value));
+ }
+
+ // Convert to hardware pixel format p010 when using SW decoder.
+ filters.Add("format=p010");
+ }
+
+ if (isNvdecHevcDecoder || isSwDecoder || isD3d11vaDecoder)
+ {
+ // Upload the HDR10 or HLG data to the OpenCL device,
+ // use tonemap_opencl filter for tone mapping,
+ // and then download the SDR data to memory.
+ filters.Add("hwupload");
+ }
+
+ if (isVaapiDecoder)
+ {
+ isScalingInAdvance = true;
+ filters.AddRange(
+ GetScalingFilters(
+ state,
+ options,
+ inputWidth,
+ inputHeight,
+ threeDFormat,
+ videoDecoder,
+ outputVideoCodec,
+ request.Width,
+ request.Height,
+ request.MaxWidth,
+ request.MaxHeight));
+
+ // hwmap the HDR data to opencl device by cl-va p010 interop.
+ filters.Add("hwmap");
+ }
+
+ filters.Add(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ parameters,
+ options.TonemappingAlgorithm,
+ options.TonemappingDesat,
+ options.TonemappingThreshold,
+ options.TonemappingPeak,
+ options.TonemappingParam,
+ options.TonemappingRange));
+
+ if (isNvdecHevcDecoder || isSwDecoder || isD3d11vaDecoder)
+ {
+ filters.Add("hwdownload");
+ }
+
+ if (isSwDecoder || isD3d11vaDecoder)
+ {
+ if (isLibX264Encoder
+ || isLibX265Encoder
+ || hasGraphicalSubs
+ || (isNvdecHevcDecoder && isDeinterlaceHevc)
+ || (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc))
+ {
+ filters.Add("format=nv12");
+ }
+ }
+
+ if (isVaapiDecoder)
+ {
+ // Reverse the data route from opencl to vaapi.
+ filters.Add("hwmap=derive_device=vaapi:reverse=1");
+ }
}
}
- // When the input may or may not be hardware VAAPI decodable
- if (isVaapiH264Encoder)
+ // When the input may or may not be hardware VAAPI decodable.
+ if ((isVaapiH264Encoder || isVaapiHevcEncoder) && !isTonemappingSupported && !isTonemappingSupportedOnVaapi)
{
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)
+ // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context.
+ 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)
+ // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first.
+ else if (IsVaapiSupported(state) && isVaapiDecoder && (isLibX264Encoder || isLibX265Encoder))
{
var codec = videoStream.Codec.ToLowerInvariant();
@@ -2201,8 +2509,8 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
- // Add hardware deinterlace filter before scaling filter
- if (state.DeInterlace("h264", true) || state.DeInterlace("avc", true))
+ // Add hardware deinterlace filter before scaling filter.
+ if (isDeinterlaceH264)
{
if (isVaapiH264Encoder)
{
@@ -2214,13 +2522,12 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
- // Add software deinterlace filter before scaling filter
- if ((state.DeInterlace("h264", true)
- || state.DeInterlace("avc", true)
- || state.DeInterlace("h265", true)
- || state.DeInterlace("hevc", true))
+ // Add software deinterlace filter before scaling filter.
+ if ((isDeinterlaceH264 || isDeinterlaceHevc)
&& !isVaapiH264Encoder
+ && !isVaapiHevcEncoder
&& !isQsvH264Encoder
+ && !isQsvHevcEncoder
&& !isNvdecH264Decoder)
{
if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase))
@@ -2242,13 +2549,35 @@ namespace MediaBrowser.Controller.MediaEncoding
}
// Add scaling filter: scale_*=format=nv12 or scale_*=w=*:h=*:format=nv12 or scale=expr
- filters.AddRange(GetScalingFilters(state, inputWidth, inputHeight, threeDFormat, videoDecoder, outputVideoCodec, request.Width, request.Height, request.MaxWidth, request.MaxHeight));
+ if (!isScalingInAdvance)
+ {
+ filters.AddRange(
+ GetScalingFilters(
+ state,
+ options,
+ inputWidth,
+ inputHeight,
+ threeDFormat,
+ videoDecoder,
+ outputVideoCodec,
+ request.Width,
+ request.Height,
+ request.MaxWidth,
+ request.MaxHeight));
+ }
// Add parameters to use VAAPI with burn-in text subtitles (GH issue #642)
- if (isVaapiH264Encoder)
+ if (isVaapiH264Encoder || isVaapiHevcEncoder)
{
if (hasTextSubs)
{
+ // Convert hw context from ocl to va.
+ // For tonemapping and text subs burn-in.
+ if (isTonemappingSupported && isTonemappingSupportedOnVaapi)
+ {
+ filters.Add("scale_vaapi");
+ }
+
// Test passed on Intel and AMD gfx
filters.Add("hwmap=mode=read+write");
filters.Add("format=nv12");
@@ -2275,7 +2604,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
output += string.Format(
CultureInfo.InvariantCulture,
- " -vf \"{0}\"",
+ "{0}",
string.Join(",", filters));
}
@@ -2285,7 +2614,8 @@ namespace MediaBrowser.Controller.MediaEncoding
///
/// Gets the number of threads.
///
- 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))
{
@@ -2295,17 +2625,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))
@@ -2513,6 +2847,7 @@ namespace MediaBrowser.Controller.MediaEncoding
public void AttachMediaSourceInfo(
EncodingJobInfo state,
+ EncodingOptions encodingOptions,
MediaSourceInfo mediaSource,
string requestedUrl)
{
@@ -2631,9 +2966,10 @@ namespace MediaBrowser.Controller.MediaEncoding
state.MediaSource = mediaSource;
var request = state.BaseRequest;
- if (!string.IsNullOrWhiteSpace(request.AudioCodec))
+ var supportedAudioCodecs = state.SupportedAudioCodecs;
+ if (request != null && supportedAudioCodecs != null && supportedAudioCodecs.Length > 0)
{
- var supportedAudioCodecsList = request.AudioCodec.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList();
+ var supportedAudioCodecsList = supportedAudioCodecs.ToList();
ShiftAudioCodecsIfNeeded(supportedAudioCodecsList, state.AudioStream);
@@ -2642,11 +2978,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 audioCodecs, MediaStream audioStream)
{
- // Nothing to do here
+ // No need to shift if there is only one supported audio codec.
if (audioCodecs.Count < 2)
{
return;
@@ -2674,6 +3022,34 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
+ private void ShiftVideoCodecsIfNeeded(List 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)
@@ -2707,7 +3083,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;
@@ -3040,7 +3416,7 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
- var whichCodec = videoStream.Codec.ToLowerInvariant();
+ var whichCodec = videoStream.Codec?.ToLowerInvariant();
switch (whichCodec)
{
case "avc":
@@ -3068,21 +3444,31 @@ namespace MediaBrowser.Controller.MediaEncoding
var isWindows8orLater = Environment.OSVersion.Version.Major > 6 || (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor > 1);
var isDxvaSupported = _mediaEncoder.SupportsHwaccel("dxva2") || _mediaEncoder.SupportsHwaccel("d3d11va");
- if ((isDxvaSupported || IsVaapiSupported(state)) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase))
+ if (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase))
{
- if (isLinux)
+ // Currently there is no AMF decoder on Linux, only have h264 encoder.
+ if (isDxvaSupported && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase))
{
- return "-hwaccel vaapi";
- }
+ if (isWindows && isWindows8orLater)
+ {
+ return "-hwaccel d3d11va";
+ }
- if (isWindows && isWindows8orLater)
- {
- return "-hwaccel d3d11va";
+ if (isWindows && !isWindows8orLater)
+ {
+ return "-hwaccel dxva2";
+ }
}
+ }
- if (isWindows && !isWindows8orLater)
+ if (string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase))
+ {
+ if (IsVaapiSupported(state) && options.HardwareDecodingCodecs.Contains(videoCodec, StringComparer.OrdinalIgnoreCase))
{
- return "-hwaccel dxva2";
+ if (isLinux)
+ {
+ return "-hwaccel vaapi";
+ }
}
}
@@ -3297,7 +3683,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 6cd0c70d2f..52794a69b3 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -287,6 +287,11 @@ namespace MediaBrowser.Controller.MediaEncoding
return BaseRequest.AudioChannels;
}
+ if (BaseRequest.TranscodingMaxAudioChannels.HasValue)
+ {
+ return BaseRequest.TranscodingMaxAudioChannels;
+ }
+
if (!string.IsNullOrEmpty(codec))
{
var value = BaseRequest.GetOption(codec, "audiochannels");
@@ -404,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;
@@ -588,6 +593,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{
get
{
+ if (VideoStream == null)
+ {
+ return null;
+ }
+
if (EncodingHelper.IsCopyCodec(OutputVideoCodec))
{
return VideoStream?.Codec;
@@ -601,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
{
+ ///
+ /// The request authorization info.
+ ///
public class AuthorizationInfo
{
///
@@ -43,6 +44,19 @@ namespace MediaBrowser.Controller.Net
/// The token.
public string Token { get; set; }
+ ///
+ /// Gets or sets a value indicating whether the authorization is from an api key.
+ ///
+ public bool IsApiKey { get; set; }
+
+ ///
+ /// Gets or sets the user making the request.
+ ///
public User User { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the token is authenticated.
+ ///
+ public bool IsAuthenticated { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Net/IWebSocketManager.cs b/MediaBrowser.Controller/Net/IWebSocketManager.cs
index e9f00ae88b..ce74173e70 100644
--- a/MediaBrowser.Controller/Net/IWebSocketManager.cs
+++ b/MediaBrowser.Controller/Net/IWebSocketManager.cs
@@ -16,12 +16,6 @@ namespace MediaBrowser.Controller.Net
///
event EventHandler> WebSocketConnected;
- ///
- /// Inits this instance.
- ///
- /// The websocket listeners.
- void Init(IEnumerable listeners);
-
///
/// The HTTP request handler.
///
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
/// The item ids.
/// The user identifier.
/// Task.
- Task AddToPlaylistAsync(Guid playlistId, ICollection itemIds, Guid userId);
+ Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection itemIds, Guid userId);
///
/// Removes from playlist.
diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs
index 216dd27098..e8b7be7e20 100644
--- a/MediaBrowser.Controller/Playlists/Playlist.cs
+++ b/MediaBrowser.Controller/Playlists/Playlist.cs
@@ -160,7 +160,7 @@ namespace MediaBrowser.Controller.Playlists
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
Recursive = true,
- IncludeItemTypes = new[] { typeof(Audio).Name },
+ IncludeItemTypes = new[] { nameof(Audio) },
GenreIds = new[] { musicGenre.Id },
OrderBy = new[] { ItemSortBy.AlbumArtist, ItemSortBy.Album, ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(),
DtoOptions = options
@@ -172,7 +172,7 @@ namespace MediaBrowser.Controller.Playlists
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
Recursive = true,
- IncludeItemTypes = new[] { typeof(Audio).Name },
+ IncludeItemTypes = new[] { nameof(Audio) },
ArtistIds = new[] { musicArtist.Id },
OrderBy = new[] { ItemSortBy.AlbumArtist, ItemSortBy.Album, ItemSortBy.SortName }.Select(i => new ValueTuple(i, SortOrder.Ascending)).ToArray(),
DtoOptions = options
diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs
index ce58a60b9a..d09852870e 100644
--- a/MediaBrowser.Controller/Session/SessionInfo.cs
+++ b/MediaBrowser.Controller/Session/SessionInfo.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@@ -54,7 +55,7 @@ namespace MediaBrowser.Controller.Session
/// Gets or sets the playable media types.
///
/// The playable media types.
- public string[] PlayableMediaTypes
+ public IReadOnlyList PlayableMediaTypes
{
get
{
@@ -230,7 +231,7 @@ namespace MediaBrowser.Controller.Session
/// Gets or sets the supported commands.
///
/// The supported commands.
- public GeneralCommandType[] SupportedCommands
+ public IReadOnlyList SupportedCommands
=> Capabilities == null ? Array.Empty() : Capabilities.SupportedCommands;
public Tuple EnsureController(Func factory)
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
///
Task DownloadSubtitles(Video video, LibraryOptions libraryOptions, string subtitleId, CancellationToken cancellationToken);
+ ///
+ /// Upload new subtitle.
+ ///
+ /// The video the subtitle belongs to.
+ /// The subtitle response.
+ /// A representing the asynchronous operation.
+ Task UploadSubtitle(Video video, SubtitleResponse response);
+
///
/// Gets the remote subtitles.
///
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 files, string name)
+ private FileSystemMetadata? GetImage(IEnumerable 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 @@
- netstandard2.1
+ net5.0
false
true
true
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 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 @@
- netstandard2.1
+ net5.0
false
true
true
@@ -25,7 +25,7 @@
-
+
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 22537a4d95..97d61441cf 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;
}
@@ -764,6 +792,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)
{
@@ -953,6 +1010,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)
@@ -1129,7 +1230,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/Activity/IActivityManager.cs b/MediaBrowser.Model/Activity/IActivityManager.cs
index 3e4ea208ed..28073fb8d7 100644
--- a/MediaBrowser.Model/Activity/IActivityManager.cs
+++ b/MediaBrowser.Model/Activity/IActivityManager.cs
@@ -16,5 +16,12 @@ namespace MediaBrowser.Model.Activity
Task CreateAsync(ActivityLog entry);
Task> GetPagedResultAsync(ActivityLogQuery query);
+
+ ///
+ /// Remove all activity logs before the specified date.
+ ///
+ /// Activity log start date.
+ /// A representing the asynchronous operation.
+ Task CleanAsync(DateTime startDate);
}
}
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/Configuration/PathSubstitution.cs b/MediaBrowser.Model/Configuration/PathSubstitution.cs
new file mode 100644
index 0000000000..bffaa85945
--- /dev/null
+++ b/MediaBrowser.Model/Configuration/PathSubstitution.cs
@@ -0,0 +1,20 @@
+#nullable enable
+
+namespace MediaBrowser.Model.Configuration
+{
+ ///
+ /// Defines the .
+ ///
+ public class PathSubstitution
+ {
+ ///
+ /// Gets or sets the value to substitute.
+ ///
+ public string From { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the value to substitution with.
+ ///
+ public string To { get; set; } = string.Empty;
+ }
+}
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index 8b78ad842e..830c8bd102 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -1,5 +1,5 @@
-#nullable disable
#pragma warning disable CS1591
+#pragma warning disable CA1819
using System;
using System.Collections.Generic;
@@ -13,321 +13,23 @@ namespace MediaBrowser.Model.Configuration
///
public class ServerConfiguration : BaseApplicationConfiguration
{
+ ///
+ /// The default value for .
+ ///
public const int DefaultHttpPort = 8096;
+
+ ///
+ /// The default value for and .
+ ///
public const int DefaultHttpsPort = 8920;
- private string _baseUrl;
- ///
- /// Gets or sets a value indicating whether to enable automatic port forwarding.
- ///
- public bool EnableUPnP { get; set; }
-
- ///
- /// Gets or sets a value indicating whether to enable prometheus metrics exporting.
- ///
- public bool EnableMetrics { get; set; }
-
- ///
- /// Gets or sets the public mapped port.
- ///
- /// The public mapped port.
- public int PublicPort { get; set; }
-
- ///
- /// Gets or sets the public HTTPS port.
- ///
- /// The public HTTPS port.
- public int PublicHttpsPort { get; set; }
-
- ///
- /// Gets or sets the HTTP server port number.
- ///
- /// The HTTP server port number.
- public int HttpServerPortNumber { get; set; }
-
- ///
- /// Gets or sets the HTTPS server port number.
- ///
- /// The HTTPS server port number.
- public int HttpsPortNumber { get; set; }
-
- ///
- /// Gets or sets a value indicating whether to use HTTPS.
- ///
- ///
- /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
- /// provided for and .
- ///
- public bool EnableHttps { get; set; }
-
- public bool EnableNormalizedItemByNameIds { get; set; }
-
- ///
- /// Gets or sets the filesystem path of an X.509 certificate to use for SSL.
- ///
- public string CertificatePath { get; set; }
-
- ///
- /// Gets or sets the password required to access the X.509 certificate data in the file specified by .
- ///
- public string CertificatePassword { get; set; }
-
- ///
- /// Gets or sets a value indicating whether this instance is port authorized.
- ///
- /// true if this instance is port authorized; otherwise, false.
- public bool IsPortAuthorized { get; set; }
-
- ///
- /// Gets or sets if quick connect is available for use on this server.
- ///
- public bool QuickConnectAvailable { get; set; }
-
- public bool EnableRemoteAccess { get; set; }
-
- ///
- /// Gets or sets a value indicating whether [enable case sensitive item ids].
- ///
- /// true if [enable case sensitive item ids]; otherwise, false.
- public bool EnableCaseSensitiveItemIds { get; set; }
-
- public bool DisableLiveTvChannelUserDataName { get; set; }
-
- ///
- /// Gets or sets the metadata path.
- ///
- /// The metadata path.
- public string MetadataPath { get; set; }
-
- public string MetadataNetworkPath { get; set; }
-
- ///
- /// Gets or sets the preferred metadata language.
- ///
- /// The preferred metadata language.
- public string PreferredMetadataLanguage { get; set; }
-
- ///
- /// Gets or sets the metadata country code.
- ///
- /// The metadata country code.
- public string MetadataCountryCode { get; set; }
-
- ///
- /// Characters to be replaced with a ' ' in strings to create a sort name.
- ///
- /// The sort replace characters.
- public string[] SortReplaceCharacters { get; set; }
-
- ///
- /// Characters to be removed from strings to create a sort name.
- ///
- /// The sort remove characters.
- public string[] SortRemoveCharacters { get; set; }
-
- ///
- /// Words to be removed from strings to create a sort name.
- ///
- /// The sort remove words.
- public string[] SortRemoveWords { get; set; }
-
- ///
- /// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated.
- ///
- /// The min resume PCT.
- public int MinResumePct { get; set; }
-
- ///
- /// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched.
- ///
- /// The max resume PCT.
- public int MaxResumePct { get; set; }
-
- ///
- /// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates..
- ///
- /// The min resume duration seconds.
- public int MinResumeDurationSeconds { get; set; }
-
- ///
- /// The delay in seconds that we will wait after a file system change to try and discover what has been added/removed
- /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several
- /// different directories and files.
- ///
- /// The file watcher delay.
- public int LibraryMonitorDelay { get; set; }
-
- ///
- /// Gets or sets a value indicating whether [enable dashboard response caching].
- /// Allows potential contributors without visual studio to modify production dashboard code and test changes.
- ///
- /// true if [enable dashboard response caching]; otherwise, false.
- public bool EnableDashboardResponseCaching { get; set; }
-
- ///
- /// Gets or sets the image saving convention.
- ///
- /// The image saving convention.
- public ImageSavingConvention ImageSavingConvention { get; set; }
-
- public MetadataOptions[] MetadataOptions { get; set; }
-
- public bool SkipDeserializationForBasicTypes { get; set; }
-
- public string ServerName { get; set; }
-
- public string BaseUrl
- {
- get => _baseUrl;
- set
- {
- // Normalize the start of the string
- if (string.IsNullOrWhiteSpace(value))
- {
- // If baseUrl is empty, set an empty prefix string
- _baseUrl = string.Empty;
- return;
- }
-
- if (value[0] != '/')
- {
- // If baseUrl was not configured with a leading slash, append one for consistency
- value = "/" + value;
- }
-
- // Normalize the end of the string
- if (value[value.Length - 1] == '/')
- {
- // If baseUrl was configured with a trailing slash, remove it for consistency
- value = value.Remove(value.Length - 1);
- }
-
- _baseUrl = value;
- }
- }
-
- public string UICulture { get; set; }
-
- public bool SaveMetadataHidden { get; set; }
-
- public NameValuePair[] ContentTypes { get; set; }
-
- public int RemoteClientBitrateLimit { get; set; }
-
- public bool EnableFolderView { get; set; }
-
- public bool EnableGroupingIntoCollections { get; set; }
-
- public bool DisplaySpecialsWithinSeasons { get; set; }
-
- public string[] LocalNetworkSubnets { get; set; }
-
- public string[] LocalNetworkAddresses { get; set; }
-
- public string[] CodecsUsed { get; set; }
-
- public List PluginRepositories { get; set; }
-
- public bool IgnoreVirtualInterfaces { get; set; }
-
- public bool EnableExternalContentInSuggestions { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the server should force connections over HTTPS.
- ///
- public bool RequireHttps { get; set; }
-
- public bool EnableNewOmdbSupport { get; set; }
-
- public string[] RemoteIPFilter { get; set; }
-
- public bool IsRemoteIPFilterBlacklist { get; set; }
-
- public int ImageExtractionTimeoutMs { get; set; }
-
- public PathSubstitution[] PathSubstitutions { get; set; }
-
- public bool EnableSimpleArtistDetection { get; set; }
-
- public string[] UninstalledPlugins { get; set; }
-
- ///
- /// Gets or sets a value indicating whether slow server responses should be logged as a warning.
- ///
- public bool EnableSlowResponseWarning { get; set; }
-
- ///
- /// Gets or sets the threshold for the slow response time warning in ms.
- ///
- public long SlowResponseThresholdMs { get; set; }
-
- ///
- /// Gets or sets the cors hosts.
- ///
- public string[] CorsHosts { get; set; }
-
- ///
- /// Gets or sets the known proxies.
- ///
- public string[] KnownProxies { get; set; }
+ private string _baseUrl = string.Empty;
///
/// Initializes a new instance of the class.
///
public ServerConfiguration()
{
- UninstalledPlugins = Array.Empty();
- RemoteIPFilter = Array.Empty();
- LocalNetworkSubnets = Array.Empty();
- LocalNetworkAddresses = Array.Empty();
- CodecsUsed = Array.Empty();
- PathSubstitutions = Array.Empty();
- IgnoreVirtualInterfaces = false;
- EnableSimpleArtistDetection = false;
- SkipDeserializationForBasicTypes = true;
-
- PluginRepositories = new List();
-
- DisplaySpecialsWithinSeasons = true;
- EnableExternalContentInSuggestions = true;
-
- ImageSavingConvention = ImageSavingConvention.Compatible;
- PublicPort = DefaultHttpPort;
- PublicHttpsPort = DefaultHttpsPort;
- HttpServerPortNumber = DefaultHttpPort;
- HttpsPortNumber = DefaultHttpsPort;
- EnableMetrics = false;
- EnableHttps = false;
- EnableDashboardResponseCaching = true;
- EnableCaseSensitiveItemIds = true;
- EnableNormalizedItemByNameIds = true;
- DisableLiveTvChannelUserDataName = true;
- EnableNewOmdbSupport = true;
-
- EnableRemoteAccess = true;
- QuickConnectAvailable = false;
-
- EnableUPnP = false;
- MinResumePct = 5;
- MaxResumePct = 90;
-
- // 5 minutes
- MinResumeDurationSeconds = 300;
-
- LibraryMonitorDelay = 60;
-
- ContentTypes = Array.Empty();
-
- PreferredMetadataLanguage = "en";
- MetadataCountryCode = "US";
-
- SortReplaceCharacters = new[] { ".", "+", "%" };
- SortRemoveCharacters = new[] { ",", "&", "-", "{", "}", "'" };
- SortRemoveWords = new[] { "the", "a", "an" };
-
- BaseUrl = string.Empty;
- UICulture = "en-US";
-
MetadataOptions = new[]
{
new MetadataOptions()
@@ -376,18 +78,366 @@ namespace MediaBrowser.Model.Configuration
DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" }
}
};
-
- EnableSlowResponseWarning = true;
- SlowResponseThresholdMs = 500;
- CorsHosts = new[] { "*" };
- KnownProxies = Array.Empty();
}
- }
- public class PathSubstitution
- {
- public string From { get; set; }
+ ///
+ /// Gets or sets a value indicating whether to enable automatic port forwarding.
+ ///
+ public bool EnableUPnP { get; set; } = false;
- public string To { get; set; }
+ ///
+ /// Gets or sets a value indicating whether to enable prometheus metrics exporting.
+ ///
+ public bool EnableMetrics { get; set; } = false;
+
+ ///
+ /// Gets or sets the public mapped port.
+ ///
+ /// The public mapped port.
+ public int PublicPort { get; set; } = DefaultHttpPort;
+
+ ///
+ /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
+ ///
+ public bool UPnPCreateHttpPortMap { get; set; } = false;
+
+ ///
+ /// Gets or sets client udp port range.
+ ///
+ public string UDPPortRange { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether IPV6 capability is enabled.
+ ///
+ public bool EnableIPV6 { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether IPV4 capability is enabled.
+ ///
+ public bool EnableIPV4 { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether detailed ssdp logs are sent to the console/log.
+ /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to work.
+ ///
+ public bool EnableSSDPTracing { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log.
+ /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
+ ///
+ public string SSDPTracingFilter { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the number of times SSDP UDP messages are sent.
+ ///
+ public int UDPSendCount { get; set; } = 2;
+
+ ///
+ /// Gets or sets the delay between each groups of SSDP messages (in ms).
+ ///
+ public int UDPSendDelay { get; set; } = 100;
+
+ ///
+ /// Gets or sets a value indicating whether address names that match should be Ignore for the purposes of binding.
+ ///
+ public bool IgnoreVirtualInterfaces { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. .
+ ///
+ public string VirtualInterfaceNames { get; set; } = "vEthernet*";
+
+ ///
+ /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
+ ///
+ public int GatewayMonitorPeriod { get; set; } = 60;
+
+ ///
+ /// Gets a value indicating whether multi-socket binding is available.
+ ///
+ public bool EnableMultiSocketBinding { get; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network.
+ /// Depending on the address range implemented ULA ranges might not be used.
+ ///
+ public bool TrustAllIP6Interfaces { get; set; } = false;
+
+ ///
+ /// Gets or sets the ports that HDHomerun uses.
+ ///
+ public string HDHomerunPortRange { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets PublishedServerUri to advertise for specific subnets.
+ ///
+ public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
+ ///
+ public bool AutoDiscoveryTracing { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether Autodiscovery is enabled.
+ ///
+ public bool AutoDiscovery { get; set; } = true;
+
+ ///
+ /// Gets or sets the public HTTPS port.
+ ///
+ /// The public HTTPS port.
+ public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
+
+ ///
+ /// Gets or sets the HTTP server port number.
+ ///
+ /// The HTTP server port number.
+ public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
+
+ ///
+ /// Gets or sets the HTTPS server port number.
+ ///
+ /// The HTTPS server port number.
+ public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
+
+ ///
+ /// Gets or sets a value indicating whether to use HTTPS.
+ ///
+ ///
+ /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
+ /// provided for and .
+ ///
+ public bool EnableHttps { get; set; } = false;
+
+ public bool EnableNormalizedItemByNameIds { get; set; } = true;
+
+ ///
+ /// Gets or sets the filesystem path of an X.509 certificate to use for SSL.
+ ///
+ public string CertificatePath { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the password required to access the X.509 certificate data in the file specified by .
+ ///
+ public string CertificatePassword { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether this instance is port authorized.
+ ///
+ /// true if this instance is port authorized; otherwise, false.
+ public bool IsPortAuthorized { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether quick connect is available for use on this server.
+ ///
+ public bool QuickConnectAvailable { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether access outside of the LAN is permitted.
+ ///
+ public bool EnableRemoteAccess { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether [enable case sensitive item ids].
+ ///
+ /// true if [enable case sensitive item ids]; otherwise, false.
+ public bool EnableCaseSensitiveItemIds { get; set; } = true;
+
+ public bool DisableLiveTvChannelUserDataName { get; set; } = true;
+
+ ///
+ /// Gets or sets the metadata path.
+ ///
+ /// The metadata path.
+ public string MetadataPath { get; set; } = string.Empty;
+
+ public string MetadataNetworkPath { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the preferred metadata language.
+ ///
+ /// The preferred metadata language.
+ public string PreferredMetadataLanguage { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the metadata country code.
+ ///
+ /// The metadata country code.
+ public string MetadataCountryCode { get; set; } = "US";
+
+ ///
+ /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name.
+ ///
+ /// The sort replace characters.
+ public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" };
+
+ ///
+ /// Gets or sets characters to be removed from strings to create a sort name.
+ ///
+ /// The sort remove characters.
+ public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" };
+
+ ///
+ /// Gets or sets words to be removed from strings to create a sort name.
+ ///
+ /// The sort remove words.
+ public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" };
+
+ ///
+ /// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated.
+ ///
+ /// The min resume PCT.
+ public int MinResumePct { get; set; } = 5;
+
+ ///
+ /// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched.
+ ///
+ /// The max resume PCT.
+ public int MaxResumePct { get; set; } = 90;
+
+ ///
+ /// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates..
+ ///
+ /// The min resume duration seconds.
+ public int MinResumeDurationSeconds { get; set; } = 300;
+
+ ///
+ /// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed
+ /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several
+ /// different directories and files.
+ ///
+ /// The file watcher delay.
+ public int LibraryMonitorDelay { get; set; } = 60;
+
+ ///
+ /// Gets or sets a value indicating whether [enable dashboard response caching].
+ /// Allows potential contributors without visual studio to modify production dashboard code and test changes.
+ ///
+ /// true if [enable dashboard response caching]; otherwise, false.
+ public bool EnableDashboardResponseCaching { get; set; } = true;
+
+ ///
+ /// Gets or sets the image saving convention.
+ ///
+ /// The image saving convention.
+ public ImageSavingConvention ImageSavingConvention { get; set; }
+
+ public MetadataOptions[] MetadataOptions { get; set; }
+
+ public bool SkipDeserializationForBasicTypes { get; set; } = true;
+
+ public string ServerName { get; set; } = string.Empty;
+
+ public string BaseUrl
+ {
+ get => _baseUrl;
+ set
+ {
+ // Normalize the start of the string
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ // If baseUrl is empty, set an empty prefix string
+ _baseUrl = string.Empty;
+ return;
+ }
+
+ if (value[0] != '/')
+ {
+ // If baseUrl was not configured with a leading slash, append one for consistency
+ value = "/" + value;
+ }
+
+ // Normalize the end of the string
+ if (value[value.Length - 1] == '/')
+ {
+ // If baseUrl was configured with a trailing slash, remove it for consistency
+ value = value.Remove(value.Length - 1);
+ }
+
+ _baseUrl = value;
+ }
+ }
+
+ public string UICulture { get; set; } = "en-US";
+
+ public bool SaveMetadataHidden { get; set; } = false;
+
+ public NameValuePair[] ContentTypes { get; set; } = Array.Empty();
+
+ public int RemoteClientBitrateLimit { get; set; } = 0;
+
+ public bool EnableFolderView { get; set; } = false;
+
+ public bool EnableGroupingIntoCollections { get; set; } = false;
+
+ public bool DisplaySpecialsWithinSeasons { get; set; } = true;
+
+ ///
+ /// Gets or sets the subnets that are deemed to make up the LAN.
+ ///
+ public string[] LocalNetworkSubnets { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
+ ///
+ public string[] LocalNetworkAddresses { get; set; } = Array.Empty();
+
+ public string[] CodecsUsed { get; set; } = Array.Empty();
+
+ public List PluginRepositories { get; set; } = new List();
+
+ public bool EnableExternalContentInSuggestions { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the server should force connections over HTTPS.
+ ///
+ public bool RequireHttps { get; set; } = false;
+
+ public bool EnableNewOmdbSupport { get; set; } = true;
+
+ ///
+ /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with .
+ ///
+ public string[] RemoteIPFilter { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets a value indicating whether contains a blacklist or a whitelist. Default is a whitelist.
+ ///
+ public bool IsRemoteIPFilterBlacklist { get; set; } = false;
+
+ public int ImageExtractionTimeoutMs { get; set; } = 0;
+
+ public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty();
+
+ public bool EnableSimpleArtistDetection { get; set; } = false;
+
+ public string[] UninstalledPlugins { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets a value indicating whether slow server responses should be logged as a warning.
+ ///
+ public bool EnableSlowResponseWarning { get; set; } = true;
+
+ ///
+ /// Gets or sets the threshold for the slow response time warning in ms.
+ ///
+ public long SlowResponseThresholdMs { get; set; } = 500;
+
+ ///
+ /// Gets or sets the cors hosts.
+ ///
+ public string[] CorsHosts { get; set; } = new[] { "*" };
+
+ ///
+ /// Gets or sets the known proxies.
+ ///
+ public string[] KnownProxies { get; set; } = Array.Empty();
+
+ ///
+ /// Gets or sets the number of days we should retain activity logs.
+ ///
+ public int? ActivityLogRetentionDays { get; set; } = 30;
}
}
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
///
/// The application's configured quality setting.
///
- public long? MaxBitrate { get; set; }
+ public int? MaxBitrate { get; set; }
///
/// Gets or sets the context.
@@ -67,7 +67,7 @@ namespace MediaBrowser.Model.Dlna
/// Gets the maximum bitrate.
///
/// System.Nullable<System.Int32>.
- 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();
}
- 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.
///
/// The name of the friendly.
- public string FriendlyName { get; set; }
+ public string FriendlyName { get; set; } = string.Empty;
///
/// Gets or sets the model number.
///
/// The model number.
- public string ModelNumber { get; set; }
+ public string ModelNumber { get; set; } = string.Empty;
///
/// Gets or sets the serial number.
///
/// The serial number.
- public string SerialNumber { get; set; }
+ public string SerialNumber { get; set; } = string.Empty;
///
/// Gets or sets the name of the model.
///
/// The name of the model.
- public string ModelName { get; set; }
+ public string ModelName { get; set; } = string.Empty;
///
/// Gets or sets the model description.
///
/// The model description.
- public string ModelDescription { get; set; }
+ public string ModelDescription { get; set; } = string.Empty;
///
/// Gets or sets the model URL.
///
/// The model URL.
- public string ModelUrl { get; set; }
+ public string ModelUrl { get; set; } = string.Empty;
///
/// Gets or sets the manufacturer.
///
/// The manufacturer.
- public string Manufacturer { get; set; }
+ public string Manufacturer { get; set; } = string.Empty;
///
/// Gets or sets the manufacturer URL.
///
/// The manufacturer URL.
- public string ManufacturerUrl { get; set; }
+ public string ManufacturerUrl { get; set; } = string.Empty;
///
/// Gets or sets the headers.
///
/// The headers.
- public HttpHeaderInfo[] Headers { get; set; }
-
- public DeviceIdentification()
- {
- Headers = Array.Empty();
- }
+ public HttpHeaderInfo[] Headers { get; set; } = Array.Empty();
}
}
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
{
+ ///
+ /// Defines the .
+ ///
[XmlRoot("Profile")]
public class DeviceProfile
{
///
- /// Gets or sets the name.
+ /// Initializes a new instance of the class.
///
- /// The name.
- public string Name { get; set; }
-
- [XmlIgnore]
- public string Id { get; set; }
-
- ///
- /// Gets or sets the identification.
- ///
- /// The identification.
- 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; }
-
- ///
- /// Controls the content of the aggregationFlags element in the urn:schemas-sonycom:av namespace.
- ///
- 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; }
-
- ///
- /// Gets or sets the direct play profiles.
- ///
- /// The direct play profiles.
- public DirectPlayProfile[] DirectPlayProfiles { get; set; }
-
- ///
- /// Gets or sets the transcoding profiles.
- ///
- /// The transcoding profiles.
- 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();
@@ -126,11 +33,217 @@ namespace MediaBrowser.Model.Dlna
MusicStreamingTranscodingBitrate = 128000;
}
+ ///
+ /// Gets or sets the Name.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Gets or sets the Id.
+ ///
+ [XmlIgnore]
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets the Identification.
+ ///
+ public DeviceIdentification Identification { get; set; }
+
+ ///
+ /// Gets or sets the FriendlyName.
+ ///
+ public string FriendlyName { get; set; }
+
+ ///
+ /// Gets or sets the Manufacturer.
+ ///
+ public string Manufacturer { get; set; }
+
+ ///
+ /// Gets or sets the ManufacturerUrl.
+ ///
+ public string ManufacturerUrl { get; set; }
+
+ ///
+ /// Gets or sets the ModelName.
+ ///
+ public string ModelName { get; set; }
+
+ ///
+ /// Gets or sets the ModelDescription.
+ ///
+ public string ModelDescription { get; set; }
+
+ ///
+ /// Gets or sets the ModelNumber.
+ ///
+ public string ModelNumber { get; set; }
+
+ ///
+ /// Gets or sets the ModelUrl.
+ ///
+ public string ModelUrl { get; set; }
+
+ ///
+ /// Gets or sets the SerialNumber.
+ ///
+ public string SerialNumber { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether EnableAlbumArtInDidl.
+ ///
+ public bool EnableAlbumArtInDidl { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether EnableSingleAlbumArtLimit.
+ ///
+ public bool EnableSingleAlbumArtLimit { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether EnableSingleSubtitleLimit.
+ ///
+ public bool EnableSingleSubtitleLimit { get; set; }
+
+ ///
+ /// Gets or sets the SupportedMediaTypes.
+ ///
+ public string SupportedMediaTypes { get; set; }
+
+ ///
+ /// Gets or sets the UserId.
+ ///
+ public string UserId { get; set; }
+
+ ///
+ /// Gets or sets the AlbumArtPn.
+ ///
+ public string AlbumArtPn { get; set; }
+
+ ///
+ /// Gets or sets the MaxAlbumArtWidth.
+ ///
+ public int MaxAlbumArtWidth { get; set; }
+
+ ///
+ /// Gets or sets the MaxAlbumArtHeight.
+ ///
+ public int MaxAlbumArtHeight { get; set; }
+
+ ///
+ /// Gets or sets the MaxIconWidth.
+ ///
+ public int? MaxIconWidth { get; set; }
+
+ ///
+ /// Gets or sets the MaxIconHeight.
+ ///
+ public int? MaxIconHeight { get; set; }
+
+ ///
+ /// Gets or sets the MaxStreamingBitrate.
+ ///
+ public int? MaxStreamingBitrate { get; set; }
+
+ ///
+ /// Gets or sets the MaxStaticBitrate.
+ ///
+ public int? MaxStaticBitrate { get; set; }
+
+ ///
+ /// Gets or sets the MusicStreamingTranscodingBitrate.
+ ///
+ public int? MusicStreamingTranscodingBitrate { get; set; }
+
+ ///
+ /// Gets or sets the MaxStaticMusicBitrate.
+ ///
+ public int? MaxStaticMusicBitrate { get; set; }
+
+ ///
+ /// Gets or sets the content of the aggregationFlags element in the urn:schemas-sonycom:av namespace.
+ ///
+ public string SonyAggregationFlags { get; set; }
+
+ ///
+ /// Gets or sets the ProtocolInfo.
+ ///
+ public string ProtocolInfo { get; set; }
+
+ ///
+ /// Gets or sets the TimelineOffsetSeconds.
+ ///
+ public int TimelineOffsetSeconds { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether RequiresPlainVideoItems.
+ ///
+ public bool RequiresPlainVideoItems { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether RequiresPlainFolders.
+ ///
+ public bool RequiresPlainFolders { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether EnableMSMediaReceiverRegistrar.
+ ///
+ public bool EnableMSMediaReceiverRegistrar { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether IgnoreTranscodeByteRangeRequests.
+ ///
+ public bool IgnoreTranscodeByteRangeRequests { get; set; }
+
+ ///
+ /// Gets or sets the XmlRootAttributes.
+ ///
+ public XmlAttribute[] XmlRootAttributes { get; set; }
+
+ ///
+ /// Gets or sets the direct play profiles.
+ ///
+ public DirectPlayProfile[] DirectPlayProfiles { get; set; }
+
+ ///
+ /// Gets or sets the transcoding profiles.
+ ///
+ public TranscodingProfile[] TranscodingProfiles { get; set; }
+
+ ///
+ /// Gets or sets the ContainerProfiles.
+ ///
+ public ContainerProfile[] ContainerProfiles { get; set; }
+
+ ///
+ /// Gets or sets the CodecProfiles.
+ ///
+ public CodecProfile[] CodecProfiles { get; set; }
+
+ ///
+ /// Gets or sets the ResponseProfiles.
+ ///
+ public ResponseProfile[] ResponseProfiles { get; set; }
+
+ ///
+ /// Gets or sets the SubtitleProfiles.
+ ///
+ public SubtitleProfile[] SubtitleProfiles { get; set; }
+
+ ///
+ /// The GetSupportedMediaTypes.
+ ///
+ /// The .
public string[] GetSupportedMediaTypes()
{
return ContainerProfile.SplitValue(SupportedMediaTypes);
}
+ ///
+ /// Gets the audio transcoding profile.
+ ///
+ /// The container.
+ /// The audio Codec.
+ /// A .
public TranscodingProfile GetAudioTranscodingProfile(string container, string audioCodec)
{
container = (container ?? string.Empty).TrimStart('.');
@@ -158,6 +271,13 @@ namespace MediaBrowser.Model.Dlna
return null;
}
+ ///
+ /// Gets the video transcoding profile.
+ ///
+ /// The container.
+ /// The audio Codec.
+ /// The video Codec.
+ /// The .
public TranscodingProfile GetVideoTranscodingProfile(string container, string audioCodec, string videoCodec)
{
container = (container ?? string.Empty).TrimStart('.');
@@ -190,6 +310,16 @@ namespace MediaBrowser.Model.Dlna
return null;
}
+ ///
+ /// Gets the audio media profile.
+ ///
+ /// The container.
+ /// The audio codec.
+ /// The audio channels.
+ /// The audio bitrate.
+ /// The audio sample rate.
+ /// The audio bit depth.
+ /// The .
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;
}
+ ///
+ /// Gets the model profile condition.
+ ///
+ /// The c.
+ /// The .
private ProfileCondition GetModelProfileCondition(ProfileCondition c)
{
return new ProfileCondition
@@ -242,6 +377,13 @@ namespace MediaBrowser.Model.Dlna
};
}
+ ///
+ /// Gets the image media profile.
+ ///
+ /// The container.
+ /// The width.
+ /// The height.
+ /// The .
public ResponseProfile GetImageMediaProfile(string container, int? width, int? height)
{
foreach (var i in ResponseProfiles)
@@ -277,6 +419,29 @@ namespace MediaBrowser.Model.Dlna
return null;
}
+ ///
+ /// Gets the video media profile.
+ ///
+ /// The container.
+ /// The audio codec.
+ /// The video codec.
+ /// The width.
+ /// The height.
+ /// The bit depth.
+ /// The video bitrate.
+ /// The video profile.
+ /// The video level.
+ /// The video framerate.
+ /// The packet length.
+ /// The timestamp.
+ /// True if anamorphic.
+ /// True if interlaced.
+ /// The ref frames.
+ /// The number of video streams.
+ /// The number of audio streams.
+ /// The video Codec tag.
+ /// True if Avc.
+ /// The .
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) 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
{
+ ///
+ /// Defines the .
+ ///
public class XmlAttribute
{
+ ///
+ /// Gets or sets the name of the attribute.
+ ///
[XmlAttribute("name")]
public string Name { get; set; }
+ ///
+ /// Gets or sets the value of the attribute.
+ ///
[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 @@
- netstandard2.0;netstandard2.1
+ net5.0
false
true
true
@@ -32,11 +32,11 @@
-
+
-
+
-
+
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 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
-{
- ///
- /// Class HttpException.
- ///
- public class HttpException : Exception
- {
- ///
- /// Gets or sets the status code.
- ///
- /// The status code.
- public HttpStatusCode? StatusCode { get; set; }
-
- ///
- /// Gets or sets a value indicating whether this instance is timed out.
- ///
- /// true if this instance is timed out; otherwise, false.
- public bool IsTimedOut { get; set; }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The message.
- /// The inner exception.
- public HttpException(string message, Exception innerException)
- : base(message, innerException)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The message.
- 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 ItemIdList { get; set; } = Array.Empty();
public string MediaType { get; set; }
public Guid UserId { get; set; }
-
- public PlaylistCreationRequest()
- {
- ItemIdList = Array.Empty();
- }
}
}
diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs
index a85e6ff2a4..5852f4e37a 100644
--- a/MediaBrowser.Model/Session/ClientCapabilities.cs
+++ b/MediaBrowser.Model/Session/ClientCapabilities.cs
@@ -2,15 +2,16 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Generic;
using MediaBrowser.Model.Dlna;
namespace MediaBrowser.Model.Session
{
public class ClientCapabilities
{
- public string[] PlayableMediaTypes { get; set; }
+ public IReadOnlyList PlayableMediaTypes { get; set; }
- public GeneralCommandType[] SupportedCommands { get; set; }
+ public IReadOnlyList SupportedCommands { get; set; }
public bool SupportsMediaControl { get; set; }
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
+{
+ ///
+ /// Class FontFile.
+ ///
+ public class FontFile
+ {
+ ///
+ /// Gets or sets the name.
+ ///
+ /// The name.
+ public string? Name { get; set; }
+
+ ///
+ /// Gets or sets the size.
+ ///
+ /// The size.
+ public long Size { get; set; }
+
+ ///
+ /// Gets or sets the date created.
+ ///
+ /// The date created.
+ public DateTime DateCreated { get; set; }
+
+ ///
+ /// Gets or sets the date modified.
+ ///
+ /// The date modified.
+ public DateTime DateModified { get; set; }
+ }
+}
diff --git a/MediaBrowser.Model/Updates/PackageInfo.cs b/MediaBrowser.Model/Updates/PackageInfo.cs
index 98b151d551..5e93043639 100644
--- a/MediaBrowser.Model/Updates/PackageInfo.cs
+++ b/MediaBrowser.Model/Updates/PackageInfo.cs
@@ -50,17 +50,7 @@ namespace MediaBrowser.Model.Updates
/// Gets or sets the versions.
///
/// The versions.
- public IReadOnlyList versions { get; set; }
-
- ///
- /// Gets or sets the repository name.
- ///
- public string repositoryName { get; set; }
-
- ///
- /// Gets or sets the repository url.
- ///
- public string repositoryUrl { get; set; }
+ public IList versions { get; set; }
///
/// Initializes a new instance of the class.
diff --git a/MediaBrowser.Model/Updates/RepositoryInfo.cs b/MediaBrowser.Model/Updates/RepositoryInfo.cs
index bd42e77f0f..705d3b33cc 100644
--- a/MediaBrowser.Model/Updates/RepositoryInfo.cs
+++ b/MediaBrowser.Model/Updates/RepositoryInfo.cs
@@ -16,5 +16,11 @@ namespace MediaBrowser.Model.Updates
///
/// The URL.
public string? Url { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the repository is enabled.
+ ///
+ /// true if enabled.
+ public bool Enabled { get; set; } = true;
}
}
diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs
index a4aa0e75f3..844170999a 100644
--- a/MediaBrowser.Model/Updates/VersionInfo.cs
+++ b/MediaBrowser.Model/Updates/VersionInfo.cs
@@ -9,11 +9,29 @@ namespace MediaBrowser.Model.Updates
///
public class VersionInfo
{
+ private Version _version;
+
///
/// Gets or sets the version.
///
/// The version.
- public string version { get; set; }
+ public string version
+ {
+ get
+ {
+ return _version == null ? string.Empty : _version.ToString();
+ }
+
+ set
+ {
+ _version = Version.Parse(value);
+ }
+ }
+
+ ///
+ /// Gets the version as a .
+ ///
+ public Version VersionNumber => _version;
///
/// Gets or sets the changelog for this version.
@@ -44,5 +62,15 @@ namespace MediaBrowser.Model.Updates
///
/// The timestamp.
public string timestamp { get; set; }
+
+ ///
+ /// Gets or sets the repository name.
+ ///
+ public string repositoryName { get; set; }
+
+ ///
+ /// Gets or sets the repository url.
+ ///
+ public string repositoryUrl { 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 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..6dbce3067e 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -229,16 +229,16 @@ namespace MediaBrowser.Providers.Manager
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
}
- private async Task SavePeopleMetadataAsync(List people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
+ private Task SavePeopleMetadataAsync(List people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
+ var personsToSave = new List();
+
foreach (var person in people)
{
cancellationToken.ThrowIfCancellationRequested();
if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl))
{
- var updateType = ItemUpdateType.MetadataDownload;
-
var saveEntity = false;
var personEntity = LibraryManager.GetPerson(person.Name);
foreach (var id in person.ProviderIds)
@@ -252,42 +252,27 @@ 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;
}
if (saveEntity)
{
- await personEntity.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false);
+ personsToSave.Add(personEntity);
}
}
}
- }
- 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);
+ LibraryManager.RunMetadataSavers(personsToSave, ItemUpdateType.MetadataDownload);
+ LibraryManager.CreateItems(personsToSave, null, CancellationToken.None);
+ return Task.CompletedTask;
}
protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
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 _activeRefreshes = new ConcurrentDictionary();
private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
private readonly SimplePriorityQueue> _refreshQueue =
@@ -74,6 +76,7 @@ namespace MediaBrowser.Providers.Manager
/// The filesystem.
/// The server application paths.
/// The library manager.
+ /// The BaseItem manager.
public ProviderManager(
IHttpClientFactory httpClientFactory,
ISubtitleManager subtitleManager,
@@ -82,7 +85,8 @@ namespace MediaBrowser.Providers.Manager
ILogger 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;
}
///
@@ -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 24400eae53..accdea36e4 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -16,17 +16,17 @@
-
-
-
-
-
+
+
+
+
+
- netstandard2.1
+ net5.0
false
true
true
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 abfa1c6e71..8951cb62e4 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -46,6 +46,7 @@ namespace MediaBrowser.Providers.Music
private readonly string _musicBrainzBaseUrl;
+ private SemaphoreSlim _apiRequestLock = new SemaphoreSlim(1, 1);
private Stopwatch _stopWatchMusicBrainz = new Stopwatch();
public MusicBrainzAlbumProvider(
@@ -124,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);
}
@@ -283,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
{
@@ -306,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()
{
@@ -621,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
{
@@ -648,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
{
@@ -742,48 +743,49 @@ namespace MediaBrowser.Providers.Music
///
internal async Task GetMusicBrainzResponse(string url, CancellationToken cancellationToken)
{
- using var options = new HttpRequestMessage(HttpMethod.Get, _musicBrainzBaseUrl.TrimEnd('/') + url);
+ await _apiRequestLock.WaitAsync(cancellationToken).ConfigureAwait(false);
- // MusicBrainz request a contact email address is supplied, as comment, in user agent field:
- // https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting#User-Agent
- options.Headers.UserAgent.ParseAdd(string.Format(
- CultureInfo.InvariantCulture,
- "{0} ( {1} )",
- _appHost.ApplicationUserAgent,
- _appHost.ApplicationUserAgentAddress));
-
- HttpResponseMessage response;
- var attempts = 0u;
-
- do
+ try
{
- attempts++;
+ HttpResponseMessage response;
+ var attempts = 0u;
+ var requestUrl = _musicBrainzBaseUrl.TrimEnd('/') + url;
- if (_stopWatchMusicBrainz.ElapsedMilliseconds < _musicBrainzQueryIntervalMs)
+ do
{
- // MusicBrainz is extremely adamant about limiting to one request per second
- var delayMs = _musicBrainzQueryIntervalMs - _stopWatchMusicBrainz.ElapsedMilliseconds;
- await Task.Delay((int)delayMs, cancellationToken).ConfigureAwait(false);
+ attempts++;
+
+ if (_stopWatchMusicBrainz.ElapsedMilliseconds < _musicBrainzQueryIntervalMs)
+ {
+ // MusicBrainz is extremely adamant about limiting to one request per second.
+ var delayMs = _musicBrainzQueryIntervalMs - _stopWatchMusicBrainz.ElapsedMilliseconds;
+ await Task.Delay((int)delayMs, cancellationToken).ConfigureAwait(false);
+ }
+
+ // Write time since last request to debug log as evidence we're meeting rate limit
+ // requirement, before resetting stopwatch back to zero.
+ _logger.LogDebug("GetMusicBrainzResponse: Time since previous request: {0} ms", _stopWatchMusicBrainz.ElapsedMilliseconds);
+ _stopWatchMusicBrainz.Restart();
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
+ response = await _httpClientFactory.CreateClient(NamedClient.MusicBrainz).SendAsync(request).ConfigureAwait(false);
+
+ // We retry a finite number of times, and only whilst MB is indicating 503 (throttling).
+ }
+ while (attempts < MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable);
+
+ // Log error if unable to query MB database due to throttling.
+ if (attempts == MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable)
+ {
+ _logger.LogError("GetMusicBrainzResponse: 503 Service Unavailable (throttled) response received {0} times whilst requesting {1}", attempts, requestUrl);
}
- // Write time since last request to debug log as evidence we're meeting rate limit
- // requirement, before resetting stopwatch back to zero.
- _logger.LogDebug("GetMusicBrainzResponse: Time since previous request: {0} ms", _stopWatchMusicBrainz.ElapsedMilliseconds);
- _stopWatchMusicBrainz.Restart();
-
- response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options).ConfigureAwait(false);
-
- // We retry a finite number of times, and only whilst MB is indicating 503 (throttling)
+ return response;
}
- while (attempts < MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable);
-
- // Log error if unable to query MB database due to throttling
- if (attempts == MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable)
+ finally
{
- _logger.LogError("GetMusicBrainzResponse: 503 Service Unavailable (throttled) response received {0} times whilst requesting {1}", attempts, options.RequestUri);
+ _apiRequestLock.Release();
}
-
- return response;
}
///
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();
if (isSearch)
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index 32dab60a6b..e540e44713 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;
@@ -114,7 +114,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false);
- if (seasonResult == null)
+ if (seasonResult?.Episodes == null)
{
return false;
}
@@ -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(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(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();
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
- {
- 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> GetSeriesByNameAsync(string name, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", name, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByNameAsync(name, cancellationToken));
- }
-
- public Task> GetSeriesByIdAsync(int tvdbId, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", tvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetAsync(tvdbId, cancellationToken));
- }
-
- public Task> GetEpisodesAsync(int episodeTvdbId, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("episode", episodeTvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Episodes.GetAsync(episodeTvdbId, cancellationToken));
- }
-
- public Task> GetSeriesByImdbIdAsync(
- string imdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", imdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByImdbIdAsync(imdbId, cancellationToken));
- }
-
- public Task> GetSeriesByZap2ItIdAsync(
- string zap2ItId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", zap2ItId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByZap2ItIdAsync(zap2ItId, cancellationToken));
- }
-
- public Task> GetActorsAsync(
- int tvdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("actors", tvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetActorsAsync(tvdbId, cancellationToken));
- }
-
- public Task> 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> GetLanguagesAsync(CancellationToken cancellationToken)
- {
- return TryGetValue("languages", null, () => TvDbClient.Languages.GetAllAsync(cancellationToken));
- }
-
- public Task> GetSeriesEpisodeSummaryAsync(
- int tvdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("seriesepisodesummary", tvdbId, language);
- return TryGetValue(cacheKey, language,
- () => TvDbClient.Series.GetEpisodesSummaryAsync(tvdbId, cancellationToken));
- }
-
- public Task> 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 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 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> GetEpisodesPageAsync(
- int tvdbId,
- EpisodeQuery episodeQuery,
- string language,
- CancellationToken cancellationToken)
- {
- return GetEpisodesPageAsync(tvdbId, 1, episodeQuery, language, cancellationToken);
- }
-
- public async IAsyncEnumerable 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 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 TryGetValue(string key, string language, Func> 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 _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbEpisodeImageProvider(IHttpClientFactory httpClientFactory, ILogger logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public string Name => "TheTVDB";
-
- public bool Supports(BaseItem item)
- {
- return item is Episode;
- }
-
- public IEnumerable GetSupportedImages(BaseItem item)
- {
- return new List
- {
- ImageType.Primary
- };
- }
-
- public async Task> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var episode = (Episode)item;
- var series = episode.Series;
- var imageResult = new List();
- 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 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
-{
- ///
- /// Class RemoteEpisodeProvider.
- ///
- public class TvdbEpisodeProvider : IRemoteMetadataProvider, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbEpisodeProvider(IHttpClientFactory httpClientFactory, ILogger logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public async Task> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var list = new List();
-
- // 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> GetMetadata(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var result = new MetadataResult
- {
- 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> GetEpisode(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var result = new MetadataResult
- {
- 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 MapEpisodeToResult(EpisodeInfo id, EpisodeRecord episode)
- {
- var result = new MetadataResult
- {
- 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 { 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 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 dc3c60dee0..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 _logger;
- private readonly ILibraryManager _libraryManager;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbPersonImageProvider(ILibraryManager libraryManager, IHttpClientFactory httpClientFactory, ILogger logger, TvdbClientManager tvdbClientManager)
- {
- _libraryManager = libraryManager;
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- ///
- public string Name => "TheTVDB";
-
- ///
- public int Order => 1;
-
- ///
- public bool Supports(BaseItem item) => item is Person;
-
- ///
- public IEnumerable GetSupportedImages(BaseItem item)
- {
- yield return ImageType.Primary;
- }
-
- ///
- public async Task> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var seriesWithPerson = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = new[] { typeof(Series).Name },
- PersonIds = new[] { item.Id },
- DtoOptions = new DtoOptions(false)
- {
- EnableImages = false
- }
- }).Cast()
- .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 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;
- }
- }
-
- ///
- public Task 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 _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeasonImageProvider(IHttpClientFactory httpClientFactory, ILogger 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 GetSupportedImages(BaseItem item)
- {
- return new List
- {
- ImageType.Primary,
- ImageType.Banner,
- ImageType.Backdrop
- };
- }
-
- public async Task> 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();
- }
-
- var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb));
- var seasonNumber = season.IndexNumber.Value;
- var language = item.GetPreferredMetadataLanguage();
- var remoteImages = new List();
-
- 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 GetImages(Image[] images, string preferredLanguage)
- {
- var list = new List();
- // 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 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 _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeriesImageProvider(IHttpClientFactory httpClientFactory, ILogger 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 GetSupportedImages(BaseItem item)
- {
- return new List
- {
- ImageType.Primary,
- ImageType.Banner,
- ImageType.Backdrop
- };
- }
-
- public async Task> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- if (!TvdbSeriesProvider.IsValidSeries(item.ProviderIds))
- {
- return Array.Empty();
- }
-
- var language = item.GetPreferredMetadataLanguage();
- var remoteImages = new List();
- 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 GetImages(Image[] images, string preferredLanguage)
- {
- var list = new List();
- 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 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, IHasOrder
- {
- internal static TvdbSeriesProvider Current { get; private set; }
-
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger _logger;
- private readonly ILibraryManager _libraryManager;
- private readonly ILocalizationManager _localizationManager;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeriesProvider(IHttpClientFactory httpClientFactory, ILogger logger, ILibraryManager libraryManager, ILocalizationManager localizationManager, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _libraryManager = libraryManager;
- _localizationManager = localizationManager;
- Current = this;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public async Task> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
- {
- if (IsValidSeries(searchInfo.ProviderIds))
- {
- var metadata = await GetMetadata(searchInfo, cancellationToken).ConfigureAwait(false);
-
- if (metadata.HasMetadata)
- {
- return new List
- {
- 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> GetMetadata(SeriesInfo itemId, CancellationToken cancellationToken)
- {
- var result = new MetadataResult
- {
- 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 result, string metadataLanguage, Dictionary 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 GetSeriesByRemoteId(string id, string idType, string language, CancellationToken cancellationToken)
- {
- TvDbResponse 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();
- }
-
- ///
- /// Check whether a dictionary of provider IDs includes an entry for a valid TV metadata provider.
- ///
- /// The dictionary to check.
- /// True, if the dictionary contains a valid TV provider ID, otherwise false.
- internal static bool IsValidSeries(Dictionary seriesProviderIds)
- {
- return seriesProviderIds.ContainsKey(MetadataProvider.Tvdb.ToString()) ||
- seriesProviderIds.ContainsKey(MetadataProvider.Imdb.ToString()) ||
- seriesProviderIds.ContainsKey(MetadataProvider.Zap2It.ToString());
- }
-
- ///
- /// Finds the series.
- ///
- /// The name.
- /// The year.
- /// The language.
- /// The cancellation token.
- /// Task{System.String}.
- private async Task> 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> FindSeriesInternal(string name, string language, CancellationToken cancellationToken)
- {
- var comparableName = GetComparableName(name);
- var list = new List, RemoteSearchResult>>();
- TvDbResponse 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();
- }
-
- foreach (var seriesSearchResult in result.Data)
- {
- var tvdbTitles = new List
- {
- 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, 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();
- }
-
- ///
- /// Gets the name of the comparable.
- ///
- /// The name.
- /// System.String.
- 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 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 result, IEnumerable 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 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/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
index 3984e49537..bcf9459ef8 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
@@ -129,6 +129,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
.GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
.ConfigureAwait(false);
+ if (movieResult == null)
+ {
+ return new MetadataResult();
+ }
+
var movie = new Movie
{
Name = movieResult.Title ?? movieResult.OriginalTitle,
@@ -266,7 +271,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
}
-
if (movieResult.Videos?.Results != null)
{
var trailers = new List();
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();
- 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
}
}
+ ///
+ 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();
+ 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 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
///
public Task 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
- {
- ///
- public string ProviderName => "TheTVDB";
-
- ///
- public string Key => MetadataProvider.Tvdb.ToString();
-
- ///
- public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
-
- ///
- public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=episode&id={0}";
-
- ///
- 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
- {
- ///
- public string ProviderName => "TheTVDB";
-
- ///
- public string Key => MetadataProvider.Tvdb.ToString();
-
- ///
- public ExternalIdMediaType? Type => null;
-
- ///
- public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=series&id={0}";
-
- ///
- 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
- {
- ///
- public string ProviderName => "TheTVDB";
-
- ///
- public string Key => MetadataProvider.Tvdb.ToString();
-
- ///
- public ExternalIdMediaType? Type => ExternalIdMediaType.Season;
-
- ///
- public string UrlFormatString => null;
-
- ///
- 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 @@
- netstandard2.1
+ net5.0
false
true
true
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/MediaBrowser.sln b/MediaBrowser.sln
index 25402aee13..cb204137bd 100644
--- a/MediaBrowser.sln
+++ b/MediaBrowser.sln
@@ -1,6 +1,6 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 15
-VisualStudioVersion = 15.0.26730.3
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.30503.244
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server", "Jellyfin.Server\Jellyfin.Server.csproj", "{07E39F42-A2C6-4B32-AF8C-725F957A73FF}"
EndProject
@@ -66,12 +66,20 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Data", "Jellyfin.D
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server.Implementations", "Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj", "{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking", "Jellyfin.Networking\Jellyfin.Networking.csproj", "{0A3FCC4D-C714-4072-B90F-E374A15F9FF9}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking.Tests", "tests\Jellyfin.Networking.Tests\NetworkTesting\Jellyfin.Networking.Tests.csproj", "{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.Build.0 = Release|Any CPU
{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -132,10 +140,6 @@ Global
{960295EE-4AF4-4440-A525-B4C295B01A61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{960295EE-4AF4-4440-A525-B4C295B01A61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{960295EE-4AF4-4440-A525-B4C295B01A61}.Release|Any CPU.Build.0 = Release|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.Build.0 = Release|Any CPU
{154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{154872D9-6C12-4007-96E3-8F70A58386CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -176,10 +180,27 @@ Global
{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {DF194677-DFD3-42AF-9F75-D44D5A416478} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {28464062-0939-4AA7-9F7B-24DDDA61A7C0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {3998657B-1CCC-49DD-A19F-275DC8495F57} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
EndGlobalSection
@@ -201,12 +222,4 @@ Global
$0.DotNetNamingPolicy = $2
$2.DirectoryNamespaceAssociation = PrefixedHierarchical
EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {DF194677-DFD3-42AF-9F75-D44D5A416478} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {28464062-0939-4AA7-9F7B-24DDDA61A7C0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {3998657B-1CCC-49DD-A19F-275DC8495F57} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- EndGlobalSection
EndGlobal
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
{
///
- /// Correclty implements the interface and pattern for an object containing only managed resources, and adds a few common niceities not on the interface such as an property.
+ /// Correctly implements the interface and pattern for an object containing only managed resources, and adds a few common niceties not on the interface such as an property.
///
public abstract class DisposableManagedObjectBase : IDisposable
{
@@ -61,10 +61,10 @@ namespace Rssdp.Infrastructure
/// Disposes this object instance and all internally managed resources.
///
///
- /// Sets the property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behaviour of derived classes.
+ /// Sets the property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behavior of derived classes.
///
///
- [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 ParseValues(string headerValue)
+ private List 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
}
///
- /// 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.
///
/// A task whose result is an of instances, representing all found devices.
System.Threading.Tasks.Task> SearchAsync();
@@ -83,7 +83,7 @@ namespace Rssdp.Infrastructure
///
/// 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.
///
- /// 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.
+ /// 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.
///
/// A task whose result is an of instances, representing all found devices.
System.Threading.Tasks.Task> SearchAsync(string searchTarget, TimeSpan searchWaitTime);
diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj
index 664663bd76..c64ee9389d 100644
--- a/RSSDP/RSSDP.csproj
+++ b/RSSDP/RSSDP.csproj
@@ -6,11 +6,12 @@
+
- netstandard2.1
+ net5.0
false
true
diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs
index a4be32e7d2..8f1f0fa613 100644
--- a/RSSDP/SsdpCommunicationsServer.cs
+++ b/RSSDP/SsdpCommunicationsServer.cs
@@ -352,7 +352,7 @@ namespace Rssdp.Infrastructure
if (_enableMultiSocketBinding)
{
- foreach (var address in _networkManager.GetLocalIpAddresses())
+ foreach (var address in _networkManager.GetInternalBindAddresses())
{
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
@@ -362,7 +362,7 @@ namespace Rssdp.Infrastructure
try
{
- sockets.Add(_SocketFactory.CreateSsdpUdpSocket(address, _LocalPort));
+ sockets.Add(_SocketFactory.CreateSsdpUdpSocket(address.Address, _LocalPort));
}
catch (Exception ex)
{
diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs
index 1a8577d8da..c9e795d565 100644
--- a/RSSDP/SsdpDevicePublisher.cs
+++ b/RSSDP/SsdpDevicePublisher.cs
@@ -102,7 +102,7 @@ namespace Rssdp.Infrastructure
/// The instance to add.
/// Thrown if the argument is null.
/// Thrown if the contains property values that are not acceptable to the UPnP 1.0 specification.
- [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
///
///
/// 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.
- /// 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.
+ /// 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.
///
public bool SupportPnpRootDevice
{
@@ -300,17 +300,15 @@ namespace Rssdp.Infrastructure
foreach (var device in deviceList)
{
- if (!_sendOnlyMatchedHost ||
- _networkManager.IsInSameSubnet(device.ToRootDevice().Address, remoteEndPoint.Address, device.ToRootDevice().SubnetMask))
+ var root = device.ToRootDevice();
+ var source = new IPNetAddress(root.Address, root.PrefixLength);
+ var destination = new IPNetAddress(remoteEndPoint.Address, root.PrefixLength);
+ if (!_sendOnlyMatchedHost || source.NetworkAddress.Equals(destination.NetworkAddress))
{
SendDeviceSearchResponses(device, remoteEndPoint, receivedOnlocalIpAddress, cancellationToken);
}
}
}
- else
- {
- // WriteTrace(String.Format("Sending 0 search responses."));
- }
});
}
diff --git a/RSSDP/SsdpRootDevice.cs b/RSSDP/SsdpRootDevice.cs
index 8937ec331f..5ecb1f86f6 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 which means no-caching. Recommended value is half an hour.
///
///
- /// Specifiy to indicate no caching allowed.
+ /// Specify to indicate no caching allowed.
/// Also used to specify how often to rebroadcast alive notifications.
/// The UPnP/SSDP specifications indicate this should not be less than 1800 seconds (half an hour), but this is not enforced by this library.
///
@@ -45,12 +45,12 @@ namespace Rssdp
public IPAddress Address { get; set; }
///
- /// Gets or sets the SubnetMask used to check if the received message from same interface with this device/tree. Required.
+ /// Gets or sets the prefix length used to check if the received message from same interface with this device/tree. Required.
///
- public IPAddress SubnetMask { get; set; }
+ public byte PrefixLength { get; set; }
///
- /// 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.
///
///
/// 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.
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/HexDecodeBenches.cs b/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs
deleted file mode 100644
index d9a107b696..0000000000
--- a/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System;
-using System.Globalization;
-using BenchmarkDotNet.Attributes;
-using BenchmarkDotNet.Running;
-using MediaBrowser.Common;
-
-namespace Jellyfin.Common.Benches
-{
- [MemoryDiagnoser]
- public class HexDecodeBenches
- {
- private string _data;
-
- [Params(0, 10, 100, 1000, 10000, 1000000)]
- public int N { get; set; }
-
- [GlobalSetup]
- public void GlobalSetup()
- {
- var bytes = new byte[N];
- new Random(42).NextBytes(bytes);
- _data = Hex.Encode(bytes);
- }
-
- [Benchmark]
- public byte[] Decode() => Hex.Decode(_data);
-
- [Benchmark]
- public byte[] DecodeSubString() => DecodeSubString(_data);
-
- private static byte[] DecodeSubString(string str)
- {
- byte[] bytes = new byte[str.Length / 2];
- for (int i = 0; i < str.Length; i += 2)
- {
- bytes[i / 2] = byte.Parse(
- str.Substring(i, 2),
- NumberStyles.HexNumber,
- CultureInfo.InvariantCulture);
- }
-
- return bytes;
- }
- }
-}
diff --git a/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs b/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs
deleted file mode 100644
index 7abf93c510..0000000000
--- a/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using System;
-using BenchmarkDotNet.Attributes;
-using BenchmarkDotNet.Running;
-using MediaBrowser.Common;
-
-namespace Jellyfin.Common.Benches
-{
- [MemoryDiagnoser]
- public class HexEncodeBenches
- {
- private byte[] _data;
-
- [Params(0, 10, 100, 1000, 10000, 1000000)]
- public int N { get; set; }
-
- [GlobalSetup]
- public void GlobalSetup()
- {
- _data = new byte[N];
- new Random(42).NextBytes(_data);
- }
-
- [Benchmark]
- public string HexEncode() => Hex.Encode(_data);
-
- [Benchmark]
- public string BitConverterToString() => BitConverter.ToString(_data);
-
- [Benchmark]
- public string BitConverterToStringWithReplace() => BitConverter.ToString(_data).Replace("-", "");
- }
-}
diff --git a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj b/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj
deleted file mode 100644
index 47aeed05ef..0000000000
--- a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
- Exe
- netcoreapp3.1
-
-
-
-
-
-
-
-
-
-
-
diff --git a/benches/Jellyfin.Common.Benches/Program.cs b/benches/Jellyfin.Common.Benches/Program.cs
deleted file mode 100644
index b218b0dc10..0000000000
--- a/benches/Jellyfin.Common.Benches/Program.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System;
-using BenchmarkDotNet.Running;
-
-namespace Jellyfin.Common.Benches
-{
- public static class Program
- {
- public static void Main(string[] args)
- {
- _ = BenchmarkRunner.Run();
- _ = BenchmarkRunner.Run();
- }
- }
-}
diff --git a/debian/bin/restart.sh b/debian/bin/restart.sh
index 9b64b6d728..34fce06708 100755
--- a/debian/bin/restart.sh
+++ b/debian/bin/restart.sh
@@ -24,13 +24,13 @@ cmd="$( get_service_command )"
echo "Detected service control platform '$cmd'; using it to restart Jellyfin..."
case $cmd in
'systemctl')
- echo "sleep 2; /usr/bin/sudo $( which systemctl ) restart jellyfin" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now
;;
'service')
- echo "sleep 2; /usr/bin/sudo $( which service ) jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now
;;
'sysv')
- echo "sleep 2; /usr/bin/sudo /etc/init.d/jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now
;;
esac
exit 0
diff --git a/debian/control b/debian/control
index 39c2aa055b..9675d36ca6 100644
--- a/debian/control
+++ b/debian/control
@@ -3,7 +3,7 @@ Section: misc
Priority: optional
Maintainer: Jellyfin Team
Build-Depends: debhelper (>= 9),
- dotnet-sdk-3.1,
+ dotnet-sdk-5.0,
libc6-dev,
libcurl4-openssl-dev,
libfontconfig1-dev,
@@ -20,7 +20,6 @@ Breaks: jellyfin (<<10.6.0)
Architecture: any
Depends: at,
libsqlite3-0,
- jellyfin-ffmpeg (>= 4.2.1-2),
libfontconfig1,
libfreetype6,
libssl1.1
diff --git a/debian/postrm b/debian/postrm
index 1d00a984ec..3d56a5f1e8 100644
--- a/debian/postrm
+++ b/debian/postrm
@@ -25,7 +25,7 @@ case "$1" in
purge)
echo PURGE | debconf-communicate $NAME > /dev/null 2>&1 || true
- if [[ -x "/etc/init.d/jellyfin" ]] || [[ -e "/etc/init/jellyfin.connf" ]]; then
+ if [[ -x "/etc/init.d/jellyfin" ]] || [[ -e "/etc/init/jellyfin.conf" ]]; then
update-rc.d jellyfin remove >/dev/null 2>&1 || true
fi
@@ -54,7 +54,7 @@ case "$1" in
rm -rf $PROGRAMDATA
fi
# Remove binary symlink
- [[ -f /usr/bin/jellyfin ]] && rm /usr/bin/jellyfin
+ rm -f /usr/bin/jellyfin
# Remove sudoers config
[[ -f /etc/sudoers.d/jellyfin-sudoers ]] && rm /etc/sudoers.d/jellyfin-sudoers
# Remove anything at the default locations; catches situations where the user moved the defaults
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..197126ee56 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
@@ -40,7 +40,7 @@ Jellyfin is a free software media system that puts you in control of managing an
Summary: The Free Software Media System Server backend
Requires(pre): shadow-utils
Requires: ffmpeg
-Requires: libcurl, fontconfig, freetype, openssl, glibc libicu
+Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at
%description server
The Jellyfin media server backend.
@@ -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/fedora/restart.sh b/fedora/restart.sh
index 9e53efecd0..34fce06708 100755
--- a/fedora/restart.sh
+++ b/fedora/restart.sh
@@ -24,13 +24,13 @@ cmd="$( get_service_command )"
echo "Detected service control platform '$cmd'; using it to restart Jellyfin..."
case $cmd in
'systemctl')
- echo "sleep 2; /usr/bin/sudo $( which systemctl ) restart jellyfin" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now
;;
'service')
- echo "sleep 2; /usr/bin/sudo $( which service ) jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now
;;
'sysv')
- echo "sleep 2; /usr/bin/sudo /etc/init.d/jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now
;;
esac
exit 0
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();
_jellyfinAuthServiceMock.Setup(
a => a.Authenticate(
It.IsAny()))
- .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.User = _fixture.Create();
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(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 aae436fb73..14eed30e03 100644
--- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
+++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
@@ -6,23 +6,23 @@
- netcoreapp3.1
+ net5.0
false
true
enable
-
-
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs
index bd3d356870..54f8eb225f 100644
--- a/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs
+++ b/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs
@@ -3,8 +3,6 @@ using System.Collections.Concurrent;
using System.IO;
using Emby.Server.Implementations;
using Emby.Server.Implementations.IO;
-using Emby.Server.Implementations.Networking;
-using Jellyfin.Drawing.Skia;
using Jellyfin.Server;
using MediaBrowser.Common;
using Microsoft.AspNetCore.Hosting;
@@ -80,7 +78,6 @@ namespace Jellyfin.Api.Tests
loggerFactory,
commandLineOpts,
new ManagedFileSystem(loggerFactory.CreateLogger(), appPaths),
- new NetworkManager(loggerFactory.CreateLogger()),
serviceCollection);
_disposableComponents.Add(appHost);
appHost.Init();
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 queryParamValues = new[] { "lol", "xd" };
var queryParamString = "lol,xd";
var queryParamType = typeof(string[]);
- var modelBinder = new CommaDelimitedArrayModelBinder();
+ var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger());
var valueProvider = new QueryStringValueProvider(
new BindingSource(string.Empty, string.Empty, false, false),
new QueryCollection(new Dictionary { { 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?)bindingContextMock.Object?.Result.Model, queryParamValues);
}
[Fact]
public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedIntArrayQuery()
{
var queryParamName = "test";
- var queryParamValues = new[] { 42, 0 };
+ IReadOnlyList queryParamValues = new[] { 42, 0 };
var queryParamString = "42,0";
var queryParamType = typeof(int[]);
- var modelBinder = new CommaDelimitedArrayModelBinder();
+ var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger());
var valueProvider = new QueryStringValueProvider(
new BindingSource(string.Empty, string.Empty, false, false),
new QueryCollection(new Dictionary { { 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?)bindingContextMock.Object.Result.Model, queryParamValues);
}
[Fact]
public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedEnumArrayQuery()
{
var queryParamName = "test";
- var queryParamValues = new[] { TestType.How, TestType.Much };
+ IReadOnlyList queryParamValues = new[] { TestType.How, TestType.Much };
var queryParamString = "How,Much";
var queryParamType = typeof(TestType[]);
- var modelBinder = new CommaDelimitedArrayModelBinder();
+ var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger());
var valueProvider = new QueryStringValueProvider(
new BindingSource(string.Empty, string.Empty, false, false),
new QueryCollection(new Dictionary { { 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?)bindingContextMock.Object.Result.Model, queryParamValues);
}
[Fact]
public async Task BindModelAsync_CorrectlyBindsValidCommaDelimitedEnumArrayQueryWithDoubleCommas()
{
var queryParamName = "test";
- var queryParamValues = new[] { TestType.How, TestType.Much };
+ IReadOnlyList queryParamValues = new[] { TestType.How, TestType.Much };
var queryParamString = "How,,Much";
var queryParamType = typeof(TestType[]);
- var modelBinder = new CommaDelimitedArrayModelBinder();
+ var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger());
var valueProvider = new QueryStringValueProvider(
new BindingSource(string.Empty, string.Empty, false, false),
new QueryCollection(new Dictionary { { 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?)bindingContextMock.Object.Result.Model, queryParamValues);
}
[Fact]
public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery()
{
var queryParamName = "test";
- var queryParamValues = new[] { TestType.How, TestType.Much };
+ IReadOnlyList 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());
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?)bindingContextMock.Object.Result.Model, queryParamValues);
}
[Fact]
public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery()
{
var queryParamName = "test";
- var queryParamValues = Array.Empty();
+ IReadOnlyList queryParamValues = Array.Empty();
var queryParamType = typeof(TestType[]);
- var modelBinder = new CommaDelimitedArrayModelBinder();
+ var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger());
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?)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);
- var modelBinder = new CommaDelimitedArrayModelBinder();
+ var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger());
var valueProvider = new QueryStringValueProvider(
new BindingSource(string.Empty, string.Empty, false, false),
new QueryCollection(new Dictionary { { 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 act = async () => await modelBinder.BindModelAsync(bindingContextMock.Object);
-
- await Assert.ThrowsAsync(act);
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Empty((IReadOnlyList?)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);
- var modelBinder = new CommaDelimitedArrayModelBinder();
+ var modelBinder = new CommaDelimitedArrayModelBinder(new NullLogger());
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 act = async () => await modelBinder.BindModelAsync(bindingContextMock.Object);
-
- await Assert.ThrowsAsync(act);
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Single((IReadOnlyList?)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 queryParamValues = new[] { "lol", "xd" };
+ var queryParamString = "lol|xd";
+ var queryParamType = typeof(string[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)bindingContextMock.Object?.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidDelimitedIntArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList queryParamValues = new[] { 42, 0 };
+ var queryParamString = "42|0";
+ var queryParamType = typeof(int[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString = "How|Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQueryWithDoublePipes()
+ {
+ var queryParamName = "test";
+ IReadOnlyList queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString = "How||Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString1 = "How";
+ var queryParamString2 = "Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary
+ {
+ { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList queryParamValues = Array.Empty();
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary
+ {
+ { queryParamName, new StringValues(value: null) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_EnumArrayQuery_BindValidOnly()
+ {
+ var queryParamName = "test";
+ var queryParamString = "🔥|😢";
+ var queryParamType = typeof(IReadOnlyList);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)bindingContextMock.Object.Result.Model);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2()
+ {
+ var queryParamName = "test";
+ var queryParamString1 = "How";
+ var queryParamString2 = "😱";
+ var queryParamType = typeof(IReadOnlyList);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary
+ {
+ { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock();
+ 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?)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/HexTests.cs b/tests/Jellyfin.Common.Tests/HexTests.cs
deleted file mode 100644
index 5b578d38cb..0000000000
--- a/tests/Jellyfin.Common.Tests/HexTests.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using MediaBrowser.Common;
-using Xunit;
-
-namespace Jellyfin.Common.Tests
-{
- public class HexTests
- {
- [Theory]
- [InlineData("")]
- [InlineData("00")]
- [InlineData("01")]
- [InlineData("000102030405060708090a0b0c0d0e0f")]
- [InlineData("0123456789abcdef")]
- public void RoundTripTest(string data)
- {
- Assert.Equal(data, Hex.Encode(Hex.Decode(data)));
- }
- }
-}
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 @@
- netcoreapp3.1
+ net5.0
false
true
enable
-
+
diff --git a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs
new file mode 100644
index 0000000000..0d2bdd1af9
--- /dev/null
+++ b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedArrayTests.cs
@@ -0,0 +1,92 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Jellyfin.Common.Tests.Models;
+using MediaBrowser.Model.Session;
+using Xunit;
+
+namespace Jellyfin.Common.Tests.Json
+{
+ public static class JsonCommaDelimitedArrayTests
+ {
+ [Fact]
+ public static void Deserialize_String_Valid_Success()
+ {
+ var desiredValue = new GenericBodyArrayModel
+ {
+ Value = new[] { "a", "b", "c" }
+ };
+
+ var options = new JsonSerializerOptions();
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a,b,c"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_String_Space_Valid_Success()
+ {
+ var desiredValue = new GenericBodyArrayModel
+ {
+ Value = new[] { "a", "b", "c" }
+ };
+
+ var options = new JsonSerializerOptions();
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a, b, c"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_GenericCommandType_Valid_Success()
+ {
+ var desiredValue = new GenericBodyArrayModel
+ {
+ Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+ };
+
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_GenericCommandType_Space_Valid_Success()
+ {
+ var desiredValue = new GenericBodyArrayModel
+ {
+ Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+ };
+
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_String_Array_Valid_Success()
+ {
+ var desiredValue = new GenericBodyArrayModel
+ {
+ Value = new[] { "a", "b", "c" }
+ };
+
+ var options = new JsonSerializerOptions();
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""a"",""b"",""c""] }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_GenericCommandType_Array_Valid_Success()
+ {
+ var desiredValue = new GenericBodyArrayModel
+ {
+ Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+ };
+
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs
new file mode 100644
index 0000000000..34ad9bac79
--- /dev/null
+++ b/tests/Jellyfin.Common.Tests/Json/JsonCommaDelimitedIReadOnlyListTests.cs
@@ -0,0 +1,92 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Jellyfin.Common.Tests.Models;
+using MediaBrowser.Model.Session;
+using Xunit;
+
+namespace Jellyfin.Common.Tests.Json
+{
+ public static class JsonCommaDelimitedIReadOnlyListTests
+ {
+ [Fact]
+ public static void Deserialize_String_Valid_Success()
+ {
+ var desiredValue = new GenericBodyIReadOnlyListModel
+ {
+ Value = new[] { "a", "b", "c" }
+ };
+
+ var options = new JsonSerializerOptions();
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a,b,c"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_String_Space_Valid_Success()
+ {
+ var desiredValue = new GenericBodyIReadOnlyListModel
+ {
+ Value = new[] { "a", "b", "c" }
+ };
+
+ var options = new JsonSerializerOptions();
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""a, b, c"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_GenericCommandType_Valid_Success()
+ {
+ var desiredValue = new GenericBodyIReadOnlyListModel
+ {
+ Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+ };
+
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp,MoveDown"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_GenericCommandType_Space_Valid_Success()
+ {
+ var desiredValue = new GenericBodyIReadOnlyListModel
+ {
+ Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+ };
+
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": ""MoveUp, MoveDown"" }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_String_Array_Valid_Success()
+ {
+ var desiredValue = new GenericBodyIReadOnlyListModel
+ {
+ Value = new[] { "a", "b", "c" }
+ };
+
+ var options = new JsonSerializerOptions();
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""a"",""b"",""c""] }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+
+ [Fact]
+ public static void Deserialize_GenericCommandType_Array_Valid_Success()
+ {
+ var desiredValue = new GenericBodyIReadOnlyListModel
+ {
+ Value = new[] { GeneralCommandType.MoveUp, GeneralCommandType.MoveDown }
+ };
+
+ var options = new JsonSerializerOptions();
+ options.Converters.Add(new JsonStringEnumConverter());
+ var value = JsonSerializer.Deserialize>(@"{ ""Value"": [""MoveUp"", ""MoveDown""] }", options);
+ Assert.Equal(desiredValue.Value, value?.Value);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs b/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs
new file mode 100644
index 0000000000..276e1bfbe2
--- /dev/null
+++ b/tests/Jellyfin.Common.Tests/Models/GenericBodyArrayModel.cs
@@ -0,0 +1,20 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
+
+namespace Jellyfin.Common.Tests.Models
+{
+ ///
+ /// The generic body model.
+ ///
+ /// The value type.
+ public class GenericBodyArrayModel
+ {
+ ///
+ /// Gets or sets the value.
+ ///
+ [SuppressMessage("Microsoft.Performance", "CA1819:Properties should not return arrays", MessageId = "Value", Justification = "Imported from ServiceStack")]
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public T[] Value { get; set; } = default!;
+ }
+}
diff --git a/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs b/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs
new file mode 100644
index 0000000000..627454b25f
--- /dev/null
+++ b/tests/Jellyfin.Common.Tests/Models/GenericBodyIReadOnlyListModel.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
+
+namespace Jellyfin.Common.Tests.Models
+{
+ ///
+ /// The generic body IReadOnlyList model.
+ ///
+ /// The value type.
+ public class GenericBodyIReadOnlyListModel
+ {
+ ///
+ /// Gets or sets the value.
+ ///
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList Value { get; set; } = default!;
+ }
+}
diff --git a/tests/Jellyfin.Common.Tests/PasswordHashTests.cs b/tests/Jellyfin.Common.Tests/PasswordHashTests.cs
index 46926f4f81..c4422bd105 100644
--- a/tests/Jellyfin.Common.Tests/PasswordHashTests.cs
+++ b/tests/Jellyfin.Common.Tests/PasswordHashTests.cs
@@ -1,3 +1,4 @@
+using System;
using MediaBrowser.Common;
using MediaBrowser.Common.Cryptography;
using Xunit;
@@ -16,8 +17,8 @@ namespace Jellyfin.Common.Tests
{
var pass = PasswordHash.Parse(passwordHash);
Assert.Equal(id, pass.Id);
- Assert.Equal(salt, Hex.Encode(pass.Salt, false));
- Assert.Equal(hash, Hex.Encode(pass.Hash, false));
+ Assert.Equal(salt, Convert.ToHexString(pass.Salt));
+ Assert.Equal(hash, Convert.ToHexString(pass.Hash));
}
[Theory]
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 @@
- netcoreapp3.1
+ net5.0
false
true
enable
-
+
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 @@
- netcoreapp3.1
+ net5.0
false
true
enable
@@ -19,7 +19,7 @@
-
+
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 @@
- netcoreapp3.1
+ net5.0
false
true
enable
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();
+
+ 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 83d44721c4..b3257ace3b 100644
--- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using Emby.Naming.AudioBook;
using Emby.Naming.Common;
using Xunit;
@@ -13,45 +14,54 @@ 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)
};
}
[Theory]
[MemberData(nameof(GetResolveFileTestData))]
- public void ResolveFile_ValidFileName_Success(AudioBookFileInfo expectedResult)
+ public void Resolve_ValidFileName_Success(AudioBookFileInfo expectedResult)
{
var result = new AudioBookResolver(_namingOptions).Resolve(expectedResult.Path);
Assert.NotNull(result);
- Assert.Equal(result.Path, expectedResult.Path);
- Assert.Equal(result.Container, expectedResult.Container);
- Assert.Equal(result.ChapterNumber, expectedResult.ChapterNumber);
- Assert.Equal(result.PartNumber, expectedResult.PartNumber);
- Assert.Equal(result.IsDirectory, expectedResult.IsDirectory);
+ Assert.Equal(result!.Path, expectedResult.Path);
+ Assert.Equal(result!.Container, expectedResult.Container);
+ Assert.Equal(result!.ChapterNumber, expectedResult.ChapterNumber);
+ Assert.Equal(result!.PartNumber, expectedResult.PartNumber);
+ }
+
+ [Fact]
+ public void Resolve_InvalidExtension()
+ {
+ 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 @@
- netcoreapp3.1
+ net5.0
false
enable
true
-
+
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(() => 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 ed971eed7b..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;
@@ -47,10 +47,13 @@ namespace Jellyfin.Naming.Tests.Video
// FIXME: [InlineData("Robin Hood [Multi-Subs] [2018].mkv", "Robin Hood", 2018)]
[InlineData(@"3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again
[InlineData("3 days to kill (2005).mkv", "3 days to kill", 2005)]
+ [InlineData(@"Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)]
[InlineData("My Movie 2013.12.09", "My Movie 2013.12.09", null)]
[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()).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 99828b2eb7..b6447a7a64 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,143 @@ 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[]
+ {
+ new VideoFileInfo(
+ path: @"/server/Movies/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4",
+ container: "mp4",
+ name: "Rain Man",
+ year: 1988)
};
}
@@ -194,6 +173,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.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj
new file mode 100644
index 0000000000..703d43210b
--- /dev/null
+++ b/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj
@@ -0,0 +1,39 @@
+
+
+
+
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}
+
+
+
+ net5.0
+ false
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ../../jellyfin-tests.ruleset
+
+
+ DEBUG
+
+
diff --git a/tests/Jellyfin.Networking.Tests/NetworkTesting/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkTesting/NetworkParseTests.cs
new file mode 100644
index 0000000000..c350685af0
--- /dev/null
+++ b/tests/Jellyfin.Networking.Tests/NetworkTesting/NetworkParseTests.cs
@@ -0,0 +1,519 @@
+using System;
+using System.Net;
+using Jellyfin.Networking.Configuration;
+using Jellyfin.Networking.Manager;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using Moq;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+using System.Collections.ObjectModel;
+
+namespace Jellyfin.Networking.Tests
+{
+ public class NetworkParseTests
+ {
+ ///
+ /// Tries to identify the string and return an object of that class.
+ ///
+ /// String to parse.
+ /// IPObject to return.
+ /// True if the value parsed successfully.
+ private static bool TryParse(string addr, out IPObject result)
+ {
+ if (!string.IsNullOrEmpty(addr))
+ {
+ // Is it an IP address
+ if (IPNetAddress.TryParse(addr, out IPNetAddress nw))
+ {
+ result = nw;
+ return true;
+ }
+
+ if (IPHost.TryParse(addr, out IPHost h))
+ {
+ result = h;
+ return true;
+ }
+ }
+
+ result = IPNetAddress.None;
+ return false;
+ }
+
+ private static IConfigurationManager GetMockConfig(NetworkConfiguration conf)
+ {
+ var configManager = new Mock
+ {
+ CallBase = true
+ };
+ configManager.Setup(x => x.GetConfiguration(It.IsAny())).Returns(conf);
+ return (IConfigurationManager)configManager.Object;
+ }
+
+ ///
+ /// Checks the ability to ignore interfaces
+ ///
+ /// Mock network setup, in the format (IP address, interface index, interface name) : ....
+ /// LAN addresses.
+ /// Bind addresses that are excluded.
+ [Theory]
+ [InlineData("192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11", "192.168.1.0/24;200.200.200.0/24", "[192.168.1.208/24,200.200.200.200/24]")]
+ [InlineData("192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11", "192.168.1.0/24", "[192.168.1.208/24]")]
+ [InlineData("192.168.1.208/24,-16,vEthernet1:192.168.1.208/24,-16,vEthernet212;200.200.200.200/24,11,eth11", "192.168.1.0/24", "[192.168.1.208/24]")]
+ public void IgnoreVirtualInterfaces(string interfaces, string lan, string value)
+ {
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ LocalNetworkSubnets = lan?.Split(';') ?? throw new ArgumentNullException(nameof(lan))
+ };
+
+ NetworkManager.MockNetworkSettings = interfaces;
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ Assert.Equal(nm.GetInternalBindAddresses().AsString(), value);
+ }
+
+ ///
+ /// Check that the value given is in the network provided.
+ ///
+ /// Network address.
+ /// Value to check.
+ [Theory]
+ [InlineData("192.168.10.0/24, !192.168.10.60/32", "192.168.10.60")]
+ public void IsInNetwork(string network, string value)
+ {
+ if (network == null)
+ {
+ throw new ArgumentNullException(nameof(network));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ LocalNetworkSubnets = network.Split(',')
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger());
+
+ Assert.False(nm.IsInLocalNetwork(value));
+ }
+
+ ///
+ /// Checks IP address formats.
+ ///
+ ///
+ [Theory]
+ [InlineData("127.0.0.1")]
+ [InlineData("127.0.0.1:123")]
+ [InlineData("localhost")]
+ [InlineData("localhost:1345")]
+ [InlineData("www.google.co.uk")]
+ [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517")]
+ [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")]
+ [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517]:124")]
+ [InlineData("fe80::7add:12ff:febb:c67b%16")]
+ [InlineData("[fe80::7add:12ff:febb:c67b%16]:123")]
+ [InlineData("192.168.1.2/255.255.255.0")]
+ [InlineData("192.168.1.2/24")]
+ public void ValidIPStrings(string address)
+ {
+ Assert.True(TryParse(address, out _));
+ }
+
+
+ ///
+ /// All should be invalid address strings.
+ ///
+ /// Invalid address strings.
+ [Theory]
+ [InlineData("256.128.0.0.0.1")]
+ [InlineData("127.0.0.1#")]
+ [InlineData("localhost!")]
+ [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")]
+ [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")]
+ public void InvalidAddressString(string address)
+ {
+ Assert.False(TryParse(address, out _));
+ }
+
+
+ ///
+ /// Test collection parsing.
+ ///
+ /// Collection to parse.
+ /// Included addresses from the collection.
+ /// Included IP4 addresses from the collection.
+ /// Excluded addresses from the collection.
+ /// Excluded IP4 addresses from the collection.
+ /// Network addresses of the collection.
+ [Theory]
+ [InlineData("127.0.0.1#",
+ "[]",
+ "[]",
+ "[]",
+ "[]",
+ "[]")]
+ [InlineData("!127.0.0.1",
+ "[]",
+ "[]",
+ "[127.0.0.1/32]",
+ "[127.0.0.1/32]",
+ "[]")]
+ [InlineData("",
+ "[]",
+ "[]",
+ "[]",
+ "[]",
+ "[]")]
+ [InlineData(
+ "192.158.1.2/16, localhost, fd23:184f:2029:0:3139:7386:67d7:d517, !10.10.10.10",
+ "[192.158.1.2/16,127.0.0.1/32,fd23:184f:2029:0:3139:7386:67d7:d517/128]",
+ "[192.158.1.2/16,127.0.0.1/32]",
+ "[10.10.10.10/32]",
+ "[10.10.10.10/32]",
+ "[192.158.0.0/16,127.0.0.1/32,fd23:184f:2029:0:3139:7386:67d7:d517/128]")]
+ [InlineData("192.158.1.2/255.255.0.0,192.169.1.2/8",
+ "[192.158.1.2/16,192.169.1.2/8]",
+ "[192.158.1.2/16,192.169.1.2/8]",
+ "[]",
+ "[]",
+ "[192.158.0.0/16,192.0.0.0/8]")]
+ public void TestCollections(string settings, string result1, string result2, string result3, string result4, string result5)
+ {
+ if (settings == null)
+ {
+ throw new ArgumentNullException(nameof(settings));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger());
+
+ // Test included.
+ Collection nc = nm.CreateIPCollection(settings.Split(","), false);
+ Assert.Equal(nc.AsString(), result1);
+
+ // Test excluded.
+ nc = nm.CreateIPCollection(settings.Split(","), true);
+ Assert.Equal(nc.AsString(), result3);
+
+ conf.EnableIPV6 = false;
+ nm.UpdateSettings(conf);
+
+ // Test IP4 included.
+ nc = nm.CreateIPCollection(settings.Split(","), false);
+ Assert.Equal(nc.AsString(), result2);
+
+ // Test IP4 excluded.
+ nc = nm.CreateIPCollection(settings.Split(","), true);
+ Assert.Equal(nc.AsString(), result4);
+
+ conf.EnableIPV6 = true;
+ nm.UpdateSettings(conf);
+
+ // Test network addresses of collection.
+ nc = nm.CreateIPCollection(settings.Split(","), false);
+ nc = nc.AsNetworks();
+ Assert.Equal(nc.AsString(), result5);
+ }
+
+ ///
+ /// Union two collections.
+ ///
+ /// Source.
+ /// Destination.
+ /// Result.
+ [Theory]
+ [InlineData("127.0.0.1", "fd23:184f:2029:0:3139:7386:67d7:d517/64,fd23:184f:2029:0:c0f0:8a8a:7605:fffa/128,fe80::3139:7386:67d7:d517%16/64,192.168.1.208/24,::1/128,127.0.0.1/8", "[127.0.0.1/32]")]
+ [InlineData("127.0.0.1", "127.0.0.1/8", "[127.0.0.1/32]")]
+ public void UnionCheck(string settings, string compare, string result)
+ {
+ if (settings == null)
+ {
+ throw new ArgumentNullException(nameof(settings));
+ }
+
+ if (compare == null)
+ {
+ throw new ArgumentNullException(nameof(compare));
+ }
+
+ if (result == null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
+
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger());
+
+ Collection nc1 = nm.CreateIPCollection(settings.Split(","), false);
+ Collection nc2 = nm.CreateIPCollection(compare.Split(","), false);
+
+ Assert.Equal(nc1.Union(nc2).AsString(), result);
+ }
+
+ [Theory]
+ [InlineData("192.168.5.85/24", "192.168.5.1")]
+ [InlineData("192.168.5.85/24", "192.168.5.254")]
+ [InlineData("10.128.240.50/30", "10.128.240.48")]
+ [InlineData("10.128.240.50/30", "10.128.240.49")]
+ [InlineData("10.128.240.50/30", "10.128.240.50")]
+ [InlineData("10.128.240.50/30", "10.128.240.51")]
+ [InlineData("127.0.0.1/8", "127.0.0.1")]
+ public void IpV4SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("192.168.5.85/24", "192.168.4.254")]
+ [InlineData("192.168.5.85/24", "191.168.5.254")]
+ [InlineData("10.128.240.50/30", "10.128.240.47")]
+ [InlineData("10.128.240.50/30", "10.128.240.52")]
+ [InlineData("10.128.240.50/30", "10.128.239.50")]
+ [InlineData("10.128.240.50/30", "10.127.240.51")]
+ public void IpV4SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFFF")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0001:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFF0")]
+ [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")]
+ public void IpV6SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0011:FFFF:FFFF:FFFF:FFFF")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0013:0000:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0013:0001:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0011:FFFF:FFFF:FFFF:FFF0")]
+ [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0001")]
+ public void IpV6SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1/32")]
+ [InlineData("10.0.0.0/8", "10.10.10.1/32")]
+ [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1")]
+
+ [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1/32")]
+ [InlineData("10.10.0.0/16", "10.10.10.1/32")]
+ [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1")]
+
+ [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1/32")]
+ [InlineData("10.10.10.0/24", "10.10.10.1/32")]
+ [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1")]
+
+ public void TestSubnetContains(string network, string ip)
+ {
+ Assert.True(TryParse(network, out IPObject? networkObj));
+ Assert.True(TryParse(ip, out IPObject? ipObj));
+ Assert.True(networkObj.Contains(ipObj));
+ }
+
+ [Theory]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24", "172.168.1.2/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24, 10.10.10.1", "172.168.1.2/24,10.10.10.1/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/255.255.255.0, 10.10.10.1", "192.168.1.2/24,10.10.10.1/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/24, 100.10.10.1", "192.168.1.2/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "194.168.1.2/24, 100.10.10.1", "")]
+
+ public void TestCollectionEquality(string source, string dest, string result)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ if (dest == null)
+ {
+ throw new ArgumentNullException(nameof(dest));
+ }
+
+ if (result == null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger());
+
+ // Test included, IP6.
+ Collection ncSource = nm.CreateIPCollection(source.Split(","));
+ Collection ncDest = nm.CreateIPCollection(dest.Split(","));
+ Collection ncResult = ncSource.Union(ncDest);
+ Collection resultCollection = nm.CreateIPCollection(result.Split(","));
+ Assert.True(ncResult.Compare(resultCollection));
+ }
+
+
+ [Theory]
+ [InlineData("10.1.1.1/32", "10.1.1.1")]
+ [InlineData("192.168.1.254/32", "192.168.1.254/255.255.255.255")]
+
+ public void TestEquals(string source, string dest)
+ {
+ Assert.True(IPNetAddress.Parse(source).Equals(IPNetAddress.Parse(dest)));
+ Assert.True(IPNetAddress.Parse(dest).Equals(IPNetAddress.Parse(source)));
+ }
+
+ [Theory]
+
+ // Testing bind interfaces.
+ // On my system eth16 is internal, eth11 external (Windows defines the indexes).
+ //
+ // This test is to replicate how DNLA requests work throughout the system.
+
+ // User on internal network, we're bound internal and external - so result is internal.
+ [InlineData("192.168.1.1", "eth16,eth11", false, "eth16")]
+ // User on external network, we're bound internal and external - so result is external.
+ [InlineData("8.8.8.8", "eth16,eth11", false, "eth11")]
+ // User on internal network, we're bound internal only - so result is internal.
+ [InlineData("10.10.10.10", "eth16", false, "eth16")]
+ // User on internal network, no binding specified - so result is the 1st internal.
+ [InlineData("192.168.1.1", "", false, "eth16")]
+ // User on external network, internal binding only - so result is the 1st internal.
+ [InlineData("jellyfin.org", "eth16", false, "eth16")]
+ // User on external network, no binding - so result is the 1st external.
+ [InlineData("jellyfin.org", "", false, "eth11")]
+ // User assumed to be internal, no binding - so result is the 1st internal.
+ [InlineData("", "", false, "eth16")]
+ public void TestBindInterfaces(string source, string bindAddresses, bool ipv6enabled, string result)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ if (bindAddresses == null)
+ {
+ throw new ArgumentNullException(nameof(bindAddresses));
+ }
+
+ if (result == null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ LocalNetworkAddresses = bindAddresses.Split(','),
+ EnableIPV6 = ipv6enabled,
+ EnableIPV4 = true
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11";
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ _ = nm.TryParseInterface(result, out Collection? resultObj);
+
+ if (resultObj != null)
+ {
+ result = ((IPNetAddress)resultObj[0]).ToString(true);
+ var intf = nm.GetBindInterface(source, out int? _);
+
+ Assert.Equal(intf, result);
+ }
+ }
+
+ [Theory]
+
+ // Testing bind interfaces. These are set for my system so won't work elsewhere.
+ // On my system eth16 is internal, eth11 external (Windows defines the indexes).
+ //
+ // This test is to replicate how subnet bound ServerPublisherUri work throughout the system.
+
+ // User on internal network, we're bound internal and external - so result is internal override.
+ [InlineData("192.168.1.1", "192.168.1.0/24", "eth16,eth11", false, "192.168.1.0/24=internal.jellyfin", "internal.jellyfin")]
+
+ // User on external network, we're bound internal and external - so result is override.
+ [InlineData("8.8.8.8", "192.168.1.0/24", "eth16,eth11", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")]
+
+ // User on internal network, we're bound internal only, but the address isn't in the LAN - so return the override.
+ [InlineData("10.10.10.10", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://internalButNotDefinedAsLan.com", "http://internalButNotDefinedAsLan.com")]
+
+ // User on internal network, no binding specified - so result is the 1st internal.
+ [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")]
+
+ // User on external network, internal binding only - so asumption is a proxy forward, return external override.
+ [InlineData("jellyfin.org", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")]
+
+ // User on external network, no binding - so result is the 1st external which is overriden.
+ [InlineData("jellyfin.org", "192.168.1.0/24", "", false, "0.0.0.0 = http://helloworld.com", "http://helloworld.com")]
+
+ // User assumed to be internal, no binding - so result is the 1st internal.
+ [InlineData("", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")]
+
+ // User is internal, no binding - so result is the 1st internal, which is then overridden.
+ [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "eth16=http://helloworld.com", "http://helloworld.com")]
+
+ public void TestBindInterfaceOverrides(string source, string lan, string bindAddresses, bool ipv6enabled, string publishedServers, string result)
+ {
+ if (lan == null)
+ {
+ throw new ArgumentNullException(nameof(lan));
+ }
+
+ if (bindAddresses == null)
+ {
+ throw new ArgumentNullException(nameof(bindAddresses));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ LocalNetworkSubnets = lan.Split(','),
+ LocalNetworkAddresses = bindAddresses.Split(','),
+ EnableIPV6 = ipv6enabled,
+ EnableIPV4 = true,
+ PublishedServerUriBySubnet = new string[] { publishedServers }
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11";
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ if (nm.TryParseInterface(result, out Collection? resultObj) && resultObj != null)
+ {
+ // Parse out IPAddresses so we can do a string comparison. (Ignore subnet masks).
+ result = ((IPNetAddress)resultObj[0]).ToString(true);
+ }
+
+ var intf = nm.GetBindInterface(source, out int? _);
+
+ Assert.Equal(intf, result);
+ }
+ }
+}
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 75b67f460f..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 @@
- netcoreapp3.1
+ net5.0
false
true
enable
@@ -14,10 +14,10 @@
-
-
-
-
+
+
+
+