aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ci/azure-pipelines-test.yml4
-rw-r--r--.github/workflows/codeql-analysis.yml36
-rw-r--r--Emby.Dlna/Common/Argument.cs2
-rw-r--r--Emby.Dlna/Configuration/DlnaOptions.cs69
-rw-r--r--Emby.Dlna/ConnectionManager/ConnectionManagerService.cs12
-rw-r--r--Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs145
-rw-r--r--Emby.Dlna/ConnectionManager/ControlHandler.cs13
-rw-r--r--Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs37
-rw-r--r--Emby.Dlna/ContentDirectory/ControlHandler.cs2
-rw-r--r--Emby.Dlna/DlnaManager.cs4
-rw-r--r--Emby.Dlna/PlayTo/Device.cs8
-rw-r--r--Emby.Naming/Subtitles/SubtitleParser.cs2
-rw-r--r--Emby.Naming/Video/VideoListResolver.cs2
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs5
-rw-r--r--Emby.Server.Implementations/Channels/ChannelManager.cs2
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs8
-rw-r--r--Emby.Server.Implementations/Devices/DeviceManager.cs71
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs2
-rw-r--r--Emby.Server.Implementations/Library/MediaStreamSelector.cs2
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs2
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs13
-rw-r--r--Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs2
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs2
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/ta.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json5
-rw-r--r--Emby.Server.Implementations/Networking/NetworkManager.cs30
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs6
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/TaskManager.cs4
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs24
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs2
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs98
-rw-r--r--Jellyfin.Api/Controllers/ApiKeyController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ArtistsController.cs114
-rw-r--r--Jellyfin.Api/Controllers/AudioController.cs6
-rw-r--r--Jellyfin.Api/Controllers/BrandingController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ChannelsController.cs8
-rw-r--r--Jellyfin.Api/Controllers/CollectionController.cs19
-rw-r--r--Jellyfin.Api/Controllers/DashboardController.cs2
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs255
-rw-r--r--Jellyfin.Api/Controllers/FilterController.cs43
-rw-r--r--Jellyfin.Api/Controllers/GenresController.cs12
-rw-r--r--Jellyfin.Api/Controllers/HlsSegmentController.cs28
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs4
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemLookupController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs187
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs13
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs33
-rw-r--r--Jellyfin.Api/Controllers/LocalizationController.cs2
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs2
-rw-r--r--Jellyfin.Api/Controllers/MoviesController.cs2
-rw-r--r--Jellyfin.Api/Controllers/MusicGenresController.cs12
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs2
-rw-r--r--Jellyfin.Api/Controllers/PersonsController.cs10
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs15
-rw-r--r--Jellyfin.Api/Controllers/PlaystateController.cs5
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs13
-rw-r--r--Jellyfin.Api/Controllers/SessionController.cs13
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs6
-rw-r--r--Jellyfin.Api/Controllers/StudiosController.cs15
-rw-r--r--Jellyfin.Api/Controllers/SuggestionsController.cs11
-rw-r--r--Jellyfin.Api/Controllers/SyncPlayController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SystemController.cs2
-rw-r--r--Jellyfin.Api/Controllers/TimeSyncController.cs2
-rw-r--r--Jellyfin.Api/Controllers/TrailersController.cs90
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs6
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs17
-rw-r--r--Jellyfin.Api/Controllers/UserController.cs2
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs8
-rw-r--r--Jellyfin.Api/Controllers/UserViewsController.cs9
-rw-r--r--Jellyfin.Api/Controllers/VideoHlsController.cs177
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs9
-rw-r--r--Jellyfin.Api/Controllers/YearsController.cs20
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs198
-rw-r--r--Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs92
-rw-r--r--Jellyfin.Api/Helpers/HlsHelpers.cs57
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs43
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs53
-rw-r--r--Jellyfin.Api/Helpers/TranscodingJobHelper.cs45
-rw-r--r--Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs49
-rw-r--r--Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs47
-rw-r--r--Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs27
-rw-r--r--Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs90
-rw-r--r--Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs9
-rw-r--r--Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs6
-rw-r--r--Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs87
-rw-r--r--Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs44
-rw-r--r--Jellyfin.Data/Entities/Libraries/CollectionItem.cs4
-rw-r--r--Jellyfin.Data/Entities/Libraries/ItemMetadata.cs2
-rw-r--r--Jellyfin.Networking/Configuration/NetworkConfiguration.cs221
-rw-r--r--Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs21
-rw-r--r--Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs27
-rw-r--r--Jellyfin.Networking/Jellyfin.Networking.csproj30
-rw-r--r--Jellyfin.Networking/Manager/INetworkManager.cs234
-rw-r--r--Jellyfin.Networking/Manager/NetworkManager.cs1319
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs2
-rw-r--r--Jellyfin.Server/CoreAppHost.cs2
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs4
-rw-r--r--Jellyfin.Server/Startup.cs6
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs3
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs24
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs75
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs28
-rw-r--r--MediaBrowser.Common/Json/JsonDefaults.cs1
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj2
-rw-r--r--MediaBrowser.Common/Net/INetworkManager.cs4
-rw-r--r--MediaBrowser.Common/Net/IPHost.cs445
-rw-r--r--MediaBrowser.Common/Net/IPNetAddress.cs277
-rw-r--r--MediaBrowser.Common/Net/IPObject.cs406
-rw-r--r--MediaBrowser.Common/Net/NetworkExtensions.cs262
-rw-r--r--MediaBrowser.Common/Plugins/BasePlugin.cs21
-rw-r--r--MediaBrowser.Common/Updates/IInstallationManager.cs7
-rw-r--r--MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs86
-rw-r--r--MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs29
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs56
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs8
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs6
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs4
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs544
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs10
-rw-r--r--MediaBrowser.Controller/Playlists/IPlaylistManager.cs2
-rw-r--r--MediaBrowser.Controller/Session/SessionInfo.cs5
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs34
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs105
-rw-r--r--MediaBrowser.Model/Configuration/EncodingOptions.cs3
-rw-r--r--MediaBrowser.Model/Configuration/PathSubstitution.cs20
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs410
-rw-r--r--MediaBrowser.Model/Dlna/ResolutionNormalizer.cs12
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs72
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs2
-rw-r--r--MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs8
-rw-r--r--MediaBrowser.Model/Session/ClientCapabilities.cs5
-rw-r--r--MediaBrowser.Model/Updates/PackageInfo.cs12
-rw-r--r--MediaBrowser.Model/Updates/RepositoryInfo.cs6
-rw-r--r--MediaBrowser.Model/Updates/VersionInfo.cs30
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs11
-rw-r--r--MediaBrowser.Providers/Manager/ProviderUtils.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs12
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs10
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs29
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs289
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs130
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs262
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs113
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs155
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs153
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs419
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs39
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs2
-rw-r--r--MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs28
-rw-r--r--MediaBrowser.Providers/TV/TvdbExternalId.cs28
-rw-r--r--MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs28
-rw-r--r--MediaBrowser.Providers/TV/Zap2ItExternalId.cs1
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs10
-rw-r--r--MediaBrowser.sln34
-rw-r--r--RSSDP/DisposableManagedObjectBase.cs6
-rw-r--r--RSSDP/HttpParserBase.cs2
-rw-r--r--RSSDP/ISsdpDeviceLocator.cs4
-rw-r--r--RSSDP/SsdpDevicePublisher.cs4
-rw-r--r--RSSDP/SsdpRootDevice.cs4
-rw-r--r--tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs226
168 files changed, 6461 insertions, 3121 deletions
diff --git a/.ci/azure-pipelines-test.yml b/.ci/azure-pipelines-test.yml
index 4ceda978a..36152c82a 100644
--- a/.ci/azure-pipelines-test.yml
+++ b/.ci/azure-pipelines-test.yml
@@ -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"
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 000000000..538894818
--- /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/Emby.Dlna/Common/Argument.cs b/Emby.Dlna/Common/Argument.cs
index 430a3b47d..e4e9c55e0 100644
--- a/Emby.Dlna/Common/Argument.cs
+++ b/Emby.Dlna/Common/Argument.cs
@@ -1,7 +1,7 @@
namespace Emby.Dlna.Common
{
/// <summary>
- /// DLNA Query parameter type, used when quering DLNA devices via SOAP.
+ /// DLNA Query parameter type, used when querying DLNA devices via SOAP.
/// </summary>
public class Argument
{
diff --git a/Emby.Dlna/Configuration/DlnaOptions.cs b/Emby.Dlna/Configuration/DlnaOptions.cs
index 6dd9a445a..e63a85860 100644
--- a/Emby.Dlna/Configuration/DlnaOptions.cs
+++ b/Emby.Dlna/Configuration/DlnaOptions.cs
@@ -2,8 +2,14 @@
namespace Emby.Dlna.Configuration
{
+ /// <summary>
+ /// The DlnaOptions class contains the user definable parameters for the dlna subsystems.
+ /// </summary>
public class DlnaOptions
{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DlnaOptions"/> class.
+ /// </summary>
public DlnaOptions()
{
EnablePlayTo = true;
@@ -11,23 +17,76 @@ namespace Emby.Dlna.Configuration
BlastAliveMessages = true;
SendOnlyMatchedHost = true;
ClientDiscoveryIntervalSeconds = 60;
- BlastAliveMessageIntervalSeconds = 1800;
+ AliveMessageIntervalSeconds = 1800;
}
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna playTo subsystem.
+ /// </summary>
public bool EnablePlayTo { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna server subsystem.
+ /// </summary>
public bool EnableServer { get; set; }
+ /// <summary>
+ /// 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.
+ /// </summary>
public bool EnableDebugLog { get; set; }
- public bool BlastAliveMessages { get; set; }
-
- public bool SendOnlyMatchedHost { get; set; }
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public bool EnablePlayToTracing { get; set; }
+ /// <summary>
+ /// 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.
+ /// </summary>
public int ClientDiscoveryIntervalSeconds { get; set; }
- public int BlastAliveMessageIntervalSeconds { get; set; }
+ /// <summary>
+ /// Gets or sets the frequency at which ssdp alive notifications are transmitted.
+ /// </summary>
+ public int AliveMessageIntervalSeconds { get; set; }
+
+ /// <summary>
+ /// Gets or sets the frequency at which ssdp alive notifications are transmitted. MIGRATING - TO BE REMOVED ONCE WEB HAS BEEN ALTERED.
+ /// </summary>
+ public int BlastAliveMessageIntervalSeconds
+ {
+ get
+ {
+ return AliveMessageIntervalSeconds;
+ }
+
+ set
+ {
+ AliveMessageIntervalSeconds = value;
+ }
+ }
+ /// <summary>
+ /// Gets or sets the default user account that the dlna server uses.
+ /// </summary>
public string DefaultUserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether playTo device profiles should be created.
+ /// </summary>
+ public bool AutoCreatePlayToProfiles { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to blast alive messages.
+ /// </summary>
+ public bool BlastAliveMessages { get; set; } = true;
+
+ /// <summary>
+ /// gets or sets a value indicating whether to send only matched host.
+ /// </summary>
+ public bool SendOnlyMatchedHost { get; set; } = true;
}
}
diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs
index f5a7eca72..916044a0c 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
{
+ /// <summary>
+ /// Defines the <see cref="ConnectionManagerService" />.
+ /// </summary>
public class ConnectionManagerService : BaseService, IConnectionManager
{
private readonly IDlnaManager _dlna;
private readonly IServerConfigurationManager _config;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ConnectionManagerService"/> class.
+ /// </summary>
+ /// <param name="dlna">The <see cref="IDlnaManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
+ /// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
+ /// <param name="logger">The <see cref="ILogger{ConnectionManagerService}"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
+ /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
public ConnectionManagerService(
IDlnaManager dlna,
IServerConfigurationManager config,
@@ -28,7 +38,7 @@ namespace Emby.Dlna.ConnectionManager
/// <inheritdoc />
public string GetServiceXml()
{
- return new ConnectionManagerXmlBuilder().GetXml();
+ return ConnectionManagerXmlBuilder.GetXml();
}
/// <inheritdoc />
diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs
index c8db5a367..c484dac54 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
+ /// <summary>
+ /// Defines the <see cref="ConnectionManagerXmlBuilder" />.
+ /// </summary>
+ public static class ConnectionManagerXmlBuilder
{
- public string GetXml()
+ /// <summary>
+ /// Gets the ConnectionManager:1 service template.
+ /// See http://upnp.org/specs/av/UPnP-av-ConnectionManager-v1-Service.pdf.
+ /// </summary>
+ /// <returns>An XML description of this service.</returns>
+ public static string GetXml()
{
- return new ServiceXmlBuilder().GetXml(new ServiceActionListBuilder().GetActions(), GetStateVariables());
+ return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables());
}
+ /// <summary>
+ /// Get the list of state variables for this invocation.
+ /// </summary>
+ /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
private static IEnumerable<StateVariable> GetStateVariables()
{
- var list = new List<StateVariable>();
-
- list.Add(new StateVariable
+ var list = new List<StateVariable>
{
- 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 d4cc65394..2f8d197a7 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
{
+ /// <summary>
+ /// Defines the <see cref="ControlHandler" />.
+ /// </summary>
public class ControlHandler : BaseControlHandler
{
private readonly DeviceProfile _profile;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ControlHandler"/> class.
+ /// </summary>
+ /// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ControlHandler"/> instance.</param>
+ /// <param name="logger">The <see cref="ILogger"/> for use with the <see cref="ControlHandler"/> instance.</param>
+ /// <param name="profile">The <see cref="DeviceProfile"/> for use with the <see cref="ControlHandler"/> instance.</param>
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);
}
+ /// <summary>
+ /// Builds the response to the GetProtocolInfo request.
+ /// </summary>
+ /// <param name="xmlWriter">The <see cref="XmlWriter"/>.</param>
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 b853e7eab..542c7bfb4 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
+ /// <summary>
+ /// Defines the <see cref="ServiceActionListBuilder" />.
+ /// </summary>
+ public static class ServiceActionListBuilder
{
- public IEnumerable<ServiceAction> GetActions()
+ /// <summary>
+ /// Returns an enumerable of the ConnectionManagar:1 DLNA actions.
+ /// </summary>
+ /// <returns>An <see cref="IEnumerable{ServiceAction}"/>.</returns>
+ public static IEnumerable<ServiceAction> GetActions()
{
var list = new List<ServiceAction>
{
@@ -21,6 +28,10 @@ namespace Emby.Dlna.ConnectionManager
return list;
}
+ /// <summary>
+ /// Returns the action details for "PrepareForConnection".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
private static ServiceAction PrepareForConnection()
{
var action = new ServiceAction
@@ -80,6 +91,10 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
+ /// <summary>
+ /// Returns the action details for "GetCurrentConnectionInfo".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
private static ServiceAction GetCurrentConnectionInfo()
{
var action = new ServiceAction
@@ -146,7 +161,11 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
- private ServiceAction GetProtocolInfo()
+ /// <summary>
+ /// Returns the action details for "GetProtocolInfo".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
+ private static ServiceAction GetProtocolInfo()
{
var action = new ServiceAction
{
@@ -170,7 +189,11 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
- private ServiceAction GetCurrentConnectionIDs()
+ /// <summary>
+ /// Returns the action details for "GetCurrentConnectionIDs".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
+ private static ServiceAction GetCurrentConnectionIDs()
{
var action = new ServiceAction
{
@@ -187,7 +210,11 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
- private ServiceAction ConnectionComplete()
+ /// <summary>
+ /// Returns the action details for "ConnectionComplete".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
+ private static ServiceAction ConnectionComplete()
{
var action = new ServiceAction
{
diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs
index 9f35c1959..b93651746 100644
--- a/Emby.Dlna/ContentDirectory/ControlHandler.cs
+++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs
@@ -1674,7 +1674,7 @@ namespace Emby.Dlna.ContentDirectory
}
/// <summary>
- /// Retreives the ServerItem id.
+ /// Retrieves the ServerItem id.
/// </summary>
/// <param name="id">The id<see cref="string"/>.</param>
/// <returns>The <see cref="ServerItem"/>.</returns>
diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs
index 069400833..fedd20b68 100644
--- a/Emby.Dlna/DlnaManager.cs
+++ b/Emby.Dlna/DlnaManager.cs
@@ -484,10 +484,10 @@ namespace Emby.Dlna
/// <summary>
/// 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).
/// </summary>
/// <param name="profile">The device profile.</param>
- /// <returns>The reserialized device profile.</returns>
+ /// <returns>The re-serialized device profile.</returns>
private DeviceProfile ReserializeProfile(DeviceProfile profile)
{
if (profile.GetType() == typeof(DeviceProfile))
diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs
index c97acdb02..f8ff03076 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("<data xmlns:dlna=\"urn:schemas-dlna-org:device-1-0\">" + xml + "</data>")
diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleParser.cs
index e87245251..a19340ef6 100644
--- a/Emby.Naming/Subtitles/SubtitleParser.cs
+++ b/Emby.Naming/Subtitles/SubtitleParser.cs
@@ -60,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/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs
index 19cc491cf..5f83355c8 100644
--- a/Emby.Naming/Video/VideoListResolver.cs
+++ b/Emby.Naming/Video/VideoListResolver.cs
@@ -30,7 +30,7 @@ namespace Emby.Naming.Video
/// </summary>
/// <param name="files">List of related video files.</param>
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
- /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files togeather when related.</returns>
+ /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
public IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, bool supportMultiVersion = true)
{
var videoResolver = new VideoResolver(_options);
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 17b99c858..5d47d1e40 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -94,7 +94,6 @@ using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks;
using MediaBrowser.Providers.Chapters;
using MediaBrowser.Providers.Manager;
-using MediaBrowser.Providers.Plugins.TheTvdb;
using MediaBrowser.Providers.Plugins.Tmdb;
using MediaBrowser.Providers.Subtitles;
using MediaBrowser.XbmcMetadata.Providers;
@@ -520,7 +519,6 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>();
ServiceCollection.AddSingleton(_fileSystemManager);
- ServiceCollection.AddSingleton<TvdbClientManager>();
ServiceCollection.AddSingleton<TmdbClientManager>();
ServiceCollection.AddSingleton(_networkManager);
@@ -1070,7 +1068,6 @@ namespace Emby.Server.Implementations
if (!string.IsNullOrEmpty(lastName) && cleanup)
{
// Attempt a cleanup of old folders.
- versions.RemoveAt(x);
try
{
Logger.LogDebug("Deleting {Path}", versions[x].Path);
@@ -1080,6 +1077,8 @@ namespace Emby.Server.Implementations
{
Logger.LogWarning(e, "Unable to delete {Path}", versions[x].Path);
}
+
+ versions.RemoveAt(x);
}
}
diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs
index 19045b72b..3d97a6ca8 100644
--- a/Emby.Server.Implementations/Channels/ChannelManager.cs
+++ b/Emby.Server.Implementations/Channels/ChannelManager.cs
@@ -634,7 +634,7 @@ namespace Emby.Server.Implementations.Channels
{
var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray();
- if (query.ChannelIds.Length > 0)
+ if (query.ChannelIds.Count > 0)
{
// Avoid implicitly captured closure
var ids = query.ChannelIds;
diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
index 638c7a9b4..7e01bd4b6 100644
--- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -3611,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})");
@@ -4076,7 +4076,7 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add(clause);
}
- if (query.GenreIds.Length > 0)
+ if (query.GenreIds.Count > 0)
{
var clauses = new List<string>();
var index = 0;
@@ -4097,7 +4097,7 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add(clause);
}
- if (query.Genres.Length > 0)
+ if (query.Genres.Count > 0)
{
var clauses = new List<string>();
var index = 0;
diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs
index f98c694c4..da5047d24 100644
--- a/Emby.Server.Implementations/Devices/DeviceManager.cs
+++ b/Emby.Server.Implementations/Devices/DeviceManager.cs
@@ -1,61 +1,38 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
using System.Linq;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Security;
using MediaBrowser.Model.Devices;
using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Session;
-using Microsoft.Extensions.Caching.Memory;
namespace Emby.Server.Implementations.Devices
{
public class DeviceManager : IDeviceManager
{
- private readonly IMemoryCache _memoryCache;
- private readonly IJsonSerializer _json;
private readonly IUserManager _userManager;
- private readonly IServerConfigurationManager _config;
private readonly IAuthenticationRepository _authRepo;
- private readonly object _capabilitiesSyncLock = new object();
+ private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new ();
- public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
-
- public DeviceManager(
- IAuthenticationRepository authRepo,
- IJsonSerializer json,
- IUserManager userManager,
- IServerConfigurationManager config,
- IMemoryCache memoryCache)
+ public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager)
{
- _json = json;
_userManager = userManager;
- _config = config;
- _memoryCache = memoryCache;
_authRepo = authRepo;
}
+ public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
+
public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
{
- var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json");
- Directory.CreateDirectory(Path.GetDirectoryName(path));
-
- lock (_capabilitiesSyncLock)
- {
- _memoryCache.Set(deviceId, capabilities);
- _json.SerializeToFile(capabilities, path);
- }
+ _capabilitiesMap[deviceId] = capabilities;
}
public void UpdateDeviceOptions(string deviceId, DeviceOptions options)
@@ -72,33 +49,13 @@ namespace Emby.Server.Implementations.Devices
public ClientCapabilities GetCapabilities(string id)
{
- if (_memoryCache.TryGetValue(id, out ClientCapabilities result))
- {
- return result;
- }
-
- lock (_capabilitiesSyncLock)
- {
- var path = Path.Combine(GetDevicePath(id), "capabilities.json");
- try
- {
- return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities();
- }
- catch
- {
- }
- }
-
- return new ClientCapabilities();
+ return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result)
+ ? result
+ : new ClientCapabilities();
}
public DeviceInfo GetDevice(string id)
{
- return GetDevice(id, true);
- }
-
- private DeviceInfo GetDevice(string id, bool includeCapabilities)
- {
var session = _authRepo.Get(new AuthenticationInfoQuery
{
DeviceId = id
@@ -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/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index d83873441..8ffb05e1c 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1503,7 +1503,7 @@ namespace Emby.Server.Implementations.Library
{
if (query.AncestorIds.Length == 0 &&
query.ParentId.Equals(Guid.Empty) &&
- query.ChannelIds.Length == 0 &&
+ query.ChannelIds.Count == 0 &&
query.TopParentIds.Length == 0 &&
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
index 179e0ed98..28fa06239 100644
--- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs
+++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
@@ -101,7 +101,7 @@ namespace Emby.Server.Implementations.Library
private static IEnumerable<MediaStream> GetSortedStreams(IEnumerable<MediaStream> streams, MediaStreamType type, string[] languagePreferences)
{
- // Give some preferance to external text subs for better performance
+ // Give some preference to external text subs for better performance
return streams.Where(i => i.Type == type)
.OrderBy(i =>
{
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
index fcc2d1eeb..0dc045ee6 100644
--- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
@@ -1635,7 +1635,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
{
- return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer);
+ return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, _config);
}
return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
index 3e5457dbd..e6ee9819e 100644
--- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
@@ -8,7 +8,9 @@ using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
@@ -25,6 +27,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
private readonly IServerApplicationPaths _appPaths;
private readonly IJsonSerializer _json;
private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>();
+ private readonly IServerConfigurationManager _serverConfigurationManager;
private bool _hasExited;
private Stream _logFileStream;
@@ -35,12 +38,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
ILogger logger,
IMediaEncoder mediaEncoder,
IServerApplicationPaths appPaths,
- IJsonSerializer json)
+ IJsonSerializer json,
+ IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_appPaths = appPaths;
_json = json;
+ _serverConfigurationManager = serverConfigurationManager;
}
private static bool CopySubtitles => false;
@@ -179,15 +184,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
var outputParam = string.Empty;
+ var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null);
var commandLineArgs = string.Format(
CultureInfo.InvariantCulture,
- "-i \"{0}\" {2} -map_metadata -1 -threads 0 {3}{4}{5} -y \"{1}\"",
+ "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
inputTempFile,
targetFile,
videoArgs,
GetAudioArgs(mediaSource),
subtitleArgs,
- outputParam);
+ outputParam,
+ threads);
return inputModifier + " " + commandLineArgs;
}
diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
index 91f7c7931..5d17ba1de 100644
--- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
+++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
@@ -261,7 +261,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
Id = newID,
StartDate = startAt,
EndDate = endAt,
- Name = details.titles[0].title120 ?? "Unkown",
+ Name = details.titles[0].title120 ?? "Unknown",
OfficialRating = null,
CommunityRating = null,
EpisodeTitle = episodeTitle,
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
index 6ea1e1dd7..1d6c26c13 100644
--- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
@@ -197,7 +197,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
if (string.IsNullOrWhiteSpace(numberString))
{
// Using this as a fallback now as this leads to Problems with channels like "5 USA"
- // where 5 isnt ment to be the channel number
+ // where 5 isn't ment to be the channel number
// Check for channel number with the format from SatIp
// #EXTINF:0,84. VOX Schweiz
// #EXTINF:0,84.0 - VOX Schweiz
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index c45cc11cb..dcf3311cd 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -113,5 +113,10 @@
"TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή",
"TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.",
"TaskUpdatePlugins": "Ενημέρωση Προσθηκών",
- "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας."
+ "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.",
+ "TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής δραστηριοτήτων παλαιότερες από την ηλικία που έχει διαμορφωθεί.",
+ "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων",
+ "Undefined": "Απροσδιόριστο",
+ "Forced": "Εξαναγκασμένο",
+ "Default": "Προεπιλογή"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index f906d6e11..981e8a06e 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -113,5 +113,10 @@
"TaskRefreshChannels": "רענן ערוץ",
"TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.",
"TaskCleanTranscode": "נקה תקיית Transcode",
- "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי."
+ "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי.",
+ "TaskCleanActivityLogDescription": "מחק רשומת פעילות הישנה יותר מהגיל המוגדר.",
+ "TaskCleanActivityLog": "נקה רשומת פעילות",
+ "Undefined": "לא מוגדר",
+ "Forced": "כפוי",
+ "Default": "ברירת מחדל"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index 804dabe57..e5707e78c 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -115,5 +115,8 @@
"TaskRefreshChannels": "Csatornák frissítése",
"TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.",
"TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.",
- "TaskCleanActivityLog": "Tevékenységnapló törlése"
+ "TaskCleanActivityLog": "Tevékenységnapló törlése",
+ "Undefined": "Meghatározatlan",
+ "Forced": "Kényszerített",
+ "Default": "Alapértelmezett"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json
index 003e591b3..e3da96a85 100644
--- a/Emby.Server.Implementations/Localization/Core/pl.json
+++ b/Emby.Server.Implementations/Localization/Core/pl.json
@@ -113,5 +113,10 @@
"TasksChannelsCategory": "Kanały internetowe",
"TasksApplicationCategory": "Aplikacja",
"TasksLibraryCategory": "Biblioteka",
- "TasksMaintenanceCategory": "Konserwacja"
+ "TasksMaintenanceCategory": "Konserwacja",
+ "TaskCleanActivityLogDescription": "Usuwa wpisy dziennika aktywności starsze niż skonfigurowany wiek.",
+ "TaskCleanActivityLog": "Czyść dziennik aktywności",
+ "Undefined": "Nieustalony",
+ "Forced": "Wymuszony",
+ "Default": "Domyślne"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
index e8cd23d5d..5fcdb1f74 100644
--- a/Emby.Server.Implementations/Localization/Core/ta.json
+++ b/Emby.Server.Implementations/Localization/Core/ta.json
@@ -114,5 +114,8 @@
"UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது",
"UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது",
"TaskCleanActivityLogDescription": "உள்ளமைக்கப்பட்ட வயதை விட பழைய செயல்பாட்டு பதிவு உள்ளீடுகளை நீக்குகிறது.",
- "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி"
+ "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி",
+ "Undefined": "வரையறுக்கப்படாத",
+ "Forced": "கட்டாயப்படுத்தப்பட்டது",
+ "Default": "இயல்புநிலை"
}
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index ba58e4beb..0549995c8 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -114,5 +114,8 @@
"Application": "Ứng Dụng",
"AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}",
"TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.",
- "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động"
+ "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động",
+ "Undefined": "Không Xác Định",
+ "Forced": "Bắt Buộc",
+ "Default": "Mặc Định"
}
diff --git a/Emby.Server.Implementations/Networking/NetworkManager.cs b/Emby.Server.Implementations/Networking/NetworkManager.cs
index 089ec30e6..ff0a2a361 100644
--- a/Emby.Server.Implementations/Networking/NetworkManager.cs
+++ b/Emby.Server.Implementations/Networking/NetworkManager.cs
@@ -18,13 +18,12 @@ namespace Emby.Server.Implementations.Networking
public class NetworkManager : INetworkManager
{
private readonly ILogger<NetworkManager> _logger;
-
- private IPAddress[] _localIpAddresses;
private readonly object _localIpAddressSyncLock = new object();
-
private readonly object _subnetLookupLock = new object();
private readonly Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal);
+ private IPAddress[] _localIpAddresses;
+
private List<PhysicalAddress> _macAddresses;
/// <summary>
@@ -157,7 +156,9 @@ namespace Emby.Server.Implementations.Networking
return false;
}
- byte[] octet = ipAddress.GetAddressBytes();
+ // GetAddressBytes
+ Span<byte> octet = stackalloc byte[ipAddress.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+ ipAddress.TryWriteBytes(octet, out _);
if ((octet[0] == 10) ||
(octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918
@@ -260,7 +261,9 @@ namespace Emby.Server.Implementations.Networking
/// <inheritdoc/>
public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC)
{
- byte[] octet = address.GetAddressBytes();
+ // GetAddressBytes
+ Span<byte> octet = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+ address.TryWriteBytes(octet, out _);
if ((octet[0] == 127) || // RFC1122
(octet[0] == 169 && octet[1] == 254)) // RFC3927
@@ -503,18 +506,25 @@ namespace Emby.Server.Implementations.Networking
private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask)
{
- byte[] ipAdressBytes = address.GetAddressBytes();
- byte[] subnetMaskBytes = subnetMask.GetAddressBytes();
+ int size = address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16;
+
+ // GetAddressBytes
+ Span<byte> ipAddressBytes = stackalloc byte[size];
+ address.TryWriteBytes(ipAddressBytes, out _);
+
+ // GetAddressBytes
+ Span<byte> subnetMaskBytes = stackalloc byte[size];
+ subnetMask.TryWriteBytes(subnetMaskBytes, out _);
- if (ipAdressBytes.Length != subnetMaskBytes.Length)
+ if (ipAddressBytes.Length != subnetMaskBytes.Length)
{
throw new ArgumentException("Lengths of IP address and subnet mask do not match.");
}
- byte[] broadcastAddress = new byte[ipAdressBytes.Length];
+ byte[] broadcastAddress = new byte[ipAddressBytes.Length];
for (int i = 0; i < broadcastAddress.Length; i++)
{
- broadcastAddress[i] = (byte)(ipAdressBytes[i] & subnetMaskBytes[i]);
+ broadcastAddress[i] = (byte)(ipAddressBytes[i] & subnetMaskBytes[i]);
}
return new IPAddress(broadcastAddress);
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
index d3b64fb31..932f721ab 100644
--- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -150,7 +150,7 @@ namespace Emby.Server.Implementations.Playlists
await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None)
.ConfigureAwait(false);
- if (options.ItemIdList.Length > 0)
+ if (options.ItemIdList.Count > 0)
{
await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false)
{
@@ -184,7 +184,7 @@ namespace Emby.Server.Implementations.Playlists
return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
}
- public Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId)
+ public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
{
var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId);
@@ -194,7 +194,7 @@ namespace Emby.Server.Implementations.Playlists
});
}
- private async Task AddToPlaylistInternal(Guid playlistId, ICollection<Guid> newItemIds, User user, DtoOptions options)
+ private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options)
{
// Retrieve the existing playlist
var playlist = _libraryManager.GetItemById(playlistId) as Playlist
diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
index 6f81bf49b..cfbf03ddc 100644
--- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
@@ -136,7 +136,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
{
var type = scheduledTask.ScheduledTask.GetType();
- _logger.LogInformation("Queueing task {0}", type.Name);
+ _logger.LogInformation("Queuing task {0}", type.Name);
lock (_taskQueue)
{
@@ -176,7 +176,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
{
var type = task.ScheduledTask.GetType();
- _logger.LogInformation("Queueing task {0}", type.Name);
+ _logger.LogInformation("Queuing task {0}", type.Name);
lock (_taskQueue)
{
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 607b322f2..afddfa856 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -58,8 +58,7 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// The active connections.
/// </summary>
- private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections =
- new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase);
private Timer _idleTimer;
@@ -196,7 +195,7 @@ namespace Emby.Server.Implementations.Session
{
if (!string.IsNullOrEmpty(info.DeviceId))
{
- var capabilities = GetSavedCapabilities(info.DeviceId);
+ var capabilities = _deviceManager.GetCapabilities(info.DeviceId);
if (capabilities != null)
{
@@ -1677,27 +1676,10 @@ namespace Emby.Server.Implementations.Session
SessionInfo = session
});
- try
- {
- SaveCapabilities(session.DeviceId, capabilities);
- }
- catch (Exception ex)
- {
- _logger.LogError("Error saving device capabilities", ex);
- }
+ _deviceManager.SaveCapabilities(session.DeviceId, capabilities);
}
}
- private ClientCapabilities GetSavedCapabilities(string deviceId)
- {
- return _deviceManager.GetCapabilities(deviceId);
- }
-
- private void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
- {
- _deviceManager.SaveCapabilities(deviceId, capabilities);
- }
-
/// <summary>
/// Converts a BaseItem to a BaseItemInfo.
/// </summary>
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
index 966ed5024..7c4e00311 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.SyncPlay
new Dictionary<Guid, ISyncPlayController>();
/// <summary>
- /// Lock used for accesing any group.
+ /// Lock used for accessing any group.
/// </summary>
private readonly object _groupsLock = new object();
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 851e7bd68..7a071c071 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -93,17 +93,29 @@ namespace Emby.Server.Implementations.Updates
public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
/// <inheritdoc />
- public async Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default)
+ public async Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default)
{
try
{
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
- .GetAsync(manifest, cancellationToken).ConfigureAwait(false);
+ .GetAsync(new Uri(manifest), cancellationToken).ConfigureAwait(false);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
try
{
- return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false);
+ var package = await _jsonSerializer.DeserializeFromStreamAsync<IList<PackageInfo>>(stream).ConfigureAwait(false);
+
+ // Store the repository and repository url with each version, as they may be spread apart.
+ foreach (var entry in package)
+ {
+ foreach (var ver in entry.versions)
+ {
+ ver.repositoryName = manifestName;
+ ver.repositoryUrl = manifest;
+ }
+ }
+
+ return package;
}
catch (SerializationException ex)
{
@@ -123,17 +135,69 @@ namespace Emby.Server.Implementations.Updates
}
}
+ private static void MergeSort(IList<VersionInfo> source, IList<VersionInfo> 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;
+ }
+ }
+ }
+
/// <inheritdoc />
public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
{
var result = new List<PackageInfo>();
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))
+ {
+ var existing = FilterPackages(result, package.name, Guid.Parse(package.guid)).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);
+ }
+ }
}
}
@@ -144,7 +208,8 @@ namespace Emby.Server.Implementations.Updates
public IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages,
string name = null,
- Guid guid = default)
+ Guid guid = default,
+ Version specificVersion = null)
{
if (name != null)
{
@@ -156,6 +221,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;
}
@@ -167,7 +237,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)
@@ -181,21 +251,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
};
@@ -333,7 +403,7 @@ 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);
+ .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
diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs
index e8d6ccdf2..8c43d786a 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 9bad206e0..c65dc8620 100644
--- a/Jellyfin.Api/Controllers/ArtistsController.cs
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -89,24 +89,24 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [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, 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,
@@ -131,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,
@@ -174,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
{
@@ -230,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;
@@ -297,24 +293,24 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [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, 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,
@@ -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 d4c6e4af9..ae8c05d85 100644
--- a/Jellyfin.Api/Controllers/AudioController.cs
+++ b/Jellyfin.Api/Controllers/AudioController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
@@ -42,7 +42,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -71,7 +71,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs
index 1d4836f27..d3ea41201 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 ec9d7cdce..c4dc44cc3 100644
--- a/Jellyfin.Api/Controllers/ChannelsController.cs
+++ b/Jellyfin.Api/Controllers/ChannelsController.cs
@@ -198,7 +198,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? channelIds)
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
@@ -208,11 +208,7 @@ namespace Jellyfin.Api.Controllers
{
Limit = limit,
StartIndex = startIndex,
- ChannelIds = (channelIds ?? string.Empty)
- .Split(',')
- .Where(i => !string.IsNullOrWhiteSpace(i))
- .Select(i => new Guid(i))
- .ToArray(),
+ ChannelIds = channelIds,
DtoOptions = new DtoOptions { Fields = fields }
};
diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs
index eae06b767..2a7b2b5c6 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<ActionResult<CollectionCreationResult>> CreateCollection(
[FromQuery] string? name,
- [FromQuery] string? ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
[FromQuery] Guid? parentId,
[FromQuery] bool isLocked = false)
{
@@ -65,7 +66,7 @@ namespace Jellyfin.Api.Controllers
IsLocked = isLocked,
Name = name,
ParentId = parentId,
- ItemIdList = RequestHelpers.Split(ids, ',', true),
+ ItemIdList = ids,
UserIds = new[] { userId }
}).ConfigureAwait(false);
@@ -88,9 +89,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
+ public async Task<ActionResult> AddToCollection(
+ [FromRoute, Required] Guid collectionId,
+ [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true);
+ await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
return NoContent();
}
@@ -103,9 +106,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpDelete("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
+ public async Task<ActionResult> RemoveFromCollection(
+ [FromRoute, Required] Guid collectionId,
+ [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false);
+ await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
return NoContent();
}
}
diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs
index a859ac114..ccc81dfc5 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/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index 6e59da798..22628a59d 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;
@@ -41,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;
@@ -56,8 +60,7 @@ namespace Jellyfin.Api.Controllers
private readonly ILogger<DynamicHlsController> _logger;
private readonly EncodingHelper _encodingHelper;
private readonly DynamicHlsHelper _dynamicHlsHelper;
-
- private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
+ private readonly EncodingOptions _encodingOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@@ -92,6 +95,8 @@ namespace Jellyfin.Api.Controllers
ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper)
{
+ _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+
_libraryManager = libraryManager;
_userManager = userManager;
_dlnaManager = dlnaManager;
@@ -106,8 +111,7 @@ namespace Jellyfin.Api.Controllers
_transcodingJobHelper = transcodingJobHelper;
_logger = logger;
_dynamicHlsHelper = dynamicHlsHelper;
-
- _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+ _encodingOptions = serverConfigurationManager.GetEncodingOptions();
}
/// <summary>
@@ -120,7 +124,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -149,7 +153,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
@@ -272,7 +276,7 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
};
- return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+ return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
}
/// <summary>
@@ -285,7 +289,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -315,7 +319,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
@@ -439,7 +443,7 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
};
- return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+ return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
}
/// <summary>
@@ -452,7 +456,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -481,7 +485,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
@@ -615,7 +619,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -645,7 +649,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
@@ -812,7 +816,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
@@ -953,7 +957,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -983,7 +987,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
@@ -1129,7 +1133,7 @@ namespace Jellyfin.Api.Controllers
_dlnaManager,
_deviceManager,
_transcodingJobHelper,
- _transcodingJobType,
+ TranscodingJobType,
cancellationTokenSource.Token)
.ConfigureAwait(false);
@@ -1137,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()
@@ -1151,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:")
@@ -1194,7 +1218,7 @@ namespace Jellyfin.Api.Controllers
_dlnaManager,
_deviceManager,
_transcodingJobHelper,
- _transcodingJobType,
+ TranscodingJobType,
cancellationTokenSource.Token)
.ConfigureAwait(false);
@@ -1208,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);
}
@@ -1222,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);
@@ -1233,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;
@@ -1265,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
@@ -1284,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);
@@ -1301,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);
}
@@ -1325,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)
{
@@ -1341,36 +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 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 outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer);
-
- 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 + "\"";
+ }
+
+ segmentFormat = "fmp4" + outputFmp4HeaderArg;
+ }
+ else
+ {
+ _logger.LogError("Invalid HLS segment container: " + segmentFormat);
+ }
- var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128
- ? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+ 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,
@@ -1379,50 +1428,63 @@ namespace Jellyfin.Api.Controllers
outputPath).Trim();
}
- private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions)
+ /// <summary>
+ /// Gets the audio arguments for transcoding.
+ /// </summary>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
+ /// <returns>The command line arguments for audio transcoding.</returns>
+ private string GetAudioArguments(StreamState state)
{
+ if (state.AudioStream == null)
+ {
+ return string.Empty;
+ }
+
var audioCodec = _encodingHelper.GetAudioEncoder(state);
if (!state.IsOutputVideo)
{
if (EncodingHelper.IsCopyCodec(audioCodec))
{
- return "-acodec copy";
+ var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+ return "-acodec copy -strict -2" + bitStreamArgs;
}
- var audioTranscodeParams = new List<string>();
+ var audioTranscodeParams = string.Empty;
- audioTranscodeParams.Add("-acodec " + audioCodec);
+ audioTranscodeParams += "-acodec " + audioCodec;
if (state.OutputAudioBitrate.HasValue)
{
- audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
+ audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
}
if (state.OutputAudioChannels.HasValue)
{
- audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture));
+ audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
}
if (state.OutputAudioSampleRate.HasValue)
{
- audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture));
+ audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
}
- audioTranscodeParams.Add("-vn");
- return string.Join(' ', audioTranscodeParams);
+ audioTranscodeParams += " -vn";
+ return audioTranscodeParams;
}
if (EncodingHelper.IsCopyCodec(audioCodec))
{
- var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+ var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+ var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
{
- return "-codec:a:0 copy -copypriorss:a:0 0";
+ return "-codec:a:0 copy -strict -2 -copypriorss:a:0 0" + bitStreamArgs;
}
- return "-codec:a:0 copy";
+ return "-codec:a:0 copy -strict -2" + bitStreamArgs;
}
var args = "-codec:a:0 " + audioCodec;
@@ -1446,94 +1508,89 @@ namespace Jellyfin.Api.Controllers
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
}
- args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true);
+ args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
return args;
}
- private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber)
+ /// <summary>
+ /// Gets the video arguments for transcoding.
+ /// </summary>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
+ /// <param name="startNumber">The first number in the hls sequence.</param>
+ /// <returns>The command line arguments for video transcoding.</returns>
+ private string GetVideoArguments(StreamState state, int startNumber)
{
+ if (state.VideoStream == null)
+ {
+ return string.Empty;
+ }
+
if (!state.IsOutputVideo)
{
return string.Empty;
}
- var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+ var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var args = "-codec:v:0 " + codec;
+ // Prefer hvc1 to hev1.
+ if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ args += " -tag:v:0 hvc1";
+ }
+
// if (state.EnableMpegtsM2TsMode)
// {
// args += " -mpegts_m2ts_mode 1";
// }
- // See if we can save come cpu cycles by avoiding encoding
+ // See if we can save come cpu cycles by avoiding encoding.
if (EncodingHelper.IsCopyCodec(codec))
{
if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
{
- string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
+ string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
if (!string.IsNullOrEmpty(bitStreamArgs))
{
args += " " + bitStreamArgs;
}
}
+ args += " -start_at_zero";
+
// args += " -flags -global_header";
}
else
{
- var gopArg = string.Empty;
- var keyFrameArg = string.Format(
- CultureInfo.InvariantCulture,
- " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
- startNumber * state.SegmentLength,
- state.SegmentLength);
+ args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
- var framerate = state.VideoStream?.RealFrameRate;
+ // Set the key frame params for video encoding to match the hls segment time.
+ args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber);
- if (framerate.HasValue)
+ // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+ if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
{
- // This is to make sure keyframe interval is limited to our segment,
- // as forcing keyframes is not enough.
- // Example: we encoded half of desired length, then codec detected
- // scene cut and inserted a keyframe; next forced keyframe would
- // be created outside of segment, which breaks seeking
- // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe
- gopArg = string.Format(
- CultureInfo.InvariantCulture,
- " -g {0} -keyint_min {0} -sc_threshold 0",
- Math.Ceiling(state.SegmentLength * framerate.Value));
- }
-
- args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast");
-
- // Unable to force key frames using these hw encoders, set key frames by GOP
- if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase))
- {
- args += " " + gopArg;
- }
- else
- {
- args += " " + keyFrameArg + gopArg;
+ args += " -bf 0";
}
// args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
- // This is for graphical subs
if (hasGraphicalSubs)
{
- args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec);
+ // Graphical subs overlay and resolution params.
+ args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
}
-
- // Add resolution params, if specified
else
{
- args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec);
+ // Resolution params.
+ args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
}
// -start_at_zero is necessary to use with -ss when seeking,
@@ -1693,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/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index 008bb58d1..31cb9e273 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<QueryFiltersLegacy> GetQueryFiltersLegacy(
[FromQuery] Guid? userId,
[FromQuery] string? parentId,
- [FromQuery] string? includeItemTypes,
- [FromQuery] string? mediaTypes)
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
{
var parentItem = string.IsNullOrEmpty(parentId)
? null
@@ -61,10 +62,11 @@ namespace Jellyfin.Api.Controllers
? _userManager.GetUserById(userId.Value)
: null;
- if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+ if (includeItemTypes.Length == 1
+ && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
{
parentItem = null;
}
@@ -78,8 +80,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery
{
User = user,
- MediaTypes = (mediaTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
- IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+ MediaTypes = mediaTypes,
+ IncludeItemTypes = includeItemTypes,
Recursive = true,
EnableTotalRecordCount = false,
DtoOptions = new DtoOptions
@@ -139,7 +141,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult<QueryFilters> GetQueryFilters(
[FromQuery] Guid? userId,
[FromQuery] string? parentId,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isAiring,
[FromQuery] bool? isMovie,
[FromQuery] bool? isSports,
@@ -156,10 +158,11 @@ namespace Jellyfin.Api.Controllers
? _userManager.GetUserById(userId.Value)
: null;
- if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+ if (includeItemTypes.Length == 1
+ && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
{
parentItem = null;
}
@@ -167,8 +170,7 @@ namespace Jellyfin.Api.Controllers
var filters = new QueryFilters();
var genreQuery = new InternalItemsQuery(user)
{
- IncludeItemTypes =
- (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+ IncludeItemTypes = includeItemTypes,
DtoOptions = new DtoOptions
{
Fields = Array.Empty<ItemFields>(),
@@ -192,10 +194,11 @@ namespace Jellyfin.Api.Controllers
genreQuery.Parent = parentItem;
}
- if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase))
+ if (includeItemTypes.Length == 1
+ && (string.Equals(includeItemTypes[0], nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Audio), StringComparison.OrdinalIgnoreCase)))
{
filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
{
diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs
index 9c222135d..d2b41e0a8 100644
--- a/Jellyfin.Api/Controllers/GenresController.cs
+++ b/Jellyfin.Api/Controllers/GenresController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -74,8 +74,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
@@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers
result = _libraryManager.GetGenres(query);
}
- var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs
index 3b75e8d43..f51987732 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;
@@ -112,11 +112,13 @@ namespace Jellyfin.Api.Controllers
/// <param name="segmentId">The segment id.</param>
/// <param name="segmentContainer">The segment container.</param>
/// <response code="200">Hls video segment returned.</response>
+ /// <response code="404">Hls segment not found.</response>
/// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
// Can't require authentication just yet due to seeing some requests come from Chrome without full query string
// [Authenticated]
[HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesVideoFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsVideoSegmentLegacy(
@@ -132,13 +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)
- ?? throw new ResourceNotFoundException($"Provided path ({transcodeFolderPath}) is not valid.");
+ 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/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index 76e53b9a5..f48c1df72 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
@@ -928,7 +928,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute] int? imageIndex = null)
{
var user = _userManager.GetUserById(userId);
- if (user == null)
+ if (user?.ProfileImage == null)
{
return NotFound();
}
diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs
index d17a26db4..6913afd0f 100644
--- a/Jellyfin.Api/Controllers/InstantMixController.cs
+++ b/Jellyfin.Api/Controllers/InstantMixController.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/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs
index a7c1a6388..6c38f77ce 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;
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 0a6ed31ae..9e1a39853 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 d8d371ebc..177df22cf 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
@@ -73,8 +73,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
/// <param name="isHd">Optional filter by items that are HD or not.</param>
/// <param name="is4K">Optional filter by items that are 4K or not.</param>
- /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
- /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
+ /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
/// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
/// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
@@ -87,42 +87,42 @@ namespace Jellyfin.Api.Controllers
/// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
/// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
/// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
- /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
/// <param name="searchTerm">Optional. Filter based on a search term.</param>
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
- /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
- /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
- /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
- /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
/// <param name="isPlayed">Optional filter by items that are played, or not.</param>
- /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
- /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
- /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
- /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+ /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+ /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+ /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+ /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
- /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
- /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
- /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+ /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
+ /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
/// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
/// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
/// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
- /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
- /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+ /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
+ /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
/// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
- /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+ /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
/// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="isLocked">Optional filter by items that are locked.</param>
/// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
@@ -133,12 +133,12 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
/// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
/// <param name="is3D">Optional filter by items that are 3D, or not.</param>
- /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+ /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
- /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
- /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+ /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
@@ -159,7 +159,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
- [FromQuery] string? locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
@@ -173,7 +173,7 @@ 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,
@@ -181,34 +181,34 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? sortOrder,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [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, 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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person,
- [FromQuery] string? personIds,
- [FromQuery] string? personTypes,
- [FromQuery] string? studios,
- [FromQuery] string? artists,
- [FromQuery] string? excludeArtistIds,
- [FromQuery] string? artistIds,
- [FromQuery] string? albumArtistIds,
- [FromQuery] string? contributingArtistIds,
- [FromQuery] string? albums,
- [FromQuery] string? albumIds,
- [FromQuery] string? ids,
- [FromQuery] string? videoTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
[FromQuery] string? minOfficialRating,
[FromQuery] bool? isLocked,
[FromQuery] bool? isPlaceHolder,
@@ -219,12 +219,12 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] bool? is3D,
- [FromQuery] string? seriesStatus,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan,
- [FromQuery] string? studioIds,
- [FromQuery] string? genreIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
{
@@ -238,8 +238,9 @@ namespace Jellyfin.Api.Controllers
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
- if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase))
+ if (includeItemTypes.Length == 1
+ && (includeItemTypes[0].Equals("Playlist", StringComparison.OrdinalIgnoreCase)
+ || includeItemTypes[0].Equals("BoxSet", StringComparison.OrdinalIgnoreCase)))
{
parentId = null;
}
@@ -262,7 +263,7 @@ namespace Jellyfin.Api.Controllers
&& string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
{
recursive = true;
- includeItemTypes = "Playlist";
+ includeItemTypes = new[] { "Playlist" };
}
bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
@@ -291,14 +292,14 @@ namespace Jellyfin.Api.Controllers
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
}
- if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder))
+ if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder))
{
var query = new InternalItemsQuery(user!)
{
IsPlayed = isPlayed,
- MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+ MediaTypes = mediaTypes,
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite,
@@ -330,28 +331,28 @@ namespace Jellyfin.Api.Controllers
HasTrailer = hasTrailer,
IsHD = isHd,
Is4K = is4K,
- Tags = RequestHelpers.Split(tags, '|', true),
- OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
- Genres = RequestHelpers.Split(genres, '|', true),
- ArtistIds = RequestHelpers.GetGuids(artistIds),
- AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds),
- ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds),
- GenreIds = RequestHelpers.GetGuids(genreIds),
- StudioIds = RequestHelpers.GetGuids(studioIds),
+ Tags = tags,
+ OfficialRatings = officialRatings,
+ Genres = genres,
+ ArtistIds = artistIds,
+ AlbumArtistIds = albumArtistIds,
+ ContributingArtistIds = contributingArtistIds,
+ GenreIds = genreIds,
+ StudioIds = studioIds,
Person = person,
- PersonIds = RequestHelpers.GetGuids(personIds),
- PersonTypes = RequestHelpers.Split(personTypes, ',', true),
- Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+ PersonIds = personIds,
+ PersonTypes = personTypes,
+ Years = years,
ImageTypes = imageTypes,
- VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
+ VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
- ItemIds = RequestHelpers.GetGuids(ids),
+ ItemIds = ids,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
- ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds),
+ ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
@@ -360,7 +361,7 @@ namespace Jellyfin.Api.Controllers
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
};
- if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm))
+ if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
{
query.CollapseBoxSetItems = false;
}
@@ -400,9 +401,9 @@ namespace Jellyfin.Api.Controllers
}
// Filter by Series Status
- if (!string.IsNullOrEmpty(seriesStatus))
+ if (seriesStatus.Length != 0)
{
- query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray();
+ query.SeriesStatuses = seriesStatus;
}
// ExcludeLocationTypes
@@ -411,13 +412,9 @@ namespace Jellyfin.Api.Controllers
query.IsVirtualItem = false;
}
- if (!string.IsNullOrEmpty(locationTypes))
+ if (locationTypes.Length > 0 && locationTypes.Length < 4)
{
- var requestedLocationTypes = locationTypes.Split(',');
- if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4)
- {
- query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString());
- }
+ query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
}
// Min official rating
@@ -433,9 +430,9 @@ namespace Jellyfin.Api.Controllers
}
// Artists
- if (!string.IsNullOrEmpty(artists))
+ if (artists.Length != 0)
{
- query.ArtistIds = artists.Split('|').Select(i =>
+ query.ArtistIds = artists.Select(i =>
{
try
{
@@ -449,29 +446,29 @@ namespace Jellyfin.Api.Controllers
}
// ExcludeArtistIds
- if (!string.IsNullOrWhiteSpace(excludeArtistIds))
+ if (excludeArtistIds.Length != 0)
{
- query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+ query.ExcludeArtistIds = excludeArtistIds;
}
- if (!string.IsNullOrWhiteSpace(albumIds))
+ if (albumIds.Length != 0)
{
- query.AlbumIds = RequestHelpers.GetGuids(albumIds);
+ query.AlbumIds = albumIds;
}
// Albums
- if (!string.IsNullOrEmpty(albums))
+ if (albums.Length != 0)
{
- query.AlbumIds = albums.Split('|').SelectMany(i =>
+ query.AlbumIds = albums.SelectMany(i =>
{
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
}).ToArray();
}
// Studios
- if (!string.IsNullOrEmpty(studios))
+ if (studios.Length != 0)
{
- query.StudioIds = studios.Split('|').Select(i =>
+ query.StudioIds = studios.Select(i =>
{
try
{
@@ -513,13 +510,13 @@ namespace Jellyfin.Api.Controllers
/// <param name="limit">The item limit.</param>
/// <param name="searchTerm">The search term.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional. Include user data.</param>
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
- /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
- /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional. Include image information in output.</param>
/// <response code="200">Items returned.</response>
@@ -533,12 +530,12 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
{
@@ -569,13 +566,13 @@ namespace Jellyfin.Api.Controllers
ParentId = parentIdGuid,
Recursive = true,
DtoOptions = dtoOptions,
- MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+ MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
SearchTerm = searchTerm
});
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index 1b115d800..3ff77e8e0 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -362,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);
@@ -691,7 +690,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
[FromRoute, Required] Guid itemId,
- [FromQuery] string? excludeArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
@@ -753,9 +752,9 @@ namespace Jellyfin.Api.Controllers
};
// ExcludeArtistIds
- if (!string.IsNullOrEmpty(excludeArtistIds))
+ if (excludeArtistIds.Length != 0)
{
- query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+ query.ExcludeArtistIds = excludeArtistIds;
}
List<BaseItem> itemsResult = _libraryManager.GetItemList(query);
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index 29c0f1df4..410f3a340 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
@@ -150,7 +150,7 @@ namespace Jellyfin.Api.Controllers
[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)
@@ -175,7 +175,7 @@ namespace Jellyfin.Api.Controllers
IsNews = isNews,
IsKids = isKids,
IsSports = isSports,
- SortBy = RequestHelpers.Split(sortBy, ',', true),
+ SortBy = sortBy,
SortOrder = sortOrder ?? SortOrder.Ascending,
AddCurrentProgram = addCurrentProgram
},
@@ -539,7 +539,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.DefaultAuthorization)]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
- [FromQuery] string? channelIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
[FromQuery] Guid? userId,
[FromQuery] DateTime? minStartDate,
[FromQuery] bool? hasAired,
@@ -556,8 +556,8 @@ 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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -573,8 +573,7 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ChannelIds = RequestHelpers.Split(channelIds, ',', true)
- .Select(i => new Guid(i)).ToArray(),
+ ChannelIds = channelIds,
HasAired = hasAired,
IsAiring = isAiring,
EnableTotalRecordCount = enableTotalRecordCount,
@@ -591,8 +590,8 @@ namespace Jellyfin.Api.Controllers
IsKids = isKids,
IsSports = isSports,
SeriesTimerId = seriesTimerId,
- Genres = RequestHelpers.Split(genres, '|', true),
- GenreIds = RequestHelpers.GetGuids(genreIds)
+ Genres = genres,
+ GenreIds = genreIds
};
if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty))
@@ -628,8 +627,7 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true)
- .Select(i => new Guid(i)).ToArray(),
+ ChannelIds = body.ChannelIds,
HasAired = body.HasAired,
IsAiring = body.IsAiring,
EnableTotalRecordCount = body.EnableTotalRecordCount,
@@ -646,8 +644,8 @@ namespace Jellyfin.Api.Controllers
IsKids = body.IsKids,
IsSports = body.IsSports,
SeriesTimerId = body.SeriesTimerId,
- Genres = RequestHelpers.Split(body.Genres, '|', true),
- GenreIds = RequestHelpers.GetGuids(body.GenreIds)
+ Genres = body.Genres,
+ GenreIds = body.GenreIds
};
if (!body.LibrarySeriesId.Equals(Guid.Empty))
@@ -703,7 +701,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
- [FromQuery] string? genreIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] bool? enableUserData,
[FromQuery] bool enableTotalRecordCount = true)
@@ -723,7 +721,7 @@ namespace Jellyfin.Api.Controllers
IsNews = isNews,
IsSports = isSports,
EnableTotalRecordCount = enableTotalRecordCount,
- GenreIds = RequestHelpers.GetGuids(genreIds)
+ GenreIds = genreIds
};
var dtoOptions = new DtoOptions { Fields = fields }
@@ -1155,7 +1153,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="newDevicesOnly">Only discover new tuners.</param>
/// <response code="200">Tuners returned.</response>
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
- [HttpGet("Tuners/Discvover")]
+ [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
+ [HttpGet("Tuners/Discover")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs
index ef2e7e8b1..3d8b9e0ca 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 186024585..b42e6686e 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;
diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs
index ebc148fe5..75dfd4e68 100644
--- a/Jellyfin.Api/Controllers/MoviesController.cs
+++ b/Jellyfin.Api/Controllers/MoviesController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs
index 229d9ff02..e7d0a61c5 100644
--- a/Jellyfin.Api/Controllers/MusicGenresController.cs
+++ b/Jellyfin.Api/Controllers/MusicGenresController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -74,8 +74,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
@@ -123,7 +123,7 @@ namespace Jellyfin.Api.Controllers
var result = _libraryManager.GetMusicGenres(query);
- var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes);
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index 1f797d6bc..83b359766 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -99,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();
}
diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs
index 6ac3e6417..aaad36551 100644
--- a/Jellyfin.Api/Controllers/PersonsController.cs
+++ b/Jellyfin.Api/Controllers/PersonsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -77,8 +77,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
- [FromQuery] string? excludePersonTypes,
- [FromQuery] string? personTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
[FromQuery] string? appearsInItemId,
[FromQuery] Guid? userId,
[FromQuery] bool? enableImages = true)
@@ -97,8 +97,8 @@ namespace Jellyfin.Api.Controllers
var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
{
- PersonTypes = RequestHelpers.Split(personTypes, ',', true),
- ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true),
+ PersonTypes = personTypes,
+ ExcludePersonTypes = excludePersonTypes,
NameContains = searchTerm,
User = user,
IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 4b3d8d3d3..3e55434c0 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@@ -63,11 +63,10 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
[FromBody, Required] CreatePlaylistDto createPlaylistRequest)
{
- Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids);
var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
{
Name = createPlaylistRequest.Name,
- ItemIdList = idGuidArray,
+ ItemIdList = createPlaylistRequest.Ids,
UserId = createPlaylistRequest.UserId,
MediaType = createPlaylistRequest.MediaType
}).ConfigureAwait(false);
@@ -87,10 +86,10 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> AddToPlaylist(
[FromRoute, Required] Guid playlistId,
- [FromQuery] string? ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery] Guid? userId)
{
- await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false);
+ await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false);
return NoContent();
}
@@ -122,9 +121,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpDelete("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds)
+ public async Task<ActionResult> RemoveFromPlaylist(
+ [FromRoute, Required] string playlistId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
{
- await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false);
+ await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
index 5c15e9a0d..ec7b84ff6 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<UserItemDataDto> 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 0f8ceba29..98f1bc2d2 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/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
index e75f0d06b..076fe58f1 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,
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index e506ac7bf..e2269a2ce 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,7 +379,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostCapabilities(
[FromQuery] string? id,
- [FromQuery] string? playableMediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
[FromQuery] bool supportsMediaControl = false,
[FromQuery] bool supportsSync = false,
@@ -391,7 +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 9c259cc19..e59c6e1dd 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -72,9 +72,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();
}
diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs
index 27dcd51bc..5090bf1de 100644
--- a/Jellyfin.Api/Controllers/StudiosController.cs
+++ b/Jellyfin.Api/Controllers/StudiosController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
@@ -73,8 +73,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? searchTerm,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
@@ -94,13 +94,10 @@ namespace Jellyfin.Api.Controllers
var parentItem = _libraryManager.GetParentItem(parentId, userId);
- var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
- var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = excludeItemTypesArr,
- IncludeItemTypes = includeItemTypesArr,
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
@@ -125,7 +122,7 @@ namespace Jellyfin.Api.Controllers
}
var result = _libraryManager.GetStudios(query);
- var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs
index ad64adfba..9f1dec712 100644
--- a/Jellyfin.Api/Controllers/SuggestionsController.cs
+++ b/Jellyfin.Api/Controllers/SuggestionsController.cs
@@ -1,9 +1,10 @@
-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;
@@ -58,8 +59,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
[FromRoute, Required] Guid userId,
- [FromQuery] string? mediaType,
- [FromQuery] string? type,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool enableTotalRecordCount = false)
@@ -70,8 +71,8 @@ namespace Jellyfin.Api.Controllers
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{
OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
- MediaTypes = RequestHelpers.Split(mediaType!, ',', true),
- IncludeItemTypes = RequestHelpers.Split(type!, ',', true),
+ MediaTypes = mediaType,
+ IncludeItemTypes = type,
IsVirtualItem = false,
StartIndex = startIndex,
Limit = limit,
diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs
index e16a10ba4..346431e60 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 4cb1984a2..92875d735 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;
diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs
index 2dc744e7c..27c7186fc 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 d78adcbcd..41e1d0812 100644
--- a/Jellyfin.Api/Controllers/TrailersController.cs
+++ b/Jellyfin.Api/Controllers/TrailersController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using Jellyfin.Api.Constants;
using Jellyfin.Api.ModelBinders;
using MediaBrowser.Model.Dto;
@@ -42,8 +42,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
/// <param name="isHd">Optional filter by items that are HD or not.</param>
/// <param name="is4K">Optional filter by items that are 4K or not.</param>
- /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
- /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
+ /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
/// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
/// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
@@ -56,41 +56,41 @@ namespace Jellyfin.Api.Controllers
/// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
/// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
/// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
- /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
/// <param name="searchTerm">Optional. Filter based on a search term.</param>
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
- /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
- /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
- /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
/// <param name="isPlayed">Optional filter by items that are played, or not.</param>
- /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
- /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
- /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
- /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+ /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+ /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+ /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+ /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
- /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
- /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
- /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+ /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
+ /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
/// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
/// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
/// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
- /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
- /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+ /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
+ /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
/// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
- /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+ /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
/// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="isLocked">Optional filter by items that are locked.</param>
/// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
@@ -101,12 +101,12 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
/// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
/// <param name="is3D">Optional filter by items that are 3D, or not.</param>
- /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+ /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
- /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
- /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+ /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
@@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
- [FromQuery] string? locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
@@ -139,7 +139,7 @@ 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,
@@ -147,33 +147,33 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? sortOrder,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
- [FromQuery] string? mediaTypes,
+ [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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person,
- [FromQuery] string? personIds,
- [FromQuery] string? personTypes,
- [FromQuery] string? studios,
- [FromQuery] string? artists,
- [FromQuery] string? excludeArtistIds,
- [FromQuery] string? artistIds,
- [FromQuery] string? albumArtistIds,
- [FromQuery] string? contributingArtistIds,
- [FromQuery] string? albums,
- [FromQuery] string? albumIds,
- [FromQuery] string? ids,
- [FromQuery] string? videoTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
[FromQuery] string? minOfficialRating,
[FromQuery] bool? isLocked,
[FromQuery] bool? isPlaceHolder,
@@ -184,16 +184,16 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] bool? is3D,
- [FromQuery] string? seriesStatus,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan,
- [FromQuery] string? studioIds,
- [FromQuery] string? genreIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
{
- var includeItemTypes = "Trailer";
+ var includeItemTypes = new[] { "Trailer" };
return _itemsController
.GetItems(
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index 6fd154836..57b056f50 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -176,7 +176,7 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="seriesId">The series id.</param>
/// <param name="userId">The user id.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
/// <param name="season">Optional filter by season number.</param>
/// <param name="seasonId">Optional. Filter by season id.</param>
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
@@ -188,7 +188,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="enableUserData">Optional. Include user data.</param>
- /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
[HttpGet("{seriesId}/Episodes")]
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -303,7 +303,7 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="seriesId">The series id.</param>
/// <param name="userId">The user id.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
/// <param name="isSpecialSeason">Optional. Filter by special season.</param>
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index e10f1fe91..34c9f32fa 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;
@@ -96,7 +97,7 @@ namespace Jellyfin.Api.Controllers
[ProducesAudioFile]
public async Task<ActionResult> GetUniversalAudioStream(
[FromRoute, Required] Guid itemId,
- [FromQuery] string? container,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] Guid? userId,
@@ -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,
@@ -258,7 +262,7 @@ namespace Jellyfin.Api.Controllers
}
private DeviceProfile GetDeviceProfile(
- string? container,
+ string[] containers,
string? transcodingContainer,
string? audioCodec,
string? transcodingProtocol,
@@ -270,7 +274,6 @@ namespace Jellyfin.Api.Controllers
{
var deviceProfile = new DeviceProfile();
- var containers = RequestHelpers.Split(container, ',', true);
int len = containers.Length;
var directPlayProfiles = new DirectPlayProfile[len];
for (int i = 0; i < len; i++)
@@ -327,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 0f7c25d0e..9805b84b1 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;
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index cfd851129..0e65591cc 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;
@@ -253,7 +253,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="userId">User id.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
- /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
/// <param name="isPlayed">Filter by items that are played, or not.</param>
/// <param name="enableImages">Optional. include image information in output.</param>
/// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
@@ -269,7 +269,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid userId,
[FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isPlayed,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
@@ -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 d575bfc3b..e1483ce9d 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<QueryResult<BaseItemDto>> GetUserViews(
[FromRoute, Required] Guid userId,
[FromQuery] bool? includeExternalContent,
- [FromQuery] string? presetViews,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews,
[FromQuery] bool includeHidden = false)
{
var query = new UserViewQuery
@@ -81,9 +82,9 @@ namespace Jellyfin.Api.Controllers
query.IncludeExternalContent = includeExternalContent.Value;
}
- if (!string.IsNullOrWhiteSpace(presetViews))
+ if (presetViews.Length != 0)
{
- query.PresetViews = RequestHelpers.Split(presetViews, ',', true);
+ query.PresetViews = presetViews;
}
var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;
diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs
index 389dc8a08..2ac16de6b 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;
@@ -145,7 +146,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
@@ -296,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)
@@ -328,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);
}
}
}
@@ -338,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"));
}
@@ -359,17 +360,46 @@ namespace Jellyfin.Api.Controllers
private string GetCommandLineArguments(string outputPath, StreamState state)
{
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
- var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
+ var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static.
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
- var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts";
+ var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
+
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
- var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format;
+ 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 = format.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 + "\"";
+ }
+
+ 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,
@@ -378,20 +408,19 @@ namespace Jellyfin.Api.Controllers
return string.Format(
CultureInfo.InvariantCulture,
- "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"",
+ "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"",
inputModifier,
_encodingHelper.GetInputArgument(state, _encodingOptions),
threads,
- _encodingHelper.GetMapArgs(state),
+ mapArgs,
GetVideoArguments(state),
GetAudioArguments(state),
+ maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
- string.Empty,
segmentFormat,
baseUrlParam,
- outputPath,
- outputTsArg)
- .Trim();
+ outputTsArg,
+ outputPath).Trim();
}
/// <summary>
@@ -401,14 +430,53 @@ namespace Jellyfin.Api.Controllers
/// <returns>The command line arguments for audio transcoding.</returns>
private string GetAudioArguments(StreamState state)
{
- var codec = _encodingHelper.GetAudioEncoder(state);
+ if (state.AudioStream == null)
+ {
+ return string.Empty;
+ }
- if (EncodingHelper.IsCopyCodec(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))
{
- return "-codec:a:0 copy";
+ var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+ return "-acodec copy -strict -2" + bitStreamArgs;
}
- var args = "-codec:a:0 " + codec;
+ var args = "-codec:a:0 " + audioCodec;
var channels = state.OutputAudioChannels;
@@ -429,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;
}
@@ -441,6 +509,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>The command line arguments for video transcoding.</returns>
private string GetVideoArguments(StreamState state)
{
+ if (state.VideoStream == null)
+ {
+ return string.Empty;
+ }
+
if (!state.IsOutputVideo)
{
return string.Empty;
@@ -450,47 +523,65 @@ 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);
- var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+ // Set the key frame params for video encoding to match the hls segment time.
+ args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null);
- args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg;
-
- // Add resolution params, if specified
- if (!hasGraphicalSubs)
+ // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+ if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
{
- args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+ args += " -bf 0";
}
- // This is for internal graphical subs
+ var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+
if (hasGraphicalSubs)
{
+ // Graphical subs overlay and resolution params.
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
}
+ else
+ {
+ // Resolution params.
+ args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+ }
+
+ if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream)
+ {
+ args += " -start_at_zero";
+ }
}
args += " -flags -global_header";
diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index 4de7aac71..7e326f410 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -10,6 +10,7 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
@@ -203,9 +204,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
- public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds)
+ public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds)
{
- var items = RequestHelpers.Split(itemIds, ',', true)
+ var items = itemIds
.Select(i => _libraryManager.GetItemById(i))
.OfType<Video>()
.OrderBy(i => i.Id)
@@ -283,7 +284,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -312,7 +313,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs
index 1b38e399d..ec7c3de97 100644
--- a/Jellyfin.Api/Controllers/YearsController.cs
+++ b/Jellyfin.Api/Controllers/YearsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@@ -73,9 +73,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? sortOrder,
[FromQuery] string? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
- [FromQuery] string? mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] string? sortBy,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
@@ -103,19 +103,15 @@ namespace Jellyfin.Api.Controllers
IList<BaseItem> items;
- var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
- var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
- var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
-
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = excludeItemTypesArr,
- IncludeItemTypes = includeItemTypesArr,
- MediaTypes = mediaTypesArr,
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
+ MediaTypes = mediaTypes,
DtoOptions = dtoOptions
};
- bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr);
+ bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
if (parentItem.IsFolder)
{
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index e7fac50c6..a4da54cfd 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -207,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()))
{
@@ -217,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);
+
+ AppendPlaylistCodecsField(playlistBuilder, state);
- AppendPlaylistResolutionField(builder, state);
+ AppendPlaylistResolutionField(playlistBuilder, state);
- AppendPlaylistFramerateField(builder, state);
+ AppendPlaylistFramerateField(playlistBuilder, state);
if (!string.IsNullOrWhiteSpace(subtitleGroup))
{
- builder.Append(",SUBTITLES=\"")
+ playlistBuilder.Append(",SUBTITLES=\"")
.Append(subtitleGroup)
.Append('"');
}
- builder.Append(Environment.NewLine);
- builder.AppendLine(url);
+ playlistBuilder.Append(Environment.NewLine);
+ playlistBuilder.AppendLine(url);
+ builder.Append(playlistBuilder);
+
+ return playlistBuilder;
+ }
+
+ /// <summary>
+ /// Appends a VIDEO-RANGE field containing the range of the output video stream.
+ /// </summary>
+ /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+ /// <param name="builder">StringBuilder to append the field to.</param>
+ /// <param name="state">StreamState of the current stream.</param>
+ private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state)
+ {
+ if (state.VideoStream != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange))
+ {
+ var videoRange = state.VideoStream.VideoRange;
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+ {
+ if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.Append(",VIDEO-RANGE=SDR");
+ }
+
+ if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.Append(",VIDEO-RANGE=PQ");
+ }
+ }
+ else
+ {
+ // Currently we only encode to SDR.
+ builder.Append(",VIDEO-RANGE=SDR");
+ }
+ }
}
/// <summary>
@@ -419,15 +510,27 @@ namespace Jellyfin.Api.Helpers
/// <returns>H.26X level of the output video stream.</returns>
private int? GetOutputVideoCodecLevel(StreamState state)
{
- string? levelString;
+ string levelString = string.Empty;
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && state.VideoStream != null
&& state.VideoStream.Level.HasValue)
{
- levelString = state.VideoStream?.Level.ToString();
+ levelString = state.VideoStream.Level.ToString() ?? string.Empty;
}
else
{
- levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec);
+ if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41";
+ levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
+ }
+
+ if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120";
+ levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
+ }
}
if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
@@ -439,6 +542,38 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
+ /// Get the H.26X profile of the output video stream.
+ /// </summary>
+ /// <param name="state">StreamState of the current stream.</param>
+ /// <param name="codec">Video codec.</param>
+ /// <returns>H.26X profile of the output video stream.</returns>
+ private string GetOutputVideoCodecProfile(StreamState state, string codec)
+ {
+ string profileString = string.Empty;
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && !string.IsNullOrEmpty(state.VideoStream.Profile))
+ {
+ profileString = state.VideoStream.Profile;
+ }
+ else if (!string.IsNullOrEmpty(codec))
+ {
+ profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty;
+ if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ profileString = profileString ?? "high";
+ }
+
+ if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ profileString = profileString ?? "main";
+ }
+ }
+
+ return profileString;
+ }
+
+ /// <summary>
/// Gets a formatted string of the output audio codec, for use in the CODECS field.
/// </summary>
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
@@ -468,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;
}
@@ -492,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);
}
@@ -544,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/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
index 1bd3d67ff..a5369c441 100644
--- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
+++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
@@ -10,12 +10,37 @@ namespace Jellyfin.Api.Helpers
public static class HlsCodecStringHelpers
{
/// <summary>
+ /// Codec name for MP3.
+ /// </summary>
+ public const string MP3 = "mp4a.40.34";
+
+ /// <summary>
+ /// Codec name for AC-3.
+ /// </summary>
+ public const string AC3 = "mp4a.a5";
+
+ /// <summary>
+ /// Codec name for E-AC-3.
+ /// </summary>
+ public const string EAC3 = "mp4a.a6";
+
+ /// <summary>
+ /// Codec name for FLAC.
+ /// </summary>
+ public const string FLAC = "fLaC";
+
+ /// <summary>
+ /// Codec name for ALAC.
+ /// </summary>
+ public const string ALAC = "alac";
+
+ /// <summary>
/// Gets a MP3 codec string.
/// </summary>
/// <returns>MP3 codec string.</returns>
public static string GetMP3String()
{
- return "mp4a.40.34";
+ return MP3;
}
/// <summary>
@@ -41,6 +66,42 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
+ /// Gets an AC-3 codec string.
+ /// </summary>
+ /// <returns>AC-3 codec string.</returns>
+ public static string GetAC3String()
+ {
+ return AC3;
+ }
+
+ /// <summary>
+ /// Gets an E-AC-3 codec string.
+ /// </summary>
+ /// <returns>E-AC-3 codec string.</returns>
+ public static string GetEAC3String()
+ {
+ return EAC3;
+ }
+
+ /// <summary>
+ /// Gets an FLAC codec string.
+ /// </summary>
+ /// <returns>FLAC codec string.</returns>
+ public static string GetFLACString()
+ {
+ return FLAC;
+ }
+
+ /// <summary>
+ /// Gets an ALAC codec string.
+ /// </summary>
+ /// <returns>ALAC codec string.</returns>
+ public static string GetALACString()
+ {
+ return ALAC;
+ }
+
+ /// <summary>
/// Gets a H.264 codec string.
/// </summary>
/// <param name="profile">H.264 profile.</param>
@@ -85,41 +146,24 @@ namespace Jellyfin.Api.Helpers
// The h265 syntax is a bit of a mystery at the time this comment was written.
// This is what I've found through various sources:
// FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
- StringBuilder result = new StringBuilder("hev1", 16);
+ StringBuilder result = new StringBuilder("hvc1", 16);
- if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
{
- result.Append(".2.6");
+ result.Append(".2.4");
}
else
{
// Default to main if profile is invalid
- result.Append(".1.6");
+ result.Append(".1.4");
}
result.Append(".L")
- .Append(level * 3)
+ .Append(level)
.Append(".B0");
return result.ToString();
}
-
- /// <summary>
- /// Gets an AC-3 codec string.
- /// </summary>
- /// <returns>AC-3 codec string.</returns>
- public static string GetAC3String()
- {
- return "mp4a.a5";
- }
-
- /// <summary>
- /// Gets an E-AC-3 codec string.
- /// </summary>
- /// <returns>E-AC-3 codec string.</returns>
- public static string GetEAC3String()
- {
- return "mp4a.a6";
- }
}
}
diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs
index 45ce90566..18e23fb5c 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;
@@ -75,24 +78,64 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
+ /// Gets the #EXT-X-MAP string.
+ /// </summary>
+ /// <param name="outputPath">The output path of the file.</param>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
+ /// <param name="isOsDepends">Get a normal string or depends on OS.</param>
+ /// <returns>The string text of #EXT-X-MAP.</returns>
+ public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
+ {
+ var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+ var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+ var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+ var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+
+ // on Linux/Unix
+ // #EXT-X-MAP:URI="prefix-1.mp4"
+ var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
+ if (!isOsDepends)
+ {
+ return fmp4InitFileName;
+ }
+
+ var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ if (isWindows)
+ {
+ // on Windows
+ // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
+ fmp4InitFileName = outputPrefix + "-1" + outputExtension;
+ }
+
+ return fmp4InitFileName;
+ }
+
+ /// <summary>
/// Gets the hls playlist text.
/// </summary>
/// <param name="path">The path to the playlist file.</param>
- /// <param name="segmentLength">The segment length.</param>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
/// <returns>The playlist text as a string.</returns>
- public static string GetLivePlaylistText(string path, int segmentLength)
+ public static string GetLivePlaylistText(string path, StreamState state)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
var text = reader.ReadToEnd();
- text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture);
-
- var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.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);
- 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/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index f06f038ab..efce11f8a 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -122,49 +122,6 @@ namespace Jellyfin.Api.Helpers
return session;
}
- /// <summary>
- /// Get Guid array from string.
- /// </summary>
- /// <param name="value">String value.</param>
- /// <returns>Guid array.</returns>
- internal static Guid[] GetGuids(string? value)
- {
- if (value == null)
- {
- return Array.Empty<Guid>();
- }
-
- return Split(value, ',', true)
- .Select(i => new Guid(i))
- .ToArray();
- }
-
- /// <summary>
- /// Gets the item fields.
- /// </summary>
- /// <param name="fields">The fields string.</param>
- /// <returns>IEnumerable{ItemFields}.</returns>
- internal static ItemFields[] GetItemFields(string? fields)
- {
- if (string.IsNullOrEmpty(fields))
- {
- return Array.Empty<ItemFields>();
- }
-
- return Split(fields, ',', true)
- .Select(v =>
- {
- if (Enum.TryParse(v, true, out ItemFields value))
- {
- return (ItemFields?)value;
- }
-
- return null;
- }).Where(i => i.HasValue)
- .Select(i => i!.Value)
- .ToArray();
- }
-
internal static QueryResult<BaseItemDto> CreateQueryResult(
QueryResult<(BaseItem, ItemCounts)> result,
DtoOptions dtoOptions,
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index 0566f4c4d..c6d844c4f 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -169,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);
@@ -187,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;
@@ -200,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);
-
- state.VideoRequest.MaxWidth = resolution.MaxWidth;
- state.VideoRequest.MaxHeight = resolution.MaxHeight;
+ var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
+ && !state.VideoRequest.Height.HasValue
+ && !state.VideoRequest.MaxWidth.HasValue
+ && !state.VideoRequest.MaxHeight.HasValue;
+
+ 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 168dc27a8..99c90c315 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;
@@ -145,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();
}
@@ -241,7 +241,7 @@ namespace Jellyfin.Api.Helpers
lock (_activeTranscodingJobs)
{
// This is really only needed for HLS.
- // Progressive streams can stop on their own reliably
+ // Progressive streams can stop on their own reliably.
jobs.AddRange(_activeTranscodingJobs.Where(killJob));
}
@@ -304,10 +304,10 @@ namespace Jellyfin.Api.Helpers
process!.StandardInput.WriteLine("q");
- // Need to wait because killing is asynchronous
+ // Need to wait because killing is asynchronous.
if (!process.WaitForExit(5000))
{
- _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path);
+ _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path);
process.Kill();
}
}
@@ -470,11 +470,11 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
- /// Starts the FFMPEG.
+ /// Starts FFmpeg.
/// </summary>
/// <param name="state">The state.</param>
/// <param name="outputPath">The output path.</param>
- /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param>
+ /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param>
/// <param name="request">The <see cref="HttpRequest"/>.</param>
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
/// <param name="cancellationTokenSource">The cancellation token source.</param>
@@ -501,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
@@ -544,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);
@@ -569,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)
@@ -748,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();
@@ -771,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)
{
diff --git a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs
new file mode 100644
index 000000000..e1cb725f3
--- /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
+{
+ /// <summary>
+ /// DateTime model binder.
+ /// </summary>
+ public class LegacyDateTimeModelBinder : IModelBinder
+ {
+ // Borrowed from the DateTimeModelBinderProvider
+ private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
+ private readonly DateTimeModelBinder _defaultModelBinder;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory)
+ {
+ _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory);
+ }
+
+ /// <inheritdoc />
+ 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 000000000..5d296227e
--- /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
+{
+ /// <summary>
+ /// Nullable enum model binder.
+ /// </summary>
+ public class NullableEnumModelBinder : IModelBinder
+ {
+ private readonly ILogger<NullableEnumModelBinder> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param>
+ public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+ var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+ var converter = TypeDescriptor.GetConverter(elementType);
+ if (valueProviderResult.Length != 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 000000000..bc12ad05d
--- /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
+{
+ /// <summary>
+ /// Nullable enum model binder provider.
+ /// </summary>
+ public class NullableEnumModelBinderProvider : IModelBinderProvider
+ {
+ /// <inheritdoc />
+ 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<ILogger<NullableEnumModelBinder>>();
+ 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 000000000..a42e0e4da
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ /// <summary>
+ /// Comma delimited array model binder.
+ /// Returns an empty array of specified type if there is no query parameter.
+ /// </summary>
+ public class PipeDelimitedArrayModelBinder : IModelBinder
+ {
+ private readonly ILogger<PipeDelimitedArrayModelBinder> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param>
+ public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+ var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+ var converter = TypeDescriptor.GetConverter(elementType);
+
+ if (valueProviderResult.Length > 1)
+ {
+ var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter);
+ bindingContext.Result = ModelBindingResult.Success(typedValues);
+ }
+ else
+ {
+ var value = valueProviderResult.FirstValue;
+
+ if (value != null)
+ {
+ var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
+ var typedValues = GetParsedResult(splitValues, elementType, converter);
+ bindingContext.Result = ModelBindingResult.Success(typedValues);
+ }
+ else
+ {
+ var emptyResult = Array.CreateInstance(elementType, 0);
+ bindingContext.Result = ModelBindingResult.Success(emptyResult);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter)
+ {
+ var parsedValues = new object?[values.Count];
+ var convertedCount = 0;
+ for (var i = 0; i < values.Count; i++)
+ {
+ try
+ {
+ parsedValues[i] = converter.ConvertFromString(values[i].Trim());
+ convertedCount++;
+ }
+ catch (FormatException e)
+ {
+ _logger.LogWarning(e, "Error converting value.");
+ }
+ }
+
+ var typedValues = Array.CreateInstance(elementType, convertedCount);
+ var typedValueIndex = 0;
+ for (var i = 0; i < parsedValues.Length; i++)
+ {
+ if (parsedValues[i] != null)
+ {
+ typedValues.SetValue(parsedValues[i], typedValueIndex);
+ typedValueIndex++;
+ }
+ }
+
+ return typedValues;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
index 5ca4408d1..a47ae926c 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
+++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
@@ -16,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// <summary>
/// Gets or sets the channels to return guide information for.
/// </summary>
- public string? ChannelIds { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets optional. Filter by user id.
@@ -115,12 +116,14 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// <summary>
/// Gets or sets the genres to return guide information for.
/// </summary>
- public string? Genres { get; set; }
+ [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))]
+ public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the genre ids to return guide information for.
/// </summary>
- public string? GenreIds { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets include image information in output.
diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
index 0d67c86f7..d0d6889fc 100644
--- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
+++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
@@ -1,4 +1,7 @@
using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
namespace Jellyfin.Api.Models.PlaylistDtos
{
@@ -15,7 +18,8 @@ namespace Jellyfin.Api.Models.PlaylistDtos
/// <summary>
/// Gets or sets item ids to add to the playlist.
/// </summary>
- public string? Ids { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets the user id.
diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
new file mode 100644
index 000000000..ac1259ef2
--- /dev/null
+++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Common.Json.Converters;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Session;
+using Newtonsoft.Json;
+
+namespace Jellyfin.Api.Models.SessionDtos
+{
+ /// <summary>
+ /// Client capabilities dto.
+ /// </summary>
+ public class ClientCapabilitiesDto
+ {
+ /// <summary>
+ /// Gets or sets the list of playable media types.
+ /// </summary>
+ public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the list of supported commands.
+ /// </summary>
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports media control.
+ /// </summary>
+ public bool SupportsMediaControl { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports content uploading.
+ /// </summary>
+ public bool SupportsContentUploading { get; set; }
+
+ /// <summary>
+ /// Gets or sets the message callback url.
+ /// </summary>
+ public string? MessageCallbackUrl { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports a persistent identifier.
+ /// </summary>
+ public bool SupportsPersistentIdentifier { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports sync.
+ /// </summary>
+ public bool SupportsSync { get; set; }
+
+ /// <summary>
+ /// Gets or sets the device profile.
+ /// </summary>
+ public DeviceProfile? DeviceProfile { get; set; }
+
+ /// <summary>
+ /// Gets or sets the app store url.
+ /// </summary>
+ public string? AppStoreUrl { get; set; }
+
+ /// <summary>
+ /// Gets or sets the icon url.
+ /// </summary>
+ public string? IconUrl { get; set; }
+
+ /// <summary>
+ /// Convert the dto to the full <see cref="ClientCapabilities"/> model.
+ /// </summary>
+ /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns>
+ 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/TypeConverters/DateTimeTypeConverter.cs b/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
deleted file mode 100644
index 315b47329..000000000
--- a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.ComponentModel;
-using System.Globalization;
-
-namespace Jellyfin.Api.TypeConverters
-{
- /// <summary>
- /// Custom datetime parser.
- /// </summary>
- public class DateTimeTypeConverter : TypeConverter
- {
- /// <inheritdoc />
- public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
- {
- if (sourceType == typeof(string))
- {
- return true;
- }
-
- return base.CanConvertFrom(context, sourceType);
- }
-
- /// <inheritdoc />
- 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.Data/Entities/Libraries/CollectionItem.cs b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs
index 4467c9bbd..f9539964d 100644
--- a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs
+++ b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs
@@ -73,7 +73,7 @@ namespace Jellyfin.Data.Entities.Libraries
/// Gets or sets the next item in the collection.
/// </summary>
/// <remarks>
- /// TODO check if this properly updated dependant and has the proper principal relationship.
+ /// TODO check if this properly updated Dependant and has the proper principal relationship.
/// </remarks>
public virtual CollectionItem Next { get; set; }
@@ -81,7 +81,7 @@ namespace Jellyfin.Data.Entities.Libraries
/// Gets or sets the previous item in the collection.
/// </summary>
/// <remarks>
- /// TODO check if this properly updated dependant and has the proper principal relationship.
+ /// TODO check if this properly updated Dependant and has the proper principal relationship.
/// </remarks>
public virtual CollectionItem Previous { get; set; }
diff --git a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs
index 1d2dc0f66..d74330c05 100644
--- a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs
+++ b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs
@@ -141,7 +141,7 @@ namespace Jellyfin.Data.Entities.Libraries
public virtual ICollection<PersonRole> PersonRoles { get; protected set; }
/// <summary>
- /// Gets or sets a collection containing the generes for this item.
+ /// Gets or sets a collection containing the genres for this item.
/// </summary>
public virtual ICollection<Genre> Genres { get; protected set; }
diff --git a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs
new file mode 100644
index 000000000..df420f48a
--- /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
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkConfiguration" />.
+ /// </summary>
+ public class NetworkConfiguration
+ {
+ /// <summary>
+ /// The default value for <see cref="HttpServerPortNumber"/>.
+ /// </summary>
+ public const int DefaultHttpPort = 8096;
+
+ /// <summary>
+ /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>.
+ /// </summary>
+ public const int DefaultHttpsPort = 8920;
+
+ private string _baseUrl = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the server should force connections over HTTPS.
+ /// </summary>
+ public bool RequireHttps { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
+ /// </summary>
+ 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;
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the public HTTPS port.
+ /// </summary>
+ /// <value>The public HTTPS port.</value>
+ public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
+
+ /// <summary>
+ /// Gets or sets the HTTP server port number.
+ /// </summary>
+ /// <value>The HTTP server port number.</value>
+ public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
+
+ /// <summary>
+ /// Gets or sets the HTTPS server port number.
+ /// </summary>
+ /// <value>The HTTPS server port number.</value>
+ public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to use HTTPS.
+ /// </summary>
+ /// <remarks>
+ /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
+ /// provided for <see cref="ServerConfiguration.CertificatePath"/> and <see cref="ServerConfiguration.CertificatePassword"/>.
+ /// </remarks>
+ public bool EnableHttps { get; set; }
+
+ /// <summary>
+ /// Gets or sets the public mapped port.
+ /// </summary>
+ /// <value>The public mapped port.</value>
+ public int PublicPort { get; set; } = DefaultHttpPort;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
+ /// </summary>
+ public bool UPnPCreateHttpPortMap { get; set; }
+
+ /// <summary>
+ /// Gets or sets the UDPPortRange.
+ /// </summary>
+ public string UDPPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets IPV6 capability.
+ /// </summary>
+ public bool EnableIPV6 { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets IPV4 capability.
+ /// </summary>
+ public bool EnableIPV4 { get; set; } = true;
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public bool EnableSSDPTracing { get; set; }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public string SSDPTracingFilter { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the number of times SSDP UDP messages are sent.
+ /// </summary>
+ public int UDPSendCount { get; set; } = 2;
+
+ /// <summary>
+ /// Gets or sets the delay between each groups of SSDP messages (in ms).
+ /// </summary>
+ public int UDPSendDelay { get; set; } = 100;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding.
+ /// </summary>
+ public bool IgnoreVirtualInterfaces { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>.
+ /// </summary>
+ public string VirtualInterfaceNames { get; set; } = "vEthernet*";
+
+ /// <summary>
+ /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
+ /// </summary>
+ public int GatewayMonitorPeriod { get; set; } = 60;
+
+ /// <summary>
+ /// Gets a value indicating whether multi-socket binding is available.
+ /// </summary>
+ public bool EnableMultiSocketBinding { get; } = true;
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public bool TrustAllIP6Interfaces { get; set; }
+
+ /// <summary>
+ /// Gets or sets the ports that HDHomerun uses.
+ /// </summary>
+ public string HDHomerunPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the PublishedServerUriBySubnet
+ /// Gets or sets PublishedServerUri to advertise for specific subnets.
+ /// </summary>
+ public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
+ /// </summary>
+ public bool AutoDiscoveryTracing { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery is enabled.
+ /// </summary>
+ public bool AutoDiscovery { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>.
+ /// </summary>
+ public string[] RemoteIPFilter { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist.
+ /// </summary>
+ public bool IsRemoteIPFilterBlacklist { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to enable automatic port forwarding.
+ /// </summary>
+ public bool EnableUPnP { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether access outside of the LAN is permitted.
+ /// </summary>
+ public bool EnableRemoteAccess { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets the subnets that are deemed to make up the LAN.
+ /// </summary>
+ public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
+ /// </summary>
+ public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the known proxies.
+ /// </summary>
+ public string[] KnownProxies { get; set; } = Array.Empty<string>();
+ }
+}
diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs
new file mode 100644
index 000000000..e77b17ba9
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs
@@ -0,0 +1,21 @@
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkConfigurationExtensions" />.
+ /// </summary>
+ public static class NetworkConfigurationExtensions
+ {
+ /// <summary>
+ /// Retrieves the network configuration.
+ /// </summary>
+ /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
+ /// <returns>The <see cref="NetworkConfiguration"/>.</returns>
+ public static NetworkConfiguration GetNetworkConfiguration(this IConfigurationManager config)
+ {
+ return config.GetConfiguration<NetworkConfiguration>("network");
+ }
+ }
+}
diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs
new file mode 100644
index 000000000..ac0485d87
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkConfigurationFactory" />.
+ /// </summary>
+ public class NetworkConfigurationFactory : IConfigurationFactory
+ {
+ /// <summary>
+ /// The GetConfigurations.
+ /// </summary>
+ /// <returns>The <see cref="IEnumerable{ConfigurationStore}"/>.</returns>
+ public IEnumerable<ConfigurationStore> 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 000000000..cbda74361
--- /dev/null
+++ b/Jellyfin.Networking/Jellyfin.Networking.csproj
@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Include="..\SharedVersion.cs" />
+ </ItemGroup>
+
+ <!-- Code Analyzers-->
+ <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
+ <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
+ <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
+ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+ </ItemGroup>
+
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
+ <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/Jellyfin.Networking/Manager/INetworkManager.cs b/Jellyfin.Networking/Manager/INetworkManager.cs
new file mode 100644
index 000000000..eababa6a9
--- /dev/null
+++ b/Jellyfin.Networking/Manager/INetworkManager.cs
@@ -0,0 +1,234 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Net.NetworkInformation;
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Net;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Networking.Manager
+{
+ /// <summary>
+ /// Interface for the NetworkManager class.
+ /// </summary>
+ public interface INetworkManager
+ {
+ /// <summary>
+ /// Event triggered on network changes.
+ /// </summary>
+ event EventHandler NetworkChanged;
+
+ /// <summary>
+ /// Gets the published server urls list.
+ /// </summary>
+ Dictionary<IPNetAddress, string> PublishedServerUrls { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
+ /// </summary>
+ bool TrustAllIP6Interfaces { get; }
+
+ /// <summary>
+ /// Gets the remote address filter.
+ /// </summary>
+ Collection<IPObject> RemoteAddressFilter { get; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether iP6 is enabled.
+ /// </summary>
+ bool IsIP6Enabled { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether iP4 is enabled.
+ /// </summary>
+ bool IsIP4Enabled { get; set; }
+
+ /// <summary>
+ /// Calculates the list of interfaces to use for Kestrel.
+ /// </summary>
+ /// <returns>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.</returns>
+ /// <param name="individualInterfaces">When false, return <see cref="IPAddress.Any"/> or <see cref="IPAddress.IPv6Any"/> for all interfaces.</param>
+ Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false);
+
+ /// <summary>
+ /// Returns a collection containing the loopback interfaces.
+ /// </summary>
+ /// <returns>Collection{IPObject}.</returns>
+ Collection<IPObject> GetLoopbacks();
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ /// <param name="source">Source of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(IPObject source, out int? port);
+
+ /// <summary>
+ /// 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 <see cref="GetBindInterface(IPObject, out int?)"/>.
+ /// </summary>
+ /// <param name="source">Source of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(HttpRequest source, out int? port);
+
+ /// <summary>
+ /// 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 <see cref="GetBindInterface(IPObject, out int?)"/>.
+ /// </summary>
+ /// <param name="source">IP address of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(IPAddress source, out int? port);
+
+ /// <summary>
+ /// 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 <see cref="GetBindInterface(IPObject, out int?)"/>.
+ /// </summary>
+ /// <param name="source">Source of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(string source, out int? port);
+
+ /// <summary>
+ /// Checks to see if the ip address is specifically excluded in LocalNetworkAddresses.
+ /// </summary>
+ /// <param name="address">IP address to check.</param>
+ /// <returns>True if it is.</returns>
+ bool IsExcludedInterface(IPAddress address);
+
+ /// <summary>
+ /// Get a list of all the MAC addresses associated with active interfaces.
+ /// </summary>
+ /// <returns>List of MAC addresses.</returns>
+ IReadOnlyCollection<PhysicalAddress> GetMacAddresses();
+
+ /// <summary>
+ /// Checks to see if the IP Address provided matches an interface that has a gateway.
+ /// </summary>
+ /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
+ /// <returns>Result of the check.</returns>
+ bool IsGatewayInterface(IPObject? addressObj);
+
+ /// <summary>
+ /// Checks to see if the IP Address provided matches an interface that has a gateway.
+ /// </summary>
+ /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
+ /// <returns>Result of the check.</returns>
+ bool IsGatewayInterface(IPAddress? addressObj);
+
+ /// <summary>
+ /// Returns true if the address is a private address.
+ /// The config option TrustIP6Interfaces overrides this functions behaviour.
+ /// </summary>
+ /// <param name="address">Address to check.</param>
+ /// <returns>True or False.</returns>
+ bool IsPrivateAddressRange(IPObject address);
+
+ /// <summary>
+ /// Returns true if the address is part of the user defined LAN.
+ /// The config option TrustIP6Interfaces overrides this functions behaviour.
+ /// </summary>
+ /// <param name="address">IP to check.</param>
+ /// <returns>True if endpoint is within the LAN range.</returns>
+ bool IsInLocalNetwork(string address);
+
+ /// <summary>
+ /// Returns true if the address is part of the user defined LAN.
+ /// The config option TrustIP6Interfaces overrides this functions behaviour.
+ /// </summary>
+ /// <param name="address">IP to check.</param>
+ /// <returns>True if endpoint is within the LAN range.</returns>
+ bool IsInLocalNetwork(IPObject address);
+
+ /// <summary>
+ /// Returns true if the address is part of the user defined LAN.
+ /// The config option TrustIP6Interfaces overrides this functions behaviour.
+ /// </summary>
+ /// <param name="address">IP to check.</param>
+ /// <returns>True if endpoint is within the LAN range.</returns>
+ bool IsInLocalNetwork(IPAddress address);
+
+ /// <summary>
+ /// Attempts to convert the token to an IP address, permitting for interface descriptions and indexes.
+ /// eg. "eth1", or "TP-LINK Wireless USB Adapter".
+ /// </summary>
+ /// <param name="token">Token to parse.</param>
+ /// <param name="result">Resultant object's ip addresses, if successful.</param>
+ /// <returns>Success of the operation.</returns>
+ bool TryParseInterface(string token, out Collection<IPObject>? result);
+
+ /// <summary>
+ /// Parses an array of strings into a Collection{IPObject}.
+ /// </summary>
+ /// <param name="values">Values to parse.</param>
+ /// <param name="bracketed">When true, only include values in []. When false, ignore bracketed values.</param>
+ /// <returns>IPCollection object containing the value strings.</returns>
+ Collection<IPObject> CreateIPCollection(string[] values, bool bracketed = false);
+
+ /// <summary>
+ /// Returns all the internal Bind interface addresses.
+ /// </summary>
+ /// <returns>An internal list of interfaces addresses.</returns>
+ Collection<IPObject> GetInternalBindAddresses();
+
+ /// <summary>
+ /// Checks to see if an IP address is still a valid interface address.
+ /// </summary>
+ /// <param name="address">IP address to check.</param>
+ /// <returns>True if it is.</returns>
+ bool IsValidInterfaceAddress(IPAddress address);
+
+ /// <summary>
+ /// Returns true if the IP address is in the excluded list.
+ /// </summary>
+ /// <param name="ip">IP to check.</param>
+ /// <returns>True if excluded.</returns>
+ bool IsExcluded(IPAddress ip);
+
+ /// <summary>
+ /// Returns true if the IP address is in the excluded list.
+ /// </summary>
+ /// <param name="ip">IP to check.</param>
+ /// <returns>True if excluded.</returns>
+ bool IsExcluded(EndPoint ip);
+
+ /// <summary>
+ /// Gets the filtered LAN ip addresses.
+ /// </summary>
+ /// <param name="filter">Optional filter for the list.</param>
+ /// <returns>Returns a filtered list of LAN addresses.</returns>
+ Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null);
+ }
+}
diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs
new file mode 100644
index 000000000..515ae669a
--- /dev/null
+++ b/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -0,0 +1,1319 @@
+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
+{
+ /// <summary>
+ /// Class to take care of network interface management.
+ /// Note: The normal collection methods and properties will not work with Collection{IPObject}. <see cref="MediaBrowser.Common.Net.NetworkExtensions"/>.
+ /// </summary>
+ public class NetworkManager : INetworkManager, IDisposable
+ {
+ /// <summary>
+ /// Contains the description of the interface along with its index.
+ /// </summary>
+ private readonly Dictionary<string, int> _interfaceNames;
+
+ /// <summary>
+ /// Threading lock for network properties.
+ /// </summary>
+ private readonly object _intLock = new object();
+
+ /// <summary>
+ /// List of all interface addresses and masks.
+ /// </summary>
+ private readonly Collection<IPObject> _interfaceAddresses;
+
+ /// <summary>
+ /// List of all interface MAC addresses.
+ /// </summary>
+ private readonly List<PhysicalAddress> _macAddresses;
+
+ private readonly ILogger<NetworkManager> _logger;
+
+ private readonly IConfigurationManager _configurationManager;
+
+ private readonly object _eventFireLock;
+
+ /// <summary>
+ /// Holds the bind address overrides.
+ /// </summary>
+ private readonly Dictionary<IPNetAddress, string> _publishedServerUrls;
+
+ /// <summary>
+ /// Used to stop "event-racing conditions".
+ /// </summary>
+ private bool _eventfire;
+
+ /// <summary>
+ /// Unfiltered user defined LAN subnets. (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>)
+ /// or internal interface network subnets if undefined by user.
+ /// </summary>
+ private Collection<IPObject> _lanSubnets;
+
+ /// <summary>
+ /// User defined list of subnets to excluded from the LAN.
+ /// </summary>
+ private Collection<IPObject> _excludedSubnets;
+
+ /// <summary>
+ /// List of interface addresses to bind the WS.
+ /// </summary>
+ private Collection<IPObject> _bindAddresses;
+
+ /// <summary>
+ /// List of interface addresses to exclude from bind.
+ /// </summary>
+ private Collection<IPObject> _bindExclusions;
+
+ /// <summary>
+ /// Caches list of all internal filtered interface addresses and masks.
+ /// </summary>
+ private Collection<IPObject> _internalInterfaces;
+
+ /// <summary>
+ /// Flag set when no custom LAN has been defined in the config.
+ /// </summary>
+ private bool _usingPrivateAddresses;
+
+ /// <summary>
+ /// True if this object is disposed.
+ /// </summary>
+ private bool _disposed;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NetworkManager"/> class.
+ /// </summary>
+ /// <param name="configurationManager">IServerConfigurationManager instance.</param>
+ /// <param name="logger">Logger to use for messages.</param>
+#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<NetworkManager> logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager));
+
+ _interfaceAddresses = new Collection<IPObject>();
+ _macAddresses = new List<PhysicalAddress>();
+ _interfaceNames = new Dictionary<string, int>();
+ _publishedServerUrls = new Dictionary<IPNetAddress, string>();
+ _eventFireLock = new object();
+
+ UpdateSettings(_configurationManager.GetNetworkConfiguration());
+
+ NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
+ NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
+
+ _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated;
+ }
+#pragma warning restore CS8618 // Non-nullable field is uninitialized.
+
+ /// <summary>
+ /// Event triggered on network changes.
+ /// </summary>
+ public event EventHandler? NetworkChanged;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether testing is taking place.
+ /// </summary>
+ public static string MockNetworkSettings { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IP6 is enabled.
+ /// </summary>
+ public bool IsIP6Enabled { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IP4 is enabled.
+ /// </summary>
+ public bool IsIP4Enabled { get; set; }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> RemoteAddressFilter { get; private set; }
+
+ /// <summary>
+ /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
+ /// </summary>
+ public bool TrustAllIP6Interfaces { get; internal set; }
+
+ /// <summary>
+ /// Gets the Published server override list.
+ /// </summary>
+ public Dictionary<IPNetAddress, string> PublishedServerUrls => _publishedServerUrls;
+
+ /// <summary>
+ /// Creates a new network collection.
+ /// </summary>
+ /// <param name="source">Items to assign the collection, or null.</param>
+ /// <returns>The collection created.</returns>
+ public static Collection<IPObject> CreateCollection(IEnumerable<IPObject>? source = null)
+ {
+ var result = new Collection<IPObject>();
+ if (source != null)
+ {
+ foreach (var item in source)
+ {
+ result.AddItem(item);
+ }
+ }
+
+ return result;
+ }
+
+ /// <inheritdoc/>
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <inheritdoc/>
+ public IReadOnlyCollection<PhysicalAddress> GetMacAddresses()
+ {
+ // Populated in construction - so always has values.
+ return _macAddresses;
+ }
+
+ /// <inheritdoc/>
+ public bool IsGatewayInterface(IPObject? addressObj)
+ {
+ var address = addressObj?.Address ?? IPAddress.None;
+ return _internalInterfaces.Any(i => i.Address.Equals(address) && i.Tag < 0);
+ }
+
+ /// <inheritdoc/>
+ public bool IsGatewayInterface(IPAddress? addressObj)
+ {
+ return _internalInterfaces.Any(i => i.Address.Equals(addressObj ?? IPAddress.None) && i.Tag < 0);
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> GetLoopbacks()
+ {
+ Collection<IPObject> nc = new Collection<IPObject>();
+ if (IsIP4Enabled)
+ {
+ nc.AddItem(IPAddress.Loopback);
+ }
+
+ if (IsIP6Enabled)
+ {
+ nc.AddItem(IPAddress.IPv6Loopback);
+ }
+
+ return nc;
+ }
+
+ /// <inheritdoc/>
+ public bool IsExcluded(IPAddress ip)
+ {
+ return _excludedSubnets.ContainsAddress(ip);
+ }
+
+ /// <inheritdoc/>
+ public bool IsExcluded(EndPoint ip)
+ {
+ return ip != null && IsExcluded(((IPEndPoint)ip).Address);
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> CreateIPCollection(string[] values, bool bracketed = false)
+ {
+ Collection<IPObject> col = new Collection<IPObject>();
+ if (values == null)
+ {
+ return col;
+ }
+
+ for (int a = 0; a < values.Length; a++)
+ {
+ string v = values[a].Trim();
+
+ try
+ {
+ if (v.StartsWith('[') && v.EndsWith(']'))
+ {
+ if (bracketed)
+ {
+ AddToCollection(col, v[1..^1]);
+ }
+ }
+ else if (v.StartsWith('!'))
+ {
+ if (bracketed)
+ {
+ AddToCollection(col, v[1..]);
+ }
+ }
+ else if (!bracketed)
+ {
+ AddToCollection(col, v);
+ }
+ }
+ catch (ArgumentException e)
+ {
+ _logger.LogWarning(e, "Ignoring LAN value {value}.", v);
+ }
+ }
+
+ return col;
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> 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<IPObject>(_interfaceAddresses);
+ }
+
+ // No bind address and no exclusions, so listen on all interfaces.
+ Collection<IPObject> result = new Collection<IPObject>();
+
+ if (IsIP4Enabled)
+ {
+ result.AddItem(IPAddress.Any);
+ }
+
+ if (IsIP6Enabled)
+ {
+ result.AddItem(IPAddress.IPv6Any);
+ }
+
+ return result;
+ }
+
+ // Remove any excluded bind interfaces.
+ return _bindAddresses.Exclude(_bindExclusions);
+ }
+
+ /// <inheritdoc/>
+ 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);
+ }
+
+ /// <inheritdoc/>
+ public string GetBindInterface(IPAddress source, out int? port)
+ {
+ return GetBindInterface(new IPNetAddress(source), out port);
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> 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<IPObject>(_bindAddresses);
+ }
+
+ /// <inheritdoc/>
+ 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);
+ }
+
+ /// <inheritdoc/>
+ public bool IsInLocalNetwork(string address)
+ {
+ if (IPHost.TryParse(address, out IPHost ep))
+ {
+ return _lanSubnets.ContainsAddress(ep) && !_excludedSubnets.ContainsAddress(ep);
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ 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);
+ }
+
+ /// <inheritdoc/>
+ 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();
+ }
+ }
+
+ /// <inheritdoc/>
+ public bool IsExcludedInterface(IPAddress address)
+ {
+ return _bindExclusions.ContainsAddress(address);
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null)
+ {
+ if (filter == null)
+ {
+ return _lanSubnets.Exclude(_excludedSubnets).AsNetworks();
+ }
+
+ return _lanSubnets.Exclude(filter);
+ }
+
+ /// <inheritdoc/>
+ public bool IsValidInterfaceAddress(IPAddress address)
+ {
+ return _interfaceAddresses.ContainsAddress(address);
+ }
+
+ /// <inheritdoc/>
+ public bool TryParseInterface(string token, out Collection<IPObject>? result)
+ {
+ result = null;
+ if (string.IsNullOrEmpty(token))
+ {
+ return false;
+ }
+
+ if (_interfaceNames != null && _interfaceNames.TryGetValue(token.ToLower(CultureInfo.InvariantCulture), out int index))
+ {
+ result = new Collection<IPObject>();
+
+ _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;
+ }
+
+ /// <summary>
+ /// Reloads all settings and re-initialises the instance.
+ /// </summary>
+ /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param>
+ 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 <IPAddress>,<Index>,<Name>: <next interface>. 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);
+ }
+
+ /// <summary>
+ /// Protected implementation of Dispose pattern.
+ /// </summary>
+ /// <param name="disposing"><c>True</c> to dispose the managed state.</param>
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ _configurationManager.NamedConfigurationUpdated -= ConfigurationUpdated;
+ NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
+ NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged;
+ }
+
+ _disposed = true;
+ }
+ }
+
+ /// <summary>
+ /// Tries to identify the string and return an object of that class.
+ /// </summary>
+ /// <param name="addr">String to parse.</param>
+ /// <param name="result">IPObject to return.</param>
+ /// <returns><c>true</c> if the value parsed successfully, <c>false</c> otherwise.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Converts an IPAddress into a string.
+ /// Ipv6 addresses are returned in [ ], with their scope removed.
+ /// </summary>
+ /// <param name="address">Address to convert.</param>
+ /// <returns>URI safe conversion of the address.</returns>
+ 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);
+ }
+ }
+
+ /// <summary>
+ /// Checks the string to see if it matches any interface names.
+ /// </summary>
+ /// <param name="token">String to check.</param>
+ /// <param name="index">Interface index number.</param>
+ /// <returns><c>true</c> if an interface name matches the token, <c>False</c> otherwise.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Parses a string and adds it into the the collection, replacing any interface references.
+ /// </summary>
+ /// <param name="col"><see cref="Collection{IPObject}"/>Collection.</param>
+ /// <param name="token">String value to parse.</param>
+ private void AddToCollection(Collection<IPObject> 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))
+ {
+ 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);
+ }
+ }
+
+ /// <summary>
+ /// Handler for network change events.
+ /// </summary>
+ /// <param name="sender">Sender.</param>
+ /// <param name="e">A <see cref="NetworkAvailabilityEventArgs"/> containing network availability information.</param>
+ private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
+ {
+ _logger.LogDebug("Network availability changed.");
+ OnNetworkChanged();
+ }
+
+ /// <summary>
+ /// Handler for network change events.
+ /// </summary>
+ /// <param name="sender">Sender.</param>
+ /// <param name="e">An <see cref="EventArgs"/>.</param>
+ private void OnNetworkAddressChanged(object? sender, EventArgs e)
+ {
+ _logger.LogDebug("Network address change detected.");
+ OnNetworkChanged();
+ }
+
+ /// <summary>
+ /// Async task that waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ 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;
+ }
+ }
+
+ /// <summary>
+ /// Triggers our event, and re-loads interface information.
+ /// </summary>
+ 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();
+ }
+ }
+ }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ 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], "remaining", 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<IPObject>? 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]);
+ }
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Initialises the network bind addresses.
+ /// </summary>
+ 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());
+ }
+ }
+
+ /// <summary>
+ /// Initialises the remote address values.
+ /// </summary>
+ private void InitialiseRemote(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ RemoteAddressFilter = CreateIPCollection(config.RemoteIPFilter);
+ }
+ }
+
+ /// <summary>
+ /// Initialises internal LAN cache settings.
+ /// </summary>
+ private void InitialiseLAN(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ _logger.LogDebug("Refreshing LAN information.");
+
+ // Get config 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<IPObject>();
+
+ // 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());
+ }
+ }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ private void InitialiseInterfaces()
+ {
+ lock (_intLock)
+ {
+ _logger.LogDebug("Refreshing interfaces.");
+
+ _interfaceNames.Clear();
+ _interfaceAddresses.Clear();
+ _macAddresses.Clear();
+
+ try
+ {
+ IEnumerable<NetworkInterface> 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.");
+ }
+ }
+ }
+
+ /// <summary>
+ /// Attempts to match the source against a user defined bind interface.
+ /// </summary>
+ /// <param name="source">IP source address to use.</param>
+ /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param>
+ /// <param name="bindPreference">The published server url that matches the source address.</param>
+ /// <param name="port">The resultant port, if one exists.</param>
+ /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Attempts to match the source against a user defined bind interface.
+ /// </summary>
+ /// <param name="source">IP source address to use.</param>
+ /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param>
+ /// <param name="result">The result, if a match is found.</param>
+ /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Attempts to match the source against an external interface.
+ /// </summary>
+ /// <param name="source">IP source address to use.</param>
+ /// <param name="result">The result, if a match is found.</param>
+ /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+ 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/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
index f79e433a6..662b4bf65 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/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index c44736447..cb8ae91f5 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -13,6 +13,7 @@ using Jellyfin.Server.Implementations.Events;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.BaseItemManager;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
@@ -76,6 +77,7 @@ namespace Jellyfin.Server
options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"));
ServiceCollection.AddEventServices();
+ ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>();
ServiceCollection.AddSingleton<IEventManager, EventManager>();
ServiceCollection.AddSingleton<JellyfinDbProvider>();
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index cc98955df..6cb88c9f7 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -17,6 +17,7 @@ using Jellyfin.Api.Auth.LocalAccessPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Server.Configuration;
using Jellyfin.Server.Filters;
using Jellyfin.Server.Formatters;
@@ -169,6 +170,8 @@ namespace Jellyfin.Server.Extensions
opts.OutputFormatters.Add(new CssOutputFormatter());
opts.OutputFormatters.Add(new XmlOutputFormatter());
+
+ opts.ModelBinderProviders.Insert(0, new NullableEnumModelBinderProvider());
})
// Clear app parts to avoid other assemblies being picked up
diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
index b281b5cc0..394f14d63 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/Startup.cs b/Jellyfin.Server/Startup.cs
index c1a8b75c6..6de0dd7ec 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,8 +1,5 @@
-using System;
-using System.ComponentModel;
using System.Net.Http.Headers;
using System.Net.Mime;
-using Jellyfin.Api.TypeConverters;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Implementations;
using Jellyfin.Server.Middleware;
@@ -172,9 +169,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/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
index 06a29a0db..a259cb7bc 100644
--- a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
+++ b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
@@ -43,7 +43,8 @@ namespace MediaBrowser.Common.Json.Converters
}
catch (FormatException)
{
- // TODO log when upgraded to .Net5
+ // TODO log when upgraded to .Net6
+ // https://github.com/dotnet/runtime/issues/42975
// _logger.LogWarning(e, "Error converting value.");
}
}
diff --git a/MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs b/MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs
new file mode 100644
index 000000000..63b344a9d
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonDateTimeIso8601Converter.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ /// <summary>
+ /// Returns an ISO8601 formatted datetime.
+ /// </summary>
+ /// <remarks>
+ /// Used for legacy compatibility.
+ /// </remarks>
+ public class JsonDateTimeIso8601Converter : JsonConverter<DateTime>
+ {
+ /// <inheritdoc />
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => reader.GetDateTime();
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture));
+ }
+}
diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs
new file mode 100644
index 000000000..75fbcea1f
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs
@@ -0,0 +1,75 @@
+using System;
+using System.ComponentModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ /// <summary>
+ /// Convert Pipe delimited string to array of type.
+ /// </summary>
+ /// <typeparam name="T">Type to convert to.</typeparam>
+ public class JsonPipeDelimitedArrayConverter<T> : JsonConverter<T[]>
+ {
+ private readonly TypeConverter _typeConverter;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class.
+ /// </summary>
+ public JsonPipeDelimitedArrayConverter()
+ {
+ _typeConverter = TypeDescriptor.GetConverter(typeof(T));
+ }
+
+ /// <inheritdoc />
+ public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ var stringEntries = reader.GetString()?.Split('|', StringSplitOptions.RemoveEmptyEntries);
+ if (stringEntries == null || stringEntries.Length == 0)
+ {
+ return Array.Empty<T>();
+ }
+
+ var parsedValues = new object[stringEntries.Length];
+ var convertedCount = 0;
+ for (var i = 0; i < stringEntries.Length; i++)
+ {
+ try
+ {
+ parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim());
+ convertedCount++;
+ }
+ catch (FormatException)
+ {
+ // TODO log when upgraded to .Net6
+ // https://github.com/dotnet/runtime/issues/42975
+ // _logger.LogWarning(e, "Error converting value.");
+ }
+ }
+
+ var typedValues = new T[convertedCount];
+ var typedValueIndex = 0;
+ for (var i = 0; i < stringEntries.Length; i++)
+ {
+ if (parsedValues[i] != null)
+ {
+ typedValues.SetValue(parsedValues[i], typedValueIndex);
+ typedValueIndex++;
+ }
+ }
+
+ return typedValues;
+ }
+
+ return JsonSerializer.Deserialize<T[]>(ref reader, options);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value, options);
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
new file mode 100644
index 000000000..5e77223ef
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ /// <summary>
+ /// Json Pipe delimited array converter factory.
+ /// </summary>
+ /// <remarks>
+ /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
+ /// </remarks>
+ public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory
+ {
+ /// <inheritdoc />
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return true;
+ }
+
+ /// <inheritdoc />
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
+ return (JsonConverter)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType));
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs
index 6605ae962..9a94664ac 100644
--- a/MediaBrowser.Common/Json/JsonDefaults.cs
+++ b/MediaBrowser.Common/Json/JsonDefaults.cs
@@ -42,6 +42,7 @@ namespace MediaBrowser.Common.Json
options.Converters.Add(new JsonGuidConverter());
options.Converters.Add(new JsonStringEnumConverter());
options.Converters.Add(new JsonNullableStructConverterFactory());
+ options.Converters.Add(new JsonDateTimeIso8601Converter());
return options;
}
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index c2145aec5..be5e7f5b4 100644
--- a/MediaBrowser.Common/MediaBrowser.Common.csproj
+++ b/MediaBrowser.Common/MediaBrowser.Common.csproj
@@ -20,7 +20,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
- <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
+ <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
</ItemGroup>
diff --git a/MediaBrowser.Common/Net/INetworkManager.cs b/MediaBrowser.Common/Net/INetworkManager.cs
index a0330afef..12966a474 100644
--- a/MediaBrowser.Common/Net/INetworkManager.cs
+++ b/MediaBrowser.Common/Net/INetworkManager.cs
@@ -58,7 +58,7 @@ namespace MediaBrowser.Common.Net
/// <summary>
/// Investigates an caches a list of interface addresses, excluding local link and LAN excluded addresses.
/// </summary>
- /// <returns>The list of ipaddresses.</returns>
+ /// <returns>The list of ip addresses.</returns>
IPAddress[] GetLocalIpAddresses();
/// <summary>
@@ -73,7 +73,7 @@ namespace MediaBrowser.Common.Net
/// Returns true if address is in the LAN list in the config file.
/// </summary>
/// <param name="address">The address to check.</param>
- /// <param name="excludeInterfaces">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param>
+ /// <param name="excludeInterfaces">If true, check against addresses in the LAN settings which have [] around and return true if it matches the address give in address.</param>
/// <param name="excludeRFC">If true, returns false if address is in the 127.x.x.x or 169.128.x.x range.</param>
/// <returns><c>false</c>if the address isn't in the LAN list, <c>true</c> if the address has been defined as a LAN address.</returns>
bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC);
diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs
new file mode 100644
index 000000000..4cede9ab1
--- /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
+{
+ /// <summary>
+ /// Object that holds a host name.
+ /// </summary>
+ public class IPHost : IPObject
+ {
+ /// <summary>
+ /// Gets or sets timeout value before resolve required, in minutes.
+ /// </summary>
+ public const int Timeout = 30;
+
+ /// <summary>
+ /// Represents an IPHost that has no value.
+ /// </summary>
+ public static readonly IPHost None = new IPHost(string.Empty, IPAddress.None);
+
+ /// <summary>
+ /// Time when last resolved in ticks.
+ /// </summary>
+ private DateTime? _lastResolved = null;
+
+ /// <summary>
+ /// Gets the IP Addresses, attempting to resolve the name, if there are none.
+ /// </summary>
+ private IPAddress[] _addresses;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPHost"/> class.
+ /// </summary>
+ /// <param name="name">Host name to assign.</param>
+ public IPHost(string name)
+ {
+ HostName = name ?? throw new ArgumentNullException(nameof(name));
+ _addresses = Array.Empty<IPAddress>();
+ Resolved = false;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPHost"/> class.
+ /// </summary>
+ /// <param name="name">Host name to assign.</param>
+ /// <param name="address">Address to assign.</param>
+ 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);
+ }
+
+ /// <summary>
+ /// Gets or sets the object's first IP address.
+ /// </summary>
+ 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.");
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the object's first IP's subnet prefix.
+ /// The setter does nothing, but shouldn't raise an exception.
+ /// </summary>
+ 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.
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether the address has a value.
+ /// </summary>
+ public bool HasAddress => _addresses.Length != 0;
+
+ /// <summary>
+ /// Gets the host name of this object.
+ /// </summary>
+ public string HostName { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether this host has attempted to be resolved.
+ /// </summary>
+ public bool Resolved { get; private set; }
+
+ /// <summary>
+ /// Gets or sets the IP Addresses associated with this object.
+ /// </summary>
+ /// <param name="index">Index of address.</param>
+ public IPAddress this[int index]
+ {
+ get
+ {
+ ResolveHost();
+ return index >= 0 && index < _addresses.Length ? _addresses[index] : IPAddress.None;
+ }
+ }
+
+ /// <summary>
+ /// Attempts to parse the host string.
+ /// </summary>
+ /// <param name="host">Host name to parse.</param>
+ /// <param name="hostObj">Object representing the string, if it has successfully been parsed.</param>
+ /// <returns><c>true</c> if the parsing is successful, <c>false</c> if not.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Attempts to parse the host string.
+ /// </summary>
+ /// <param name="host">Host name to parse.</param>
+ /// <returns>Object representing the string, if it has successfully been parsed.</returns>
+ 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}");
+ }
+
+ /// <summary>
+ /// Attempts to parse the host string, ensuring that it resolves only to a specific IP type.
+ /// </summary>
+ /// <param name="host">Host name to parse.</param>
+ /// <param name="family">Addressfamily filter.</param>
+ /// <returns>Object representing the string, if it has successfully been parsed.</returns>
+ 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}");
+ }
+
+ /// <summary>
+ /// Returns the Addresses that this item resolved to.
+ /// </summary>
+ /// <returns>IPAddress Array.</returns>
+ public IPAddress[] GetAddresses()
+ {
+ ResolveHost();
+ return _addresses;
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ public override void Remove(AddressFamily family)
+ {
+ if (ResolveHost())
+ {
+ _addresses = _addresses.Where(p => p.AddressFamily != family).ToArray();
+ }
+ }
+
+ /// <inheritdoc/>
+ public override bool Contains(IPObject address)
+ {
+ // An IPHost cannot contain another IPObject, it can only be equal.
+ return Equals(address);
+ }
+
+ /// <inheritdoc/>
+ protected override IPObject CalculateNetworkAddress()
+ {
+ var netAddr = NetworkAddressOf(this[0], PrefixLength);
+ return new IPNetAddress(netAddr.Address, netAddr.PrefixLength);
+ }
+
+ /// <summary>
+ /// Attempt to resolve the ip address of a host.
+ /// </summary>
+ /// <returns><c>true</c> if any addresses have been resolved, otherwise <c>false</c>.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Task that looks up a Host name and returns its IP addresses.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ 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 000000000..a6f5fe4b3
--- /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
+{
+ /// <summary>
+ /// An object that holds and IP address and subnet mask.
+ /// </summary>
+ public class IPNetAddress : IPObject
+ {
+ /// <summary>
+ /// Represents an IPNetAddress that has no value.
+ /// </summary>
+ public static readonly IPNetAddress None = new IPNetAddress(IPAddress.None);
+
+ /// <summary>
+ /// IPv4 multicast address.
+ /// </summary>
+ public static readonly IPAddress SSDPMulticastIPv4 = IPAddress.Parse("239.255.255.250");
+
+ /// <summary>
+ /// IPv6 local link multicast address.
+ /// </summary>
+ public static readonly IPAddress SSDPMulticastIPv6LinkLocal = IPAddress.Parse("ff02::C");
+
+ /// <summary>
+ /// IPv6 site local multicast address.
+ /// </summary>
+ public static readonly IPAddress SSDPMulticastIPv6SiteLocal = IPAddress.Parse("ff05::C");
+
+ /// <summary>
+ /// IP4Loopback address host.
+ /// </summary>
+ public static readonly IPNetAddress IP4Loopback = IPNetAddress.Parse("127.0.0.1/32");
+
+ /// <summary>
+ /// IP6Loopback address host.
+ /// </summary>
+ public static readonly IPNetAddress IP6Loopback = IPNetAddress.Parse("::1");
+
+ /// <summary>
+ /// Object's IP address.
+ /// </summary>
+ private IPAddress _address;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPNetAddress"/> class.
+ /// </summary>
+ /// <param name="address">Address to assign.</param>
+ public IPNetAddress(IPAddress address)
+ {
+ _address = address ?? throw new ArgumentNullException(nameof(address));
+ PrefixLength = (byte)(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128);
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPNetAddress"/> class.
+ /// </summary>
+ /// <param name="address">IP Address.</param>
+ /// <param name="prefixLength">Mask as a CIDR.</param>
+ public IPNetAddress(IPAddress address, byte prefixLength)
+ {
+ if (address?.IsIPv4MappedToIPv6 ?? throw new ArgumentNullException(nameof(address)))
+ {
+ _address = address.MapToIPv4();
+ }
+ else
+ {
+ _address = address;
+ }
+
+ PrefixLength = prefixLength;
+ }
+
+ /// <summary>
+ /// Gets or sets the object's IP address.
+ /// </summary>
+ public override IPAddress Address
+ {
+ get
+ {
+ return _address;
+ }
+
+ set
+ {
+ _address = value ?? IPAddress.None;
+ }
+ }
+
+ /// <inheritdoc/>
+ public override byte PrefixLength { get; set; }
+
+ /// <summary>
+ /// Try to parse the address and subnet strings into an IPNetAddress object.
+ /// </summary>
+ /// <param name="addr">IP address to parse. Can be CIDR or X.X.X.X notation.</param>
+ /// <param name="ip">Resultant object.</param>
+ /// <returns>True if the values parsed successfully. False if not, resulting in the IP being null.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Parses the string provided, throwing an exception if it is badly formed.
+ /// </summary>
+ /// <param name="addr">String to parse.</param>
+ /// <returns>IPNetAddress object.</returns>
+ public static IPNetAddress Parse(string addr)
+ {
+ if (TryParse(addr, out IPNetAddress o))
+ {
+ return o;
+ }
+
+ throw new ArgumentException("Unable to recognise object :" + addr);
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ public override bool Equals(IPAddress address)
+ {
+ if (address != null && !address.Equals(IPAddress.None) && !Address.Equals(IPAddress.None))
+ {
+ return address.Equals(Address);
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override string ToString()
+ {
+ return ToString(false);
+ }
+
+ /// <summary>
+ /// Returns a textual representation of this object.
+ /// </summary>
+ /// <param name="shortVersion">Set to true, if the subnet is to be excluded as part of the address.</param>
+ /// <returns>String representation of this object.</returns>
+ 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;
+ }
+
+ /// <inheritdoc/>
+ 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 000000000..69cd57f8a
--- /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
+{
+ /// <summary>
+ /// Base network object class.
+ /// </summary>
+ public abstract class IPObject : IEquatable<IPObject>
+ {
+ /// <summary>
+ /// IPv6 Loopback address.
+ /// </summary>
+ protected static readonly byte[] Ipv6Loopback = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
+
+ /// <summary>
+ /// IPv4 Loopback address.
+ /// </summary>
+ protected static readonly byte[] Ipv4Loopback = { 127, 0, 0, 1 };
+
+ /// <summary>
+ /// The network address of this object.
+ /// </summary>
+ private IPObject? _networkAddress;
+
+ /// <summary>
+ /// Gets or sets a user defined value that is associated with this object.
+ /// </summary>
+ public int Tag { get; set; }
+
+ /// <summary>
+ /// Gets or sets the object's IP address.
+ /// </summary>
+ public abstract IPAddress Address { get; set; }
+
+ /// <summary>
+ /// Gets the object's network address.
+ /// </summary>
+ public IPObject NetworkAddress => _networkAddress ??= CalculateNetworkAddress();
+
+ /// <summary>
+ /// Gets or sets the object's IP address.
+ /// </summary>
+ public abstract byte PrefixLength { get; set; }
+
+ /// <summary>
+ /// Gets the AddressFamily of this object.
+ /// </summary>
+ 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;
+ }
+ }
+
+ /// <summary>
+ /// Returns the network address of an object.
+ /// </summary>
+ /// <param name="address">IP Address to convert.</param>
+ /// <param name="prefixLength">Subnet prefix.</param>
+ /// <returns>IPAddress.</returns>
+ 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<byte> 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);
+ }
+
+ /// <summary>
+ /// Tests to see if the ip address is a Loopback address.
+ /// </summary>
+ /// <param name="address">Value to test.</param>
+ /// <returns>True if it is.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Tests to see if the ip address is an IP6 address.
+ /// </summary>
+ /// <param name="address">Value to test.</param>
+ /// <returns>True if it is.</returns>
+ 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);
+ }
+
+ /// <summary>
+ /// Tests to see if the address in the private address range.
+ /// </summary>
+ /// <param name="address">Object to test.</param>
+ /// <returns>True if it contains a private address.</returns>
+ 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<byte> 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<byte> 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;
+ }
+
+ /// <summary>
+ /// Returns true if the IPAddress contains an IP6 Local link address.
+ /// </summary>
+ /// <param name="address">IPAddress object to check.</param>
+ /// <returns>True if it is a local link address.</returns>
+ /// <remarks>
+ /// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress
+ /// it appears that the IPAddress.IsIPv6LinkLocal is out of date.
+ /// </remarks>
+ 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<byte> 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.
+ }
+
+ /// <summary>
+ /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only.
+ /// </summary>
+ /// <param name="cidr">Subnet mask in CIDR notation.</param>
+ /// <param name="family">IPv4 or IPv6 family.</param>
+ /// <returns>String value of the subnet mask in dotted decimal notation.</returns>
+ 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);
+ }
+
+ /// <summary>
+ /// Convert a mask to a CIDR. IPv4 only.
+ /// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask.
+ /// </summary>
+ /// <param name="mask">Subnet mask.</param>
+ /// <returns>Byte CIDR representing the mask.</returns>
+ public static byte MaskToCidr(IPAddress mask)
+ {
+ if (mask == null)
+ {
+ throw new ArgumentNullException(nameof(mask));
+ }
+
+ byte cidrnet = 0;
+ if (!mask.Equals(IPAddress.Any))
+ {
+ // GetAddressBytes
+ Span<byte> 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;
+ }
+
+ /// <summary>
+ /// Tests to see if this object is a Loopback address.
+ /// </summary>
+ /// <returns>True if it is.</returns>
+ public virtual bool IsLoopback()
+ {
+ return IsLoopback(Address);
+ }
+
+ /// <summary>
+ /// Removes all addresses of a specific type from this object.
+ /// </summary>
+ /// <param name="family">Type of address to remove.</param>
+ public virtual void Remove(AddressFamily family)
+ {
+ // This method only performs a function in the IPHost implementation of IPObject.
+ }
+
+ /// <summary>
+ /// Tests to see if this object is an IPv6 address.
+ /// </summary>
+ /// <returns>True if it is.</returns>
+ public virtual bool IsIP6()
+ {
+ return IsIP6(Address);
+ }
+
+ /// <summary>
+ /// Returns true if this IP address is in the RFC private address range.
+ /// </summary>
+ /// <returns>True this object has a private address.</returns>
+ public virtual bool IsPrivateAddressRange()
+ {
+ return IsPrivateAddressRange(Address);
+ }
+
+ /// <summary>
+ /// Compares this to the object passed as a parameter.
+ /// </summary>
+ /// <param name="ip">Object to compare to.</param>
+ /// <returns>Equality result.</returns>
+ 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;
+ }
+
+ /// <summary>
+ /// Compares this to the object passed as a parameter.
+ /// </summary>
+ /// <param name="other">Object to compare to.</param>
+ /// <returns>Equality result.</returns>
+ public virtual bool Equals(IPObject? other)
+ {
+ if (other != null)
+ {
+ return !Address.Equals(IPAddress.None) && Address.Equals(other.Address);
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Compares the address in this object and the address in the object passed as a parameter.
+ /// </summary>
+ /// <param name="address">Object's IP address to compare to.</param>
+ /// <returns>Comparison result.</returns>
+ public abstract bool Contains(IPObject address);
+
+ /// <summary>
+ /// Compares the address in this object and the address in the object passed as a parameter.
+ /// </summary>
+ /// <param name="address">Object's IP address to compare to.</param>
+ /// <returns>Comparison result.</returns>
+ public abstract bool Contains(IPAddress address);
+
+ /// <inheritdoc/>
+ public override int GetHashCode()
+ {
+ return Address.GetHashCode();
+ }
+
+ /// <inheritdoc/>
+ public override bool Equals(object? obj)
+ {
+ return Equals(obj as IPObject);
+ }
+
+ /// <summary>
+ /// Calculates the network address of this object.
+ /// </summary>
+ /// <returns>Returns the network address of this object.</returns>
+ protected abstract IPObject CalculateNetworkAddress();
+ }
+}
diff --git a/MediaBrowser.Common/Net/NetworkExtensions.cs b/MediaBrowser.Common/Net/NetworkExtensions.cs
new file mode 100644
index 000000000..d07bba249
--- /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
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkExtensions" />.
+ /// </summary>
+ public static class NetworkExtensions
+ {
+ /// <summary>
+ /// Add an address to the collection.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="ip">Item to add.</param>
+ public static void AddItem(this Collection<IPObject> source, IPAddress ip)
+ {
+ if (!source.ContainsAddress(ip))
+ {
+ source.Add(new IPNetAddress(ip, 32));
+ }
+ }
+
+ /// <summary>
+ /// Adds a network to the collection.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="item">Item to add.</param>
+ public static void AddItem(this Collection<IPObject> source, IPObject item)
+ {
+ if (!source.ContainsAddress(item))
+ {
+ source.Add(item);
+ }
+ }
+
+ /// <summary>
+ /// Converts this object to a string.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <returns>Returns a string representation of this object.</returns>
+ public static string AsString(this Collection<IPObject> source)
+ {
+ return $"[{string.Join(',', source)}]";
+ }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="item">The item to look for.</param>
+ /// <returns>True if the collection contains the item.</returns>
+ public static bool ContainsAddress(this Collection<IPObject> 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;
+ }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="item">The item to look for.</param>
+ /// <returns>True if the collection contains the item.</returns>
+ public static bool ContainsAddress(this Collection<IPObject> 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;
+ }
+
+ /// <summary>
+ /// Compares two Collection{IPObject} objects. The order is ignored.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="dest">Item to compare to.</param>
+ /// <returns>True if both are equal.</returns>
+ public static bool Compare(this Collection<IPObject> source, Collection<IPObject> 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;
+ }
+
+ /// <summary>
+ /// Returns a collection containing the subnets of this collection given.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <returns>Collection{IPObject} object containing the subnets.</returns>
+ public static Collection<IPObject> AsNetworks(this Collection<IPObject> source)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ Collection<IPObject> res = new Collection<IPObject>();
+
+ 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;
+ }
+
+ /// <summary>
+ /// Excludes all the items from this list that are found in excludeList.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="excludeList">Items to exclude.</param>
+ /// <returns>A new collection, with the items excluded.</returns>
+ public static Collection<IPObject> Exclude(this Collection<IPObject> source, Collection<IPObject> excludeList)
+ {
+ if (source.Count == 0 || excludeList == null)
+ {
+ return new Collection<IPObject>(source);
+ }
+
+ Collection<IPObject> results = new Collection<IPObject>();
+
+ 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;
+ }
+
+ /// <summary>
+ /// Returns all items that co-exist in this object and target.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="target">Collection to compare with.</param>
+ /// <returns>A collection containing all the matches.</returns>
+ public static Collection<IPObject> Union(this Collection<IPObject> source, Collection<IPObject> target)
+ {
+ if (source.Count == 0)
+ {
+ return new Collection<IPObject>();
+ }
+
+ if (target == null)
+ {
+ throw new ArgumentNullException(nameof(target));
+ }
+
+ Collection<IPObject> nc = new Collection<IPObject>();
+
+ 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 e21d8c7d1..084e91d50 100644
--- a/MediaBrowser.Common/Plugins/BasePlugin.cs
+++ b/MediaBrowser.Common/Plugins/BasePlugin.cs
@@ -247,23 +247,34 @@ namespace MediaBrowser.Common.Plugins
}
catch
{
- return (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
+ var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
+ SaveConfiguration(config);
+ return config;
}
}
/// <summary>
/// Saves the current configuration to the file system.
/// </summary>
- public virtual void SaveConfiguration()
+ /// <param name="config">Configuration to save.</param>
+ public virtual void SaveConfiguration(TConfigurationType config)
{
lock (_configurationSaveLock)
{
_directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
- XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath);
+ XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
}
}
+ /// <summary>
+ /// Saves the current configuration to the file system.
+ /// </summary>
+ public virtual void SaveConfiguration()
+ {
+ SaveConfiguration(Configuration);
+ }
+
/// <inheritdoc />
public virtual void UpdateConfiguration(BasePluginConfiguration configuration)
{
@@ -274,9 +285,9 @@ namespace MediaBrowser.Common.Plugins
Configuration = (TConfigurationType)configuration;
- SaveConfiguration();
+ SaveConfiguration(Configuration);
- ConfigurationChanged.Invoke(this, configuration);
+ ConfigurationChanged?.Invoke(this, configuration);
}
/// <inheritdoc />
diff --git a/MediaBrowser.Common/Updates/IInstallationManager.cs b/MediaBrowser.Common/Updates/IInstallationManager.cs
index 6aa16fea7..585b1ee19 100644
--- a/MediaBrowser.Common/Updates/IInstallationManager.cs
+++ b/MediaBrowser.Common/Updates/IInstallationManager.cs
@@ -19,10 +19,11 @@ namespace MediaBrowser.Common.Updates
/// <summary>
/// Parses a plugin manifest at the supplied URL.
/// </summary>
+ /// <param name="manifestName">Name of the repository.</param>
/// <param name="manifest">The URL to query.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
- Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default);
+ Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all available packages.
@@ -37,11 +38,13 @@ namespace MediaBrowser.Common.Updates
/// <param name="availablePackages">The available packages.</param>
/// <param name="name">The name of the plugin.</param>
/// <param name="guid">The id of the plugin.</param>
+ /// <param name="specificVersion">The version of the plugin.</param>
/// <returns>All plugins matching the requirements.</returns>
IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages,
string name = null,
- Guid guid = default);
+ Guid guid = default,
+ Version specificVersion = null);
/// <summary>
/// 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 000000000..67aa7f338
--- /dev/null
+++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Linq;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.BaseItemManager
+{
+ /// <inheritdoc />
+ public class BaseItemManager : IBaseItemManager
+ {
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BaseItemManager"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public BaseItemManager(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc />
+ public bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name)
+ {
+ if (baseItem is Channel)
+ {
+ // Hack alert.
+ return true;
+ }
+
+ if (baseItem.SourceType == SourceType.Channel)
+ {
+ // Hack alert.
+ return !baseItem.EnableMediaSourceDisplay;
+ }
+
+ var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+ if (typeOptions != null)
+ {
+ return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ if (!libraryOptions.EnableInternetProviders)
+ {
+ return false;
+ }
+
+ var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase));
+
+ return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ /// <inheritdoc />
+ public bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name)
+ {
+ if (baseItem is Channel)
+ {
+ // Hack alert.
+ return true;
+ }
+
+ if (baseItem.SourceType == SourceType.Channel)
+ {
+ // Hack alert.
+ return !baseItem.EnableMediaSourceDisplay;
+ }
+
+ var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+ if (typeOptions != null)
+ {
+ return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ if (!libraryOptions.EnableInternetProviders)
+ {
+ return false;
+ }
+
+ var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase));
+
+ return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs
new file mode 100644
index 000000000..ee4d3dcdc
--- /dev/null
+++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs
@@ -0,0 +1,29 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.BaseItemManager
+{
+ /// <summary>
+ /// The <c>BaseItem</c> manager.
+ /// </summary>
+ public interface IBaseItemManager
+ {
+ /// <summary>
+ /// Is metadata fetcher enabled.
+ /// </summary>
+ /// <param name="baseItem">The base item.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <param name="name">The metadata fetcher name.</param>
+ /// <returns><c>true</c> if metadata fetcher is enabled, else false.</returns>
+ bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name);
+
+ /// <summary>
+ /// Is image fetcher enabled.
+ /// </summary>
+ /// <param name="baseItem">The base item.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <param name="name">The image fetcher name.</param>
+ /// <returns><c>true</c> if image fetcher is enabled, else false.</returns>
+ bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name);
+ }
+} \ No newline at end of file
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 1d44a5511..1b25fbdbb 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -463,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)
@@ -2611,7 +2557,7 @@ namespace MediaBrowser.Controller.Entities
{
if (!AllowsMultipleImages(type))
{
- throw new ArgumentException("The change index operation is only applicable to backdrops and screenshots");
+ throw new ArgumentException("The change index operation is only applicable to backdrops and screen shots");
}
var info1 = GetImageInfo(type, index1);
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index a76c8a376..3087b1d1e 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -212,7 +212,7 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Loads our children. Validation will occur externally.
- /// We want this sychronous.
+ /// We want this synchronous.
/// </summary>
protected virtual List<BaseItem> LoadChildren()
{
@@ -1067,12 +1067,12 @@ namespace MediaBrowser.Controller.Entities
return false;
}
- if (request.Genres.Length > 0)
+ if (request.Genres.Count > 0)
{
return false;
}
- if (request.GenreIds.Length > 0)
+ if (request.GenreIds.Count > 0)
{
return false;
}
@@ -1177,7 +1177,7 @@ namespace MediaBrowser.Controller.Entities
return false;
}
- if (request.GenreIds.Length > 0)
+ if (request.GenreIds.Count > 0)
{
return false;
}
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 904752a22..270217356 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -46,7 +46,7 @@ namespace MediaBrowser.Controller.Entities
public string[] ExcludeInheritedTags { get; set; }
- public string[] Genres { get; set; }
+ public IReadOnlyList<string> Genres { get; set; }
public bool? IsSpecialSeason { get; set; }
@@ -116,7 +116,7 @@ namespace MediaBrowser.Controller.Entities
public Guid[] StudioIds { get; set; }
- public Guid[] GenreIds { get; set; }
+ public IReadOnlyList<Guid> GenreIds { get; set; }
public ImageType[] ImageTypes { get; set; }
@@ -162,7 +162,7 @@ namespace MediaBrowser.Controller.Entities
public double? MinCommunityRating { get; set; }
- public Guid[] ChannelIds { get; set; }
+ public IReadOnlyList<Guid> ChannelIds { get; set; }
public int? ParentIndexNumber { get; set; }
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index a262fee15..4e33a6bbd 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -791,7 +791,7 @@ namespace MediaBrowser.Controller.Entities
}
// Apply genre filter
- if (query.Genres.Length > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase)))
+ if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase)))
{
return false;
}
@@ -822,7 +822,7 @@ namespace MediaBrowser.Controller.Entities
}
// Apply genre filter
- if (query.GenreIds.Length > 0 && !query.GenreIds.Any(id =>
+ if (query.GenreIds.Count > 0 && !query.GenreIds.Any(id =>
{
var genreItem = libraryManager.GetItemById(id);
return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase);
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 5846a603a..9a6f1231f 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -7,6 +7,7 @@ using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
@@ -23,7 +24,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
public class EncodingHelper
{
- private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+ private static readonly CultureInfo _usCulture = new CultureInfo("en-US");
private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
@@ -63,7 +64,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
// Only use alternative encoders for video files.
// When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully
- // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this.
+ // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this.
if (state.VideoType == VideoType.VideoFile)
{
var hwType = encodingOptions.HardwareAccelerationType;
@@ -247,7 +248,7 @@ namespace MediaBrowser.Controller.MediaEncoding
return null;
}
- // Seeing reported failures here, not sure yet if this is related to specfying input format
+ // Seeing reported failures here, not sure yet if this is related to specifying input format
if (string.Equals(container, "m4v", StringComparison.OrdinalIgnoreCase))
{
return null;
@@ -440,6 +441,12 @@ namespace MediaBrowser.Controller.MediaEncoding
return "libopus";
}
+ if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase))
+ {
+ // flac is experimental in mp4 muxer
+ return "flac -strict -2";
+ }
+
return codec.ToLowerInvariant();
}
@@ -573,7 +580,7 @@ namespace MediaBrowser.Controller.MediaEncoding
/// </summary>
/// <param name="stream">The stream.</param>
/// <returns><c>true</c> if the specified stream is H264; otherwise, <c>false</c>.</returns>
- public bool IsH264(MediaStream stream)
+ public static bool IsH264(MediaStream stream)
{
var codec = stream.Codec ?? string.Empty;
@@ -581,7 +588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|| codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1;
}
- public bool IsH265(MediaStream stream)
+ public static bool IsH265(MediaStream stream)
{
var codec = stream.Codec ?? string.Empty;
@@ -589,10 +596,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|| codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1;
}
- // TODO This is auto inserted into the mpegts mux so it might not be needed
- // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
- public string GetBitStreamArgs(MediaStream stream)
+ public static bool IsAAC(MediaStream stream)
+ {
+ var codec = stream.Codec ?? string.Empty;
+
+ return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1;
+ }
+
+ public static string GetBitStreamArgs(MediaStream stream)
{
+ // TODO This is auto inserted into the mpegts mux so it might not be needed.
+ // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
if (IsH264(stream))
{
return "-bsf:v h264_mp4toannexb";
@@ -601,12 +615,44 @@ namespace MediaBrowser.Controller.MediaEncoding
{
return "-bsf:v hevc_mp4toannexb";
}
+ else if (IsAAC(stream))
+ {
+ // Convert adts header(mpegts) to asc header(mp4).
+ return "-bsf:a aac_adtstoasc";
+ }
else
{
return null;
}
}
+ public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer)
+ {
+ var bitStreamArgs = string.Empty;
+ var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.');
+
+ // Apply aac_adtstoasc bitstream filter when media source is in mpegts.
+ if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)
+ && (string.Equals(mediaSourceContainer, "mpegts", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)))
+ {
+ bitStreamArgs = GetBitStreamArgs(state.AudioStream);
+ bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs;
+ }
+
+ return bitStreamArgs;
+ }
+
+ public static string GetSegmentFileExtension(string segmentContainer)
+ {
+ if (!string.IsNullOrWhiteSpace(segmentContainer))
+ {
+ return "." + segmentContainer;
+ }
+
+ return ".ts";
+ }
+
public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
{
var bitrate = state.OutputVideoBitrate;
@@ -654,16 +700,30 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Empty;
}
- public string NormalizeTranscodingLevel(string videoCodec, string level)
+ public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
{
- // Clients may direct play higher than level 41, but there's no reason to transcode higher
- if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel)
- && requestLevel > 41
- && (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)))
+ if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel))
{
- return "41";
+ if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
+ {
+ // Transcode to level 5.0 and lower for maximum compatibility.
+ // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
+ // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
+ // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
+ if (requestLevel >= 150)
+ {
+ return "150";
+ }
+ }
+ else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ // Clients may direct play higher than level 41, but there's no reason to transcode higher.
+ if (requestLevel >= 41)
+ {
+ return "41";
+ }
+ }
}
return level;
@@ -766,6 +826,72 @@ namespace MediaBrowser.Controller.MediaEncoding
return null;
}
+ public string GetHlsVideoKeyFrameArguments(
+ EncodingJobInfo state,
+ string codec,
+ int segmentLength,
+ bool isEventPlaylist,
+ int? startNumber)
+ {
+ var args = string.Empty;
+ var gopArg = string.Empty;
+ var keyFrameArg = string.Empty;
+ if (isEventPlaylist)
+ {
+ keyFrameArg = string.Format(
+ CultureInfo.InvariantCulture,
+ " -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"",
+ segmentLength);
+ }
+ else if (startNumber.HasValue)
+ {
+ keyFrameArg = string.Format(
+ CultureInfo.InvariantCulture,
+ " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
+ startNumber.Value * segmentLength,
+ segmentLength);
+ }
+
+ var framerate = state.VideoStream?.RealFrameRate;
+ if (framerate.HasValue)
+ {
+ // This is to make sure keyframe interval is limited to our segment,
+ // as forcing keyframes is not enough.
+ // Example: we encoded half of desired length, then codec detected
+ // scene cut and inserted a keyframe; next forced keyframe would
+ // be created outside of segment, which breaks seeking.
+ // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
+ gopArg = string.Format(
+ CultureInfo.InvariantCulture,
+ " -g:v:0 {0} -keyint_min:v:0 {0} -sc_threshold:v:0 0",
+ Math.Ceiling(segmentLength * framerate.Value));
+ }
+
+ // Unable to force key frames using these encoders, set key frames by GOP.
+ if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ args += gopArg;
+ }
+ else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
+ {
+ args += " " + keyFrameArg;
+ }
+ else
+ {
+ args += " " + keyFrameArg + gopArg;
+ }
+
+ return args;
+ }
+
/// <summary>
/// Gets the video bitrate to specify on the command line.
/// </summary>
@@ -773,6 +899,47 @@ namespace MediaBrowser.Controller.MediaEncoding
{
var param = string.Empty;
+ if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -pix_fmt yuv420p";
+ }
+
+ if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ var videoStream = state.VideoStream;
+ var isColorDepth10 = IsColorDepth10(state);
+
+ if (isColorDepth10
+ && _mediaEncoder.SupportsHwaccel("opencl")
+ && encodingOptions.EnableTonemapping
+ && !string.IsNullOrEmpty(videoStream.VideoRange)
+ && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -pix_fmt nv12";
+ }
+ else
+ {
+ param += " -pix_fmt yuv420p";
+ }
+ }
+
+ if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -pix_fmt nv21";
+ }
+
var isVc1 = state.VideoStream != null &&
string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase);
var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase);
@@ -781,11 +948,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset))
{
- param += "-preset " + encodingOptions.EncoderPreset;
+ param += " -preset " + encodingOptions.EncoderPreset;
}
else
{
- param += "-preset " + defaultPreset;
+ param += " -preset " + defaultPreset;
}
int encodeCrf = encodingOptions.H264Crf;
@@ -809,38 +976,40 @@ namespace MediaBrowser.Controller.MediaEncoding
param += " -crf " + defaultCrf;
}
}
- else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) // h264 (h264_qsv)
+ else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv)
+ || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_qsv)
{
string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" };
if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase))
{
- param += "-preset " + encodingOptions.EncoderPreset;
+ param += " -preset " + encodingOptions.EncoderPreset;
}
else
{
- param += "-preset 7";
+ param += " -preset 7";
}
param += " -look_ahead 0";
}
else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc)
- || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc)
{
+ // following preset will be deprecated in ffmpeg 4.4, use p1~p7 instead.
switch (encodingOptions.EncoderPreset)
{
case "veryslow":
- param += "-preset slow"; // lossless is only supported on maxwell and newer(2014+)
+ param += " -preset slow"; // lossless is only supported on maxwell and newer(2014+)
break;
case "slow":
case "slower":
- param += "-preset slow";
+ param += " -preset slow";
break;
case "medium":
- param += "-preset medium";
+ param += " -preset medium";
break;
case "fast":
@@ -848,27 +1017,27 @@ namespace MediaBrowser.Controller.MediaEncoding
case "veryfast":
case "superfast":
case "ultrafast":
- param += "-preset fast";
+ param += " -preset fast";
break;
default:
- param += "-preset default";
+ param += " -preset default";
break;
}
}
- else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf)
+ || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_amf)
{
switch (encodingOptions.EncoderPreset)
{
case "veryslow":
case "slow":
case "slower":
- param += "-quality quality";
+ param += " -quality quality";
break;
case "medium":
- param += "-quality balanced";
+ param += " -quality balanced";
break;
case "fast":
@@ -876,11 +1045,11 @@ namespace MediaBrowser.Controller.MediaEncoding
case "veryfast":
case "superfast":
case "ultrafast":
- param += "-quality speed";
+ param += " -quality speed";
break;
default:
- param += "-quality speed";
+ param += " -quality speed";
break;
}
@@ -896,6 +1065,11 @@ namespace MediaBrowser.Controller.MediaEncoding
// Enhance workload when tone mapping with AMF on some APUs
param += " -preanalysis true";
}
+
+ if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -header_insertion_mode gop -gops_per_idr 1";
+ }
}
else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm
{
@@ -917,7 +1091,7 @@ namespace MediaBrowser.Controller.MediaEncoding
profileScore = Math.Min(profileScore, 2);
// http://www.webmproject.org/docs/encoder-parameters/
- param += string.Format(CultureInfo.InvariantCulture, "-speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}",
+ param += string.Format(CultureInfo.InvariantCulture, " -speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}",
profileScore.ToString(_usCulture),
crf,
qmin,
@@ -925,15 +1099,15 @@ namespace MediaBrowser.Controller.MediaEncoding
}
else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase))
{
- param += "-mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2";
+ param += " -mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2";
}
else if (string.Equals(videoEncoder, "wmv2", StringComparison.OrdinalIgnoreCase)) // asf/wmv
{
- param += "-qmin 2";
+ param += " -qmin 2";
}
else if (string.Equals(videoEncoder, "msmpeg4", StringComparison.OrdinalIgnoreCase))
{
- param += "-mbd 2";
+ param += " -mbd 2";
}
param += GetVideoBitrateParam(state, videoEncoder);
@@ -945,11 +1119,25 @@ namespace MediaBrowser.Controller.MediaEncoding
}
var targetVideoCodec = state.ActualOutputVideoCodec;
+ if (string.Equals(targetVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(targetVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ targetVideoCodec = "hevc";
+ }
var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault();
+ profile = Regex.Replace(profile, @"\s+", String.Empty);
- // vaapi does not support Baseline profile, force Constrained Baseline in this case,
- // which is compatible (and ugly)
+ // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile.
+ if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
+ && profile != null
+ && profile.IndexOf("high 10", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ profile = "high";
+ }
+
+ // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case,
+ // which is compatible (and ugly).
if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
&& profile != null
&& profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
@@ -957,13 +1145,31 @@ namespace MediaBrowser.Controller.MediaEncoding
profile = "constrained_baseline";
}
+ // libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case.
+ if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase))
+ && profile != null
+ && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ profile = "baseline";
+ }
+
+ // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile.
+ if (!string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)
+ && profile != null
+ && profile.IndexOf("main 10", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ profile = "main";
+ }
+
if (!string.IsNullOrEmpty(profile))
{
if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
{
// not supported by h264_omx
- param += " -profile:v " + profile;
+ param += " -profile:v:0 " + profile;
}
}
@@ -971,55 +1177,35 @@ namespace MediaBrowser.Controller.MediaEncoding
if (!string.IsNullOrEmpty(level))
{
- level = NormalizeTranscodingLevel(state.OutputVideoCodec, level);
+ level = NormalizeTranscodingLevel(state, level);
- // h264_qsv and h264_nvenc expect levels to be expressed as a decimal. libx264 supports decimal and non-decimal format
- // also needed for libx264 due to https://trac.ffmpeg.org/ticket/3307
+ // libx264, QSV, AMF, VAAPI can adjust the given level to match the output.
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -level " + level;
+ }
+ else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
{
- switch (level)
+ // 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_amf", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -level " + level;
+ }
else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
{
- // nvenc doesn't decode with param -level set ?!
- // TODO:
+ // 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))
+ else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
{
param += " -level " + level;
}
@@ -1032,42 +1218,11 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
{
- // todo
- }
-
- if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
- {
- param = "-pix_fmt yuv420p " + param;
- }
-
- if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase))
- {
- var videoStream = state.VideoStream;
- var isColorDepth10 = IsColorDepth10(state);
-
- if (isColorDepth10
- && _mediaEncoder.SupportsHwaccel("opencl")
- && encodingOptions.EnableTonemapping
- && !string.IsNullOrEmpty(videoStream.VideoRange)
- && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
- {
- param = "-pix_fmt nv12 " + param;
- }
- else
- {
- param = "-pix_fmt yuv420p " + param;
- }
- }
-
- if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
- {
- param = "-pix_fmt nv21 " + param;
+ // libx265 only accept level option in -x265-params.
+ // level option may cause libx265 to fail.
+ // libx265 cannot adjust the given level, just throw an error.
+ // TODO: set fine tuned params.
+ param += " -x265-params:0 no-info=1";
}
return param;
@@ -1346,7 +1501,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
{
- return .5;
+ return .6;
}
return 1;
@@ -1380,36 +1535,48 @@ namespace MediaBrowser.Controller.MediaEncoding
public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream)
{
- if (audioStream == null)
- {
- return null;
- }
-
- if (request.AudioBitRate.HasValue)
- {
- // Don't encode any higher than this
- return Math.Min(384000, request.AudioBitRate.Value);
- }
-
- // Empty bitrate area is not allow on iOS
- // Default audio bitrate to 128K if it is not being requested
- // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
- return 128000;
+ return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream);
}
- public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream)
+ public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream)
{
if (audioStream == null)
{
return null;
}
- if (audioBitRate.HasValue)
+ if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec))
{
- // Don't encode any higher than this
return Math.Min(384000, audioBitRate.Value);
}
+ if (audioBitRate.HasValue && !string.IsNullOrEmpty(audioCodec))
+ {
+ if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioStream.Channels ?? 0) >= 6)
+ {
+ return Math.Min(640000, audioBitRate.Value);
+ }
+
+ return Math.Min(384000, audioBitRate.Value);
+ }
+
+ if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioStream.Channels ?? 0) >= 6)
+ {
+ return Math.Min(3584000, audioBitRate.Value);
+ }
+
+ return Math.Min(1536000, audioBitRate.Value);
+ }
+ }
+
// Empty bitrate area is not allow on iOS
// Default audio bitrate to 128K if it is not being requested
// https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
@@ -1447,7 +1614,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (filters.Count > 0)
{
- return "-af \"" + string.Join(",", filters) + "\"";
+ return " -af \"" + string.Join(",", filters) + "\"";
}
return string.Empty;
@@ -1462,6 +1629,11 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <returns>System.Nullable{System.Int32}.</returns>
public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec)
{
+ if (audioStream == null)
+ {
+ return null;
+ }
+
var request = state.BaseRequest;
var inputChannels = audioStream?.Channels;
@@ -1484,6 +1656,11 @@ namespace MediaBrowser.Controller.MediaEncoding
// libmp3lame currently only supports two channel output
transcoderChannelLimit = 2;
}
+ else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ // aac is able to handle 8ch(7.1 layout)
+ transcoderChannelLimit = 8;
+ }
else
{
// If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels
@@ -1708,7 +1885,8 @@ namespace MediaBrowser.Controller.MediaEncoding
}
// For QSV, feed it into hardware encoder now
- if (isLinux && string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+ if (isLinux && (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)))
{
videoSizeParam += ",hwupload=extra_hw_frames=64";
}
@@ -1729,7 +1907,8 @@ namespace MediaBrowser.Controller.MediaEncoding
: " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\"";
// When the input may or may not be hardware VAAPI decodable
- if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
{
/*
[base]: HW scaling video to OutputSize
@@ -1741,7 +1920,8 @@ namespace MediaBrowser.Controller.MediaEncoding
// If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1
- && string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase))
+ && (string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "libx265", StringComparison.OrdinalIgnoreCase)))
{
/*
[base]: SW scaling video to OutputSize
@@ -1750,7 +1930,8 @@ namespace MediaBrowser.Controller.MediaEncoding
*/
retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\"";
}
- else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
{
/*
QSV in FFMpeg can now setup hardware overlay for transcodes.
@@ -1776,7 +1957,7 @@ namespace MediaBrowser.Controller.MediaEncoding
videoSizeParam);
}
- private (int? width, int? height) GetFixedOutputSize(
+ public static (int? width, int? height) GetFixedOutputSize(
int? videoWidth,
int? videoHeight,
int? requestedWidth,
@@ -1836,7 +2017,9 @@ namespace MediaBrowser.Controller.MediaEncoding
requestedMaxHeight);
if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
&& width.HasValue
&& height.HasValue)
{
@@ -1845,7 +2028,8 @@ namespace MediaBrowser.Controller.MediaEncoding
// output dimensions. Output dimensions are guaranteed to be even.
var outputWidth = width.Value;
var outputHeight = height.Value;
- var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase);
+ var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase);
var isDeintEnabled = state.DeInterlace("h264", true)
|| state.DeInterlace("avc", true)
|| state.DeInterlace("h265", true)
@@ -2107,10 +2291,13 @@ namespace MediaBrowser.Controller.MediaEncoding
var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+ var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1;
+ var isQsvHevcEncoder = outputVideoCodec.IndexOf("hevc_qsv", StringComparison.OrdinalIgnoreCase) != -1;
var isNvdecH264Decoder = videoDecoder.IndexOf("h264_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1;
+ var isLibX265Encoder = outputVideoCodec.IndexOf("libx265", StringComparison.OrdinalIgnoreCase) != -1;
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
var isColorDepth10 = IsColorDepth10(state);
@@ -2185,6 +2372,7 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.Add("hwdownload");
if (isLibX264Encoder
+ || isLibX265Encoder
|| hasGraphicalSubs
|| (isNvdecHevcDecoder && isDeinterlaceHevc)
|| (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc))
@@ -2195,20 +2383,20 @@ namespace MediaBrowser.Controller.MediaEncoding
}
// When the input may or may not be hardware VAAPI decodable
- if (isVaapiH264Encoder)
+ if (isVaapiH264Encoder || isVaapiHevcEncoder)
{
filters.Add("format=nv12|vaapi");
filters.Add("hwupload");
}
// When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context
- else if (isLinux && hasGraphicalSubs && isQsvH264Encoder)
+ else if (isLinux && hasGraphicalSubs && (isQsvH264Encoder || isQsvHevcEncoder))
{
filters.Add("hwupload=extra_hw_frames=64");
}
// If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
- else if (IsVaapiSupported(state) && isVaapiDecoder && isLibX264Encoder)
+ else if (IsVaapiSupported(state) && isVaapiDecoder && (isLibX264Encoder || isLibX265Encoder))
{
var codec = videoStream.Codec.ToLowerInvariant();
@@ -2250,7 +2438,9 @@ namespace MediaBrowser.Controller.MediaEncoding
// Add software deinterlace filter before scaling filter
if ((isDeinterlaceH264 || isDeinterlaceHevc)
&& !isVaapiH264Encoder
+ && !isVaapiHevcEncoder
&& !isQsvH264Encoder
+ && !isQsvHevcEncoder
&& !isNvdecH264Decoder)
{
if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase))
@@ -2289,7 +2479,7 @@ namespace MediaBrowser.Controller.MediaEncoding
}
// Add parameters to use VAAPI with burn-in text subtitles (GH issue #642)
- if (isVaapiH264Encoder)
+ if (isVaapiH264Encoder || isVaapiHevcEncoder)
{
if (hasTextSubs)
{
@@ -2329,7 +2519,8 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <summary>
/// Gets the number of threads.
/// </summary>
- public int GetNumberOfThreads(EncodingJobInfo state, EncodingOptions encodingOptions, string outputVideoCodec)
+#nullable enable
+ public static int GetNumberOfThreads(EncodingJobInfo? state, EncodingOptions encodingOptions, string? outputVideoCodec)
{
if (string.Equals(outputVideoCodec, "libvpx", StringComparison.OrdinalIgnoreCase))
{
@@ -2339,17 +2530,21 @@ namespace MediaBrowser.Controller.MediaEncoding
return Math.Max(Environment.ProcessorCount - 1, 1);
}
- var threads = state.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount;
+ var threads = state?.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount;
// Automatic
- if (threads <= 0 || threads >= Environment.ProcessorCount)
+ if (threads <= 0)
{
return 0;
+ }
+ else if (threads >= Environment.ProcessorCount)
+ {
+ return Environment.ProcessorCount;
}
return threads;
}
-
+#nullable disable
public void TryStreamCopy(EncodingJobInfo state)
{
if (state.VideoStream != null && CanStreamCopyVideo(state, state.VideoStream))
@@ -2557,6 +2752,7 @@ namespace MediaBrowser.Controller.MediaEncoding
public void AttachMediaSourceInfo(
EncodingJobInfo state,
+ EncodingOptions encodingOptions,
MediaSourceInfo mediaSource,
string requestedUrl)
{
@@ -2687,11 +2883,23 @@ namespace MediaBrowser.Controller.MediaEncoding
request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i))
?? state.SupportedAudioCodecs.FirstOrDefault();
}
+
+ var supportedVideoCodecs = state.SupportedVideoCodecs;
+ if (request != null && supportedVideoCodecs != null && supportedVideoCodecs.Length > 0)
+ {
+ var supportedVideoCodecsList = supportedVideoCodecs.ToList();
+
+ ShiftVideoCodecsIfNeeded(supportedVideoCodecsList, encodingOptions);
+
+ state.SupportedVideoCodecs = supportedVideoCodecsList.ToArray();
+
+ request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
+ }
}
private void ShiftAudioCodecsIfNeeded(List<string> audioCodecs, MediaStream audioStream)
{
- // Nothing to do here
+ // No need to shift if there is only one supported audio codec.
if (audioCodecs.Count < 2)
{
return;
@@ -2719,6 +2927,34 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
+ private void ShiftVideoCodecsIfNeeded(List<string> videoCodecs, EncodingOptions encodingOptions)
+ {
+ // Shift hevc/h265 to the end of list if hevc encoding is not allowed.
+ if (encodingOptions.AllowHevcEncoding)
+ {
+ return;
+ }
+
+ // No need to shift if there is only one supported video codec.
+ if (videoCodecs.Count < 2)
+ {
+ return;
+ }
+
+ var shiftVideoCodecs = new[] { "hevc", "h265" };
+ if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparer.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
+ while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparer.OrdinalIgnoreCase))
+ {
+ var removed = shiftVideoCodecs[0];
+ videoCodecs.RemoveAt(0);
+ videoCodecs.Add(removed);
+ }
+ }
+
private void NormalizeSubtitleEmbed(EncodingJobInfo state)
{
if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed)
@@ -2752,7 +2988,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var videoType = state.MediaSource.VideoType ?? VideoType.VideoFile;
// Only use alternative encoders for video files.
// When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully
- // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this.
+ // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this.
if (videoType != VideoType.VideoFile)
{
return null;
@@ -3352,7 +3588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture);
}
- args += " " + GetAudioFilterParam(state, encodingOptions, false);
+ args += GetAudioFilterParam(state, encodingOptions, false);
return args;
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index db72fa56c..52794a69b 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -593,6 +593,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{
get
{
+ if (VideoStream == null)
+ {
+ return null;
+ }
+
if (EncodingHelper.IsCopyCodec(OutputVideoCodec))
{
return VideoStream?.Codec;
@@ -606,6 +611,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{
get
{
+ if (AudioStream == null)
+ {
+ return null;
+ }
+
if (EncodingHelper.IsCopyCodec(OutputAudioCodec))
{
return AudioStream?.Codec;
diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
index fbf2c5213..f6c592070 100644
--- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
+++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
@@ -31,7 +31,7 @@ namespace MediaBrowser.Controller.Playlists
/// <param name="itemIds">The item ids.</param>
/// <param name="userId">The user identifier.</param>
/// <returns>Task.</returns>
- Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId);
+ Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
/// <summary>
/// Removes from playlist.
diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs
index ce58a60b9..d09852870 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.
/// </summary>
/// <value>The playable media types.</value>
- public string[] PlayableMediaTypes
+ public IReadOnlyList<string> PlayableMediaTypes
{
get
{
@@ -230,7 +231,7 @@ namespace MediaBrowser.Controller.Session
/// Gets or sets the supported commands.
/// </summary>
/// <value>The supported commands.</value>
- public GeneralCommandType[] SupportedCommands
+ public IReadOnlyList<GeneralCommandType> SupportedCommands
=> Capabilities == null ? Array.Empty<GeneralCommandType>() : Capabilities.SupportedCommands;
public Tuple<ISessionController, bool> EnsureController<T>(Func<SessionInfo, ISessionController> factory)
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 5a3a9185d..5f60c09ae 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -64,6 +64,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
private string _ffmpegPath = string.Empty;
private string _ffprobePath;
+ private int threads;
public MediaEncoder(
ILogger<MediaEncoder> logger,
@@ -129,6 +130,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
SetAvailableDecoders(validator.GetDecoders());
SetAvailableEncoders(validator.GetEncoders());
SetAvailableHwaccels(validator.GetHwaccels());
+ threads = EncodingHelper.GetNumberOfThreads(null, _configurationManager.GetEncodingOptions(), null);
}
_logger.LogInformation("FFmpeg: {EncoderLocation}: {FfmpegPath}", EncoderLocation, _ffmpegPath ?? string.Empty);
@@ -377,9 +379,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
CancellationToken cancellationToken)
{
var args = extractChapters
- ? "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_chapters -show_format"
- : "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_format";
- args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath).Trim();
+ ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format"
+ : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format";
+ args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath, threads).Trim();
var process = new Process
{
@@ -520,29 +522,29 @@ namespace MediaBrowser.MediaEncoding.Encoder
var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".jpg");
Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath));
- // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar then scale to width 600.
+ // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar.
// This filter chain may have adverse effects on recorded tv thumbnails if ar changes during presentation ex. commercials @ diff ar
- var vf = "scale=600:trunc(600/dar/2)*2";
+ var vf = string.Empty;
if (threedFormat.HasValue)
{
switch (threedFormat.Value)
{
case Video3DFormat.HalfSideBySide:
- vf = "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not.
+ vf = "-vf crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not.
break;
case Video3DFormat.FullSideBySide:
- vf = "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // fsbs crop width in half,set the display aspect,crop out any black bars we may have made the scale width to 600.
+ vf = "-vf crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // fsbs crop width in half,set the display aspect,crop out any black bars we may have made
break;
case Video3DFormat.HalfTopAndBottom:
- vf = "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600
+ vf = "-vf crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made
break;
case Video3DFormat.FullTopAndBottom:
- vf = "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made the scale width to 600
+ vf = "-vf crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made
break;
default:
break;
@@ -555,8 +557,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
// Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case.
var thumbnail = enableThumbnail ? ",thumbnail=24" : string.Empty;
- var args = useIFrame ? string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}{4}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, thumbnail) :
- string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg);
+ var args = useIFrame ? string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {5} -v quiet -vframes 1 {2}{4} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, thumbnail, threads) :
+ string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, threads);
var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1);
var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1);
@@ -693,7 +695,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
Directory.CreateDirectory(targetDirectory);
var outputPath = Path.Combine(targetDirectory, filenamePrefix + "%05d.jpg");
- var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads 0 -v quiet -vf \"{2}\" -f image2 \"{1}\"", inputArgument, outputPath, vf);
+ var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads {3} -v quiet {2} -f image2 \"{1}\"", inputArgument, outputPath, vf, threads);
var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1);
var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1);
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 15a70e2e7..3d3d1eb48 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -234,8 +234,8 @@ namespace MediaBrowser.MediaEncoding.Probing
var channelsValue = channels.Value;
- if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
{
if (channelsValue <= 2)
{
@@ -248,6 +248,34 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
+ if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase))
+ {
+ if (channelsValue <= 2)
+ {
+ return 192000;
+ }
+
+ if (channelsValue >= 5)
+ {
+ return 640000;
+ }
+ }
+
+ if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ if (channelsValue <= 2)
+ {
+ return 960000;
+ }
+
+ if (channelsValue >= 5)
+ {
+ return 2880000;
+ }
+ }
+
return null;
}
@@ -774,6 +802,35 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.BitRate = bitrate;
}
+ // Extract bitrate info from tag "BPS" if possible.
+ if (!stream.BitRate.HasValue
+ && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)))
+ {
+ var bps = GetBPSFromTags(streamInfo);
+ if (bps != null && bps > 0)
+ {
+ stream.BitRate = bps;
+ }
+ }
+
+ // Get average bitrate info from tag "NUMBER_OF_BYTES" and "DURATION" if possible.
+ if (!stream.BitRate.HasValue
+ && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)))
+ {
+ var durationInSeconds = GetRuntimeSecondsFromTags(streamInfo);
+ var bytes = GetNumberOfBytesFromTags(streamInfo);
+ if (durationInSeconds != null && bytes != null)
+ {
+ var bps = Convert.ToInt32(bytes * 8 / durationInSeconds, CultureInfo.InvariantCulture);
+ if (bps > 0)
+ {
+ stream.BitRate = bps;
+ }
+ }
+ }
+
var disposition = streamInfo.Disposition;
if (disposition != null)
{
@@ -963,6 +1020,50 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
+ private int? GetBPSFromTags(MediaStreamInfo streamInfo)
+ {
+ if (streamInfo != null && streamInfo.Tags != null)
+ {
+ var bps = GetDictionaryValue(streamInfo.Tags, "BPS-eng") ?? GetDictionaryValue(streamInfo.Tags, "BPS");
+ if (!string.IsNullOrEmpty(bps)
+ && int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps))
+ {
+ return parsedBps;
+ }
+ }
+
+ return null;
+ }
+
+ private double? GetRuntimeSecondsFromTags(MediaStreamInfo streamInfo)
+ {
+ if (streamInfo != null && streamInfo.Tags != null)
+ {
+ var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION");
+ if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration))
+ {
+ return parsedDuration.TotalSeconds;
+ }
+ }
+
+ return null;
+ }
+
+ private long? GetNumberOfBytesFromTags(MediaStreamInfo streamInfo)
+ {
+ if (streamInfo != null && streamInfo.Tags != null)
+ {
+ var numberOfBytes = GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES-eng") ?? GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES");
+ if (!string.IsNullOrEmpty(numberOfBytes)
+ && long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes))
+ {
+ return parsedBytes;
+ }
+ }
+
+ return null;
+ }
+
private void SetSize(InternalMediaInfoResult data, MediaInfo info)
{
if (data.Format != null)
diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs
index c34825667..100756c24 100644
--- a/MediaBrowser.Model/Configuration/EncodingOptions.cs
+++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs
@@ -67,6 +67,8 @@ namespace MediaBrowser.Model.Configuration
public bool EnableHardwareEncoding { get; set; }
+ public bool AllowHevcEncoding { get; set; }
+
public bool EnableSubtitleExtraction { get; set; }
public string[] HardwareDecodingCodecs { get; set; }
@@ -99,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/PathSubstitution.cs b/MediaBrowser.Model/Configuration/PathSubstitution.cs
new file mode 100644
index 000000000..bffaa8594
--- /dev/null
+++ b/MediaBrowser.Model/Configuration/PathSubstitution.cs
@@ -0,0 +1,20 @@
+#nullable enable
+
+namespace MediaBrowser.Model.Configuration
+{
+ /// <summary>
+ /// Defines the <see cref="PathSubstitution" />.
+ /// </summary>
+ public class PathSubstitution
+ {
+ /// <summary>
+ /// Gets or sets the value to substitute.
+ /// </summary>
+ public string From { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the value to substitution with.
+ /// </summary>
+ public string To { get; set; } = string.Empty;
+ }
+}
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index 23a5201f7..06985ebf4 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,43 +13,194 @@ namespace MediaBrowser.Model.Configuration
/// </summary>
public class ServerConfiguration : BaseApplicationConfiguration
{
+ /// <summary>
+ /// The default value for <see cref="HttpServerPortNumber"/>.
+ /// </summary>
public const int DefaultHttpPort = 8096;
+
+ /// <summary>
+ /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>.
+ /// </summary>
public const int DefaultHttpsPort = 8920;
- private string _baseUrl;
+
+ private string _baseUrl = string.Empty;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ServerConfiguration" /> class.
+ /// </summary>
+ public ServerConfiguration()
+ {
+ MetadataOptions = new[]
+ {
+ new MetadataOptions()
+ {
+ ItemType = "Book"
+ },
+ new MetadataOptions()
+ {
+ ItemType = "Movie"
+ },
+ new MetadataOptions
+ {
+ ItemType = "MusicVideo",
+ DisabledMetadataFetchers = new[] { "The Open Movie Database" },
+ DisabledImageFetchers = new[] { "The Open Movie Database" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "Series",
+ DisabledMetadataFetchers = new[] { "TheMovieDb" },
+ DisabledImageFetchers = new[] { "TheMovieDb" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "MusicAlbum",
+ DisabledMetadataFetchers = new[] { "TheAudioDB" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "MusicArtist",
+ DisabledMetadataFetchers = new[] { "TheAudioDB" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "BoxSet"
+ },
+ new MetadataOptions
+ {
+ ItemType = "Season",
+ DisabledMetadataFetchers = new[] { "TheMovieDb" },
+ },
+ new MetadataOptions
+ {
+ ItemType = "Episode",
+ DisabledMetadataFetchers = new[] { "The Open Movie Database", "TheMovieDb" },
+ DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" }
+ }
+ };
+ }
/// <summary>
/// Gets or sets a value indicating whether to enable automatic port forwarding.
/// </summary>
- public bool EnableUPnP { get; set; }
+ public bool EnableUPnP { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to enable prometheus metrics exporting.
/// </summary>
- public bool EnableMetrics { get; set; }
+ public bool EnableMetrics { get; set; } = false;
/// <summary>
/// Gets or sets the public mapped port.
/// </summary>
/// <value>The public mapped port.</value>
- public int PublicPort { get; set; }
+ public int PublicPort { get; set; } = DefaultHttpPort;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
+ /// </summary>
+ public bool UPnPCreateHttpPortMap { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets client udp port range.
+ /// </summary>
+ public string UDPPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IPV6 capability is enabled.
+ /// </summary>
+ public bool EnableIPV6 { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IPV4 capability is enabled.
+ /// </summary>
+ public bool EnableIPV4 { get; set; } = true;
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public bool EnableSSDPTracing { get; set; } = false;
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public string SSDPTracingFilter { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the number of times SSDP UDP messages are sent.
+ /// </summary>
+ public int UDPSendCount { get; set; } = 2;
+
+ /// <summary>
+ /// Gets or sets the delay between each groups of SSDP messages (in ms).
+ /// </summary>
+ public int UDPSendDelay { get; set; } = 100;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding.
+ /// </summary>
+ public bool IgnoreVirtualInterfaces { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>.
+ /// </summary>
+ public string VirtualInterfaceNames { get; set; } = "vEthernet*";
+
+ /// <summary>
+ /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
+ /// </summary>
+ public int GatewayMonitorPeriod { get; set; } = 60;
+
+ /// <summary>
+ /// Gets a value indicating whether multi-socket binding is available.
+ /// </summary>
+ public bool EnableMultiSocketBinding { get; } = true;
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ public bool TrustAllIP6Interfaces { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets the ports that HDHomerun uses.
+ /// </summary>
+ public string HDHomerunPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets PublishedServerUri to advertise for specific subnets.
+ /// </summary>
+ public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
+ /// </summary>
+ public bool AutoDiscoveryTracing { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery is enabled.
+ /// </summary>
+ public bool AutoDiscovery { get; set; } = true;
/// <summary>
/// Gets or sets the public HTTPS port.
/// </summary>
/// <value>The public HTTPS port.</value>
- public int PublicHttpsPort { get; set; }
+ public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
/// <summary>
/// Gets or sets the HTTP server port number.
/// </summary>
/// <value>The HTTP server port number.</value>
- public int HttpServerPortNumber { get; set; }
+ public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
/// <summary>
/// Gets or sets the HTTPS server port number.
/// </summary>
/// <value>The HTTPS server port number.</value>
- public int HttpsPortNumber { get; set; }
+ public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
/// <summary>
/// Gets or sets a value indicating whether to use HTTPS.
@@ -58,19 +209,19 @@ namespace MediaBrowser.Model.Configuration
/// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
/// provided for <see cref="CertificatePath"/> and <see cref="CertificatePassword"/>.
/// </remarks>
- public bool EnableHttps { get; set; }
+ public bool EnableHttps { get; set; } = false;
- public bool EnableNormalizedItemByNameIds { get; set; }
+ public bool EnableNormalizedItemByNameIds { get; set; } = true;
/// <summary>
/// Gets or sets the filesystem path of an X.509 certificate to use for SSL.
/// </summary>
- public string CertificatePath { get; set; }
+ public string CertificatePath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>.
/// </summary>
- public string CertificatePassword { get; set; }
+ public string CertificatePassword { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether this instance is port authorized.
@@ -79,90 +230,93 @@ namespace MediaBrowser.Model.Configuration
public bool IsPortAuthorized { get; set; }
/// <summary>
- /// Gets or sets if quick connect is available for use on this server.
+ /// Gets or sets a value indicating whether quick connect is available for use on this server.
/// </summary>
- public bool QuickConnectAvailable { get; set; }
-
- public bool EnableRemoteAccess { get; set; }
+ public bool QuickConnectAvailable { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether access outside of the LAN is permitted.
+ /// </summary>
+ public bool EnableRemoteAccess { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether [enable case sensitive item ids].
/// </summary>
/// <value><c>true</c> if [enable case sensitive item ids]; otherwise, <c>false</c>.</value>
- public bool EnableCaseSensitiveItemIds { get; set; }
+ public bool EnableCaseSensitiveItemIds { get; set; } = true;
- public bool DisableLiveTvChannelUserDataName { get; set; }
+ public bool DisableLiveTvChannelUserDataName { get; set; } = true;
/// <summary>
/// Gets or sets the metadata path.
/// </summary>
/// <value>The metadata path.</value>
- public string MetadataPath { get; set; }
+ public string MetadataPath { get; set; } = string.Empty;
- public string MetadataNetworkPath { get; set; }
+ public string MetadataNetworkPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the preferred metadata language.
/// </summary>
/// <value>The preferred metadata language.</value>
- public string PreferredMetadataLanguage { get; set; }
+ public string PreferredMetadataLanguage { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the metadata country code.
/// </summary>
/// <value>The metadata country code.</value>
- public string MetadataCountryCode { get; set; }
+ public string MetadataCountryCode { get; set; } = "US";
/// <summary>
- /// Characters to be replaced with a ' ' in strings to create a sort name.
+ /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name.
/// </summary>
/// <value>The sort replace characters.</value>
- public string[] SortReplaceCharacters { get; set; }
+ public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" };
/// <summary>
- /// Characters to be removed from strings to create a sort name.
+ /// Gets or sets characters to be removed from strings to create a sort name.
/// </summary>
/// <value>The sort remove characters.</value>
- public string[] SortRemoveCharacters { get; set; }
+ public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" };
/// <summary>
- /// Words to be removed from strings to create a sort name.
+ /// Gets or sets words to be removed from strings to create a sort name.
/// </summary>
/// <value>The sort remove words.</value>
- public string[] SortRemoveWords { get; set; }
+ public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" };
/// <summary>
/// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated.
/// </summary>
/// <value>The min resume PCT.</value>
- public int MinResumePct { get; set; }
+ public int MinResumePct { get; set; } = 5;
/// <summary>
/// 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.
/// </summary>
/// <value>The max resume PCT.</value>
- public int MaxResumePct { get; set; }
+ public int MaxResumePct { get; set; } = 90;
/// <summary>
/// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates..
/// </summary>
/// <value>The min resume duration seconds.</value>
- public int MinResumeDurationSeconds { get; set; }
+ public int MinResumeDurationSeconds { get; set; } = 300;
/// <summary>
- /// The delay in seconds that we will wait after a file system change to try and discover what has been added/removed
+ /// 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.
/// </summary>
/// <value>The file watcher delay.</value>
- public int LibraryMonitorDelay { get; set; }
+ public int LibraryMonitorDelay { get; set; } = 60;
/// <summary>
/// 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.
/// </summary>
/// <value><c>true</c> if [enable dashboard response caching]; otherwise, <c>false</c>.</value>
- public bool EnableDashboardResponseCaching { get; set; }
+ public bool EnableDashboardResponseCaching { get; set; } = true;
/// <summary>
/// Gets or sets the image saving convention.
@@ -172,9 +326,9 @@ namespace MediaBrowser.Model.Configuration
public MetadataOptions[] MetadataOptions { get; set; }
- public bool SkipDeserializationForBasicTypes { get; set; }
+ public bool SkipDeserializationForBasicTypes { get; set; } = true;
- public string ServerName { get; set; }
+ public string ServerName { get; set; } = string.Empty;
public string BaseUrl
{
@@ -206,194 +360,84 @@ namespace MediaBrowser.Model.Configuration
}
}
- public string UICulture { get; set; }
-
- public bool SaveMetadataHidden { get; set; }
+ public string UICulture { get; set; } = "en-US";
- public NameValuePair[] ContentTypes { get; set; }
+ public bool SaveMetadataHidden { get; set; } = false;
- public int RemoteClientBitrateLimit { get; set; }
+ public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>();
- public bool EnableFolderView { get; set; }
+ public int RemoteClientBitrateLimit { get; set; } = 0;
- public bool EnableGroupingIntoCollections { get; set; }
+ public bool EnableFolderView { get; set; } = false;
- public bool DisplaySpecialsWithinSeasons { get; set; }
+ public bool EnableGroupingIntoCollections { get; set; } = false;
- public string[] LocalNetworkSubnets { get; set; }
+ public bool DisplaySpecialsWithinSeasons { get; set; } = true;
- public string[] LocalNetworkAddresses { get; set; }
+ /// <summary>
+ /// Gets or sets the subnets that are deemed to make up the LAN.
+ /// </summary>
+ public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>();
- public string[] CodecsUsed { get; set; }
+ /// <summary>
+ /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
+ /// </summary>
+ public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>();
- public List<RepositoryInfo> PluginRepositories { get; set; }
+ public string[] CodecsUsed { get; set; } = Array.Empty<string>();
- public bool IgnoreVirtualInterfaces { get; set; }
+ public List<RepositoryInfo> PluginRepositories { get; set; } = new List<RepositoryInfo>();
- public bool EnableExternalContentInSuggestions { get; set; }
+ public bool EnableExternalContentInSuggestions { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the server should force connections over HTTPS.
/// </summary>
- public bool RequireHttps { get; set; }
+ public bool RequireHttps { get; set; } = false;
- public bool EnableNewOmdbSupport { get; set; }
+ public bool EnableNewOmdbSupport { get; set; } = true;
- public string[] RemoteIPFilter { get; set; }
+ /// <summary>
+ /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>.
+ /// </summary>
+ public string[] RemoteIPFilter { get; set; } = Array.Empty<string>();
- public bool IsRemoteIPFilterBlacklist { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist.
+ /// </summary>
+ public bool IsRemoteIPFilterBlacklist { get; set; } = false;
- public int ImageExtractionTimeoutMs { get; set; }
+ public int ImageExtractionTimeoutMs { get; set; } = 0;
- public PathSubstitution[] PathSubstitutions { get; set; }
+ public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>();
- public bool EnableSimpleArtistDetection { get; set; }
+ public bool EnableSimpleArtistDetection { get; set; } = false;
- public string[] UninstalledPlugins { get; set; }
+ public string[] UninstalledPlugins { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets a value indicating whether slow server responses should be logged as a warning.
/// </summary>
- public bool EnableSlowResponseWarning { get; set; }
+ public bool EnableSlowResponseWarning { get; set; } = true;
/// <summary>
/// Gets or sets the threshold for the slow response time warning in ms.
/// </summary>
- public long SlowResponseThresholdMs { get; set; }
+ public long SlowResponseThresholdMs { get; set; } = 500;
/// <summary>
/// Gets or sets the cors hosts.
/// </summary>
- public string[] CorsHosts { get; set; }
+ public string[] CorsHosts { get; set; } = new[] { "*" };
/// <summary>
/// Gets or sets the known proxies.
/// </summary>
- public string[] KnownProxies { get; set; }
+ public string[] KnownProxies { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the number of days we should retain activity logs.
/// </summary>
- public int? ActivityLogRetentionDays { get; set; }
-
- /// <summary>
- /// Initializes a new instance of the <see cref="ServerConfiguration" /> class.
- /// </summary>
- public ServerConfiguration()
- {
- UninstalledPlugins = Array.Empty<string>();
- RemoteIPFilter = Array.Empty<string>();
- LocalNetworkSubnets = Array.Empty<string>();
- LocalNetworkAddresses = Array.Empty<string>();
- CodecsUsed = Array.Empty<string>();
- PathSubstitutions = Array.Empty<PathSubstitution>();
- IgnoreVirtualInterfaces = false;
- EnableSimpleArtistDetection = false;
- SkipDeserializationForBasicTypes = true;
-
- PluginRepositories = new List<RepositoryInfo>();
-
- 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<NameValuePair>();
-
- PreferredMetadataLanguage = "en";
- MetadataCountryCode = "US";
-
- SortReplaceCharacters = new[] { ".", "+", "%" };
- SortRemoveCharacters = new[] { ",", "&", "-", "{", "}", "'" };
- SortRemoveWords = new[] { "the", "a", "an" };
-
- BaseUrl = string.Empty;
- UICulture = "en-US";
-
- MetadataOptions = new[]
- {
- new MetadataOptions()
- {
- ItemType = "Book"
- },
- new MetadataOptions()
- {
- ItemType = "Movie"
- },
- new MetadataOptions
- {
- ItemType = "MusicVideo",
- DisabledMetadataFetchers = new[] { "The Open Movie Database" },
- DisabledImageFetchers = new[] { "The Open Movie Database" }
- },
- new MetadataOptions
- {
- ItemType = "Series",
- DisabledMetadataFetchers = new[] { "TheMovieDb" },
- DisabledImageFetchers = new[] { "TheMovieDb" }
- },
- new MetadataOptions
- {
- ItemType = "MusicAlbum",
- DisabledMetadataFetchers = new[] { "TheAudioDB" }
- },
- new MetadataOptions
- {
- ItemType = "MusicArtist",
- DisabledMetadataFetchers = new[] { "TheAudioDB" }
- },
- new MetadataOptions
- {
- ItemType = "BoxSet"
- },
- new MetadataOptions
- {
- ItemType = "Season",
- DisabledMetadataFetchers = new[] { "TheMovieDb" },
- },
- new MetadataOptions
- {
- ItemType = "Episode",
- DisabledMetadataFetchers = new[] { "The Open Movie Database", "TheMovieDb" },
- DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" }
- }
- };
-
- EnableSlowResponseWarning = true;
- SlowResponseThresholdMs = 500;
- CorsHosts = new[] { "*" };
- KnownProxies = Array.Empty<string>();
- ActivityLogRetentionDays = 30;
- }
- }
-
- public class PathSubstitution
- {
- public string From { get; set; }
-
- public string To { get; set; }
+ public int? ActivityLogRetentionDays { get; set; } = 30;
}
}
diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs
index a4305c810..65fccbdd4 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 43d1f3b44..59c981000 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -872,11 +872,34 @@ namespace MediaBrowser.Model.Dlna
return playlistItem;
}
- private static int GetDefaultAudioBitrateIfUnknown(MediaStream audioStream)
+ private static int GetDefaultAudioBitrate(string audioCodec, int? audioChannels)
{
- if ((audioStream.Channels ?? 0) >= 6)
+ if (!string.IsNullOrEmpty(audioCodec))
{
- return 384000;
+ // Default to a higher bitrate for stream copy
+ if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioChannels ?? 0) < 2)
+ {
+ return 128000;
+ }
+
+ return (audioChannels ?? 0) >= 6 ? 640000 : 384000;
+ }
+
+ if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioChannels ?? 0) < 2)
+ {
+ return 768000;
+ }
+
+ return (audioChannels ?? 0) >= 6 ? 3584000 : 1536000;
+ }
}
return 192000;
@@ -897,14 +920,27 @@ namespace MediaBrowser.Model.Dlna
}
else
{
- if (targetAudioChannels.HasValue && audioStream.Channels.HasValue && targetAudioChannels.Value < audioStream.Channels.Value)
+ if (targetAudioChannels.HasValue
+ && audioStream.Channels.HasValue
+ && audioStream.Channels.Value > targetAudioChannels.Value)
{
- // Reduce the bitrate if we're downmixing
- defaultBitrate = targetAudioChannels.Value < 2 ? 128000 : 192000;
+ // Reduce the bitrate if we're downmixing.
+ defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels);
+ }
+ else if (targetAudioChannels.HasValue
+ && audioStream.Channels.HasValue
+ && audioStream.Channels.Value <= targetAudioChannels.Value
+ && !string.IsNullOrEmpty(audioStream.Codec)
+ && targetAudioCodecs != null
+ && targetAudioCodecs.Length > 0
+ && !Array.Exists(targetAudioCodecs, elem => string.Equals(audioStream.Codec, elem, StringComparison.OrdinalIgnoreCase)))
+ {
+ // Shift the bitrate if we're transcoding to a different audio codec.
+ defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, audioStream.Channels.Value);
}
else
{
- defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrateIfUnknown(audioStream);
+ defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels);
}
// Seeing webm encoding failures when source has 1 audio channel and 22k bitrate.
@@ -938,8 +974,28 @@ namespace MediaBrowser.Model.Dlna
{
return 448000;
}
+ else if (totalBitrate <= 4000000)
+ {
+ return 640000;
+ }
+ else if (totalBitrate <= 5000000)
+ {
+ return 768000;
+ }
+ else if (totalBitrate <= 10000000)
+ {
+ return 1536000;
+ }
+ else if (totalBitrate <= 15000000)
+ {
+ return 2304000;
+ }
+ else if (totalBitrate <= 20000000)
+ {
+ return 3584000;
+ }
- return 640000;
+ return 7168000;
}
private (PlayMethod?, List<TranscodeReason>) GetVideoDirectPlayProfile(
diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs
index 93ea82c1c..55b12ae81 100644
--- a/MediaBrowser.Model/Dlna/StreamInfo.cs
+++ b/MediaBrowser.Model/Dlna/StreamInfo.cs
@@ -794,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/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
index ef435b21e..e8ee49403 100644
--- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
+++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
@@ -2,6 +2,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Generic;
namespace MediaBrowser.Model.Playlists
{
@@ -9,15 +10,10 @@ namespace MediaBrowser.Model.Playlists
{
public string Name { get; set; }
- public Guid[] ItemIdList { get; set; }
+ public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>();
public string MediaType { get; set; }
public Guid UserId { get; set; }
-
- public PlaylistCreationRequest()
- {
- ItemIdList = Array.Empty<Guid>();
- }
}
}
diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs
index a85e6ff2a..5852f4e37 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<string> PlayableMediaTypes { get; set; }
- public GeneralCommandType[] SupportedCommands { get; set; }
+ public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; }
public bool SupportsMediaControl { get; set; }
diff --git a/MediaBrowser.Model/Updates/PackageInfo.cs b/MediaBrowser.Model/Updates/PackageInfo.cs
index 98b151d55..5e9304363 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.
/// </summary>
/// <value>The versions.</value>
- public IReadOnlyList<VersionInfo> versions { get; set; }
-
- /// <summary>
- /// Gets or sets the repository name.
- /// </summary>
- public string repositoryName { get; set; }
-
- /// <summary>
- /// Gets or sets the repository url.
- /// </summary>
- public string repositoryUrl { get; set; }
+ public IList<VersionInfo> versions { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="PackageInfo"/> class.
diff --git a/MediaBrowser.Model/Updates/RepositoryInfo.cs b/MediaBrowser.Model/Updates/RepositoryInfo.cs
index bd42e77f0..705d3b33c 100644
--- a/MediaBrowser.Model/Updates/RepositoryInfo.cs
+++ b/MediaBrowser.Model/Updates/RepositoryInfo.cs
@@ -16,5 +16,11 @@ namespace MediaBrowser.Model.Updates
/// </summary>
/// <value>The URL.</value>
public string? Url { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the repository is enabled.
+ /// </summary>
+ /// <value><c>true</c> if enabled.</value>
+ public bool Enabled { get; set; } = true;
}
}
diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs
index a4aa0e75f..844170999 100644
--- a/MediaBrowser.Model/Updates/VersionInfo.cs
+++ b/MediaBrowser.Model/Updates/VersionInfo.cs
@@ -9,11 +9,29 @@ namespace MediaBrowser.Model.Updates
/// </summary>
public class VersionInfo
{
+ private Version _version;
+
/// <summary>
/// Gets or sets the version.
/// </summary>
/// <value>The version.</value>
- public string version { get; set; }
+ public string version
+ {
+ get
+ {
+ return _version == null ? string.Empty : _version.ToString();
+ }
+
+ set
+ {
+ _version = Version.Parse(value);
+ }
+ }
+
+ /// <summary>
+ /// Gets the version as a <see cref="Version"/>.
+ /// </summary>
+ public Version VersionNumber => _version;
/// <summary>
/// Gets or sets the changelog for this version.
@@ -44,5 +62,15 @@ namespace MediaBrowser.Model.Updates
/// </summary>
/// <value>The timestamp.</value>
public string timestamp { get; set; }
+
+ /// <summary>
+ /// Gets or sets the repository name.
+ /// </summary>
+ public string repositoryName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the repository url.
+ /// </summary>
+ public string repositoryUrl { get; set; }
}
}
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index 58ca88a5b..e7e44876d 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -13,6 +13,7 @@ using Jellyfin.Data.Events;
using MediaBrowser.Common.Net;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.BaseItemManager;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -51,6 +52,7 @@ namespace MediaBrowser.Providers.Manager
private readonly ILibraryManager _libraryManager;
private readonly ISubtitleManager _subtitleManager;
private readonly IServerConfigurationManager _configurationManager;
+ private readonly IBaseItemManager _baseItemManager;
private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new ConcurrentDictionary<Guid, double>();
private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue =
@@ -74,6 +76,7 @@ namespace MediaBrowser.Providers.Manager
/// <param name="fileSystem">The filesystem.</param>
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
+ /// <param name="baseItemManager">The BaseItem manager.</param>
public ProviderManager(
IHttpClientFactory httpClientFactory,
ISubtitleManager subtitleManager,
@@ -82,7 +85,8 @@ namespace MediaBrowser.Providers.Manager
ILogger<ProviderManager> logger,
IFileSystem fileSystem,
IServerApplicationPaths appPaths,
- ILibraryManager libraryManager)
+ ILibraryManager libraryManager,
+ IBaseItemManager baseItemManager)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@@ -92,6 +96,7 @@ namespace MediaBrowser.Providers.Manager
_appPaths = appPaths;
_libraryManager = libraryManager;
_subtitleManager = subtitleManager;
+ _baseItemManager = baseItemManager;
}
/// <inheritdoc/>
@@ -392,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;
}
@@ -436,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 70a5a6ac1..5621d2b86 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/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index 6af52b591..e540e4471 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;
@@ -385,7 +385,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var isConfiguredForEnglish = IsConfiguredForEnglish(item) || _configurationManager.Configuration.EnableNewOmdbSupport;
// Grab series genres because IMDb data is better than TVDB. Leave movies alone
- // But only do it if english is the preferred language because this data will not be localized
+ // But only do it if English is the preferred language because this data will not be localized
if (isConfiguredForEnglish && !string.IsNullOrWhiteSpace(result.Genre))
{
item.Genres = Array.Empty<string>();
@@ -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 690a52c4d..000000000
--- 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 e7079ed3c..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Plugins;
-using MediaBrowser.Model.Serialization;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class Plugin : BasePlugin<PluginConfiguration>
- {
- public static Plugin Instance { get; private set; }
-
- public override Guid Id => new Guid("a677c0da-fac5-4cde-941a-7134223f14c8");
-
- public override string Name => "TheTVDB";
-
- public override string Description => "Get metadata for movies and other video content from TheTVDB.";
-
- // TODO remove when plugin removed from server.
- public override string ConfigurationFileName => "Jellyfin.Plugin.TheTvdb.xml";
-
- public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
- : base(applicationPaths, xmlSerializer)
- {
- Instance = this;
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs
deleted file mode 100644
index ce0dab701..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs
+++ /dev/null
@@ -1,289 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using Microsoft.Extensions.Caching.Memory;
-using TvDbSharper;
-using TvDbSharper.Dto;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbClientManager
- {
- private const string DefaultLanguage = "en";
-
- private readonly IMemoryCache _cache;
- private readonly TvDbClient _tvDbClient;
- private DateTime _tokenCreatedAt;
-
- public TvdbClientManager(IMemoryCache memoryCache)
- {
- _cache = memoryCache;
- _tvDbClient = new TvDbClient();
- }
-
- private TvDbClient TvDbClient
- {
- get
- {
- if (string.IsNullOrEmpty(_tvDbClient.Authentication.Token))
- {
- _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult();
- _tokenCreatedAt = DateTime.Now;
- }
-
- // Refresh if necessary
- if (_tokenCreatedAt < DateTime.Now.Subtract(TimeSpan.FromHours(20)))
- {
- try
- {
- _tvDbClient.Authentication.RefreshTokenAsync().GetAwaiter().GetResult();
- }
- catch
- {
- _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult();
- }
-
- _tokenCreatedAt = DateTime.Now;
- }
-
- return _tvDbClient;
- }
- }
-
- public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByNameAsync(string name, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", name, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByNameAsync(name, cancellationToken));
- }
-
- public Task<TvDbResponse<Series>> GetSeriesByIdAsync(int tvdbId, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", tvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetAsync(tvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<EpisodeRecord>> GetEpisodesAsync(int episodeTvdbId, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("episode", episodeTvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Episodes.GetAsync(episodeTvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByImdbIdAsync(
- string imdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", imdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByImdbIdAsync(imdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByZap2ItIdAsync(
- string zap2ItId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", zap2ItId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByZap2ItIdAsync(zap2ItId, cancellationToken));
- }
-
- public Task<TvDbResponse<Actor[]>> GetActorsAsync(
- int tvdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("actors", tvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetActorsAsync(tvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<Image[]>> GetImagesAsync(
- int tvdbId,
- ImagesQuery imageQuery,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("images", tvdbId, language, imageQuery);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesAsync(tvdbId, imageQuery, cancellationToken));
- }
-
- public Task<TvDbResponse<Language[]>> GetLanguagesAsync(CancellationToken cancellationToken)
- {
- return TryGetValue("languages", null, () => TvDbClient.Languages.GetAllAsync(cancellationToken));
- }
-
- public Task<TvDbResponse<EpisodesSummary>> GetSeriesEpisodeSummaryAsync(
- int tvdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("seriesepisodesummary", tvdbId, language);
- return TryGetValue(cacheKey, language,
- () => TvDbClient.Series.GetEpisodesSummaryAsync(tvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync(
- int tvdbId,
- int page,
- EpisodeQuery episodeQuery,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey(language, tvdbId, episodeQuery);
-
- return TryGetValue(cacheKey, language,
- () => TvDbClient.Series.GetEpisodesAsync(tvdbId, page, episodeQuery, cancellationToken));
- }
-
- public Task<string> GetEpisodeTvdbId(
- EpisodeInfo searchInfo,
- string language,
- CancellationToken cancellationToken)
- {
- searchInfo.SeriesProviderIds.TryGetValue(
- nameof(MetadataProvider.Tvdb),
- out var seriesTvdbId);
-
- var episodeQuery = new EpisodeQuery();
-
- // Prefer SxE over premiere date as it is more robust
- if (searchInfo.IndexNumber.HasValue && searchInfo.ParentIndexNumber.HasValue)
- {
- switch (searchInfo.SeriesDisplayOrder)
- {
- case "dvd":
- episodeQuery.DvdEpisode = searchInfo.IndexNumber.Value;
- episodeQuery.DvdSeason = searchInfo.ParentIndexNumber.Value;
- break;
- case "absolute":
- episodeQuery.AbsoluteNumber = searchInfo.IndexNumber.Value;
- break;
- default:
- // aired order
- episodeQuery.AiredEpisode = searchInfo.IndexNumber.Value;
- episodeQuery.AiredSeason = searchInfo.ParentIndexNumber.Value;
- break;
- }
- }
- else if (searchInfo.PremiereDate.HasValue)
- {
- // tvdb expects yyyy-mm-dd format
- episodeQuery.FirstAired = searchInfo.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
- }
-
- return GetEpisodeTvdbId(Convert.ToInt32(seriesTvdbId, CultureInfo.InvariantCulture), episodeQuery, language, cancellationToken);
- }
-
- public async Task<string> GetEpisodeTvdbId(
- int seriesTvdbId,
- EpisodeQuery episodeQuery,
- string language,
- CancellationToken cancellationToken)
- {
- var episodePage =
- await GetEpisodesPageAsync(Convert.ToInt32(seriesTvdbId), episodeQuery, language, cancellationToken)
- .ConfigureAwait(false);
- return episodePage.Data.FirstOrDefault()?.Id.ToString(CultureInfo.InvariantCulture);
- }
-
- public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync(
- int tvdbId,
- EpisodeQuery episodeQuery,
- string language,
- CancellationToken cancellationToken)
- {
- return GetEpisodesPageAsync(tvdbId, 1, episodeQuery, language, cancellationToken);
- }
-
- public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeriesAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId);
- var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false);
-
- if (imagesSummary.Data.Fanart > 0)
- {
- yield return KeyType.Fanart;
- }
-
- if (imagesSummary.Data.Series > 0)
- {
- yield return KeyType.Series;
- }
-
- if (imagesSummary.Data.Poster > 0)
- {
- yield return KeyType.Poster;
- }
- }
-
- public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeasonAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId);
- var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false);
-
- if (imagesSummary.Data.Season > 0)
- {
- yield return KeyType.Season;
- }
-
- if (imagesSummary.Data.Fanart > 0)
- {
- yield return KeyType.Fanart;
- }
-
- // TODO seasonwide is not supported in TvDbSharper
- }
-
- private async Task<T> TryGetValue<T>(string key, string language, Func<Task<T>> resultFactory)
- {
- if (_cache.TryGetValue(key, out T cachedValue))
- {
- return cachedValue;
- }
-
- _tvDbClient.AcceptedLanguage = TvdbUtils.NormalizeLanguage(language) ?? DefaultLanguage;
- var result = await resultFactory.Invoke().ConfigureAwait(false);
- _cache.Set(key, result, TimeSpan.FromHours(1));
- return result;
- }
-
- private static string GenerateKey(params object[] objects)
- {
- var key = string.Empty;
-
- foreach (var obj in objects)
- {
- var objType = obj.GetType();
- if (objType.IsPrimitive || objType == typeof(string))
- {
- key += obj + ";";
- }
- else
- {
- foreach (PropertyInfo propertyInfo in objType.GetProperties())
- {
- var currentValue = propertyInfo.GetValue(obj, null);
- if (currentValue == null)
- {
- continue;
- }
-
- key += propertyInfo.Name + "=" + currentValue + ";";
- }
- }
- }
-
- return key;
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs
deleted file mode 100644
index 50a876d6c..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Globalization;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbEpisodeImageProvider : IRemoteImageProvider
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbEpisodeImageProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbEpisodeImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public string Name => "TheTVDB";
-
- public bool Supports(BaseItem item)
- {
- return item is Episode;
- }
-
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new List<ImageType>
- {
- ImageType.Primary
- };
- }
-
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var episode = (Episode)item;
- var series = episode.Series;
- var imageResult = new List<RemoteImageInfo>();
- var language = item.GetPreferredMetadataLanguage();
- if (series != null && TvdbSeriesProvider.IsValidSeries(series.ProviderIds))
- {
- // Process images
- try
- {
- string episodeTvdbId = null;
-
- if (episode.IndexNumber.HasValue && episode.ParentIndexNumber.HasValue)
- {
- var episodeInfo = new EpisodeInfo
- {
- IndexNumber = episode.IndexNumber.Value,
- ParentIndexNumber = episode.ParentIndexNumber.Value,
- SeriesProviderIds = series.ProviderIds,
- SeriesDisplayOrder = series.DisplayOrder
- };
-
- episodeTvdbId = await _tvdbClientManager
- .GetEpisodeTvdbId(episodeInfo, language, cancellationToken).ConfigureAwait(false);
- }
-
- if (string.IsNullOrEmpty(episodeTvdbId))
- {
- _logger.LogError(
- "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}",
- episode.ParentIndexNumber,
- episode.IndexNumber,
- series.GetProviderId(MetadataProvider.Tvdb));
- return imageResult;
- }
-
- var episodeResult =
- await _tvdbClientManager
- .GetEpisodesAsync(Convert.ToInt32(episodeTvdbId, CultureInfo.InvariantCulture), language, cancellationToken)
- .ConfigureAwait(false);
-
- var image = GetImageInfo(episodeResult.Data);
- if (image != null)
- {
- imageResult.Add(image);
- }
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve episode images for series {TvDbId}", series.GetProviderId(MetadataProvider.Tvdb));
- }
- }
-
- return imageResult;
- }
-
- private RemoteImageInfo GetImageInfo(EpisodeRecord episode)
- {
- if (string.IsNullOrEmpty(episode.Filename))
- {
- return null;
- }
-
- return new RemoteImageInfo
- {
- Width = Convert.ToInt32(episode.ThumbWidth, CultureInfo.InvariantCulture),
- Height = Convert.ToInt32(episode.ThumbHeight, CultureInfo.InvariantCulture),
- ProviderName = Name,
- Url = TvdbUtils.BannerUrl + episode.Filename,
- Type = ImageType.Primary
- };
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs
deleted file mode 100644
index fd72ea4a8..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs
+++ /dev/null
@@ -1,262 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- /// <summary>
- /// Class RemoteEpisodeProvider.
- /// </summary>
- public class TvdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbEpisodeProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbEpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var list = new List<RemoteSearchResult>();
-
- // Either an episode number or date must be provided; and the dictionary of provider ids must be valid
- if ((searchInfo.IndexNumber == null && searchInfo.PremiereDate == null)
- || !TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds))
- {
- return list;
- }
-
- var metadataResult = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false);
-
- if (!metadataResult.HasMetadata)
- {
- return list;
- }
-
- var item = metadataResult.Item;
-
- list.Add(new RemoteSearchResult
- {
- IndexNumber = item.IndexNumber,
- Name = item.Name,
- ParentIndexNumber = item.ParentIndexNumber,
- PremiereDate = item.PremiereDate,
- ProductionYear = item.ProductionYear,
- ProviderIds = item.ProviderIds,
- SearchProviderName = Name,
- IndexNumberEnd = item.IndexNumberEnd
- });
-
- return list;
- }
-
- public string Name => "TheTVDB";
-
- public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var result = new MetadataResult<Episode>
- {
- QueriedById = true
- };
-
- if (TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds) &&
- (searchInfo.IndexNumber.HasValue || searchInfo.PremiereDate.HasValue))
- {
- result = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- _logger.LogDebug("No series identity found for {EpisodeName}", searchInfo.Name);
- }
-
- return result;
- }
-
- private async Task<MetadataResult<Episode>> GetEpisode(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var result = new MetadataResult<Episode>
- {
- QueriedById = true
- };
-
- string seriesTvdbId = searchInfo.GetProviderId(MetadataProvider.Tvdb);
- string episodeTvdbId = null;
- try
- {
- episodeTvdbId = await _tvdbClientManager
- .GetEpisodeTvdbId(searchInfo, searchInfo.MetadataLanguage, cancellationToken)
- .ConfigureAwait(false);
- if (string.IsNullOrEmpty(episodeTvdbId))
- {
- _logger.LogError(
- "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}",
- searchInfo.ParentIndexNumber, searchInfo.IndexNumber, seriesTvdbId);
- return result;
- }
-
- var episodeResult = await _tvdbClientManager.GetEpisodesAsync(
- Convert.ToInt32(episodeTvdbId), searchInfo.MetadataLanguage,
- cancellationToken).ConfigureAwait(false);
-
- result = MapEpisodeToResult(searchInfo, episodeResult.Data);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve episode with id {EpisodeTvDbId}, series id {SeriesTvdbId}", episodeTvdbId, seriesTvdbId);
- }
-
- return result;
- }
-
- private static MetadataResult<Episode> MapEpisodeToResult(EpisodeInfo id, EpisodeRecord episode)
- {
- var result = new MetadataResult<Episode>
- {
- HasMetadata = true,
- Item = new Episode
- {
- IndexNumber = id.IndexNumber,
- ParentIndexNumber = id.ParentIndexNumber,
- IndexNumberEnd = id.IndexNumberEnd,
- AirsBeforeEpisodeNumber = episode.AirsBeforeEpisode,
- AirsAfterSeasonNumber = episode.AirsAfterSeason,
- AirsBeforeSeasonNumber = episode.AirsBeforeSeason,
- Name = episode.EpisodeName,
- Overview = episode.Overview,
- CommunityRating = (float?)episode.SiteRating,
- OfficialRating = episode.ContentRating,
- }
- };
- result.ResetPeople();
-
- var item = result.Item;
- item.SetProviderId(MetadataProvider.Tvdb, episode.Id.ToString());
- item.SetProviderId(MetadataProvider.Imdb, episode.ImdbId);
-
- if (string.Equals(id.SeriesDisplayOrder, "dvd", StringComparison.OrdinalIgnoreCase))
- {
- item.IndexNumber = Convert.ToInt32(episode.DvdEpisodeNumber ?? episode.AiredEpisodeNumber);
- item.ParentIndexNumber = episode.DvdSeason ?? episode.AiredSeason;
- }
- else if (string.Equals(id.SeriesDisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase))
- {
- if (episode.AbsoluteNumber.GetValueOrDefault() != 0)
- {
- item.IndexNumber = episode.AbsoluteNumber;
- }
- }
- else if (episode.AiredEpisodeNumber.HasValue)
- {
- item.IndexNumber = episode.AiredEpisodeNumber;
- }
- else if (episode.AiredSeason.HasValue)
- {
- item.ParentIndexNumber = episode.AiredSeason;
- }
-
- if (DateTime.TryParse(episode.FirstAired, out var date))
- {
- // dates from tvdb are UTC but without offset or Z
- item.PremiereDate = date;
- item.ProductionYear = date.Year;
- }
-
- foreach (var director in episode.Directors)
- {
- result.AddPerson(new PersonInfo
- {
- Name = director,
- Type = PersonType.Director
- });
- }
-
- // GuestStars is a weird list of names and roles
- // Example:
- // 1: Some Actor (Role1
- // 2: Role2
- // 3: Role3)
- // 4: Another Actor (Role1
- // ...
- for (var i = 0; i < episode.GuestStars.Length; ++i)
- {
- var currentActor = episode.GuestStars[i];
- var roleStartIndex = currentActor.IndexOf('(', StringComparison.Ordinal);
-
- if (roleStartIndex == -1)
- {
- result.AddPerson(new PersonInfo
- {
- Type = PersonType.GuestStar,
- Name = currentActor,
- Role = string.Empty
- });
- continue;
- }
-
- var roles = new List<string> { currentActor.Substring(roleStartIndex + 1) };
-
- // Fetch all roles
- for (var j = i + 1; j < episode.GuestStars.Length; ++j)
- {
- var currentRole = episode.GuestStars[j];
- var roleEndIndex = currentRole.IndexOf(')', StringComparison.Ordinal);
-
- if (roleEndIndex == -1)
- {
- roles.Add(currentRole);
- continue;
- }
-
- roles.Add(currentRole.TrimEnd(')'));
- // Update the outer index (keep in mind it adds 1 after the iteration)
- i = j;
- break;
- }
-
- result.AddPerson(new PersonInfo
- {
- Type = PersonType.GuestStar,
- Name = currentActor.Substring(0, roleStartIndex).Trim(),
- Role = string.Join(", ", roles)
- });
- }
-
- foreach (var writer in episode.Writers)
- {
- result.AddPerson(new PersonInfo
- {
- Name = writer,
- Type = PersonType.Writer
- });
- }
-
- result.ResultLanguage = episode.Language.EpisodeName;
- return result;
- }
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
-
- public int Order => 0;
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs
deleted file mode 100644
index a5cd425f6..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbPersonImageProvider : IRemoteImageProvider, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbPersonImageProvider> _logger;
- private readonly ILibraryManager _libraryManager;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbPersonImageProvider(ILibraryManager libraryManager, IHttpClientFactory httpClientFactory, ILogger<TvdbPersonImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _libraryManager = libraryManager;
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- /// <inheritdoc />
- public string Name => "TheTVDB";
-
- /// <inheritdoc />
- public int Order => 1;
-
- /// <inheritdoc />
- public bool Supports(BaseItem item) => item is Person;
-
- /// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- yield return ImageType.Primary;
- }
-
- /// <inheritdoc />
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var seriesWithPerson = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = new[] { nameof(Series) },
- PersonIds = new[] { item.Id },
- DtoOptions = new DtoOptions(false)
- {
- EnableImages = false
- }
- }).Cast<Series>()
- .Where(i => TvdbSeriesProvider.IsValidSeries(i.ProviderIds))
- .ToList();
-
- var infos = (await Task.WhenAll(seriesWithPerson.Select(async i =>
- await GetImageFromSeriesData(i, item.Name, cancellationToken).ConfigureAwait(false)))
- .ConfigureAwait(false))
- .Where(i => i != null)
- .Take(1);
-
- return infos;
- }
-
- private async Task<RemoteImageInfo> GetImageFromSeriesData(Series series, string personName, CancellationToken cancellationToken)
- {
- var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb));
-
- try
- {
- var actorsResult = await _tvdbClientManager
- .GetActorsAsync(tvdbId, series.GetPreferredMetadataLanguage(), cancellationToken)
- .ConfigureAwait(false);
- var actor = actorsResult.Data.FirstOrDefault(a =>
- string.Equals(a.Name, personName, StringComparison.OrdinalIgnoreCase) &&
- !string.IsNullOrEmpty(a.Image));
- if (actor == null)
- {
- return null;
- }
-
- return new RemoteImageInfo
- {
- Url = TvdbUtils.BannerUrl + actor.Image,
- Type = ImageType.Primary,
- ProviderName = Name
- };
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve actor {ActorName} from series {SeriesTvdbId}", personName, tvdbId);
- return null;
- }
- }
-
- /// <inheritdoc />
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs
deleted file mode 100644
index 49576d488..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs
+++ /dev/null
@@ -1,155 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-using RatingType = MediaBrowser.Model.Dto.RatingType;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbSeasonImageProvider : IRemoteImageProvider, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbSeasonImageProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeasonImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeasonImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public string Name => ProviderName;
-
- public static string ProviderName => "TheTVDB";
-
- public bool Supports(BaseItem item)
- {
- return item is Season;
- }
-
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new List<ImageType>
- {
- ImageType.Primary,
- ImageType.Banner,
- ImageType.Backdrop
- };
- }
-
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var season = (Season)item;
- var series = season.Series;
-
- if (series == null || !season.IndexNumber.HasValue || !TvdbSeriesProvider.IsValidSeries(series.ProviderIds))
- {
- return Array.Empty<RemoteImageInfo>();
- }
-
- var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb));
- var seasonNumber = season.IndexNumber.Value;
- var language = item.GetPreferredMetadataLanguage();
- var remoteImages = new List<RemoteImageInfo>();
-
- var keyTypes = _tvdbClientManager.GetImageKeyTypesForSeasonAsync(tvdbId, language, cancellationToken).ConfigureAwait(false);
- await foreach (var keyType in keyTypes)
- {
- var imageQuery = new ImagesQuery
- {
- KeyType = keyType,
- SubKey = seasonNumber.ToString()
- };
- try
- {
- var imageResults = await _tvdbClientManager
- .GetImagesAsync(tvdbId, imageQuery, language, cancellationToken).ConfigureAwait(false);
- remoteImages.AddRange(GetImages(imageResults.Data, language));
- }
- catch (TvDbServerException)
- {
- _logger.LogDebug("No images of type {KeyType} found for series {TvdbId}", keyType, tvdbId);
- }
- }
-
- return remoteImages;
- }
-
- private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage)
- {
- var list = new List<RemoteImageInfo>();
- // any languages with null ids are ignored
- var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data.Where(x => x.Id.HasValue);
- foreach (Image image in images)
- {
- var imageInfo = new RemoteImageInfo
- {
- RatingType = RatingType.Score,
- CommunityRating = (double?)image.RatingsInfo.Average,
- VoteCount = image.RatingsInfo.Count,
- Url = TvdbUtils.BannerUrl + image.FileName,
- ProviderName = ProviderName,
- Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation,
- ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail
- };
-
- var resolution = image.Resolution.Split('x');
- if (resolution.Length == 2)
- {
- imageInfo.Width = Convert.ToInt32(resolution[0]);
- imageInfo.Height = Convert.ToInt32(resolution[1]);
- }
-
- imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType);
- list.Add(imageInfo);
- }
-
- var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase);
- return list.OrderByDescending(i =>
- {
- if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 3;
- }
-
- if (!isLanguageEn)
- {
- if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 2;
- }
- }
-
- if (string.IsNullOrEmpty(i.Language))
- {
- return isLanguageEn ? 3 : 2;
- }
-
- return 0;
- })
- .ThenByDescending(i => i.CommunityRating ?? 0)
- .ThenByDescending(i => i.VoteCount ?? 0);
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs
deleted file mode 100644
index d96840e51..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs
+++ /dev/null
@@ -1,153 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-using RatingType = MediaBrowser.Model.Dto.RatingType;
-using Series = MediaBrowser.Controller.Entities.TV.Series;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbSeriesImageProvider : IRemoteImageProvider, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbSeriesImageProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeriesImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public string Name => ProviderName;
-
- public static string ProviderName => "TheTVDB";
-
- public bool Supports(BaseItem item)
- {
- return item is Series;
- }
-
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new List<ImageType>
- {
- ImageType.Primary,
- ImageType.Banner,
- ImageType.Backdrop
- };
- }
-
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- if (!TvdbSeriesProvider.IsValidSeries(item.ProviderIds))
- {
- return Array.Empty<RemoteImageInfo>();
- }
-
- var language = item.GetPreferredMetadataLanguage();
- var remoteImages = new List<RemoteImageInfo>();
- var tvdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tvdb));
- var allowedKeyTypes = _tvdbClientManager.GetImageKeyTypesForSeriesAsync(tvdbId, language, cancellationToken)
- .ConfigureAwait(false);
- await foreach (KeyType keyType in allowedKeyTypes)
- {
- var imageQuery = new ImagesQuery
- {
- KeyType = keyType
- };
- try
- {
- var imageResults =
- await _tvdbClientManager.GetImagesAsync(tvdbId, imageQuery, language, cancellationToken)
- .ConfigureAwait(false);
-
- remoteImages.AddRange(GetImages(imageResults.Data, language));
- }
- catch (TvDbServerException)
- {
- _logger.LogDebug("No images of type {KeyType} exist for series {TvDbId}", keyType,
- tvdbId);
- }
- }
-
- return remoteImages;
- }
-
- private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage)
- {
- var list = new List<RemoteImageInfo>();
- var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data;
-
- foreach (Image image in images)
- {
- var imageInfo = new RemoteImageInfo
- {
- RatingType = RatingType.Score,
- CommunityRating = (double?)image.RatingsInfo.Average,
- VoteCount = image.RatingsInfo.Count,
- Url = TvdbUtils.BannerUrl + image.FileName,
- ProviderName = Name,
- Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation,
- ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail
- };
-
- var resolution = image.Resolution.Split('x');
- if (resolution.Length == 2)
- {
- imageInfo.Width = Convert.ToInt32(resolution[0]);
- imageInfo.Height = Convert.ToInt32(resolution[1]);
- }
-
- imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType);
- list.Add(imageInfo);
- }
-
- var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase);
- return list.OrderByDescending(i =>
- {
- if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 3;
- }
-
- if (!isLanguageEn)
- {
- if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 2;
- }
- }
-
- if (string.IsNullOrEmpty(i.Language))
- {
- return isLanguageEn ? 3 : 2;
- }
-
- return 0;
- })
- .ThenByDescending(i => i.CommunityRating ?? 0)
- .ThenByDescending(i => i.VoteCount ?? 0);
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs
deleted file mode 100644
index e5a3e9a6a..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs
+++ /dev/null
@@ -1,419 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Net.Http;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Globalization;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-using Series = MediaBrowser.Controller.Entities.TV.Series;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder
- {
- internal static TvdbSeriesProvider Current { get; private set; }
-
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbSeriesProvider> _logger;
- private readonly ILibraryManager _libraryManager;
- private readonly ILocalizationManager _localizationManager;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeriesProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesProvider> logger, ILibraryManager libraryManager, ILocalizationManager localizationManager, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _libraryManager = libraryManager;
- _localizationManager = localizationManager;
- Current = this;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
- {
- if (IsValidSeries(searchInfo.ProviderIds))
- {
- var metadata = await GetMetadata(searchInfo, cancellationToken).ConfigureAwait(false);
-
- if (metadata.HasMetadata)
- {
- return new List<RemoteSearchResult>
- {
- new RemoteSearchResult
- {
- Name = metadata.Item.Name,
- PremiereDate = metadata.Item.PremiereDate,
- ProductionYear = metadata.Item.ProductionYear,
- ProviderIds = metadata.Item.ProviderIds,
- SearchProviderName = Name
- }
- };
- }
- }
-
- return await FindSeries(searchInfo.Name, searchInfo.Year, searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false);
- }
-
- public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo itemId, CancellationToken cancellationToken)
- {
- var result = new MetadataResult<Series>
- {
- QueriedById = true
- };
-
- if (!IsValidSeries(itemId.ProviderIds))
- {
- result.QueriedById = false;
- await Identify(itemId).ConfigureAwait(false);
- }
-
- cancellationToken.ThrowIfCancellationRequested();
-
- if (IsValidSeries(itemId.ProviderIds))
- {
- result.Item = new Series();
- result.HasMetadata = true;
-
- await FetchSeriesData(result, itemId.MetadataLanguage, itemId.ProviderIds, cancellationToken)
- .ConfigureAwait(false);
- }
-
- return result;
- }
-
- private async Task FetchSeriesData(MetadataResult<Series> result, string metadataLanguage, Dictionary<string, string> seriesProviderIds, CancellationToken cancellationToken)
- {
- var series = result.Item;
-
- if (seriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var tvdbId) && !string.IsNullOrEmpty(tvdbId))
- {
- series.SetProviderId(MetadataProvider.Tvdb, tvdbId);
- }
-
- if (seriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out var imdbId) && !string.IsNullOrEmpty(imdbId))
- {
- series.SetProviderId(MetadataProvider.Imdb, imdbId);
- tvdbId = await GetSeriesByRemoteId(imdbId, MetadataProvider.Imdb.ToString(), metadataLanguage,
- cancellationToken).ConfigureAwait(false);
- }
-
- if (seriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out var zap2It) && !string.IsNullOrEmpty(zap2It))
- {
- series.SetProviderId(MetadataProvider.Zap2It, zap2It);
- tvdbId = await GetSeriesByRemoteId(zap2It, MetadataProvider.Zap2It.ToString(), metadataLanguage,
- cancellationToken).ConfigureAwait(false);
- }
-
- try
- {
- var seriesResult =
- await _tvdbClientManager
- .GetSeriesByIdAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken)
- .ConfigureAwait(false);
- await MapSeriesToResult(result, seriesResult.Data, metadataLanguage).ConfigureAwait(false);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve series with id {TvdbId}", tvdbId);
- return;
- }
-
- cancellationToken.ThrowIfCancellationRequested();
-
- result.ResetPeople();
-
- try
- {
- var actorsResult = await _tvdbClientManager
- .GetActorsAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken).ConfigureAwait(false);
- MapActorsToResult(result, actorsResult.Data);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve actors for series {TvdbId}", tvdbId);
- }
- }
-
- private async Task<string> GetSeriesByRemoteId(string id, string idType, string language, CancellationToken cancellationToken)
- {
- TvDbResponse<SeriesSearchResult[]> result = null;
-
- try
- {
- if (string.Equals(idType, MetadataProvider.Zap2It.ToString(), StringComparison.OrdinalIgnoreCase))
- {
- result = await _tvdbClientManager.GetSeriesByZap2ItIdAsync(id, language, cancellationToken)
- .ConfigureAwait(false);
- }
- else
- {
- result = await _tvdbClientManager.GetSeriesByImdbIdAsync(id, language, cancellationToken)
- .ConfigureAwait(false);
- }
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve series with remote id {RemoteId}", id);
- }
-
- return result?.Data[0].Id.ToString(CultureInfo.InvariantCulture);
- }
-
- /// <summary>
- /// Check whether a dictionary of provider IDs includes an entry for a valid TV metadata provider.
- /// </summary>
- /// <param name="seriesProviderIds">The dictionary to check.</param>
- /// <returns>True, if the dictionary contains a valid TV provider ID, otherwise false.</returns>
- internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds)
- {
- return seriesProviderIds.ContainsKey(MetadataProvider.Tvdb.ToString()) ||
- seriesProviderIds.ContainsKey(MetadataProvider.Imdb.ToString()) ||
- seriesProviderIds.ContainsKey(MetadataProvider.Zap2It.ToString());
- }
-
- /// <summary>
- /// Finds the series.
- /// </summary>
- /// <param name="name">The name.</param>
- /// <param name="year">The year.</param>
- /// <param name="language">The language.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task{System.String}.</returns>
- private async Task<IEnumerable<RemoteSearchResult>> FindSeries(string name, int? year, string language, CancellationToken cancellationToken)
- {
- var results = await FindSeriesInternal(name, language, cancellationToken).ConfigureAwait(false);
-
- if (results.Count == 0)
- {
- var parsedName = _libraryManager.ParseName(name);
- var nameWithoutYear = parsedName.Name;
-
- if (!string.IsNullOrWhiteSpace(nameWithoutYear) && !string.Equals(nameWithoutYear, name, StringComparison.OrdinalIgnoreCase))
- {
- results = await FindSeriesInternal(nameWithoutYear, language, cancellationToken).ConfigureAwait(false);
- }
- }
-
- return results.Where(i =>
- {
- if (year.HasValue && i.ProductionYear.HasValue)
- {
- // Allow one year tolerance
- return Math.Abs(year.Value - i.ProductionYear.Value) <= 1;
- }
-
- return true;
- });
- }
-
- private async Task<List<RemoteSearchResult>> FindSeriesInternal(string name, string language, CancellationToken cancellationToken)
- {
- var comparableName = GetComparableName(name);
- var list = new List<Tuple<List<string>, RemoteSearchResult>>();
- TvDbResponse<SeriesSearchResult[]> result;
- try
- {
- result = await _tvdbClientManager.GetSeriesByNameAsync(comparableName, language, cancellationToken)
- .ConfigureAwait(false);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "No series results found for {Name}", comparableName);
- return new List<RemoteSearchResult>();
- }
-
- foreach (var seriesSearchResult in result.Data)
- {
- var tvdbTitles = new List<string>
- {
- GetComparableName(seriesSearchResult.SeriesName)
- };
- tvdbTitles.AddRange(seriesSearchResult.Aliases.Select(GetComparableName));
-
- DateTime.TryParse(seriesSearchResult.FirstAired, out var firstAired);
- var remoteSearchResult = new RemoteSearchResult
- {
- Name = tvdbTitles.FirstOrDefault(),
- ProductionYear = firstAired.Year,
- SearchProviderName = Name
- };
-
- if (!string.IsNullOrEmpty(seriesSearchResult.Banner))
- {
- // Results from their Search endpoints already include the /banners/ part in the url, because reasons...
- remoteSearchResult.ImageUrl = TvdbUtils.TvdbImageBaseUrl + seriesSearchResult.Banner;
- }
-
- try
- {
- var seriesSesult =
- await _tvdbClientManager.GetSeriesByIdAsync(seriesSearchResult.Id, language, cancellationToken)
- .ConfigureAwait(false);
- remoteSearchResult.SetProviderId(MetadataProvider.Imdb, seriesSesult.Data.ImdbId);
- remoteSearchResult.SetProviderId(MetadataProvider.Zap2It, seriesSesult.Data.Zap2itId);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Unable to retrieve series with id {TvdbId}", seriesSearchResult.Id);
- }
-
- remoteSearchResult.SetProviderId(MetadataProvider.Tvdb, seriesSearchResult.Id.ToString());
- list.Add(new Tuple<List<string>, RemoteSearchResult>(tvdbTitles, remoteSearchResult));
- }
-
- return list
- .OrderBy(i => i.Item1.Contains(comparableName, StringComparer.OrdinalIgnoreCase) ? 0 : 1)
- .ThenBy(i => list.IndexOf(i))
- .Select(i => i.Item2)
- .ToList();
- }
-
- /// <summary>
- /// Gets the name of the comparable.
- /// </summary>
- /// <param name="name">The name.</param>
- /// <returns>System.String.</returns>
- private string GetComparableName(string name)
- {
- name = name.ToLowerInvariant();
- name = name.Normalize(NormalizationForm.FormKD);
- name = name.Replace(", the", string.Empty).Replace("the ", " ").Replace(" the ", " ");
- name = name.Replace("&", " and " );
- name = Regex.Replace(name, @"[\p{Lm}\p{Mn}]", string.Empty); // Remove diacritics, etc
- name = Regex.Replace(name, @"[\W\p{Pc}]+", " "); // Replace sequences of non-word characters and _ with " "
- return name.Trim();
- }
-
- private async Task MapSeriesToResult(MetadataResult<Series> result, TvDbSharper.Dto.Series tvdbSeries, string metadataLanguage)
- {
- Series series = result.Item;
- series.SetProviderId(MetadataProvider.Tvdb, tvdbSeries.Id.ToString());
- series.Name = tvdbSeries.SeriesName;
- series.Overview = (tvdbSeries.Overview ?? string.Empty).Trim();
- result.ResultLanguage = metadataLanguage;
- series.AirDays = TVUtils.GetAirDays(tvdbSeries.AirsDayOfWeek);
- series.AirTime = tvdbSeries.AirsTime;
- series.CommunityRating = (float?)tvdbSeries.SiteRating;
- series.SetProviderId(MetadataProvider.Imdb, tvdbSeries.ImdbId);
- series.SetProviderId(MetadataProvider.Zap2It, tvdbSeries.Zap2itId);
- if (Enum.TryParse(tvdbSeries.Status, true, out SeriesStatus seriesStatus))
- {
- series.Status = seriesStatus;
- }
-
- if (DateTime.TryParse(tvdbSeries.FirstAired, out var date))
- {
- // dates from tvdb are UTC but without offset or Z
- series.PremiereDate = date;
- series.ProductionYear = date.Year;
- }
-
- if (!string.IsNullOrEmpty(tvdbSeries.Runtime) && double.TryParse(tvdbSeries.Runtime, out double runtime))
- {
- series.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks;
- }
-
- foreach (var genre in tvdbSeries.Genre)
- {
- series.AddGenre(genre);
- }
-
- if (!string.IsNullOrEmpty(tvdbSeries.Network))
- {
- series.AddStudio(tvdbSeries.Network);
- }
-
- if (result.Item.Status.HasValue && result.Item.Status.Value == SeriesStatus.Ended)
- {
- try
- {
- var episodeSummary = await _tvdbClientManager.GetSeriesEpisodeSummaryAsync(tvdbSeries.Id, metadataLanguage, CancellationToken.None).ConfigureAwait(false);
-
- if (episodeSummary.Data.AiredSeasons.Length != 0)
- {
- var maxSeasonNumber = episodeSummary.Data.AiredSeasons.Max(s => Convert.ToInt32(s, CultureInfo.InvariantCulture));
- var episodeQuery = new EpisodeQuery
- {
- AiredSeason = maxSeasonNumber
- };
- var episodesPage = await _tvdbClientManager.GetEpisodesPageAsync(tvdbSeries.Id, episodeQuery, metadataLanguage, CancellationToken.None).ConfigureAwait(false);
-
- result.Item.EndDate = episodesPage.Data
- .Select(e => DateTime.TryParse(e.FirstAired, out var firstAired) ? firstAired : (DateTime?)null)
- .Max();
- }
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to find series end date for series {TvdbId}", tvdbSeries.Id);
- }
- }
- }
-
- private static void MapActorsToResult(MetadataResult<Series> result, IEnumerable<Actor> actors)
- {
- foreach (Actor actor in actors)
- {
- var personInfo = new PersonInfo
- {
- Type = PersonType.Actor,
- Name = (actor.Name ?? string.Empty).Trim(),
- Role = actor.Role,
- SortOrder = actor.SortOrder
- };
-
- if (!string.IsNullOrEmpty(actor.Image))
- {
- personInfo.ImageUrl = TvdbUtils.BannerUrl + actor.Image;
- }
-
- if (!string.IsNullOrWhiteSpace(personInfo.Name))
- {
- result.AddPerson(personInfo);
- }
- }
- }
-
- public string Name => "TheTVDB";
-
- public async Task Identify(SeriesInfo info)
- {
- if (!string.IsNullOrWhiteSpace(info.GetProviderId(MetadataProvider.Tvdb)))
- {
- return;
- }
-
- var srch = await FindSeries(info.Name, info.Year, info.MetadataLanguage, CancellationToken.None)
- .ConfigureAwait(false);
-
- var entry = srch.FirstOrDefault();
-
- if (entry != null)
- {
- var id = entry.GetProviderId(MetadataProvider.Tvdb);
- info.SetProviderId(MetadataProvider.Tvdb, id);
- }
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs
deleted file mode 100644
index 37a8d04a6..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Model.Entities;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public static class TvdbUtils
- {
- public const string TvdbApiKey = "OG4V3YJ3FAP7FP2K";
- public const string TvdbBaseUrl = "https://www.thetvdb.com/";
- public const string TvdbImageBaseUrl = "https://www.thetvdb.com";
- public const string BannerUrl = TvdbImageBaseUrl + "/banners/";
-
- public static ImageType GetImageTypeFromKeyType(string keyType)
- {
- switch (keyType.ToLowerInvariant())
- {
- case "poster":
- case "season": return ImageType.Primary;
- case "series":
- case "seasonwide": return ImageType.Banner;
- case "fanart": return ImageType.Backdrop;
- default: throw new ArgumentException($"Invalid or unknown keytype: {keyType}", nameof(keyType));
- }
- }
-
- public static string NormalizeLanguage(string language)
- {
- if (string.IsNullOrWhiteSpace(language))
- {
- return null;
- }
-
- // pt-br is just pt to tvdb
- return language.Split('-')[0].ToLowerInvariant();
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index b754a0795..0e8a5baab 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/TV/TvdbEpisodeExternalId.cs b/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs
deleted file mode 100644
index 40c5f2d78..000000000
--- a/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using MediaBrowser.Providers.Plugins.TheTvdb;
-
-namespace MediaBrowser.Providers.TV
-{
- public class TvdbEpisodeExternalId : IExternalId
- {
- /// <inheritdoc />
- public string ProviderName => "TheTVDB";
-
- /// <inheritdoc />
- public string Key => MetadataProvider.Tvdb.ToString();
-
- /// <inheritdoc />
- public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
-
- /// <inheritdoc />
- public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=episode&id={0}";
-
- /// <inheritdoc />
- public bool Supports(IHasProviderIds item) => item is Episode;
- }
-}
diff --git a/MediaBrowser.Providers/TV/TvdbExternalId.cs b/MediaBrowser.Providers/TV/TvdbExternalId.cs
deleted file mode 100644
index 4c54de9f8..000000000
--- a/MediaBrowser.Providers/TV/TvdbExternalId.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using MediaBrowser.Providers.Plugins.TheTvdb;
-
-namespace MediaBrowser.Providers.TV
-{
- public class TvdbExternalId : IExternalId
- {
- /// <inheritdoc />
- public string ProviderName => "TheTVDB";
-
- /// <inheritdoc />
- public string Key => MetadataProvider.Tvdb.ToString();
-
- /// <inheritdoc />
- public ExternalIdMediaType? Type => null;
-
- /// <inheritdoc />
- public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=series&id={0}";
-
- /// <inheritdoc />
- public bool Supports(IHasProviderIds item) => item is Series;
- }
-}
diff --git a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs b/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs
deleted file mode 100644
index 807ebb3ee..000000000
--- a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using MediaBrowser.Providers.Plugins.TheTvdb;
-
-namespace MediaBrowser.Providers.TV
-{
- public class TvdbSeasonExternalId : IExternalId
- {
- /// <inheritdoc />
- public string ProviderName => "TheTVDB";
-
- /// <inheritdoc />
- public string Key => MetadataProvider.Tvdb.ToString();
-
- /// <inheritdoc />
- public ExternalIdMediaType? Type => ExternalIdMediaType.Season;
-
- /// <inheritdoc />
- public string UrlFormatString => null;
-
- /// <inheritdoc />
- public bool Supports(IHasProviderIds item) => item is Season;
- }
-}
diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs
index c9f314af9..3cb18e424 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/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
index 9cc0344c1..bce4cf009 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 25402aee1..d460c0ab0 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,18 @@ 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
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 +138,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 +178,22 @@ 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
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}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
EndGlobalSection
@@ -201,12 +215,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/RSSDP/DisposableManagedObjectBase.cs b/RSSDP/DisposableManagedObjectBase.cs
index 745ec359c..7d6a471f9 100644
--- a/RSSDP/DisposableManagedObjectBase.cs
+++ b/RSSDP/DisposableManagedObjectBase.cs
@@ -5,7 +5,7 @@ using System.Text;
namespace Rssdp.Infrastructure
{
/// <summary>
- /// Correclty implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceities not on the interface such as an <see cref="IsDisposed"/> property.
+ /// Correctly implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceties not on the interface such as an <see cref="IsDisposed"/> property.
/// </summary>
public abstract class DisposableManagedObjectBase : IDisposable
{
@@ -61,10 +61,10 @@ namespace Rssdp.Infrastructure
/// Disposes this object instance and all internally managed resources.
/// </summary>
/// <remarks>
- /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behaviour of derived classes.</para>
+ /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behavior of derived classes.</para>
/// </remarks>
/// <seealso cref="IsDisposed"/>
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfer with the dispose process.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfere with the dispose process.")]
public void Dispose()
{
IsDisposed = true;
diff --git a/RSSDP/HttpParserBase.cs b/RSSDP/HttpParserBase.cs
index 11202940e..c56249523 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.
diff --git a/RSSDP/ISsdpDeviceLocator.cs b/RSSDP/ISsdpDeviceLocator.cs
index 413055643..4df166cd2 100644
--- a/RSSDP/ISsdpDeviceLocator.cs
+++ b/RSSDP/ISsdpDeviceLocator.cs
@@ -52,7 +52,7 @@ namespace Rssdp.Infrastructure
}
/// <summary>
- /// Aynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results.
+ /// Asynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results.
/// </summary>
/// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns>
System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync();
@@ -83,7 +83,7 @@ namespace Rssdp.Infrastructure
/// </param>
/// <param name="searchWaitTime">The amount of time to wait for network responses to the search request. Longer values will likely return more devices, but increase search time. A value between 1 and 5 is recommended by the UPnP 1.1 specification. Specify TimeSpan.Zero to return only devices already in the cache.</param>
/// <remarks>
- /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implemetning these services if you know the service type.</para>
+ /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implementing these services if you know the service type.</para>
/// </remarks>
/// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns>
System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync(string searchTarget, TimeSpan searchWaitTime);
diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs
index 1a8577d8d..90925b9e0 100644
--- a/RSSDP/SsdpDevicePublisher.cs
+++ b/RSSDP/SsdpDevicePublisher.cs
@@ -102,7 +102,7 @@ namespace Rssdp.Infrastructure
/// <param name="device">The <see cref="SsdpDevice"/> instance to add.</param>
/// <exception cref="ArgumentNullException">Thrown if the <paramref name="device"/> argument is null.</exception>
/// <exception cref="InvalidOperationException">Thrown if the <paramref name="device"/> contains property values that are not acceptable to the UPnP 1.0 specification.</exception>
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable supresses compiler warning, but task is not really needed.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable suppresses compiler warning, but task is not really needed.")]
public void AddDevice(SsdpRootDevice device)
{
if (device == null)
@@ -180,7 +180,7 @@ namespace Rssdp.Infrastructure
/// </summary>
/// <remarks>
/// <para>Enabling this option will cause devices to show up in Microsoft Windows Explorer's network screens (if discovery is enabled etc.). Windows Explorer appears to search only for pnp:rootdeivce and not upnp:rootdevice.</para>
- /// <para>If false, the system will only use upnp:rootdevice for notifiation broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para>
+ /// <para>If false, the system will only use upnp:rootdevice for notification broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para>
/// </remarks>
public bool SupportPnpRootDevice
{
diff --git a/RSSDP/SsdpRootDevice.cs b/RSSDP/SsdpRootDevice.cs
index 8937ec331..4084b31ca 100644
--- a/RSSDP/SsdpRootDevice.cs
+++ b/RSSDP/SsdpRootDevice.cs
@@ -25,7 +25,7 @@ namespace Rssdp
/// Specifies how long clients can cache this device's details for. Optional but defaults to <see cref="TimeSpan.Zero"/> which means no-caching. Recommended value is half an hour.
/// </summary>
/// <remarks>
- /// <para>Specifiy <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para>
+ /// <para>Specify <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para>
/// <para>Also used to specify how often to rebroadcast alive notifications.</para>
/// <para>The UPnP/SSDP specifications indicate this should not be less than 1800 seconds (half an hour), but this is not enforced by this library.</para>
/// </remarks>
@@ -50,7 +50,7 @@ namespace Rssdp
public IPAddress SubnetMask { get; set; }
/// <summary>
- /// The base URL to use for all relative url's provided in other propertise (and those of child devices). Optional.
+ /// The base URL to use for all relative url's provided in other properties (and those of child devices). Optional.
/// </summary>
/// <remarks>
/// <para>Defines the base URL. Used to construct fully-qualified URLs. All relative URLs that appear elsewhere in the description are combined with this base URL. If URLBase is empty or not given, the base URL is the URL from which the device description was retrieved (which is the preferred implementation; use of URLBase is no longer recommended). Specified by UPnP vendor. Single URL.</para>
diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs
new file mode 100644
index 000000000..938d19a15
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Api.ModelBinders;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Primitives;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.ModelBinders
+{
+ public sealed class PipeDelimitedArrayModelBinderTests
+ {
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedStringArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<string> queryParamValues = new[] { "lol", "xd" };
+ var queryParamString = "lol|xd";
+ var queryParamType = typeof(string[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<string>?)bindingContextMock.Object?.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidDelimitedIntArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<int> queryParamValues = new[] { 42, 0 };
+ var queryParamString = "42|0";
+ var queryParamType = typeof(int[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<int>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString = "How|Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQueryWithDoublePipes()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString = "How||Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString1 = "How";
+ var queryParamString2 = "Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues>
+ {
+ { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>();
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues>
+ {
+ { queryParamName, new StringValues(value: null) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_EnumArrayQuery_BindValidOnly()
+ {
+ var queryParamName = "test";
+ var queryParamString = "🔥|😢";
+ var queryParamType = typeof(IReadOnlyList<TestType>);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Empty((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2()
+ {
+ var queryParamName = "test";
+ var queryParamString1 = "How";
+ var queryParamString2 = "😱";
+ var queryParamType = typeof(IReadOnlyList<TestType>);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues>
+ {
+ { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Single((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model);
+ }
+ }
+}